diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59280e01..190901cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,6 +347,8 @@ jobs: run: ./installer/tests/package-files.ps1 - name: Test that the installer preserves user data on upgrade run: ./installer/tests/preserve-user-data.ps1 + - name: Test that bundled skins back up changed user copies before overwriting + run: ./installer/tests/bundled-skins.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/AGENTS.md b/AGENTS.md index ce87ac9d..69726b1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Windows 端的全部一方源码在本仓。**合仓改变的是仓库数量, | `ui-html/` | 候选窗、工具栏、菜单、设置页的 HTML / CSS / JS | [ui-html/README.md](ui-html/README.md) | | `installer/` | 收集产物、自签名、Inno Setup 打包 | [installer/README.md](installer/README.md) | | `log/` | 日志采集库 | — | -| `skins/` | 外部皮肤合集,每个子目录一个 `skin.toml` 皮肤包;不打进安装包 | [skins/README.md](skins/README.md) | +| `skins/` | 外部皮肤合集,每个子目录一个 `skin.toml` 皮肤包;除素材授权未核实的外随安装包装到数据目录,同名且改过的旧版改名 `.bak` 保留 | [skins/README.md](skins/README.md) | | `examples/skin-examples/` | 外部皮肤制作样例、字段说明、预览页与安装脚本;本目录单独采用 MIT 授权 | [examples/skin-examples/README.md](examples/skin-examples/README.md) | | `experiments/tsf-edit-control/` | TSF 编辑控件实验工程,不参与产品构建 | — | | `vendor/` | submodule:`opencc`、`cpp-pinyin` | 上游仓库 | diff --git a/installer/.gitignore b/installer/.gitignore index ee997b00..ede6251c 100644 --- a/installer/.gitignore +++ b/installer/.gitignore @@ -5,6 +5,8 @@ server_exe/ tsf_dll/ app_data/ default_skins/ +bundled_skins/ +bundled_skins.manifest MetasequoiaIME.ico /THIRD_PARTY_NOTICES.txt /LICENSE.txt diff --git a/installer/Prepare-PackageFiles.ps1 b/installer/Prepare-PackageFiles.ps1 index 6bfb6635..4889ca96 100644 --- a/installer/Prepare-PackageFiles.ps1 +++ b/installer/Prepare-PackageFiles.ps1 @@ -64,6 +64,7 @@ $tsf32Pdb = Join-Path $RepoRoot (Join-Path $TsfDirectory 'build32-release\Releas $tsf64Pdb = Join-Path $RepoRoot (Join-Path $TsfDirectory 'build64-release\Release\MetasequoiaImeTsf.pdb') $webviewRoot = Join-Path $RepoRoot (Join-Path $UiHtmlDirectory 'webview2') $defaultSkinsSource = Join-Path $RepoRoot 'skins\default' +$bundledSkinsSource = Join-Path $RepoRoot 'skins' $serverConfig = Join-Path $RepoRoot (Join-Path $ServerDirectory 'assets\config\config.toml') $factoryConfig = Join-Path $PSScriptRoot 'default_config\config.default.toml' $pinyinTable = Join-Path $RepoRoot (Join-Path $ServerDirectory 'assets\tables\pinyin.txt') @@ -243,6 +244,50 @@ $targetDefaultSkins = Join-Path $PSScriptRoot 'default_skins' Reset-Directory -LiteralPath $targetDefaultSkins Copy-DirectoryContents -Source $defaultSkinsSource -Destination $targetDefaultSkins +# 仓库 skins\ 下的外部皮肤随包装到数据目录的 skins\,同名的直接覆盖。覆盖前安装程序拿 +# bundled_skins.manifest 核对用户机器上的同名目录:内容与随包的一模一样就不动,否则先改名成 +# .bak 备份(Server 扫描时跳过 .bak 目录)。清单每行是「id|相对路径|sha256」, +# 安装程序按字节比较,所以路径限定 ASCII、不能含 |。 +# 素材授权没核实的皮肤([license] assets = "UNVERIFIED...")只能放在仓库里演示,不进安装包。 +$targetBundledSkins = Join-Path $PSScriptRoot 'bundled_skins' +$bundledSkinsManifest = Join-Path $PSScriptRoot 'bundled_skins.manifest' +Reset-Directory -LiteralPath $targetBundledSkins +$manifestLines = [Collections.Generic.List[string]]::new() +$bundledSkinIds = [Collections.Generic.List[string]]::new() +foreach ($skinDir in @(Get-ChildItem -LiteralPath $bundledSkinsSource -Directory | Sort-Object Name)) { + $id = $skinDir.Name + if ($id -ceq 'default') { continue } + if ($id -cnotmatch '^[a-z0-9][a-z0-9._-]{0,63}$' -or $id -match '\.bak$') { + throw "皮肤目录名不是有效的外部皮肤 ID:$($skinDir.FullName)" + } + $skinManifest = Join-Path $skinDir.FullName 'skin.toml' + Assert-PathExists -LiteralPath $skinManifest -Description "皮肤 $id 的 skin.toml" + $skinText = [IO.File]::ReadAllText($skinManifest) + if ($skinText -notmatch "(?m)^\s*id\s*=\s*`"$([regex]::Escape($id))`"\s*(#.*)?$") { + throw "皮肤 $id 的 skin.toml 里 id 与目录名不一致,Server 不会加载它:$skinManifest" + } + if ($skinText -match '(?m)^\s*assets\s*=\s*"UNVERIFIED') { + Write-Host "跳过皮肤 ${id}:素材授权未核实,不随安装包分发。" + continue + } + $destination = Join-Path $targetBundledSkins $id + Copy-DirectoryContents -Source $skinDir.FullName -Destination $destination + foreach ($file in @(Get-ChildItem -LiteralPath $destination -Recurse -File -Force | Sort-Object FullName)) { + $relative = $file.FullName.Substring($destination.Length + 1) + if ($relative -notmatch '^[\x20-\x7E]+$' -or $relative.Contains('|')) { + throw "皮肤文件名只能用 ASCII 且不能含 |(安装程序按清单逐字节比对):$($file.FullName)" + } + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $manifestLines.Add("$id|$relative|$hash") + } + $bundledSkinIds.Add($id) +} +if ($bundledSkinIds.Count -eq 0) { + throw "没有可随包分发的外部皮肤:$bundledSkinsSource" +} +[IO.File]::WriteAllLines($bundledSkinsManifest, $manifestLines, [Text.UTF8Encoding]::new($false)) +Write-Host "随包皮肤:$($bundledSkinIds -join ', ')" + # Server Release 输出整体复制,但测试程序及其 PDB 绝不能进入安装包。 # 带 -IncludeSymbols 时其他 PDB 保留在对应 EXE 旁边,方便安装后直接进行崩溃分析。 Reset-Directory -LiteralPath $targetServer diff --git a/installer/README.md b/installer/README.md index 1be72be7..a0e3fb3e 100644 --- a/installer/README.md +++ b/installer/README.md @@ -42,6 +42,7 @@ pwsh -File ./Prepare-PackageFiles.ps1 -TargetVersion 1.2.3 -RepoRoot .. ` - **数据目录必须整个归输入法**([#537](https://github.com/metasequoiaime/MSIME-Windows/issues/537))。用户选的目录里已经有别的文件、或者选了驱动器根目录时,安装器改用其中的 `metasequoiaime` 子目录,并在向导里把实际位置告诉用户;连那个子目录也不空就拒绝。输入法用过的目录(有标记,或历史默认位置)原样沿用 - 安装器在数据目录里放一个 `.metasequoiaime-data` 标记文件。只有标记里带 `exclusive: adopted empty by the installer` 这一行(安装时目录是空的),或目录就是历史默认位置,覆盖安装才整目录清理、卸载才整目录删除 - 旧版安装器不管目录里原来有什么都写标记,所以只有旧标记(没有那一行)的目录不能再信:覆盖安装、换目录和卸载都只按 `IsShippedAppDataItem` / `IsRuntimeAppDataItem` / `IsPreservedAppDataItem` 名单删输入法自己的条目,`html`、`logs`、`skins` 这种通用目录名只删输入法写进去的部分,最后目录空了才删目录。包里新增顶层文件要同步 `IsShippedAppDataItem`,`tests/package-files.ps1` 会拿真实打出的 `app_data` 核对;Server 在数据目录根下新写文件要同步 `IsRuntimeAppDataItem` +- 仓库 `skins\` 下的外部皮肤(除 `default\` 和素材授权为 `UNVERIFIED*` 的)随包装到 `skins\`,完整包和轻量包都带,卸载不删。`Prepare-PackageFiles.ps1` 同时写出 `bundled_skins.manifest`(每行 `id|相对路径|sha256`),安装器在 `PrepareToInstall` 里拿它核对用户机器上的同名目录:文件集合和内容完全一致就直接覆盖,否则先改名成 `.bak`(已有就 `.2.bak`……,不覆盖旧备份)再装。Server 扫描皮肤时跳过 `*.bak` 目录。`tests/bundled-skins.ps1` 把这段 Pascal 编进探针安装器真跑一遍 - 静默安装用 `/DATADIR="D:\MetasequoiaIME"` 指定;该值在 `PrepareToInstall` 里和向导页走同一套换子目录和校验 ## 运行时依赖检查 diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss index a4f1641b..9d65ed86 100644 --- a/installer/msime_setup.iss +++ b/installer/msime_setup.iss @@ -132,6 +132,13 @@ Source: "{#MySourceRoot}\default_skins\*"; \ DestDir: "{code:GetDataDir}\skins\default"; \ Flags: onlyifdoesntexist 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 ; 轻量包只覆盖前端 HTML,不带词库/辅助码/出厂配置。 Source: "{#MySourceRoot}\app_data\html\*"; \ @@ -1398,6 +1405,115 @@ begin 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; @@ -1428,6 +1544,13 @@ begin Result := DataDirProblem; exit; end; + { 迁移之后再备份:换了数据目录时,要比对的是已经搬到新目录的皮肤。} + DataDirProblem := BackupChangedBundledSkins; + if DataDirProblem <> '' then + begin + Result := DataDirProblem; + exit; + end; #ifdef LightPackage { 轻量包不替换词库:只清 HTML 和 Server/TSF,保留本机 msime.db 等。} TryDeleteTree(AddBackslash(GetDataDir('')) + 'html'); diff --git a/installer/tests/bundled-skins.ps1 b/installer/tests/bundled-skins.ps1 new file mode 100644 index 00000000..98508ba1 --- /dev/null +++ b/installer/tests/bundled-skins.ps1 @@ -0,0 +1,107 @@ +# 覆盖随包皮肤之前的备份逻辑跑在安装器的 Pascal 脚本里,只看 .iss 文本证明不了它对。 +# 这里把 msime_setup.iss 里的那几个函数原样抠出来,编进一个只在 InitializeSetup 里调用它们、 +# 数据目录指向临时目录的探针安装器,真跑一遍再看目录。 +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-Iscc { + $command = Get-Command 'ISCC.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + foreach ($version in @(7, 6)) { + foreach ($root in @(${env:ProgramFiles(x86)}, $env:ProgramFiles, (Join-Path $env:LOCALAPPDATA 'Programs'))) { + if (-not $root) { continue } + $candidate = Join-Path $root "Inno Setup $version\ISCC.exe" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + } + } + throw '找不到 ISCC.exe' +} + +$work = Join-Path ([IO.Path]::GetTempPath()) ('msime-bundled-skins-' + [Guid]::NewGuid()) +function Write-Text([string]$Path, [string]$Text) { + New-Item -ItemType Directory -Force -Path (Split-Path $Path) | Out-Null + [IO.File]::WriteAllText($Path, $Text) +} +try { + $bundle = Join-Path $work 'bundled_skins' + $skins = Join-Path $work 'data\skins' + $ids = @('same', 'changed', 'extra', 'missing', 'twice', 'absent') + foreach ($id in $ids) { + Write-Text (Join-Path $bundle "$id\skin.toml") "id = `"$id`"`n" + Write-Text (Join-Path $bundle "$id\assets\bg.png") "$id png" + } + $manifest = foreach ($id in $ids) { + foreach ($file in @('assets\bg.png', 'skin.toml')) { + $hash = (Get-FileHash (Join-Path $bundle "$id\$file") -Algorithm SHA256).Hash.ToLowerInvariant() + "$id|$file|$hash" + } + } + $manifestPath = Join-Path $work 'bundled_skins.manifest' + [IO.File]::WriteAllLines($manifestPath, [string[]]$manifest, [Text.UTF8Encoding]::new($false)) + + New-Item -ItemType Directory -Force -Path $skins | Out-Null + foreach ($id in @('same', 'changed', 'extra', 'missing', 'twice')) { + Copy-Item (Join-Path $bundle $id) $skins -Recurse + } + Write-Text (Join-Path $skins 'changed\assets\bg.png') 'user edit' + Write-Text (Join-Path $skins 'extra\assets\mine.png') 'user file' + Remove-Item (Join-Path $skins 'missing\assets\bg.png') + Write-Text (Join-Path $skins 'twice\skin.toml') 'user edit' + Write-Text (Join-Path $skins 'twice.bak\skin.toml') 'earlier backup' + + $iss = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../msime_setup.iss')) + $functions = [regex]::Match($iss, '(?s)function CountFilesUnder\(.*?(?=\r?\nfunction PrepareToInstall\()').Value + if (-not $functions) { throw 'msime_setup.iss is missing the bundled skin backup functions' } + $resultPath = Join-Path $work 'result.txt' + $probe = Join-Path $work 'probe.iss' + @" +[Setup] +AppName=Bundled skin probe +AppVersion=1.0 +CreateAppDir=no +PrivilegesRequired=lowest +Uninstallable=no +OutputDir=$work +OutputBaseFilename=bundled-skin-probe +[Files] +Source: "$manifestPath"; Flags: dontcopy +[Code] +function GetDataDir(Param: String): String; +begin + Result := '$(Join-Path $work 'data')'; +end; + +$functions + +function InitializeSetup: Boolean; +var + Problem: String; +begin + Problem := BackupChangedBundledSkins; + if Problem = '' then + SaveStringToFile('$resultPath', 'ok', False) + else + SaveStringToFile('$resultPath', 'error', False); + Result := False; +end; +"@ | Set-Content -Path $probe -Encoding UTF8 + + & (Resolve-Iscc) /Q $probe + if ($LASTEXITCODE -ne 0) { throw "ISCC failed to compile the probe (exit code $LASTEXITCODE)" } + $process = Start-Process (Join-Path $work 'bundled-skin-probe.exe') -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES' -PassThru -Wait + if (-not (Test-Path $resultPath)) { throw "Probe did not run (exit code $($process.ExitCode))" } + if ([IO.File]::ReadAllText($resultPath) -ne 'ok') { throw 'BackupChangedBundledSkins reported an error' } + + $actual = @(Get-ChildItem -LiteralPath $skins -Directory | ForEach-Object Name | Sort-Object) + $expected = @('changed.bak', 'extra.bak', 'missing.bak', 'same', 'twice.2.bak', 'twice.bak' | Sort-Object) + if (($actual -join ',') -cne ($expected -join ',')) { + throw "Unexpected skins directory after backup: $($actual -join ', ')" + } + if ([IO.File]::ReadAllText((Join-Path $skins 'changed.bak\assets\bg.png')) -ne 'user edit') { throw 'changed.bak lost the user edit' } + if (-not (Test-Path (Join-Path $skins 'extra.bak\assets\mine.png'))) { throw 'extra.bak lost the user file' } + if ([IO.File]::ReadAllText((Join-Path $skins 'twice.bak\skin.toml')) -ne 'earlier backup') { throw 'An earlier backup was overwritten' } + if ([IO.File]::ReadAllText((Join-Path $skins 'twice.2.bak\skin.toml')) -ne 'user edit') { throw 'twice.2.bak lost the user edit' } + Write-Host 'Bundled skins: unchanged kept, changed/extra/missing backed up, earlier backups preserved' +} finally { + if (Test-Path $work) { Remove-Item $work -Recurse -Force } +} diff --git a/installer/tests/package-files.ps1 b/installer/tests/package-files.ps1 index b11061da..d0d2a9f5 100644 --- a/installer/tests/package-files.ps1 +++ b/installer/tests/package-files.ps1 @@ -47,6 +47,9 @@ try { 'ui-html/webview2/settings/ime-settings/dist/index.html', 'skins/default/fluent/skin.toml' )) { Write-Fixture $file } + Write-Fixture 'skins/demo-skin/skin.toml' "schema_version = 1`nid = `"demo-skin`"`n" + Write-Fixture 'skins/demo-skin/assets/background.png' 'png' + Write-Fixture 'skins/unverified/skin.toml' "schema_version = 1`nid = `"unverified`"`n[license]`nassets = `"UNVERIFIED-DEMO-ONLY`"`n" Write-Fixture 'server/assets/tables/pinyin.txt' 'xing' Write-Fixture 'MetasequoiaImeDict/out/dictionary-manifest.json' '{"manifest_version":1}' $english = Join-Path $fixture 'MetasequoiaImeDict/out/english.db' @@ -72,6 +75,24 @@ try { if ($issText -notmatch '(?s)Source: "\{#MySourceRoot\}\\default_skins\\\*";[^\r\n]*\\\s*DestDir: "\{code:GetDataDir\}\\skins\\default";[^\r\n]*\\\s*Flags: onlyifdoesntexist') { throw 'Default skin settings must be installed to skins\default with onlyifdoesntexist' } + # 仓库 skins\ 下的外部皮肤随包分发;素材授权未核实的不进包,skins\default 走上面那条。 + foreach ($file in @('bundled_skins/demo-skin/skin.toml', 'bundled_skins/demo-skin/assets/background.png')) { + if (-not (Test-Path (Join-Path $installer $file))) { throw "Missing bundled skin file: $file" } + } + foreach ($excluded in @('bundled_skins/unverified', 'bundled_skins/default')) { + if (Test-Path (Join-Path $installer $excluded)) { throw "Bundled a skin that must not ship: $excluded" } + } + $expectedManifest = @( + "demo-skin|assets\background.png|$((Get-FileHash (Join-Path $fixture 'skins/demo-skin/assets/background.png') -Algorithm SHA256).Hash.ToLowerInvariant())", + "demo-skin|skin.toml|$((Get-FileHash (Join-Path $fixture 'skins/demo-skin/skin.toml') -Algorithm SHA256).Hash.ToLowerInvariant())" + ) + $actualManifest = @(Get-Content -LiteralPath (Join-Path $installer 'bundled_skins.manifest')) + if (($actualManifest -join "`n") -cne ($expectedManifest -join "`n")) { + throw "Unexpected bundled skin manifest:`n$($actualManifest -join "`n")" + } + if ($issText -notmatch '(?s)Source: "\{#MySourceRoot\}\\bundled_skins\\\*";[^\r\n]*\\\s*DestDir: "\{code:GetDataDir\}\\skins";[^\r\n]*\\\s*Flags: ignoreversion') { + throw 'Bundled skins must overwrite skins\ in the data directory' + } # 数据目录不整个归输入法时(旧版安装器把标记写进了用户原有的文件夹,#537),卸载只按 # IsShippedAppDataItem 等名单删。包里新增了顶层条目而名单没跟上,卸载就会把它漏在用户目录里。 $iss = [IO.File]::ReadAllText((Join-Path $installer 'msime_setup.iss')) diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index 9c58ad58..c7b91b4d 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -291,6 +291,17 @@ bool IsSafeId(const std::string &id) }); } +bool IsBackupFolder(const std::string &folder) +{ + constexpr std::string_view kSuffix = ".bak"; + if (folder.size() < kSuffix.size()) + { + return false; + } + return std::equal(kSuffix.begin(), kSuffix.end(), folder.end() - kSuffix.size(), + [](char a, char b) { return a == std::tolower(static_cast(b)); }); +} + bool Supports(const Package &package, const std::string &layout, const std::string &theme) { return std::find(package.layouts.begin(), package.layouts.end(), layout) != package.layouts.end() && @@ -493,7 +504,9 @@ ScanResult Scan(const std::filesystem::path &skinsRoot) continue; } const std::string folder = it->path().filename().u8string(); - if (folder == kDefaultSkinsFolder) + // .bak 是安装包覆盖随包皮肤前留下的旧版本:manifest 的 id 与目录名对不上, + // 不跳过就会在设置页被列成一条损坏的皮肤。 + if (folder == kDefaultSkinsFolder || IsBackupFolder(folder)) { continue; } diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index 6cfdafa8..fda7ddb3 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -118,6 +118,8 @@ inline constexpr bool kDefaultPageArrows = false; const std::vector &BuiltInIds(); bool IsBuiltIn(const std::string &id); bool IsSafeId(const std::string &id); +// 安装包覆盖同名随包皮肤前,把旧目录改名成 .bak(重名时 .2.bak …);Scan 跳过它们。 +bool IsBackupFolder(const std::string &folder); bool Supports(const Package &package, const std::string &layout, const std::string &theme); std::optional Load(const std::filesystem::path &skinsRoot, const std::string &id, std::string *error = nullptr); diff --git a/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp b/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp index 701ce497..a866be8a 100644 --- a/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp +++ b/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp @@ -130,3 +130,24 @@ TEST_CASE(candidate_skin_scan_skips_default_settings_folder) REQUIRE(!CandidateSkinCatalog::Load(root, "default").has_value()); Cleanup(root); } + +// The installer renames a bundled skin the user already had to .bak (or .2.bak) before overwriting it. +// Those backups keep the original manifest id, so without skipping them the settings page would list each one as +// a broken skin. +TEST_CASE(candidate_skin_scan_skips_installer_backups) +{ + const auto root = MakeSkinsRoot(L"backup"); + WriteExternalSkin(root, "ext", "fluent", ""); + fs::copy(root / L"ext", root / L"ext.bak", fs::copy_options::recursive); + fs::copy(root / L"ext", root / L"ext.2.bak", fs::copy_options::recursive); + fs::copy(root / L"ext", root / L"ext.3.BAK", fs::copy_options::recursive); + + const auto scan = CandidateSkinCatalog::Scan(root); + REQUIRE(scan.issues.empty()); + REQUIRE(scan.packages.size() == 1); + REQUIRE(scan.packages[0].id == "ext"); + REQUIRE(CandidateSkinCatalog::IsBackupFolder("ext.bak")); + REQUIRE(!CandidateSkinCatalog::IsBackupFolder("bak")); + REQUIRE(!CandidateSkinCatalog::IsBackupFolder("ext.backup")); + Cleanup(root); +} diff --git a/skins/README.md b/skins/README.md index 0764fd31..5898c4a0 100644 --- a/skins/README.md +++ b/skins/README.md @@ -31,7 +31,9 @@ ## 安装 -把想用的皮肤目录整个复制到: +除 `niya-demo`(素材授权未核实)外,这里的皮肤都随安装包装到数据目录的 `skins\`。用户那边已有同名皮肤且内容和随包的不一样(改过、多了或少了文件)时,安装器先把它改名成 `.bak` 保留下来(已有 `.bak` 则用 `.2.bak`……),再装新版本;输入法不会把 `.bak` 目录列成皮肤。 + +手动安装时,把想用的皮肤目录整个复制到: ``` %LOCALAPPDATA%\metasequoiaime\skins\