fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
This commit is contained in:
2026-10-21 09:25:17 +08:00
parent 6a0eb14b28
commit 054379dc64
52 changed files with 1070 additions and 280 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Render the existing SVG brand sources with Tauri, then export PNG/ICO/ICNS/BMP.
Requires the installed desktop Node dependencies and Python's Pillow package.
Text is rasterized with Pillow so exports do not depend on the SVG renderer's
font discovery. No dependency is added to the application or its build hooks.
"""
import argparse
import copy
import io
import os
from pathlib import Path
import shutil
import struct
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw, ImageFont
DESKTOP = Path(__file__).resolve().parents[1]
ICONS = DESKTOP / "src-tauri/icons"
INSTALLER = DESKTOP / "src-tauri/installer"
SVG = "{http://www.w3.org/2000/svg}"
ET.register_namespace("", SVG[1:-1])
def system_font(monospace=False, bold=False):
fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts"
candidates = (
[fonts / ("consolab.ttf" if bold else "consola.ttf"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf")]
if monospace else
[fonts / ("msyhbd.ttc" if bold else "msyh.ttc"),
Path("/System/Library/Fonts/PingFang.ttc"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc")]
)
for path in candidates:
if path.exists():
return path
raise RuntimeError("Set --sans-font and --mono-font to installed fonts supporting the artwork's text.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sans-font", type=Path)
parser.add_argument("--mono-font", type=Path)
args = parser.parse_args()
node = shutil.which("node")
cli = DESKTOP / "node_modules/@tauri-apps/cli/tauri.js"
if not node or not cli.exists():
raise RuntimeError("Install Node and run pnpm install --frozen-lockfile in desktop/ first.")
with tempfile.TemporaryDirectory(prefix="rmic-brand-") as temporary:
scratch = Path(temporary)
def render(root, size):
source = scratch / "source.svg"
ET.ElementTree(root).write(source, encoding="utf-8", xml_declaration=True)
subprocess.run([node, str(cli), "icon", str(source), "--output", str(scratch),
"--png", str(size)], cwd=DESKTOP, check=True, capture_output=True)
with Image.open(scratch / f"{size}x{size}.png") as image:
return image.convert("RGBA")
original = ET.parse(ICONS / "icon.svg").getroot()
images = {}
for size in (16, 20, 24, 32, 48, 64, 128, 256, 512, 1024):
root = copy.deepcopy(original)
scale = 0.97 if size <= 48 else 0.88 if size <= 128 else 0.82
group = root.find(f"{SVG}g")
group.set("transform", group.get("transform").replace("scale(0.82)", f"scale({scale})"))
images[size] = render(root, size)
for filename, size in (("32x32.png", 32), ("128x128.png", 128),
("128x128@2x.png", 256), ("icon.png", 512)):
images[size].save(ICONS / filename)
def png(size):
stream = io.BytesIO()
images[size].save(stream, format="PNG")
return stream.getvalue()
# Supply every ICO size separately, preserving optical scaling at 16–48px.
entries, payloads = [], []
sizes = (16, 20, 24, 32, 48, 64, 128, 256)
offset = 6 + 16 * len(sizes)
for size in sizes:
data = png(size)
entries.append(struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32, len(data), offset))
payloads.append(data)
offset += len(data)
(ICONS / "icon.ico").write_bytes(struct.pack("<HHH", 0, 1, len(sizes)) + b"".join(entries + payloads))
chunks = []
for kind, size in ((b"icp4", 16), (b"icp5", 32), (b"icp6", 64), (b"ic07", 128),
(b"ic08", 256), (b"ic09", 512), (b"ic10", 1024),
(b"ic11", 32), (b"ic12", 64), (b"ic13", 256), (b"ic14", 512)):
data = png(size)
chunks.append(kind + struct.pack(">I", 8 + len(data)) + data)
body = b"".join(chunks)
(ICONS / "icon.icns").write_bytes(b"icns" + struct.pack(">I", 8 + len(body)) + body)
for name in ("header", "sidebar"):
root = ET.parse(INSTALLER / f"{name}.svg").getroot()
width, height = int(root.get("width")), int(root.get("height"))
text = []
for element in list(root):
if element.tag == f"{SVG}text":
text.append(element)
root.remove(element)
elif element.tag == f"{SVG}image":
# Installer art may reference the unframed original mark.
# Preserve its own viewBox instead of replacing every image
# with the square application icon.
source = (INSTALLER / element.get("href")).resolve()
logo = ET.parse(source).getroot()
for attr in ("x", "y", "width", "height"):
logo.set(attr, element.get(attr))
logo.set("preserveAspectRatio", "xMidYMid meet")
index = list(root).index(element)
root.remove(element)
root.insert(index, logo)
edge = max(width, height)
root.set("width", str(edge))
root.set("height", str(edge))
root.set("viewBox", f"0 0 {edge} {edge}")
factor = 4
image = render(root, edge * factor)
draw = ImageDraw.Draw(image)
for element in text:
mono = element.get("font-family") == "monospace"
bold = element.get("font-weight") == "700"
font_path = (args.mono_font if mono else args.sans_font) or system_font(mono, bold)
font = ImageFont.truetype(str(font_path), round(float(element.get("font-size")) * factor))
draw.text((float(element.get("x")) * factor, float(element.get("y")) * factor),
element.text, font=font, fill=element.get("fill"), anchor="ls")
image = image.crop((0, 0, width * factor, height * factor)).convert("RGB")
image.resize((width, height), Image.Resampling.LANCZOS).save(INSTALLER / f"{name}.png")
# MUI scales these to the dialog's font/DPI. Supply 2x bitmaps so
# Windows does not have to enlarge the raster text on modern screens.
image.resize((width * 2, height * 2), Image.Resampling.LANCZOS).save(INSTALLER / f"{name}.bmp")
print("Generated rounded PNG/ICO/ICNS icons and 24-bit NSIS header/sidebar artwork.")
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
# Windows uses the executable's integrity label when creating its process. A Low
# label inherited from a sandboxed workspace prevents NSIS from writing to TEMP,
# before .onInit or any installer hook can run.
[CmdletBinding()]
param(
[string]$OutputDir,
[switch]$CheckProcessOnly,
[switch]$CheckOnly
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# whoami's group names are localized; the mandatory-label SID is stable.
$groups = (& whoami.exe /groups) -join "`n"
if ($LASTEXITCODE -ne 0 -or $groups -notmatch 'S-1-16-(\d+)') {
throw 'Cannot determine the Windows process integrity level.'
}
if ([int]$Matches[1] -lt 8192) {
throw 'Windows packaging requires Medium integrity or higher. Start the terminal / CI runner outside the Low integrity sandbox. Redirecting TEMP or changing directory write permissions does not fix executable integrity labels.'
}
if ($CheckProcessOnly) { return }
if (-not ('RmicBundleIntegrity' -as [type])) {
$compilerOptions = @{}
if ($PSVersionTable.PSEdition -eq 'Desktop') {
# Framework CodeDOM otherwise tries to label its private temp directory
# High when an admin token has been capped at Medium by an exe label.
$parameters = New-Object System.CodeDom.Compiler.CompilerParameters
$parameters.ReferencedAssemblies.Add('System.dll') | Out-Null
$parameters.TempFiles = New-Object System.CodeDom.Compiler.TempFileCollection([IO.Path]::GetTempPath(), $false)
$compilerOptions.CompilerParameters = $parameters
}
Add-Type @compilerOptions -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class RmicBundleIntegrity {
[DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
private static extern uint GetNamedSecurityInfoW(string name, int type, uint info,
out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr sd);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool ConvertSecurityDescriptorToStringSecurityDescriptorW(
IntPtr sd, uint revision, uint info, out IntPtr text, out uint length);
[DllImport("kernel32.dll")]
private static extern IntPtr LocalFree(IntPtr memory);
public static string ReadLabel(string path) {
// LABEL_SECURITY_INFORMATION reads only the label, without requiring the
// SeSecurityPrivilege needed to read an entire SACL (Get-Acl -Audit).
IntPtr owner, group, dacl, sacl, sd, text = IntPtr.Zero;
uint error = GetNamedSecurityInfoW(path, 1, 0x10, out owner, out group,
out dacl, out sacl, out sd);
if (error != 0) throw new Win32Exception((int)error, path);
try {
uint length;
if (!ConvertSecurityDescriptorToStringSecurityDescriptorW(sd, 1, 0x10,
out text, out length)) throw new Win32Exception(Marshal.GetLastWin32Error());
return Marshal.PtrToStringUni(text);
} finally {
if (text != IntPtr.Zero) LocalFree(text);
LocalFree(sd);
}
}
}
'@
}
function Assert-MediumIntegrity([string]$Path) {
$label = [RmicBundleIntegrity]::ReadLabel($Path)
# An absent label is Medium. Windows may render known SIDs as SDDL aliases.
if ($label -match ';(LW|UN|S-1-16-(\d+))\)') {
if ($Matches[1] -in @('LW', 'UN') -or [int]$Matches[2] -lt 8192) {
throw "Low / Untrusted integrity label on $Path. Run prepare-windows-bundle.ps1 from a normal Windows terminal before distributing this artifact."
}
}
}
$hostOutputDir = $null
if (-not $OutputDir) {
# Ask Cargo so CARGO_TARGET_DIR and Cargo's target-dir setting are respected.
$manifest = Join-Path $PSScriptRoot '../src-tauri/Cargo.toml'
$metadata = & cargo +stable metadata --no-deps --format-version 1 --manifest-path $manifest
if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve the Cargo output directory.' }
$target = $env:TAURI_ENV_TARGET_TRIPLE
if (-not $target) { $target = 'x86_64-pc-windows-msvc' }
$profile = if ($env:TAURI_ENV_DEBUG -eq 'true') { 'debug' } else { 'release' }
$targetDir = ($metadata | ConvertFrom-Json).target_directory
$OutputDir = Join-Path $targetDir "$target/$profile"
# Cross-target Cargo builds also run host build scripts from target/<profile>.
# Old Low executables there can fail before the bundling hook is reached.
$hostOutputDir = Join-Path $targetDir $profile
}
$OutputDir = [IO.Path]::GetFullPath($OutputDir)
# Limit changes to build outputs. Do not relabel the workspace, TEMP, user data,
# or DACLs, and do not request elevation for the installer.
$directories = @(
$OutputDir
(Join-Path $OutputDir 'bundle')
(Join-Path $OutputDir 'bundle/nsis')
(Join-Path $OutputDir 'nsis')
)
if ($hostOutputDir) { $directories = @($hostOutputDir) + $directories }
if (Test-Path -LiteralPath (Join-Path $OutputDir 'nsis')) {
$directories += @(Get-ChildItem -LiteralPath (Join-Path $OutputDir 'nsis') -Directory -Recurse |
Select-Object -ExpandProperty FullName)
}
foreach ($directory in $directories) {
if (-not $CheckOnly) {
New-Item -ItemType Directory -Force -Path $directory | Out-Null
& icacls.exe $directory /setintegritylevel '(OI)(CI)M' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Cannot set Medium integrity on $directory" }
}
Assert-MediumIntegrity $directory
}
$executables = @(Get-ChildItem -LiteralPath $OutputDir -Filter '*.exe' -File)
foreach ($directory in @('nsis', 'bundle/nsis')) {
$executables += @(Get-ChildItem -LiteralPath (Join-Path $OutputDir $directory) -Filter '*.exe' -File -Recurse)
}
if ($hostOutputDir -and (Test-Path -LiteralPath (Join-Path $hostOutputDir 'build'))) {
$executables += @(Get-ChildItem -LiteralPath (Join-Path $hostOutputDir 'build') -Filter '*.exe' -File -Recurse)
}
foreach ($executable in $executables) {
if (-not $CheckOnly) {
# Also replace explicit Low labels left by an earlier Low integrity build.
& icacls.exe $executable.FullName /setintegritylevel M | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Cannot set Medium integrity on $($executable.FullName)" }
}
Assert-MediumIntegrity $executable.FullName
}
if ($CheckOnly -and -not (Get-ChildItem -LiteralPath (Join-Path $OutputDir 'bundle/nsis') -Filter '*.exe' -File)) {
throw "No NSIS installer found in $OutputDir/bundle/nsis."
}
Write-Host "Windows bundle integrity verified: $OutputDir ($($executables.Count) executables)."
+158
View File
@@ -0,0 +1,158 @@
# Integration regression: actual Windows labels, child process tokens, and an
# NSIS bootstrap that writes to TEMP and exits without installing anything.
[CmdletBinding()]
param(
[string]$NsisCompiler = (Join-Path $env:LOCALAPPDATA 'tauri/NSIS/makensis.exe')
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$prepare = Join-Path $PSScriptRoot 'prepare-windows-bundle.ps1'
& $prepare -CheckProcessOnly
if (-not (Test-Path -LiteralPath $NsisCompiler)) { throw "NSIS compiler not found: $NsisCompiler" }
function Assert([bool]$Condition, [string]$Message) {
if (-not $Condition) { throw $Message }
}
$testId = [Guid]::NewGuid().ToString('N')
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$fixture = Join-Path $tempRoot "rmic bundle test $testId"
$probeFile = Join-Path $tempRoot "rmic-nsis-temp-$testId.txt"
$installer = Join-Path $fixture 'bundle/nsis/probe-setup.exe'
$main = Join-Path $fixture 'cam-desktop.exe'
try {
New-Item -ItemType Directory -Force -Path (Join-Path $fixture 'bundle/nsis'), (Join-Path $fixture 'nsis/x64') | Out-Null
Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32/whoami.exe') -Destination $main
$accessBefore = (Get-Acl -LiteralPath $main).Sddl
# A small real NSIS executable exercises its early TEMP validation and
# plugin extraction without touching app files, registry, or shortcuts.
$source = Join-Path $fixture 'probe.nsi'
@"
Unicode true
Name "RMIC temporary directory probe"
OutFile "$($installer.Replace('$', '$$'))"
RequestExecutionLevel user
SilentInstall silent
Section
InitPluginsDir
FileOpen `$0 "`$TEMP\rmic-nsis-temp-$testId.txt" w
IfErrors failed
FileWrite `$0 "NSIS TEMP writable"
FileClose `$0
SetErrorLevel 0
Quit
failed:
SetErrorLevel 3
SectionEnd
"@ | Set-Content -LiteralPath $source -Encoding UTF8
& $NsisCompiler /V2 $source
Assert ($LASTEXITCODE -eq 0) 'Cannot compile the NSIS TEMP probe.'
& icacls.exe $fixture /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create a Low integrity fixture.'
& icacls.exe $main /setintegritylevel L | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create an explicit Low executable label.'
$before = (& $main /groups) -join "`n"
Assert ($LASTEXITCODE -eq 0 -and $before -match 'S-1-16-4096') 'Low executable did not start with a Low token.'
$rejected = $false
try { & $prepare -OutputDir $fixture -CheckOnly } catch {
if ($_.Exception.Message -notmatch 'Low / Untrusted') { throw }
$rejected = $true
}
Assert $rejected 'Read-only artifact verification accepted a Low label.'
Assert ([RmicBundleIntegrity]::ReadLabel($installer) -match ';LW\)') 'Read-only verification changed the installer.'
# A Low process must fail before it tries to modify any output labels.
$lowShell = Join-Path $fixture 'low-shell.exe'
Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe') -Destination $lowShell
# Windows PowerShell 5 treats redirected native stderr as ErrorRecord objects.
# This command is expected to fail, so collect its diagnostic before asserting.
try {
$ErrorActionPreference = 'Continue'
$lowOutput = (& $lowShell -NoProfile -ExecutionPolicy Bypass -File $prepare -CheckProcessOnly 2>&1) -join "`n"
} finally {
$ErrorActionPreference = 'Stop'
}
Assert ($LASTEXITCODE -ne 0 -and $lowOutput -match 'requires Medium integrity') 'Low packaging process was not rejected.'
& $prepare -OutputDir $fixture
& $prepare -OutputDir $fixture -CheckOnly
& $prepare -OutputDir $fixture
$after = (& $main /groups) -join "`n"
Assert ($LASTEXITCODE -eq 0 -and $after -match 'S-1-16-(\d+)' -and [int]$Matches[1] -ge 8192) 'Repaired executable still starts below Medium integrity.'
Assert ((Get-Acl -LiteralPath $main).Sddl -eq $accessBefore) 'Repair changed the executable DACL.'
# The repaired shell now starts at Medium even from an elevated test runner.
# Verify that repairing owned Low outputs does not require elevation.
$mediumFixture = Join-Path $fixture 'medium repair'
New-Item -ItemType Directory -Path $mediumFixture | Out-Null
& icacls.exe $mediumFixture /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot prepare the Medium-process repair fixture.'
$mediumOutput = (& $lowShell -NoProfile -ExecutionPolicy Bypass -File $prepare -OutputDir $mediumFixture 2>&1) -join "`n"
Assert ($LASTEXITCODE -eq 0) "Repair failed at Medium integrity: $mediumOutput"
Assert ([RmicBundleIntegrity]::ReadLabel($mediumFixture) -match ';ME\)') 'Medium process did not repair the output label.'
$probe = Start-Process -FilePath $installer -ArgumentList '/S' -PassThru -WindowStyle Hidden
try {
Assert ($probe.WaitForExit(15000)) 'NSIS TEMP probe timed out.'
Assert ($probe.ExitCode -eq 0) "NSIS TEMP probe failed: $($probe.ExitCode)"
Assert ((Get-Content -LiteralPath $probeFile -Raw) -eq 'NSIS TEMP writable') 'NSIS could not write to the normal TEMP directory.'
} finally {
if (-not $probe.HasExited) { $probe.Kill(); $probe.WaitForExit() }
$probe.Dispose()
}
$emptyOutput = Join-Path $fixture 'empty'
& $prepare -OutputDir $emptyOutput
$rejected = $false
try { & $prepare -OutputDir $emptyOutput -CheckOnly } catch {
if ($_.Exception.Message -notmatch 'No NSIS installer') { throw }
$rejected = $true
}
Assert $rejected 'Artifact verification accepted missing installers.'
# Exercise automatic Cargo output discovery, debug target selection and the
# separate host build-script directory, without invoking a real Rust build.
$savedEnvironment = @{}
foreach ($name in @('CARGO_TARGET_DIR', 'TAURI_ENV_TARGET_TRIPLE', 'TAURI_ENV_DEBUG')) {
$savedEnvironment[$name] = @{
Exists = Test-Path -LiteralPath "Env:$name"
Value = [Environment]::GetEnvironmentVariable($name, 'Process')
}
}
try {
$env:CARGO_TARGET_DIR = Join-Path $fixture 'cargo cache'
$env:TAURI_ENV_TARGET_TRIPLE = 'x86_64-pc-windows-msvc'
$env:TAURI_ENV_DEBUG = 'true'
$hostBuildDir = Join-Path $env:CARGO_TARGET_DIR 'debug/build/probe'
New-Item -ItemType Directory -Force -Path $hostBuildDir | Out-Null
$hostScript = Join-Path $hostBuildDir 'build-script-build.exe'
Copy-Item -LiteralPath $main -Destination $hostScript
& icacls.exe $env:CARGO_TARGET_DIR /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create Low host build outputs.'
& $prepare
Assert ([RmicBundleIntegrity]::ReadLabel($hostScript) -match ';ME\)') 'Host build script was not repaired.'
$targetOutput = Join-Path $env:CARGO_TARGET_DIR 'x86_64-pc-windows-msvc/debug/bundle/nsis'
Assert (Test-Path -LiteralPath $targetOutput) 'Cargo target directory or debug profile was ignored.'
} finally {
foreach ($name in $savedEnvironment.Keys) {
if ($savedEnvironment[$name].Exists) {
Set-Item -LiteralPath "Env:$name" -Value $savedEnvironment[$name].Value
} else {
Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
}
}
}
Write-Host 'PASS: Low token reproduction, process guard, read-only rejection, label repair, unchanged DACL, repeat repair, NSIS TEMP bootstrap, missing artifacts, custom Cargo output and host build scripts.'
} finally {
# Only delete this test's uniquely named fixture, after checking its absolute path.
$resolvedFixture = [IO.Path]::GetFullPath($fixture)
if ($resolvedFixture.StartsWith($tempRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -and
(Split-Path -Leaf $resolvedFixture) -eq "rmic bundle test $testId") {
if (Test-Path -LiteralPath $resolvedFixture) { Remove-Item -LiteralPath $resolvedFixture -Recurse -Force }
}
if (Test-Path -LiteralPath $probeFile) { Remove-Item -LiteralPath $probeFile -Force }
}