fix(installer): 覆盖安装清空输入统计库——保留与迁移两份名单补上 stats.db

完整包升级走 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。
This commit is contained in:
Jia Sui committed 2026-09-28 09:13:09 +08:00
1 parent 8a47c495d1
commit 0b11322dfd
3 files changed
+116

No files matched your search

+2
View File
@@ -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
+13
View File
@@ -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,
+101
View File
@@ -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)"