1741 lines
66 KiB
Plaintext
1741 lines
66 KiB
Plaintext
; 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 走完整测试流程。
|
||
; 只改 TSF / Server 时用 .\test-light.ps1:ISCC /DLightPackage=1,
|
||
; 打出不含词库的轻量包,安装时也不会删本机已有词库。
|
||
; 上面两条都不把 PDB 打进包;要带符号用 .\test-symbols.ps1。
|
||
; 本仓库不包含任何预置代码签名证书。
|
||
|
||
#define MyAppName "Metasequoia IME 水杉输入法"
|
||
#define MyAppVersion "0.0.1"
|
||
; VersionInfoVersion 是 PE 的 VERSIONINFO,只接受数字和点号;dev 构建的 MyAppVersion 带
|
||
; -dev.<日期>.<hash> 后缀(Prepare-PackageFiles.ps1 会把数字部分同步写进这个 define),
|
||
; 文件名与 AppVersion 用完整串,这里只用数字段。
|
||
#define MyVersionInfo "0.0.1"
|
||
#define MyAppPublisher "Metasequoia"
|
||
#define MyAppExeName "MetasequoiaImeServer.exe"
|
||
#define MySettingsExeName "MetasequoiaImeSettings.exe"
|
||
; 与 settings_app.cpp / settings_launcher.cpp 的 kQuitSettings 保持一致:WM_APP + 5。
|
||
#define MySettingsWindowClass "MetasequoiaImeSettingsWindow"
|
||
#define MySettingsQuitMessage 32773
|
||
#define MyEmojiPanelExeName "MetasequoiaImeEmojiPanel.exe"
|
||
#define MyKeyboardPanelExeName "MetasequoiaImeKeyboardPanel.exe"
|
||
#define MyHandwritingPanelExeName "MetasequoiaImeHandwritingPanel.exe"
|
||
#define MyWatchdogName "MetasequoiaImeWatchdog.exe"
|
||
#define MyWatchdogTaskName "Metasequoia IME Watchdog"
|
||
#define MyReplayName "MetasequoiaImeDictionaryReplay.exe"
|
||
#define MyVersionDirBase "msime_v" + MyAppVersion
|
||
#define MySourceRoot "."
|
||
; TSF DLL 装在系统目录下:CS2 等开启 Trusted Mode 的游戏只放行「位于系统目录且有代码签名」的
|
||
; 第三方 in-proc DLL,装在 Program Files 的 DLL 连加载都会被拒,输入法在游戏里根本切不出来。
|
||
; 本安装器在 x64/ARM64 上以 64 位模式运行(ArchitecturesInstallIn64BitMode),
|
||
; Pascal Script 里访问 {sys} 就是真正的 System32,没有 WOW64 重定向可绕。
|
||
; 不要在这里用 ApplyPathRedirRulesForCurrentProcess:那是 Inno Setup 7 的 support
|
||
; function,windows-2025 runner 的 6.7.1 没有它,ISCC 直接报 Unknown identifier
|
||
; (#925 引入后第一次在 hosted runner 上编安装包就炸了,见 installer/README.md)。
|
||
#define MyTsfRoot64 "{sys}\IME\MetasequoiaIME"
|
||
#define MyTsfRoot32 "{syswow64}\IME\MetasequoiaIME"
|
||
#ifdef LightPackage
|
||
#define MyOutputSuffix "_light"
|
||
#else
|
||
#define MyOutputSuffix ""
|
||
#endif
|
||
|
||
[Setup]
|
||
AppId={{A7C3E91F-4B2D-4E8A-9F1C-6D5E8B0A2C4D}
|
||
AppName={#MyAppName}
|
||
AppVersion={#MyAppVersion}
|
||
AppPublisher={#MyAppPublisher}
|
||
DefaultDirName={autopf}\metasequoiaime
|
||
DefaultGroupName={#MyAppName}
|
||
DisableDirPage=yes
|
||
; DisableDirPage=yes 时就绪页默认不显示目标目录,显式打开以便用户确认装到哪。
|
||
AlwaysShowDirOnReadyPage=yes
|
||
DisableProgramGroupPage=yes
|
||
OutputDir=Output
|
||
OutputBaseFilename=MetasequoiaIME_Setup_v{#MyAppVersion}{#MyOutputSuffix}
|
||
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={#MyVersionInfo}
|
||
|
||
[Languages]
|
||
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||
|
||
[Dirs]
|
||
Name: "{#MyTsfRoot32}\{code:GetVersionDir}"
|
||
Name: "{#MyTsfRoot64}\{code:GetVersionDir}"
|
||
Name: "{commonpf64}\metasequoiaime\server"
|
||
; 用户数据(词库、配置、皮肤、界面资源)。默认在 LocalAppData,安装时可以改到别的盘,
|
||
; 选择写进 HKLM 的 DataDir,Server / TSF DLL / 引擎三方都从那里读。
|
||
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||
; 用户自己的辅助码方案放这里,Server 扫描后列进设置页,升级时保留。
|
||
Name: "{code:GetDataDir}\helpcodes\custom"
|
||
; 用户自己的双拼方案(.toml)放这里,同样由 Server 扫描、升级时保留。
|
||
Name: "{code:GetDataDir}\shuangpin\custom"
|
||
|
||
[Files]
|
||
; 独立安装应用图标,供 Windows“已安装的应用”列表稳定显示。
|
||
Source: "{#MySourceRoot}\MetasequoiaIME.ico"; \
|
||
DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion
|
||
|
||
; 第三方声明随包安装。词库主体含 rime-ice(GPL-3.0)内容,其许可要求保留署名,
|
||
; 因此这份文件必须落到用户磁盘上,而不能只存在于源码仓库里。
|
||
Source: "{#MySourceRoot}\THIRD_PARTY_NOTICES.txt"; \
|
||
DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion
|
||
|
||
; GPLv3 第 4、6 条要求分发时向接收者提供许可证副本,而 THIRD_PARTY_NOTICES.txt 只是指向
|
||
; "the LICENSE file"、本身不含 GPL 正文。macOS 与 Linux 的 CMake 安装规则早已随包装入许可证,
|
||
; Windows 是唯一大规模分发却漏掉这一步的平台。
|
||
Source: "{#MySourceRoot}\LICENSE.txt"; \
|
||
DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion
|
||
|
||
; 输入法语言配置(profile)的图标放在固定路径。TSF DLL 每次升级都换版本目录,图标若指向 DLL,
|
||
; 注册表里的图标路径就跟着变;任务栏恰好在换目录时取不到图标,就改显示「简体」文字并一直沿用到
|
||
; 资源管理器重启。DLL 注册时发现自己上一级目录有这个文件就改用它(windows/src/Register/Register.cpp),
|
||
; 所以这两条必须排在下面两个 regserver 条目之前;32 位和 64 位 DLL 各按自己的上一级目录找,各放一份。
|
||
Source: "{#MySourceRoot}\MetasequoiaImeTip.ico"; \
|
||
DestDir: "{#MyTsfRoot32}"; Flags: ignoreversion 32bit
|
||
|
||
Source: "{#MySourceRoot}\MetasequoiaImeTip.ico"; \
|
||
DestDir: "{#MyTsfRoot64}"; Flags: ignoreversion
|
||
|
||
; TSF DLL 使用版本独立目录,避免升级时覆盖仍被进程加载的 DLL。系统目录的原因见 MyTsfRoot64。
|
||
; PDB 与对应 DLL 放在同一目录,调试器可按二进制的内嵌路径自动找到符号。
|
||
; 只有 Prepare-PackageFiles.ps1 -IncludeSymbols 才会把 PDB 放进 tsf_dll\;默认本地打包不含符号,
|
||
; 所以这两条必须带 skipifsourcedoesntexist,否则通配符匹配不到文件时 ISCC 会直接报错。
|
||
Source: "{#MySourceRoot}\tsf_dll\32\*.dll"; \
|
||
DestDir: "{#MyTsfRoot32}\{code:GetVersionDir}"; \
|
||
Flags: ignoreversion regserver 32bit
|
||
|
||
Source: "{#MySourceRoot}\tsf_dll\64\*.dll"; \
|
||
DestDir: "{#MyTsfRoot64}\{code:GetVersionDir}"; \
|
||
Flags: ignoreversion regserver
|
||
|
||
Source: "{#MySourceRoot}\tsf_dll\32\*.pdb"; \
|
||
DestDir: "{#MyTsfRoot32}\{code:GetVersionDir}"; \
|
||
Flags: ignoreversion skipifsourcedoesntexist 32bit
|
||
|
||
Source: "{#MySourceRoot}\tsf_dll\64\*.pdb"; \
|
||
DestDir: "{#MyTsfRoot64}\{code:GetVersionDir}"; \
|
||
Flags: ignoreversion skipifsourcedoesntexist
|
||
|
||
Source: "{#MySourceRoot}\server_exe\*"; \
|
||
DestDir: "{commonpf64}\metasequoiaime\server"; \
|
||
Flags: ignoreversion recursesubdirs createallsubdirs
|
||
|
||
; 内置皮肤的出厂清单(开关与完整外观)每次安装都整份覆盖:用户技术上能改,但不建议,升级会冲掉改动。
|
||
; 单独暂存在 default_skins,不经 app_data:skins 整个目录是用户数据,卸载时不删。
|
||
Source: "{#MySourceRoot}\default_skins\*"; \
|
||
DestDir: "{code:GetDataDir}\skins\default"; \
|
||
Flags: ignoreversion recursesubdirs createallsubdirs uninsneveruninstall
|
||
|
||
; 仓库 skins\ 下的外部皮肤,同名目录整目录覆盖。用户机器上的同名皮肤和随包的不一样时,
|
||
; PrepareToInstall 里的 BackupChangedBundledSkins 先把它改名成 <id>.bak 再复制。
|
||
Source: "{#MySourceRoot}\bundled_skins\*"; \
|
||
DestDir: "{code:GetDataDir}\skins"; \
|
||
Flags: ignoreversion recursesubdirs createallsubdirs uninsneveruninstall
|
||
Source: "{#MySourceRoot}\bundled_skins.manifest"; Flags: dontcopy
|
||
|
||
#ifdef LightPackage
|
||
; 轻量包只覆盖设置窗口的图片资源,不带词库/辅助码/出厂配置。
|
||
Source: "{#MySourceRoot}\app_data\assets\settings\*"; \
|
||
DestDir: "{code:GetDataDir}\assets\settings"; \
|
||
Flags: ignoreversion recursesubdirs createallsubdirs uninsneveruninstall
|
||
#else
|
||
; 包内故意不带 config.toml。通配复制再排除一次,防止以后又把用户配置打进包内。
|
||
Source: "{#MySourceRoot}\app_data\*"; DestDir: "{code:GetDataDir}"; \
|
||
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: "{code:GetDataDir}"; DestName: "config.toml"; \
|
||
Flags: onlyifdoesntexist uninsneveruninstall
|
||
Source: "{#MySourceRoot}\app_data\config.default.toml"; \
|
||
DestDir: "{code:GetDataDir}"; DestName: "config.default.toml"; \
|
||
Flags: ignoreversion uninsneveruninstall
|
||
#endif
|
||
|
||
[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
|
||
; 用户数据目录的唯一权威来源。Server、TSF DLL 和引擎都按
|
||
; METASEQUOIA_IME_DATA_DIR → 这个键 → %LOCALAPPDATA%\metasequoiaime 的顺序解析;
|
||
; 32 位 TSF DLL 用 KEY_WOW64_64KEY 读,所以这里必须写在 64 位视图里
|
||
; (ArchitecturesInstallIn64BitMode 已经保证了这一点)。
|
||
Root: HKLM; Subkey: "Software\Metasequoia\MetasequoiaIME"; \
|
||
ValueType: string; ValueName: "DataDir"; ValueData: "{code:GetDataDir}"; \
|
||
Flags: uninsdeletevalue
|
||
|
||
[Code]
|
||
const
|
||
{ 放在数据目录里,标记「输入法用过这个目录」。}
|
||
DataDirMarkerName = '.metasequoiaime-data';
|
||
{ 标记文件里带上这一行,才表示目录是安装器在它为空时接手的,整个目录都归输入法,
|
||
覆盖安装和卸载可以整目录清理。旧版安装器不管目录里原来有什么都写标记(#537),
|
||
所以只有标记、没有这一行的目录只能按名单删输入法自己的文件。}
|
||
DataDirExclusiveTag = 'exclusive: adopted empty by the installer';
|
||
{ 用户选的目录里已有别的文件时,数据改放到这个子目录里。}
|
||
DataDirSubfolderName = 'metasequoiaime';
|
||
|
||
VCRuntimeKey = 'Software\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||
{ 安装器界面本身就是中文(InitializeSetup 在选语言对话框之前跑,拿不到用户选的语言),
|
||
说明页也统一给简体中文版,省得用户落到英文页上再自己切。}
|
||
VCRuntimeDownloadUrl =
|
||
'https://learn.microsoft.com/zh-cn/cpp/windows/latest-supported-vc-redist?view=msvc-170';
|
||
{ 微软给的「最新受支持 v14 x64 运行库」固定直链。}
|
||
VCRuntimeDirectUrl = 'https://aka.ms/vc14/vc_redist.x64.exe';
|
||
{ 见 VCRuntimeInstalledInView 上方的说明。}
|
||
VCRuntimeMinMinor = 40;
|
||
|
||
var
|
||
VersionDirName: String;
|
||
DataDirValue: String;
|
||
PreviousDataDir: String;
|
||
DataDirPage: TInputDirWizardPage;
|
||
NetworkPage: TInputOptionWizardPage;
|
||
CloudCandidatesIndex: Integer;
|
||
UserConfigExistedBeforeInstall: Boolean;
|
||
DataDirAdoptedEmpty: Boolean;
|
||
|
||
// VC 运行库不随包分发:它是系统级共享组件,安装器不该替用户装。缺了它 Server 直接
|
||
// 起不来,而故障现场在安装结束之后,用户看不到任何解释,所以在安装开始前查一遍,
|
||
// 把原因当场说清楚。
|
||
//
|
||
// 用注册表判定,而不是去 System32 里找 vcruntime140.dll:Setup.exe 是 32 位进程,
|
||
// Pascal Script 的 FileExists 会被 WOW64 重定向到 SysWOW64([Files] 和 Exec 里的
|
||
// {sys} 由 Inno 自己处理,脚本函数不管),结果查的是 x86 运行库——只装了 x64 redist
|
||
// 的机器会被误判成缺少 x64 运行库。绕开重定向的两个办法都不可移植:
|
||
// EnableFsRedirection 在 Inno Setup 7 里已被移除,而它的替代品在 6.x 里还不存在;
|
||
// {sysnative} 对 64 位安装器又不可用。注册表访问没有这个问题,HKLM32 / HKLM64
|
||
// 两个视图都是显式指定的。
|
||
//
|
||
// x64 redist 的安装状态就记在这个键里。要求 14.40 以上:server_exe 里的 exe 都用
|
||
// MSVC 14.40+(VS 2022 17.10 起)编译并导入 msvcp140.dll,这一版起 std::mutex 的
|
||
// 构造函数改成了 constexpr,配上 14.40 之前的 msvcp140.dll 会在第一次加锁时访问冲突,
|
||
// 进程无声闪退(设置页打不开就是这个样子)。装了 2019 / 早期 2022 运行库的机器
|
||
// 同样有 Installed=1,所以必须比版本号,不能只看装没装。
|
||
// 32 位 TSF DLL 用的是静态 CRT(windows/CMakeLists.txt 里的
|
||
// CMAKE_MSVC_RUNTIME_LIBRARY),所以不必检查 x86 redist。
|
||
// 注意:Inno Setup 的花括号注释不能嵌套,上面提到的常量会提前闭合 { } 注释,
|
||
// 所以这一段只能用行注释。
|
||
function VCRuntimeInstalledInView(RootKey: Integer): Boolean;
|
||
var
|
||
Installed: Cardinal;
|
||
Major: Cardinal;
|
||
Minor: Cardinal;
|
||
begin
|
||
Result :=
|
||
RegQueryDWordValue(RootKey, VCRuntimeKey, 'Installed', Installed) and
|
||
(Installed = 1) and
|
||
RegQueryDWordValue(RootKey, VCRuntimeKey, 'Major', Major) and
|
||
RegQueryDWordValue(RootKey, VCRuntimeKey, 'Minor', Minor) and
|
||
((Major > 14) or ((Major = 14) and (Minor >= VCRuntimeMinMinor)));
|
||
end;
|
||
|
||
{ redist 的安装包是 32 位的,键通常落在 WOW6432Node 下,但较新的版本两个视图都写,
|
||
所以两边都查。}
|
||
function VCRuntimeIsInstalled: Boolean;
|
||
begin
|
||
Result :=
|
||
VCRuntimeInstalledInView(HKLM32) or VCRuntimeInstalledInView(HKLM64);
|
||
end;
|
||
|
||
{ 只用于日志和提示文字,不参与判定。}
|
||
function ReadVCRuntimeVersion: String;
|
||
begin
|
||
Result := '';
|
||
if not RegQueryStringValue(HKLM32, VCRuntimeKey, 'Version', Result) then
|
||
if not RegQueryStringValue(HKLM64, VCRuntimeKey, 'Version', Result) then
|
||
Result := '';
|
||
Result := Trim(Result);
|
||
end;
|
||
|
||
procedure OpenDownloadPage(const Url: String);
|
||
var
|
||
ErrorCode: Integer;
|
||
begin
|
||
{ 安装器是提权进程,用它拉起的浏览器会以管理员身份运行;和 LaunchInstalledComponents
|
||
一样交还给原用户。}
|
||
ShellExecAsOriginalUser(
|
||
'open', Url, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode);
|
||
end;
|
||
|
||
function InitializeSetup: Boolean;
|
||
var
|
||
NeedsVCRuntime: Boolean;
|
||
VCRuntimeVersion: String;
|
||
Prompt: String;
|
||
begin
|
||
Result := True;
|
||
|
||
NeedsVCRuntime := not VCRuntimeIsInstalled;
|
||
{ 有版本号却没通过判定,就是装了但太旧,提示里要和「没装」分开说。}
|
||
VCRuntimeVersion := ReadVCRuntimeVersion;
|
||
if NeedsVCRuntime then
|
||
Log('Prerequisite missing or outdated: Visual C++ 2015-2022 Redistributable (x64), found "' +
|
||
VCRuntimeVersion + '", need 14.' + IntToStr(VCRuntimeMinMinor) + ' or later.')
|
||
else
|
||
Log('Visual C++ runtime found: ' + VCRuntimeVersion);
|
||
|
||
if not NeedsVCRuntime then
|
||
exit;
|
||
|
||
Prompt := '这台电脑缺少输入法用到的系统组件,或版本过旧:' + #13#10 + #13#10 +
|
||
'● Visual C++ 2015-2022 可再发行组件包(x64),需要 14.' +
|
||
IntToStr(VCRuntimeMinMinor) + ' 或更高版本' + #13#10;
|
||
if VCRuntimeVersion <> '' then
|
||
Prompt := Prompt +
|
||
' 当前已安装 ' + VCRuntimeVersion + ',版本过旧,设置页等程序会启动即闪退。' + #13#10
|
||
else
|
||
Prompt := Prompt +
|
||
' 缺少它输入法主程序根本无法启动(提示找不到 VCRUNTIME140.dll 之类)。' + #13#10;
|
||
Prompt := Prompt +
|
||
' 直接下载:' + VCRuntimeDirectUrl + #13#10 +
|
||
' 说明页面:' + VCRuntimeDownloadUrl + #13#10 + #13#10 +
|
||
'点击「是」直接下载该组件并结束本次安装,装好后重新运行本安装程序;' + #13#10 +
|
||
'点击「否」继续安装(装完仍需自行补齐该组件,输入法才能正常使用)。';
|
||
|
||
{ 静默安装(CI、企业批量部署)不该被一个对话框卡住:默认继续,缺什么已经写进日志了。}
|
||
if
|
||
SuppressibleMsgBox(Prompt, mbConfirmation, MB_YESNO, IDNO) = IDYES
|
||
then
|
||
begin
|
||
OpenDownloadPage(VCRuntimeDirectUrl);
|
||
Result := False;
|
||
end;
|
||
end;
|
||
|
||
{ 上一次安装(或历史版本)的数据目录。没有注册表值就是历史默认位置。}
|
||
function ResolvePreviousDataDir: String;
|
||
var
|
||
Recorded: String;
|
||
begin
|
||
if PreviousDataDir = '' then
|
||
begin
|
||
Recorded := '';
|
||
if
|
||
RegQueryStringValue(
|
||
HKLM,
|
||
'Software\Metasequoia\MetasequoiaIME',
|
||
'DataDir',
|
||
Recorded
|
||
) and (Trim(Recorded) <> '')
|
||
then
|
||
PreviousDataDir := RemoveBackslashUnlessRoot(Trim(Recorded))
|
||
else
|
||
PreviousDataDir := ExpandConstant('{localappdata}\metasequoiaime');
|
||
end;
|
||
Result := PreviousDataDir;
|
||
end;
|
||
|
||
{ 本次安装要用的数据目录。静默安装可以用 /DATADIR="D:\..." 指定;
|
||
两者都没有时沿用上一次的位置。}
|
||
function GetDataDir(Param: String): String;
|
||
var
|
||
FromCommandLine: String;
|
||
begin
|
||
if DataDirValue = '' then
|
||
begin
|
||
FromCommandLine := Trim(ExpandConstant('{param:DATADIR|}'));
|
||
if FromCommandLine <> '' then
|
||
DataDirValue := RemoveBackslashUnlessRoot(FromCommandLine)
|
||
else
|
||
DataDirValue := ResolvePreviousDataDir;
|
||
end;
|
||
Result := DataDirValue;
|
||
end;
|
||
|
||
function DataDirMarkerPath(const Directory: String): String;
|
||
begin
|
||
Result := AddBackslash(Directory) + DataDirMarkerName;
|
||
end;
|
||
|
||
{ 输入法用过这个目录(新旧标记都算)。只凭这一点,只能删名单里输入法自己的文件。}
|
||
function DataDirHasMarker(const Directory: String): Boolean;
|
||
begin
|
||
Result := (Directory <> '') and FileExists(DataDirMarkerPath(Directory));
|
||
end;
|
||
|
||
function DataDirMarkerIsExclusive(const Directory: String): Boolean;
|
||
var
|
||
Lines: TArrayOfString;
|
||
Index: Integer;
|
||
begin
|
||
Result := False;
|
||
if not LoadStringsFromFile(DataDirMarkerPath(Directory), Lines) then
|
||
exit;
|
||
for Index := 0 to GetArrayLength(Lines) - 1 do
|
||
if Trim(Lines[Index]) = DataDirExclusiveTag then
|
||
begin
|
||
Result := True;
|
||
exit;
|
||
end;
|
||
end;
|
||
|
||
{ 只有这两种目录允许整目录清理:标记里写明是安装器在空目录上接手的,
|
||
以及历史默认位置(老版本装的,那时还没有标记文件,而且它的名字本身就是输入法专用的)。}
|
||
function OwnsDataDirExclusively(const Directory: String): Boolean;
|
||
begin
|
||
Result :=
|
||
(Directory <> '') and
|
||
((CompareText(
|
||
Directory,
|
||
ExpandConstant('{localappdata}\metasequoiaime')) = 0) or
|
||
(DataDirHasMarker(Directory) and DataDirMarkerIsExclusive(Directory)));
|
||
end;
|
||
|
||
{ Exclusive 只能在确认目录接手时为空(或本来就归我们)时传 True。
|
||
已有的旧标记不会因为一次覆盖安装就升级成独占:那时目录里已经有东西,说不清是谁的。}
|
||
procedure WriteDataDirMarker(const Directory: String; Exclusive: Boolean);
|
||
var
|
||
Lines: TArrayOfString;
|
||
begin
|
||
if Exclusive then
|
||
begin
|
||
if DataDirMarkerIsExclusive(Directory) then
|
||
exit;
|
||
SetArrayLength(Lines, 2);
|
||
Lines[0] := 'Metasequoia IME user data directory.';
|
||
Lines[1] := DataDirExclusiveTag;
|
||
end
|
||
else
|
||
begin
|
||
if DataDirHasMarker(Directory) then
|
||
exit;
|
||
SetArrayLength(Lines, 1);
|
||
Lines[0] := 'Metasequoia IME user data directory.';
|
||
end;
|
||
SaveStringsToFile(DataDirMarkerPath(Directory), Lines, False);
|
||
end;
|
||
|
||
function UserConfigPath: String;
|
||
begin
|
||
Result := AddBackslash(GetDataDir('')) + 'config.toml';
|
||
end;
|
||
|
||
function IsPathInside(const Child, Parent: String): Boolean;
|
||
begin
|
||
Result :=
|
||
(CompareText(Child, Parent) = 0) or
|
||
(CompareText(
|
||
Copy(AddBackslash(Child), 1, Length(AddBackslash(Parent))),
|
||
AddBackslash(Parent)) = 0);
|
||
end;
|
||
|
||
function DirectoryIsEmpty(const Directory: String): Boolean;
|
||
var
|
||
FindRec: TFindRec;
|
||
begin
|
||
Result := True;
|
||
if not DirExists(Directory) then
|
||
exit;
|
||
if FindFirst(AddBackslash(Directory) + '*', FindRec) then
|
||
begin
|
||
try
|
||
repeat
|
||
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
|
||
begin
|
||
Result := False;
|
||
exit;
|
||
end;
|
||
until not FindNext(FindRec);
|
||
finally
|
||
FindClose(FindRec);
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
{ 返回空串表示这个路径可以用;否则返回要展示给用户的原因。}
|
||
function DataDirRejectionReason(const Directory: String): String;
|
||
var
|
||
Critical: array[0..6] of String;
|
||
Index: Integer;
|
||
ProbePath: String;
|
||
begin
|
||
Result := '';
|
||
|
||
if (Length(Directory) < 4) or (Directory[2] <> ':') or (Directory[3] <> '\') then
|
||
begin
|
||
Result := '请填写本机磁盘上的完整路径,例如 D:\MetasequoiaIME。';
|
||
exit;
|
||
end;
|
||
if not DirExists(Copy(Directory, 1, 3)) then
|
||
begin
|
||
Result := '找不到驱动器 ' + Copy(Directory, 1, 2) + ',请换一个位置。';
|
||
exit;
|
||
end;
|
||
if CompareText(RemoveBackslashUnlessRoot(Directory), Copy(Directory, 1, 2)) = 0 then
|
||
begin
|
||
Result := '不能直接使用驱动器根目录,请指定一个子目录。';
|
||
exit;
|
||
end;
|
||
|
||
{ 覆盖安装会清理数据目录里的旧资源,卸载会整个删掉它。
|
||
因此它既不能落在程序目录里,也不能反过来包住系统或用户的关键目录。}
|
||
if
|
||
IsPathInside(Directory, ExpandConstant('{commonpf64}\metasequoiaime')) or
|
||
IsPathInside(Directory, ExpandConstant('{commonpf32}\metasequoiaime'))
|
||
then
|
||
begin
|
||
Result := '数据目录不能放在输入法的程序目录里面。';
|
||
exit;
|
||
end;
|
||
|
||
Critical[0] := ExpandConstant('{win}');
|
||
Critical[1] := ExpandConstant('{commonpf64}');
|
||
Critical[2] := ExpandConstant('{commonpf32}');
|
||
Critical[3] := ExpandConstant('{localappdata}');
|
||
Critical[4] := ExpandConstant('{userappdata}');
|
||
{ Inno 没有对应用户主目录的常量,用环境变量展开。取不到时下面的空值检查会跳过这一项。}
|
||
Critical[5] := ExpandConstant('{%USERPROFILE|}');
|
||
Critical[6] := ExpandConstant('{commonappdata}');
|
||
for Index := 0 to 6 do
|
||
begin
|
||
if (Critical[Index] <> '') and IsPathInside(Critical[Index], Directory) then
|
||
begin
|
||
Result :=
|
||
'这个目录包含了系统或用户的重要目录(' + Critical[Index] + '),' +
|
||
'卸载时会连它一起删除。请另选一个专用目录。';
|
||
exit;
|
||
end;
|
||
end;
|
||
|
||
{ AdoptableDataDir 已经把有别的文件的目录换成了子目录;走到这里还不空,说明连那个子目录
|
||
里都有不归输入法的东西。}
|
||
if
|
||
(not DirectoryIsEmpty(Directory)) and
|
||
(not DataDirHasMarker(Directory)) and
|
||
(not OwnsDataDirExclusively(Directory))
|
||
then
|
||
begin
|
||
Result :=
|
||
'目录 ' + Directory + ' 里已经有其他文件。' +
|
||
'为避免卸载时误删,请选择一个空目录。';
|
||
exit;
|
||
end;
|
||
|
||
if not ForceDirectories(Directory) then
|
||
begin
|
||
Result := '无法创建目录 ' + Directory + ',请检查权限或换一个位置。';
|
||
exit;
|
||
end;
|
||
ProbePath := AddBackslash(Directory) + 'msime-write-probe.tmp';
|
||
if not SaveStringToFile(ProbePath, 'probe', False) then
|
||
begin
|
||
Result := '目录 ' + Directory + ' 不可写,请换一个位置。';
|
||
exit;
|
||
end;
|
||
DeleteFile(ProbePath);
|
||
end;
|
||
|
||
{ 数据目录必须整个归输入法:用户选了一个本来就有东西的文件夹(或者驱动器根目录),
|
||
就在里面建一个专用子目录。输入法用过的目录(有标记,或历史默认位置)原样沿用。}
|
||
function AdoptableDataDir(const Chosen: String): String;
|
||
begin
|
||
Result := Chosen;
|
||
if (Length(Chosen) = 3) and (Chosen[2] = ':') and (Chosen[3] = '\') then
|
||
Result := Chosen + DataDirSubfolderName
|
||
else if
|
||
(not DirectoryIsEmpty(Chosen)) and
|
||
(not DataDirHasMarker(Chosen)) and
|
||
(not OwnsDataDirExclusively(Chosen))
|
||
then
|
||
Result := AddBackslash(Chosen) + DataDirSubfolderName;
|
||
end;
|
||
|
||
{ CreateInputDirPage 自带的浏览按钮调用 BrowseForFolder 时不给新建文件夹按钮,用户没法在对话框里
|
||
当场建一个目录。接管它的 OnClick,换成带新建按钮的那种对话框。}
|
||
procedure DataDirBrowseClick(Sender: TObject);
|
||
var
|
||
Chosen: String;
|
||
begin
|
||
Chosen := Trim(DataDirPage.Values[0]);
|
||
if BrowseForFolder('请选择输入法数据的存放位置:', Chosen, True) then
|
||
DataDirPage.Values[0] := Chosen;
|
||
end;
|
||
|
||
{ 云候选是唯一一个装完就会联网的功能:输入过程中把当前拼写发给 Google 的 input-tools 服务。
|
||
出厂默认开启,而安装器此前没有任何一屏提到过它,用户要读文档才会知道。这一页把它摆到安装
|
||
过程里,选择写进首次生成的 config.toml。
|
||
|
||
升级时跳过:那时 config.toml 已经属于用户,安装器不该替他重新决定。}
|
||
procedure InitializeWizard;
|
||
begin
|
||
{ 词库、用户配置、皮肤和界面资源都在这个目录下,整包有几百 MB,
|
||
所以要让用户能把它放到别的盘。程序本体仍然装在 Program Files:
|
||
Server 带 uiAccess=true,只有装在受信任目录里这个标志才生效。}
|
||
DataDirPage := CreateInputDirPage(
|
||
wpLicense,
|
||
'选择数据位置',
|
||
'输入法数据存放在哪里',
|
||
'请选择输入法数据(词库、配置、皮肤)的存放位置。',
|
||
True,
|
||
'metasequoiaime'
|
||
);
|
||
DataDirPage.Add('');
|
||
DataDirPage.Values[0] := ResolvePreviousDataDir;
|
||
DataDirPage.Buttons[0].OnClick := @DataDirBrowseClick;
|
||
|
||
#ifndef LightPackage
|
||
NetworkPage := CreateInputOptionPage(
|
||
DataDirPage.ID,
|
||
'联网功能',
|
||
'选择安装后哪些功能可以联网',
|
||
'拼音切分、候选排序和词频学习全部在本机完成,不联网。' + #13#10 +
|
||
'下面这一项是唯一一个装完就会生效的联网功能。AI 联想、候选翻译、语音输入都需要你自己填入 API token 之后才会发出任何请求。' + #13#10#13#10 +
|
||
'安装后随时可以在「设置 → 输入」里改变这个选择。',
|
||
False,
|
||
False
|
||
);
|
||
CloudCandidatesIndex := NetworkPage.Add(
|
||
'启用云候选:输入过程中把当前正在输入的拼写通过 HTTPS 发送给 Google 的 input-tools 服务' +
|
||
'(inputtools.google.com),换回一条额外候选。已上屏的文本、词库内容和学习到的词频都不会发送。');
|
||
NetworkPage.Values[CloudCandidatesIndex] := True;
|
||
#endif
|
||
end;
|
||
|
||
#include "cloud_choice.iss"
|
||
|
||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||
begin
|
||
Result := False;
|
||
if (NetworkPage <> nil) and (PageID = NetworkPage.ID) then
|
||
Result := UserConfigExistsBeforeInstall;
|
||
end;
|
||
|
||
procedure LaunchInstalledComponents;
|
||
var
|
||
ErrorCode: Integer;
|
||
begin
|
||
{ uiAccess=true 的程序不能用 CreateProcess 拉起(会报 740)。
|
||
完成页点击 Finish 后,以原用户身份执行 ShellExecute(等同双击)。}
|
||
ShellExecAsOriginalUser(
|
||
'',
|
||
ExpandConstant('{commonpf64}\metasequoiaime\server\{#MyAppExeName}'),
|
||
'',
|
||
'',
|
||
SW_SHOWNORMAL,
|
||
ewNoWait,
|
||
ErrorCode
|
||
);
|
||
ShellExecAsOriginalUser(
|
||
'',
|
||
ExpandConstant('{commonpf64}\metasequoiaime\server\{#MyWatchdogName}'),
|
||
'',
|
||
'',
|
||
SW_SHOWNORMAL,
|
||
ewNoWait,
|
||
ErrorCode
|
||
);
|
||
end;
|
||
|
||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||
var
|
||
Chosen: String;
|
||
Adopted: String;
|
||
Reason: String;
|
||
begin
|
||
Result := True;
|
||
|
||
if (DataDirPage <> nil) and (CurPageID = DataDirPage.ID) then
|
||
begin
|
||
Chosen := RemoveBackslashUnlessRoot(Trim(DataDirPage.Values[0]));
|
||
Adopted := AdoptableDataDir(Chosen);
|
||
{ 用户可能指到一个本来就有东西的文件夹(#537)。数据目录会被覆盖安装清理、被卸载整个删除,
|
||
所以不能和别人的文件混在一起:改用里面的专用子目录,并先让用户看到实际位置。}
|
||
if
|
||
(CompareText(Adopted, Chosen) <> 0) and
|
||
(MsgBox(
|
||
'目录 ' + Chosen + ' 里已经有其他文件(或是驱动器根目录)。' + #13#10 +
|
||
'为了卸载时不误删这些文件,输入法数据将放在它的子目录里:' + #13#10 + #13#10 +
|
||
' ' + Adopted + #13#10 + #13#10 +
|
||
'确定吗?',
|
||
mbConfirmation, MB_OKCANCEL) <> IDOK)
|
||
then
|
||
begin
|
||
Result := False;
|
||
exit;
|
||
end;
|
||
Reason := DataDirRejectionReason(Adopted);
|
||
if Reason <> '' then
|
||
begin
|
||
MsgBox(Reason, mbError, MB_OK);
|
||
Result := False;
|
||
exit;
|
||
end;
|
||
{ 冲突要在这一页拦下,等到「安装」才报错时用户已经回不到这里了。}
|
||
Reason := ConfigMigrationProblem(ResolvePreviousDataDir, Adopted);
|
||
if Reason <> '' then
|
||
begin
|
||
MsgBox(Reason, mbError, MB_OK);
|
||
Result := False;
|
||
exit;
|
||
end;
|
||
DataDirPage.Values[0] := Adopted;
|
||
DataDirValue := Adopted;
|
||
exit;
|
||
end;
|
||
|
||
if (CurPageID = wpFinished) and (not WizardSilent) then
|
||
LaunchInstalledComponents;
|
||
end;
|
||
|
||
function UpdateReadyMemo(
|
||
Space, NewLine, MemoUserInfoInfo, MemoDirInfo, MemoTypeInfo,
|
||
MemoComponentsInfo, MemoGroupInfo, MemoTasksInfo: String): String;
|
||
begin
|
||
Result := MemoDirInfo + NewLine + NewLine +
|
||
'数据目录(词库、用户配置、皮肤):' + NewLine + Space + GetDataDir('');
|
||
if CompareText(GetDataDir(''), ResolvePreviousDataDir) <> 0 then
|
||
Result := Result + NewLine + NewLine +
|
||
'现有数据将从这里迁移:' + NewLine + Space + ResolvePreviousDataDir;
|
||
end;
|
||
|
||
// 本进程能直接访问的 TSF 根目录。64 位模式下 sys 常量不经重定向,直接就是 System32。
|
||
function TsfRoot64: String;
|
||
begin
|
||
Result := ExpandConstant('{#MyTsfRoot64}');
|
||
end;
|
||
|
||
function TsfRoot32: String;
|
||
begin
|
||
Result := ExpandConstant('{#MyTsfRoot32}');
|
||
end;
|
||
|
||
function GetVersionDir(Param: String): String;
|
||
var
|
||
Candidate: String;
|
||
Suffix: Integer;
|
||
begin
|
||
if VersionDirName = '' then
|
||
begin
|
||
Candidate := '{#MyVersionDirBase}';
|
||
Suffix := 0;
|
||
while
|
||
DirExists(AddBackslash(TsfRoot32) + Candidate) or
|
||
DirExists(AddBackslash(TsfRoot64) + 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 IsUserStatisticsDatabase(const FileName: String): Boolean;
|
||
begin
|
||
{ 输入统计库和用户词库同一条理由:Server 打开它时会留下 sidecar,只搬主库会丢掉
|
||
最后一次写入。这个库默认不建(统计开关关闭),建了之后就只有用户自己能重建它的内容。}
|
||
Result :=
|
||
(CompareText(FileName, 'stats.db') = 0) or
|
||
(CompareText(FileName, 'stats.db-wal') = 0) or
|
||
(CompareText(FileName, 'stats.db-shm') = 0) or
|
||
(CompareText(FileName, 'stats.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 IsUserTranslationFile(const FileName: String): Boolean;
|
||
begin
|
||
{ 候选窗中英翻译的人工覆盖表,由用户手工放在数据目录根下(README「自定义候选窗翻译」一节),
|
||
安装包不携带、引擎只读不写。它没有 config.toml 那样的出厂副本可回退,删掉就是内容
|
||
彻底丢失,所以必须与用户配置同等对待。}
|
||
Result := CompareText(FileName, 'custom_translations.txt') = 0;
|
||
end;
|
||
|
||
function IsUserSkinDirectory(const FileName: String): Boolean;
|
||
begin
|
||
{ 外部皮肤在 %LOCALAPPDATA%\metasequoiaime\skins,升级安装不得清掉。}
|
||
Result := CompareText(FileName, 'skins') = 0;
|
||
end;
|
||
|
||
function IsUserShuangpinDirectory(const FileName: String): Boolean;
|
||
begin
|
||
{ shuangpin\custom 放的是用户自定义双拼方案,安装包不往 shuangpin 里写任何东西,整个目录都归用户。}
|
||
Result := CompareText(FileName, 'shuangpin') = 0;
|
||
end;
|
||
|
||
function IsCollocationModelDirectory(const FileName: String): Boolean;
|
||
begin
|
||
{ octagram 语法模型包(目录首项为推荐包万象)在 <数据目录>\models\<id>,是设置页按需下载的
|
||
约 390MB,安装包不携带,
|
||
也不是每次安装都会重写的东西。神经整句效果档模型同样按需下载在 models\neural-desktop。这份名单的语义恰好是「升级保留、卸载删除」——
|
||
IsPreservedAppDataItem 只被 CleanAppDataExceptUserFiles(覆盖安装)与 RemoveDataDir
|
||
(卸载/换目录)读——所以归这里,不能进 IsRuntimeAppDataItem:后者两条路径都删,
|
||
覆盖安装会把模型一并清掉,用户得重下一次。}
|
||
Result := CompareText(FileName, 'models') = 0;
|
||
end;
|
||
|
||
function IsPreservedAppDataItem(const FileName: String): Boolean;
|
||
begin
|
||
{ 标记文件也要留下。它虽然会在 ssPostInstall 重写一遍,但安装若在中途失败,
|
||
没有它的数据目录就不再被认作我们建的,后续的清理和卸载都会跳过。}
|
||
Result :=
|
||
IsUserDatabaseFile(FileName) or
|
||
IsUserStatisticsDatabase(FileName) or
|
||
IsUserConfigFile(FileName) or
|
||
IsUserTranslationFile(FileName) or
|
||
IsUserSkinDirectory(FileName) or
|
||
IsUserShuangpinDirectory(FileName) or
|
||
IsCollocationModelDirectory(FileName) or
|
||
(CompareText(FileName, DataDirMarkerName) = 0);
|
||
end;
|
||
|
||
{ app_data 里随包装进数据目录根下的条目。installer/tests/package-files.ps1 拿真正打出来的
|
||
app_data 对这份名单,打包新增文件而漏改这里会红。}
|
||
function IsShippedAppDataItem(const FileName: String): Boolean;
|
||
begin
|
||
Result :=
|
||
(CompareText(FileName, 'pinyin.txt') = 0) or
|
||
(CompareText(FileName, 'dict_pinyin.dat') = 0) or
|
||
(CompareText(FileName, 'msime.db') = 0) or
|
||
(CompareText(FileName, 'dictionary-manifest.json') = 0) or
|
||
(CompareText(FileName, 'dict_japanese.dat') = 0) or
|
||
(CompareText(FileName, 'MOZC_DICTIONARY_LICENSE.txt') = 0) or
|
||
(CompareText(FileName, 'english.db') = 0) or
|
||
(CompareText(FileName, 'others.db') = 0) or
|
||
(CompareText(FileName, 'sc.lm') = 0) or
|
||
(CompareText(FileName, 'libime-lm-NOTICE.md') = 0) or
|
||
(CompareText(FileName, 'sentence-model.safetensors') = 0) or
|
||
(CompareText(FileName, 'chinese-ime-lm-NOTICE.md') = 0) or
|
||
(CompareText(FileName, 'config.default.toml') = 0) or
|
||
(CompareText(FileName, 'helpcodes') = 0) or
|
||
(CompareText(FileName, 'radical') = 0) or
|
||
(CompareText(FileName, 'assets') = 0);
|
||
end;
|
||
|
||
{ Server、引擎和安装器自己运行时在数据目录根下写出来的条目(不含 IsPreservedAppDataItem 已列的)。}
|
||
function IsRuntimeAppDataItem(const FileName: String): Boolean;
|
||
begin
|
||
Result :=
|
||
(CompareText(FileName, 'msime.db-wal') = 0) or
|
||
(CompareText(FileName, 'msime.db-shm') = 0) or
|
||
(CompareText(FileName, 'msime.db-journal') = 0) or
|
||
(CompareText(FileName, 'english.db-wal') = 0) or
|
||
(CompareText(FileName, 'english.db-shm') = 0) or
|
||
(CompareText(FileName, 'english.db-journal') = 0) or
|
||
(CompareText(FileName, 'others.db-wal') = 0) or
|
||
(CompareText(FileName, 'others.db-shm') = 0) or
|
||
(CompareText(FileName, 'others.db-journal') = 0) or
|
||
(CompareText(FileName, 'user_dict.dat') = 0) or
|
||
(CompareText(FileName, 'clipboard_history.json') = 0) or
|
||
(CompareText(FileName, 'voice_system_audio_mute_state.txt') = 0) or
|
||
(CompareText(FileName, 'config.toml.tmp') = 0) or
|
||
(CompareText(FileName, 'config.base.toml.tmp') = 0) or
|
||
(CompareText(Copy(FileName, 1, 20), 'config.toml.corrupt-') = 0) or
|
||
(CompareText(FileName, '.ime-write-probe') = 0) or
|
||
(CompareText(FileName, 'msime-write-probe.tmp') = 0) or
|
||
(CompareText(FileName, 'logs') = 0);
|
||
end;
|
||
|
||
{ 数据目录不整个归我们时,只有这些名字允许删(#537)。assets、html、logs 这种通用目录名
|
||
由 DeleteKnownAppDataDir 只删输入法自己写进去的部分。log\、webview2\ 等只会出现在
|
||
历史默认位置,那里整个目录归我们,不必列。}
|
||
{ 旧版随包装进数据目录根下、现已不再分发的条目。留在名单里,覆盖安装与卸载才会继续清掉它们。
|
||
神经整句效果档模型改为设置页按需下载到 models\neural-desktop\(归 IsCollocationModelDirectory 保留)。
|
||
html\webview2\ 是旧版 WebView2 界面的页面,界面改由 Direct2D 绘制后不再分发;设置窗口
|
||
用到的图片改装在 assets\settings\。}
|
||
function IsRetiredAppDataItem(const FileName: String): Boolean;
|
||
begin
|
||
Result :=
|
||
(CompareText(FileName, 'sentence-model-desktop.safetensors') = 0) or
|
||
(CompareText(FileName, 'html') = 0);
|
||
end;
|
||
|
||
function IsKnownAppDataItem(const FileName: String): Boolean;
|
||
begin
|
||
Result := IsShippedAppDataItem(FileName) or IsRuntimeAppDataItem(FileName) or IsRetiredAppDataItem(FileName);
|
||
end;
|
||
|
||
{ 0.9.x 把效果档模型随包装在数据目录根下,现在 Server 只认 models\neural-desktop\。根下那份和
|
||
设置页下载的是同一个 model-v1 发布文件,在清理把它当退役文件删掉之前挪过去,升级用户就不用重下
|
||
(下载走 GitHub,不少用户连不上)。校验和对不上、或新位置已有文件时什么都不做,留给清理删。}
|
||
procedure AdoptRetiredDesktopModel(const ExpectedSha256: String);
|
||
var
|
||
OldPath, NewDir, NewPath: String;
|
||
begin
|
||
OldPath := AddBackslash(GetDataDir('')) + 'sentence-model-desktop.safetensors';
|
||
NewDir := AddBackslash(GetDataDir('')) + 'models\neural-desktop';
|
||
NewPath := AddBackslash(NewDir) + 'sentence-model-desktop.safetensors';
|
||
if (not FileExists(OldPath)) or FileExists(NewPath) then
|
||
exit;
|
||
try
|
||
if CompareText(GetSHA256OfFile(OldPath), ExpectedSha256) <> 0 then
|
||
begin
|
||
Log('Retired desktop model does not match model-v1; leaving it to cleanup.');
|
||
exit;
|
||
end;
|
||
except
|
||
Log('Cannot hash retired desktop model: ' + GetExceptionMessage);
|
||
exit;
|
||
end;
|
||
if ForceDirectories(NewDir) and RenameFile(OldPath, NewPath) then
|
||
Log('Moved retired desktop model to ' + NewPath)
|
||
else
|
||
Log('Could not move retired desktop model to ' + NewPath);
|
||
end;
|
||
|
||
function InitializeUninstall(): Boolean;
|
||
begin
|
||
RegQueryStringValue(
|
||
HKLM,
|
||
'Software\Metasequoia\MetasequoiaIME',
|
||
'VersionDir',
|
||
VersionDirName
|
||
);
|
||
{ uninsdeletevalue 会在卸载过程中删掉 DataDir,所以要先读出来缓存住。}
|
||
ResolvePreviousDataDir;
|
||
Result := True;
|
||
end;
|
||
|
||
procedure StopProcess(const ImageName: String);
|
||
var
|
||
ResultCode: Integer;
|
||
begin
|
||
Exec(
|
||
ExpandConstant('{sys}\taskkill.exe'),
|
||
'/F /T /IM "' + ImageName + '"',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
);
|
||
end;
|
||
|
||
procedure StopSettingsProcess;
|
||
var
|
||
SettingsWindow: HWND;
|
||
WaitedMs: Integer;
|
||
begin
|
||
// 设置窗口关闭后可能只是隐藏,进程按 appearance.settings_window_linger 继续驻留。
|
||
// 覆盖安装要删掉整个 server 目录,所以这里必须把它请走:先投 kQuitSettings
|
||
// 让它自己收尾(Server 已被强杀,没人替我们发这条消息了),再强杀兜底。
|
||
SettingsWindow := FindWindowByClassName('{#MySettingsWindowClass}');
|
||
if SettingsWindow <> 0 then
|
||
begin
|
||
PostMessage(SettingsWindow, {#MySettingsQuitMessage}, 0, 0);
|
||
WaitedMs := 0;
|
||
while (WaitedMs < 3000) and
|
||
(FindWindowByClassName('{#MySettingsWindowClass}') <> 0) do
|
||
begin
|
||
Sleep(100);
|
||
WaitedMs := WaitedMs + 100;
|
||
end;
|
||
end;
|
||
StopProcess('{#MySettingsExeName}');
|
||
end;
|
||
|
||
procedure StopImeProcesses;
|
||
begin
|
||
{ Watchdog 先停,否则它会在我们删文件期间把 Server 拉起来。}
|
||
StopProcess('{#MyWatchdogName}');
|
||
StopProcess('{#MyAppExeName}');
|
||
StopSettingsProcess;
|
||
{ 面板通常是 Server 的子进程、随 /T 一起走;Server 若已崩溃它们会变成孤儿,
|
||
同样占着 server 目录里的 exe,所以显式再收一遍。}
|
||
StopProcess('{#MyEmojiPanelExeName}');
|
||
StopProcess('{#MyKeyboardPanelExeName}');
|
||
StopProcess('{#MyHandwritingPanelExeName}');
|
||
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 EnsureImeUserDataDir;
|
||
var
|
||
AppDataPath: String;
|
||
ResultCode: Integer;
|
||
begin
|
||
// Elevated setup writes {localappdata} as high integrity. Medium-IL Server
|
||
// and Settings cannot replace those files. Users who never rewrote config.toml
|
||
// at the real path (the non-ASCII path bug) keep that leftover and cannot save.
|
||
// Note: brace comments do not nest in Inno Setup, so a constant like the one
|
||
// above would close a { } comment early -- keep these as line comments.
|
||
// Also required when the data directory sits on another volume: files the elevated setup
|
||
// copied there inherit the parent's ACL, which may not let a Medium-IL Server write them.
|
||
AppDataPath := GetDataDir('');
|
||
ForceDirectories(AppDataPath);
|
||
Exec(
|
||
ExpandConstant('{sys}\icacls.exe'),
|
||
'"' + AppDataPath + '" /grant *S-1-5-32-545:(OI)(CI)M /T /C /Q',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
);
|
||
Exec(
|
||
ExpandConstant('{sys}\icacls.exe'),
|
||
'"' + AppDataPath + '" /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;
|
||
|
||
{ 不独占的数据目录里删一个名单上的子目录。名字通用的几个只删我们写进去的内容,
|
||
目录空了才删掉它:用户自己恰好也有一个 assets、html、logs 或 skins 文件夹时不能跟着没了。
|
||
skins 里分不清哪些是外部皮肤、哪些是别人的东西,宁可留下。}
|
||
procedure DeleteKnownAppDataDir(const ItemPath, FileName: String);
|
||
begin
|
||
if CompareText(FileName, 'skins') = 0 then
|
||
begin
|
||
{ 只删空目录。}
|
||
end
|
||
else if CompareText(FileName, 'assets') = 0 then
|
||
TryDeleteTree(AddBackslash(ItemPath) + 'settings')
|
||
else if CompareText(FileName, 'html') = 0 then
|
||
{ 旧版的 WebView2 页面,见 IsRetiredAppDataItem。}
|
||
TryDeleteTree(AddBackslash(ItemPath) + 'webview2')
|
||
else if CompareText(FileName, 'logs') = 0 then
|
||
begin
|
||
DeleteFile(AddBackslash(ItemPath) + '水杉IME诊断日志.log');
|
||
DeleteFile(AddBackslash(ItemPath) + '水杉IME诊断日志.log.1');
|
||
end
|
||
else
|
||
begin
|
||
TryDeleteTree(ItemPath);
|
||
exit;
|
||
end;
|
||
RemoveDir(ItemPath);
|
||
end;
|
||
|
||
{ 卸载、换数据目录时删掉旧数据目录。整个目录归我们才整目录删;只有旧版标记的目录里
|
||
可能混着用户自己的文件(#537),只删名单里输入法的东西,目录空了才顺手删掉。
|
||
两样都不是就一个文件也不碰。}
|
||
procedure RemoveDataDir(const Directory: String);
|
||
var
|
||
FindRec: TFindRec;
|
||
ItemPath: String;
|
||
begin
|
||
if (Directory = '') or (not DirExists(Directory)) then
|
||
exit;
|
||
if OwnsDataDirExclusively(Directory) then
|
||
begin
|
||
TryDeleteTree(Directory);
|
||
exit;
|
||
end;
|
||
if not DataDirHasMarker(Directory) then
|
||
exit;
|
||
|
||
Log('Data directory ' + Directory + ' is not exclusively ours; removing known items only.');
|
||
if FindFirst(AddBackslash(Directory) + '*', FindRec) then
|
||
begin
|
||
try
|
||
repeat
|
||
if
|
||
(FindRec.Name <> '.') and
|
||
(FindRec.Name <> '..') and
|
||
(IsPreservedAppDataItem(FindRec.Name) or IsKnownAppDataItem(FindRec.Name))
|
||
then
|
||
begin
|
||
ItemPath := AddBackslash(Directory) + FindRec.Name;
|
||
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0 then
|
||
DeleteKnownAppDataDir(ItemPath, FindRec.Name)
|
||
else
|
||
DeleteFile(ItemPath);
|
||
end;
|
||
until not FindNext(FindRec);
|
||
finally
|
||
FindClose(FindRec);
|
||
end;
|
||
end;
|
||
{ 只删得掉空目录;用户的文件还在,目录就留着。}
|
||
RemoveDir(Directory);
|
||
end;
|
||
|
||
{ 旧版 WebView2 的用户数据(浏览器缓存、Cookie 等)。界面改由 Direct2D 绘制后不再有人
|
||
读写它们,覆盖安装和卸载都清掉。正常情况下在 ProgramData 下;那里不可写时旧版 Server
|
||
退到历史默认数据目录下的同名子目录,那个目录整个归我们,按名字删是安全的。}
|
||
procedure RemoveLegacyWebView2UserData;
|
||
begin
|
||
TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime\webview2'));
|
||
TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime\webview2-settings'));
|
||
RemoveDir(ExpandConstant('{commonappdata}\metasequoiaime'));
|
||
TryDeleteTree(ExpandConstant('{localappdata}\metasequoiaime\webview2'));
|
||
TryDeleteTree(ExpandConstant('{localappdata}\metasequoiaime\webview2-settings'));
|
||
end;
|
||
|
||
#ifdef LightPackage
|
||
{ 轻量包只替换数据目录里的个别子目录。整个目录归我们时整个删;只有旧版标记时(#537)
|
||
按 DeleteKnownAppDataDir 的规则只删输入法自己写进去的部分。}
|
||
procedure CleanLightPackageItem(const FileName: String);
|
||
var
|
||
AppDataPath: String;
|
||
begin
|
||
AppDataPath := GetDataDir('');
|
||
if OwnsDataDirExclusively(AppDataPath) then
|
||
TryDeleteTree(AddBackslash(AppDataPath) + FileName)
|
||
else if DataDirHasMarker(AppDataPath) then
|
||
DeleteKnownAppDataDir(AddBackslash(AppDataPath) + FileName, FileName);
|
||
end;
|
||
#endif
|
||
|
||
{ helpcodes 里的内置表由本次安装重写,custom 子目录是用户自己放的辅助码,升级不得清掉。}
|
||
procedure CleanHelpcodesExceptCustom(const HelpcodesPath: String);
|
||
var
|
||
FindRec: TFindRec;
|
||
ItemPath: String;
|
||
begin
|
||
if FindFirst(AddBackslash(HelpcodesPath) + '*', FindRec) then
|
||
begin
|
||
try
|
||
repeat
|
||
if
|
||
(FindRec.Name <> '.') and
|
||
(FindRec.Name <> '..') and
|
||
(CompareText(FindRec.Name, 'custom') <> 0)
|
||
then
|
||
begin
|
||
ItemPath := AddBackslash(HelpcodesPath) + 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;
|
||
|
||
procedure CleanAppDataExceptUserFiles;
|
||
var
|
||
Exclusive: Boolean;
|
||
AppDataPath: String;
|
||
FindRec: TFindRec;
|
||
ItemPath: String;
|
||
begin
|
||
AppDataPath := GetDataDir('');
|
||
if not DirExists(AppDataPath) then
|
||
exit;
|
||
{ 整个目录归我们时清掉除用户数据以外的一切;旧版安装器可能把标记写进了一个本来就有
|
||
别的文件的文件夹(#537),那里只动名单里输入法自己的东西。}
|
||
Exclusive := OwnsDataDirExclusively(AppDataPath);
|
||
if (not Exclusive) and (not DataDirHasMarker(AppDataPath)) then
|
||
exit;
|
||
|
||
if FindFirst(AddBackslash(AppDataPath) + '*', FindRec) then
|
||
begin
|
||
try
|
||
repeat
|
||
if
|
||
(FindRec.Name <> '.') and
|
||
(FindRec.Name <> '..') and
|
||
(not IsPreservedAppDataItem(FindRec.Name)) and
|
||
(Exclusive or IsKnownAppDataItem(FindRec.Name))
|
||
then
|
||
begin
|
||
ItemPath := AddBackslash(AppDataPath) + FindRec.Name;
|
||
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) = 0 then
|
||
DeleteFile(ItemPath)
|
||
else if CompareText(FindRec.Name, 'helpcodes') = 0 then
|
||
CleanHelpcodesExceptCustom(ItemPath)
|
||
else if Exclusive then
|
||
TryDeleteTree(ItemPath)
|
||
else
|
||
DeleteKnownAppDataDir(ItemPath, FindRec.Name);
|
||
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 := GetDataDir('');
|
||
{ 先删 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 := GetDataDir('');
|
||
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;
|
||
|
||
{ 用户改了数据目录:把上一处的用户数据搬过来。只搬真正属于用户、装不回来的东西——
|
||
词库主体、界面资源和辅助码都会由本次安装重新写入新目录。
|
||
用 robocopy 而不是 RenameFile:跨盘移动目录时 MoveFile 会直接失败。}
|
||
function MigrateUserDataDir(const OldDir, NewDir: String): String;
|
||
var
|
||
ResultCode: Integer;
|
||
Moved: Boolean;
|
||
begin
|
||
Result := '';
|
||
if (OldDir = '') or (CompareText(OldDir, NewDir) = 0) or (not DirExists(OldDir)) then
|
||
exit;
|
||
|
||
Result := ConfigMigrationProblem(OldDir, NewDir);
|
||
if Result <> '' then
|
||
exit;
|
||
|
||
Log('Migrating user data from ' + OldDir + ' to ' + NewDir);
|
||
ForceDirectories(NewDir);
|
||
DropMigratedConfigCopy(OldDir, NewDir);
|
||
|
||
Moved := Exec(
|
||
ExpandConstant('{sys}\robocopy.exe'),
|
||
'"' + RemoveBackslashUnlessRoot(OldDir) + '" "' + RemoveBackslashUnlessRoot(NewDir) + '" ' +
|
||
'msime_user.db msime_user.db-wal msime_user.db-shm msime_user.db-journal ' +
|
||
'stats.db stats.db-wal stats.db-shm stats.db-journal ' +
|
||
'config.toml config.base.toml custom_translations.txt /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
) and (ResultCode < 8);
|
||
|
||
{ 外部皮肤是用户自己放进来的,同样搬走。}
|
||
if DirExists(AddBackslash(OldDir) + 'skins') then
|
||
Moved :=
|
||
Exec(
|
||
ExpandConstant('{sys}\robocopy.exe'),
|
||
'"' + AddBackslash(OldDir) + 'skins" "' + AddBackslash(NewDir) + 'skins" ' +
|
||
'/E /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
) and (ResultCode < 8) and Moved;
|
||
|
||
{ 自定义辅助码也是用户自己放进来的。}
|
||
if DirExists(AddBackslash(OldDir) + 'helpcodes\custom') then
|
||
Moved :=
|
||
Exec(
|
||
ExpandConstant('{sys}\robocopy.exe'),
|
||
'"' + AddBackslash(OldDir) + 'helpcodes\custom" "' + AddBackslash(NewDir) + 'helpcodes\custom" ' +
|
||
'/E /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
) and (ResultCode < 8) and Moved;
|
||
|
||
{ 自定义双拼方案同理。}
|
||
if DirExists(AddBackslash(OldDir) + 'shuangpin') then
|
||
Moved :=
|
||
Exec(
|
||
ExpandConstant('{sys}\robocopy.exe'),
|
||
'"' + AddBackslash(OldDir) + 'shuangpin" "' + AddBackslash(NewDir) + 'shuangpin" ' +
|
||
'/E /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
) and (ResultCode < 8) and Moved;
|
||
|
||
{ 下载的 octagram 语法模型包也搬走,否则留在旧目录里随 RemoveDataDir 一起删掉,用户得重下。}
|
||
if DirExists(AddBackslash(OldDir) + 'models') then
|
||
Moved :=
|
||
Exec(
|
||
ExpandConstant('{sys}\robocopy.exe'),
|
||
'"' + AddBackslash(OldDir) + 'models" "' + AddBackslash(NewDir) + 'models" ' +
|
||
'/E /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL',
|
||
'',
|
||
SW_HIDE,
|
||
ewWaitUntilTerminated,
|
||
ResultCode
|
||
) and (ResultCode < 8) and Moved;
|
||
|
||
if not Moved then
|
||
begin
|
||
Log('User data migration reported failures; leaving ' + OldDir + ' in place.');
|
||
{ 用户词库没搬成必须让安装失败。否则新版本会对着一个空的 msime_user.db 启动,
|
||
ssPostInstall 的回放只会记一句 skipped,用户的自造词和词频就这么静悄悄没了——
|
||
这正是 ReplayUserDictionary 失败时也要中止安装的那条理由。
|
||
config.toml 同理,见 ConfigMigrationFailure;custom_translations.txt 没有出厂副本,
|
||
见 TranslationMigrationFailure。皮肤等搬不动只记日志:它们丢了可以重建。}
|
||
if FileExists(AddBackslash(OldDir) + 'msime_user.db') then
|
||
Result :=
|
||
'无法把用户词库从 ' + OldDir + ' 移动到 ' + NewDir + '。' + #13#10 +
|
||
'请确认输入法相关进程已全部退出、目标磁盘可写且空间足够,然后重试安装。' + #13#10 +
|
||
'你的用户词库仍留在 ' + OldDir + ',本次安装没有改动它。'
|
||
else
|
||
begin
|
||
Result := ConfigMigrationFailure(OldDir, NewDir);
|
||
if Result = '' then
|
||
Result := TranslationMigrationFailure(OldDir, NewDir);
|
||
end;
|
||
exit;
|
||
end;
|
||
|
||
{ 旧目录里剩下的是可重建的资源(词库、界面资源、cache 等)。}
|
||
RemoveDataDir(OldDir);
|
||
end;
|
||
|
||
function CountFilesUnder(const Directory: String): Integer;
|
||
var
|
||
FindRec: TFindRec;
|
||
begin
|
||
Result := 0;
|
||
if FindFirst(AddBackslash(Directory) + '*', FindRec) then
|
||
begin
|
||
try
|
||
repeat
|
||
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
|
||
begin
|
||
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0 then
|
||
Result := Result + CountFilesUnder(AddBackslash(Directory) + FindRec.Name)
|
||
else
|
||
Result := Result + 1;
|
||
end;
|
||
until not FindNext(FindRec);
|
||
finally
|
||
FindClose(FindRec);
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
{ 数据目录里的同名皮肤与随包的文件集合、内容完全一致时返回 True。
|
||
清单每行「id|相对路径|sha256」,由 Prepare-PackageFiles.ps1 按 id 分组写出。}
|
||
function SkinMatchesBundle(const SkinDir, Id: String; const Lines: TArrayOfString): Boolean;
|
||
var
|
||
Index, P, Expected: Integer;
|
||
Rest, FilePath: String;
|
||
begin
|
||
Result := False;
|
||
Expected := 0;
|
||
for Index := 0 to GetArrayLength(Lines) - 1 do
|
||
begin
|
||
P := Pos('|', Lines[Index]);
|
||
if (P = 0) or (Copy(Lines[Index], 1, P - 1) <> Id) then
|
||
continue;
|
||
Rest := Copy(Lines[Index], P + 1, Length(Lines[Index]));
|
||
P := Pos('|', Rest);
|
||
if P = 0 then
|
||
exit;
|
||
Expected := Expected + 1;
|
||
FilePath := AddBackslash(SkinDir) + Copy(Rest, 1, P - 1);
|
||
if not FileExists(FilePath) then
|
||
exit;
|
||
try
|
||
if CompareText(GetSHA256OfFile(FilePath), Copy(Rest, P + 1, Length(Rest))) <> 0 then
|
||
exit;
|
||
except
|
||
exit;
|
||
end;
|
||
end;
|
||
{ 多出来的文件也算改过:整目录覆盖不会删它们,留着就和随包的不是一个皮肤了。}
|
||
Result := CountFilesUnder(SkinDir) = Expected;
|
||
end;
|
||
|
||
{ 覆盖随包皮肤之前,把用户机器上内容不同的同名目录改名成 <id>.bak;
|
||
已经有 .bak 就用 <id>.2.bak、<id>.3.bak……,不覆盖上一次的备份——那可能正是用户改过的版本。
|
||
内容一样的不备份,否则每次升级都会多一份一模一样的 .bak。Server 扫描皮肤时跳过 .bak 目录。}
|
||
function BackupChangedBundledSkins: String;
|
||
var
|
||
Lines: TArrayOfString;
|
||
Index, P, N: Integer;
|
||
Id, LastId, SkinsDir, SkinDir, BackupDir: String;
|
||
begin
|
||
Result := '';
|
||
ExtractTemporaryFile('bundled_skins.manifest');
|
||
if not LoadStringsFromFile(ExpandConstant('{tmp}\bundled_skins.manifest'), Lines) then
|
||
begin
|
||
Result := '无法读取随包皮肤清单,安装文件可能不完整。';
|
||
exit;
|
||
end;
|
||
SkinsDir := AddBackslash(GetDataDir('')) + 'skins';
|
||
LastId := '';
|
||
for Index := 0 to GetArrayLength(Lines) - 1 do
|
||
begin
|
||
P := Pos('|', Lines[Index]);
|
||
if P = 0 then
|
||
continue;
|
||
Id := Copy(Lines[Index], 1, P - 1);
|
||
if Id = LastId then
|
||
continue;
|
||
LastId := Id;
|
||
SkinDir := AddBackslash(SkinsDir) + Id;
|
||
if not DirExists(SkinDir) then
|
||
continue;
|
||
if SkinMatchesBundle(SkinDir, Id, Lines) then
|
||
begin
|
||
Log('Bundled skin ' + Id + ' is unchanged; no backup needed.');
|
||
continue;
|
||
end;
|
||
BackupDir := SkinDir + '.bak';
|
||
N := 2;
|
||
while DirExists(BackupDir) or FileExists(BackupDir) do
|
||
begin
|
||
BackupDir := AddBackslash(SkinsDir) + Id + '.' + IntToStr(N) + '.bak';
|
||
N := N + 1;
|
||
end;
|
||
if not RenameFile(SkinDir, BackupDir) then
|
||
begin
|
||
Result :=
|
||
'无法备份皮肤目录 ' + SkinDir + '。' + #13#10 +
|
||
'请关闭正在打开该目录或其中图片的程序后重试安装。';
|
||
exit;
|
||
end;
|
||
Log('Backed up skin ' + SkinDir + ' to ' + BackupDir);
|
||
end;
|
||
end;
|
||
|
||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||
var
|
||
DataDirProblem: String;
|
||
#ifndef LightPackage
|
||
FailedPath: String;
|
||
#endif
|
||
begin
|
||
{ 静默安装不会走向导页,/DATADIR= 传进来的值在这里才第一次被检查,
|
||
有别的文件时同样改用子目录。向导里选的值已经换过一次,这里再换是空操作。}
|
||
DataDirValue := AdoptableDataDir(GetDataDir(''));
|
||
DataDirProblem := DataDirRejectionReason(GetDataDir(''));
|
||
if DataDirProblem <> '' then
|
||
begin
|
||
Result := '数据目录 ' + GetDataDir('') + ' 不可用:' + DataDirProblem;
|
||
exit;
|
||
end;
|
||
#ifndef LightPackage
|
||
{ 静默安装也必须在最终目录确定后、配置迁移前检查。}
|
||
UserConfigExistedBeforeInstall := UserConfigExistsBeforeInstall;
|
||
#endif
|
||
{ 迁移会往目录里搬文件,所以要在那之前记下它是不是空着被接手的。}
|
||
DataDirAdoptedEmpty :=
|
||
OwnsDataDirExclusively(GetDataDir('')) or DirectoryIsEmpty(GetDataDir(''));
|
||
|
||
DataDirProblem := ConfigMigrationProblem(ResolvePreviousDataDir, GetDataDir(''));
|
||
if DataDirProblem <> '' then
|
||
begin
|
||
Result := DataDirProblem;
|
||
exit;
|
||
end;
|
||
|
||
{ 先锁定本次目录名,再清理能够释放的旧版本 DLL。}
|
||
VersionDirName := GetVersionDir('');
|
||
StopImeProcesses;
|
||
{ 进程停了才能动数据文件:msime_user.db 会被 Server 打开着。}
|
||
DataDirProblem := MigrateUserDataDir(ResolvePreviousDataDir, GetDataDir(''));
|
||
if DataDirProblem <> '' then
|
||
begin
|
||
Result := DataDirProblem;
|
||
exit;
|
||
end;
|
||
{ 迁移之后再备份:换了数据目录时,要比对的是已经搬到新目录的皮肤。}
|
||
DataDirProblem := BackupChangedBundledSkins;
|
||
if DataDirProblem <> '' then
|
||
begin
|
||
Result := DataDirProblem;
|
||
exit;
|
||
end;
|
||
{ 轻量包不清根目录,但新 Server 同样只认 models\neural-desktop\,所以两种包都要挪。}
|
||
AdoptRetiredDesktopModel('0a6ecba69bf1d39c7eb49549c716477dd6c03fdf05757773e1f50435262fb469');
|
||
#ifdef LightPackage
|
||
{ 轻量包不替换词库:只清设置窗口图片资源和 Server/TSF,保留本机 msime.db 等。
|
||
旧版留下的 html(WebView2 页面)也一并清掉。}
|
||
CleanLightPackageItem('assets');
|
||
CleanLightPackageItem('html');
|
||
#else
|
||
{ 不能让旧 WAL/SHM 与即将复制的新主数据库混用。}
|
||
if not RemoveOldTargetDatabaseFiles(FailedPath) then
|
||
begin
|
||
Result :=
|
||
'无法删除旧词库文件:' + FailedPath + #13#10 +
|
||
'它可能仍被输入法相关进程占用。请关闭相关程序后重试安装。';
|
||
exit;
|
||
end;
|
||
{ 目标词库已安全移除,再清理旧的随包资源。用户配置和外部皮肤目录在这里保留。}
|
||
CleanAppDataExceptUserFiles;
|
||
#endif
|
||
RemoveLegacyWebView2UserData;
|
||
TryDeleteTree(ExpandConstant(
|
||
'{commonpf64}\metasequoiaime\server'));
|
||
TryDeleteOldVersionDirs(TsfRoot32);
|
||
TryDeleteOldVersionDirs(TsfRoot64);
|
||
{ 旧版把 TSF DLL 装在 Program Files 下,升级上来时一并清掉。}
|
||
TryDeleteOldVersionDirs(ExpandConstant(
|
||
'{commonpf32}\metasequoiaime'));
|
||
TryDeleteOldVersionDirs(ExpandConstant(
|
||
'{commonpf64}\metasequoiaime'));
|
||
DeleteFile(ExpandConstant('{commonpf32}\metasequoiaime\MetasequoiaImeTip.ico'));
|
||
DeleteFile(ExpandConstant('{commonpf64}\metasequoiaime\MetasequoiaImeTip.ico'));
|
||
RemoveDir(ExpandConstant('{commonpf32}\metasequoiaime'));
|
||
{ 随后的 [Files] 与 ssPostInstall 会写入新 Server 和登录任务。}
|
||
Result := '';
|
||
end;
|
||
|
||
procedure CurStepChanged(CurStep: TSetupStep);
|
||
begin
|
||
if CurStep = ssPostInstall then
|
||
begin
|
||
{ 先打标记,再调整权限:之后的覆盖安装和卸载靠它判断这个目录是不是我们建的。}
|
||
WriteDataDirMarker(GetDataDir(''), DataDirAdoptedEmpty);
|
||
#ifndef LightPackage
|
||
ReplayUserDictionary;
|
||
#endif
|
||
CreateWatchdogLogonTask;
|
||
EnsureImeUserDataDir;
|
||
{ 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'
|
||
);
|
||
#ifndef LightPackage
|
||
{ 偏好写入放在最后:它失败只弹提示,不能挡住上面这些必需步骤。}
|
||
ApplyNetworkChoiceToUserConfig;
|
||
#endif
|
||
end;
|
||
end;
|
||
|
||
{ 旧版设置页的「设为默认」写的是「替代默认输入法」。卸载后它仍指向水杉,系统设置里
|
||
就残留一个不存在的输入法;只清指向水杉的那一项,用户自己选的别的输入法不动。}
|
||
procedure ClearOurInputMethodOverride;
|
||
var
|
||
OverrideId: String;
|
||
begin
|
||
if RegQueryStringValue(HKCU, 'Control Panel\International\User Profile',
|
||
'InputMethodOverride', OverrideId) and
|
||
(CompareText(OverrideId,
|
||
'0804:{E3062E9A-D834-4637-8958-ED8CFA427D01}{4D59B1B4-D503-44AE-9259-BAD9BB2778AB}') = 0) then
|
||
RegDeleteValue(HKCU, 'Control Panel\International\User Profile',
|
||
'InputMethodOverride');
|
||
end;
|
||
|
||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||
begin
|
||
if CurUninstallStep = usUninstall then
|
||
begin
|
||
ClearOurInputMethodOverride;
|
||
{ 设置页「游戏兼容」记下的登记名称。升级时要留着(TSF DLL 注册时读它),卸载时不能留:
|
||
否则日后重装会悄悄沿用别名。}
|
||
RegDeleteValue(HKLM, 'Software\Metasequoia\MetasequoiaIME', 'GameCompatProfileName');
|
||
DeleteWatchdogLogonTask;
|
||
RegDeleteValue(
|
||
HKLM,
|
||
'Software\Microsoft\Windows\CurrentVersion\Run',
|
||
'MetasequoiaImeWatchdog'
|
||
);
|
||
StopImeProcesses;
|
||
end
|
||
else if CurUninstallStep = usPostUninstall then
|
||
begin
|
||
TryDeleteTree(ExpandConstant(
|
||
'{commonpf64}\metasequoiaime\server'));
|
||
{ 还被宿主进程加载着的 DLL 删不掉,会留到重启后由用户或下次安装清理。}
|
||
TryDeleteTree(TsfRoot32);
|
||
TryDeleteTree(TsfRoot64);
|
||
TryDeleteTree(ExpandConstant('{commonpf32}\metasequoiaime'));
|
||
TryDeleteTree(ExpandConstant('{commonpf64}\metasequoiaime'));
|
||
{ 数据目录可能被用户指到了别的盘,甚至指到一个本来就有文件的文件夹(#537)。}
|
||
RemoveDataDir(ResolvePreviousDataDir);
|
||
{ 旧版 WebView2 的用户数据,见 RemoveLegacyWebView2UserData。}
|
||
RemoveLegacyWebView2UserData;
|
||
{ 数据目录在别处时,历史默认位置里可能只剩上面删掉的那两样;还有别的东西就删不掉,原样留着。}
|
||
RemoveDir(ExpandConstant('{localappdata}\metasequoiaime'));
|
||
TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime'));
|
||
end;
|
||
end;
|