From 0b11322dfdc12e40c8e9efd516ae42230f674230 Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Mon, 28 Sep 2026 09:13:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(installer):=20=E8=A6=86=E7=9B=96=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=B8=85=E7=A9=BA=E8=BE=93=E5=85=A5=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=BA=93=E2=80=94=E2=80=94=E4=BF=9D=E7=95=99=E4=B8=8E=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E4=B8=A4=E4=BB=BD=E5=90=8D=E5=8D=95=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=20stats.db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完整包升级走 CleanAppDataExceptUserFiles,它按 IsPreservedAppDataItem 白名单清空数据目录, 不在名单里的项一律删。名单只有 msime_user.db*、config.toml、config.base.toml、skins 和标记 文件,0.8.0 才落地的 stats.db(输入统计,ffcd1aee)从不在其中,于是每次覆盖安装都被 DeleteFile 删掉;Store::Open 固定 SQLITE_OPEN_CREATE,文件一没就立刻重建空库,用户看到的是 统计数字被清零而不是报错。换数据目录安装是同一条缝:MigrateUserDataDir 的 robocopy 清单同样 没有它,文件留在旧目录,随后随 TryDeleteTree(OldDir) 一起没了。 本次把 stats.db 及其三个 sidecar 同时补进覆盖安装保留名单和换数据目录迁移名单,并加一个 静态反解这两份清单的测试互相交叉校验。 - msime_setup.iss:新增 IsUserStatisticsDatabase(stats.db / -wal / -shm / -journal)并接入 IsPreservedAppDataItem;MigrateUserDataDir 的 robocopy 清单追加同样四个文件名。sidecar 必须 一起带:安装器先 StopImeProcesses 强杀 Server,WAL 里可能还有尚未 checkpoint 的写入, 只搬主库会丢掉最后一批数据。卸载路径不动,卸载语义本就是删光用户数据。 - installer/tests/preserve-user-data.ps1(新增):从 iss 源码反解保留名单与迁移名单,断言必需 名单逐项在两边、两份名单互为子集、Server 侧 stats_pipe.cpp 的库名也在两边,并设规模下限 防解析器自身失效造成的假绿。注册进 ci.yml 的 packaging job。 --- .github/workflows/ci.yml | 2 + installer/msime_setup.iss | 13 ++++ installer/tests/preserve-user-data.ps1 | 101 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 installer/tests/preserve-user-data.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950d1971..2fd9c244 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,6 +320,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Test full and light package contents without registering or installing run: ./installer/tests/package-files.ps1 + - name: Test that the installer preserves user data on upgrade + run: ./installer/tests/preserve-user-data.ps1 - name: Stop local packaging after failed component builds run: ./installer/tests/build-failures.ps1 - name: Test local Server test entrypoints in a moved checkout diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss index 03bcf199..441e0955 100644 --- a/installer/msime_setup.iss +++ b/installer/msime_setup.iss @@ -701,6 +701,17 @@ begin (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 是上次合并用的模板基线: @@ -722,6 +733,7 @@ begin 没有它的数据目录就不再被认作我们建的,后续的清理和卸载都会跳过。} Result := IsUserDatabaseFile(FileName) or + IsUserStatisticsDatabase(FileName) or IsUserConfigFile(FileName) or IsUserSkinDirectory(FileName) or (CompareText(FileName, DataDirMarkerName) = 0); @@ -1104,6 +1116,7 @@ begin 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 /MOVE /R:2 /W:1 /NJH /NJS /NP /NFL /NDL', '', SW_HIDE, diff --git a/installer/tests/preserve-user-data.ps1 b/installer/tests/preserve-user-data.ps1 new file mode 100644 index 00000000..d75b631d --- /dev/null +++ b/installer/tests/preserve-user-data.ps1 @@ -0,0 +1,101 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# 安装器在覆盖安装时按白名单清空数据目录,换数据目录时按另一份清单搬文件。两份清单都是 +# 纯手写列表,新增用户数据文件漏改任何一处都是静默丢数据:覆盖安装直接删掉,换目录则留在 +# 旧目录并随 TryDeleteTree 一起没了。Server 侧 `Store::Open` 用 SQLITE_OPEN_CREATE,被删后 +# 会立刻重建一个空库,用户看到的是"统计数据被清零"而不是报错。 +# 这里从 msime_setup.iss 源码反解两份清单,锁住它们的并集,也锁住 Server 侧库名。 +# 仓库没有跑 Inno Setup 的测试基建([Code] 段在安装器进程里才执行),所以只能静态校验清单; +# 见 .agents/notes/implemented/bug-fix/2026-09-28-installer-stats-db-preservation.md。 + +$issPath = Join-Path $PSScriptRoot '../msime_setup.iss' +$iss = [IO.File]::ReadAllText((Resolve-Path $issPath)) + +# 必须活过覆盖安装的用户数据。主库 + 三个 sidecar:安装器会强杀 Server,WAL 里可能还有尚未 +# checkpoint 的写入,只搬主库会丢掉最后一批。 +$required = @( + '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' +) + +# 目录类用户数据由 MigrateUserDataDir 单独 robocopy,迁移清单按文件解析取不到它们。 +$preservedDirectories = @('skins', 'helpcodes\custom') + +function Get-PascalBody([string]$Text, [string]$Signature) { + $start = [regex]::Match($Text, [regex]::Escape($Signature) + '\s*\(') + if (-not $start.Success) { throw "msime_setup.iss is missing: $Signature" } + $end = [regex]::Match($Text.Substring($start.Index), '(?m)^end;\s*$') + if (-not $end.Success) { throw "msime_setup.iss has an unterminated body: $Signature" } + return $Text.Substring($start.Index, $end.Index) +} + +function Get-PreservedItems([string]$Text) { + # IsPreservedAppDataItem 组合若干判定函数,名单散在它们各自体内:顺着调用关系收集, + # 免得有人把某个名字挪进新函数就被这里漏掉。 + $pending = [Collections.Generic.Stack[string]]::new() + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $items = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $pending.Push('function IsPreservedAppDataItem') + while ($pending.Count -gt 0) { + $name = $pending.Pop() + if (-not $seen.Add($name)) { continue } + $body = Get-PascalBody $Text $name + foreach ($call in [regex]::Matches($body, '\b(Is[A-Za-z0-9]+)\s*\(\s*FileName\s*\)')) { + $pending.Push("function $($call.Groups[1].Value)") + } + foreach ($literal in [regex]::Matches($body, "CompareText\(\s*FileName\s*,\s*'([^']+)'")) { + [void]$items.Add($literal.Groups[1].Value) + } + } + return $items +} + +function Get-MigratedFiles([string]$Text) { + # robocopy 的文件清单是 Pascal 字符串拼接。带双引号的是路径片段(含旧目录根、旧新子目录 + # 拼接),以 / 开头的是 robocopy 开关,没有扩展名的是日志文本——三类都剔掉,剩下的才是 + # 被搬走的文件。要求扩展名同时把 skins、helpcodes\custom 这类目录排除在外。 + $body = Get-PascalBody $Text 'function MigrateUserDataDir' + $files = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($literal in [regex]::Matches($body, "'([^']*)'")) { + $value = $literal.Groups[1].Value + if ($value.Contains('"')) { continue } + foreach ($token in ($value -split '\s+')) { + if ($token -match '^[\w.\-]+\.[A-Za-z0-9\-]+$') { [void]$files.Add($token) } + } + } + return $files +} + +$preserved = Get-PreservedItems $iss +$migrated = Get-MigratedFiles $iss + +if ($preserved.Count -lt $required.Count) { + throw "Preservation list collapsed to $($preserved.Count) items; the parser is broken, not the installer" +} + +foreach ($name in $required) { + if (-not $preserved.Contains($name)) { throw "覆盖安装会删除用户数据:$name 不在 IsPreservedAppDataItem 名单里" } + if (-not $migrated.Contains($name)) { throw "换数据目录会丢失用户数据:$name 不在 MigrateUserDataDir 的 robocopy 清单里" } +} + +# 两份清单必须同步:白名单里保住的文件,换目录时也得搬走,反之亦然。这正是 stats.db 当初 +# 两边都没有的原因,而两边各写一遍、只能靠人记,所以在这里交叉断言。 +foreach ($name in $preserved) { + if ($preservedDirectories -contains $name) { continue } + if (-not $migrated.Contains($name)) { throw "清单不同步:$name 覆盖安装时保留,换数据目录时却不会被搬走" } +} +foreach ($name in $migrated) { + if ($preservedDirectories -contains $name) { continue } + if (-not $preserved.Contains($name)) { throw "清单不同步:$name 换数据目录时会搬走,覆盖安装时却会被删掉" } +} + +# Server 侧库名是这份清单的源头。改了 stats.db 之类的名字而不同步安装器,升级又会清零一次。 +$statsSource = Join-Path $PSScriptRoot '../../server/src/statistics/stats_pipe.cpp' +$storeName = [regex]::Match([IO.File]::ReadAllText($statsSource), 'L"([\w.-]+\.db)"').Groups[1].Value +if (-not $storeName) { throw "stats_pipe.cpp no longer names a database file; update this test and the installer" } +if (-not $preserved.Contains($storeName)) { throw "统计库 $storeName 不在 IsPreservedAppDataItem 名单里,覆盖安装会清空统计数据" } +if (-not $migrated.Contains($storeName)) { throw "统计库 $storeName 不在 MigrateUserDataDir 的 robocopy 清单里,换数据目录会丢统计数据" } + +Write-Host "Installer user-data preservation lists are in sync ($($preserved.Count) preserved, $($migrated.Count) migrated)"