; 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.<日期>. 后缀(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 先把它改名成 .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\,是设置页按需下载的 约 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; { 覆盖随包皮肤之前,把用户机器上内容不同的同名目录改名成 .bak; 已经有 .bak 就用 .2.bak、.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;