Merge pull request #560 from fanlusky/feat/bundle-skins

feat(installer): 随包安装外部皮肤并保留覆盖备份
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-01 02:05:51 +08:00
commit 4a3fdf374c
12 files changed
+342 -3

No files matched your search

+2
View File
@@ -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
+1 -1
View File
@@ -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` | 上游仓库 |
+2
View File
@@ -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
+45
View File
@@ -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\<id>,同名的直接覆盖。覆盖前安装程序拿
# bundled_skins.manifest 核对用户机器上的同名目录:内容与随包的一模一样就不动,否则先改名成
# <id>.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
+1
View File
@@ -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\<id>`,完整包和轻量包都带,卸载不删。`Prepare-PackageFiles.ps1` 同时写出 `bundled_skins.manifest`(每行 `id|相对路径|sha256`),安装器在 `PrepareToInstall` 里拿它核对用户机器上的同名目录:文件集合和内容完全一致就直接覆盖,否则先改名成 `<id>.bak`(已有就 `<id>.2.bak`……,不覆盖旧备份)再装。Server 扫描皮肤时跳过 `*.bak` 目录。`tests/bundled-skins.ps1` 把这段 Pascal 编进探针安装器真跑一遍
- 静默安装用 `/DATADIR="D:\MetasequoiaIME"` 指定;该值在 `PrepareToInstall` 里和向导页走同一套换子目录和校验
## 运行时依赖检查
+123
View File
@@ -132,6 +132,13 @@ Source: "{#MySourceRoot}\default_skins\*"; \
DestDir: "{code:GetDataDir}\skins\default"; \
Flags: onlyifdoesntexist 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
; 轻量包只覆盖前端 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;
{ 覆盖随包皮肤之前,把用户机器上内容不同的同名目录改名成 <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;
@@ -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');
+107
View File
@@ -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 }
}
+21
View File
@@ -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\<id> in the data directory'
}
# 数据目录不整个归输入法时(旧版安装器把标记写进了用户原有的文件夹,#537),卸载只按
# IsShippedAppDataItem 等名单删。包里新增了顶层条目而名单没跟上,卸载就会把它漏在用户目录里。
$iss = [IO.File]::ReadAllText((Join-Path $installer 'msime_setup.iss'))
+14 -1
View File
@@ -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<unsigned char>(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)
// <id>.bak 是安装包覆盖随包皮肤前留下的旧版本:manifest 的 id 与目录名对不上,
// 不跳过就会在设置页被列成一条损坏的皮肤。
if (folder == kDefaultSkinsFolder || IsBackupFolder(folder))
{
continue;
}
+2
View File
@@ -118,6 +118,8 @@ inline constexpr bool kDefaultPageArrows = false;
const std::vector<std::string> &BuiltInIds();
bool IsBuiltIn(const std::string &id);
bool IsSafeId(const std::string &id);
// 安装包覆盖同名随包皮肤前,把旧目录改名成 <id>.bak(重名时 <id>.2.bak …);Scan 跳过它们。
bool IsBackupFolder(const std::string &folder);
bool Supports(const Package &package, const std::string &layout, const std::string &theme);
std::optional<Package> Load(const std::filesystem::path &skinsRoot, const std::string &id,
std::string *error = nullptr);
@@ -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 <id>.bak (or <id>.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);
}
+3 -1
View File
@@ -31,7 +31,9 @@
## 安装
把想用的皮肤目录整个复制到:
除 `niya-demo`(素材授权未核实)外,这里的皮肤都随安装包装到数据目录的 `skins\<id>`。用户那边已有同名皮肤且内容和随包的不一样(改过、多了或少了文件)时,安装器先把它改名成 `<id>.bak` 保留下来(已有 `.bak` 则用 `<id>.2.bak`……),再装新版本;输入法不会把 `.bak` 目录列成皮肤。
手动安装时,把想用的皮肤目录整个复制到:
```
%LOCALAPPDATA%\metasequoiaime\skins\