diff --git a/.github/workflows/build-client.yml b/.github/workflows/build-client.yml index ffb4bfb..9374235 100644 --- a/.github/workflows/build-client.yml +++ b/.github/workflows/build-client.yml @@ -86,10 +86,12 @@ # 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志), # 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现 # all-stores.lock)。 -# 想从根上解决:让 gitea-runner 以**中完整性**启动(在普通终端里跑 +# 现在 Windows 打包要求 gitea-runner 以**中完整性或更高**启动(在普通终端里跑 # run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向 # 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin / -# Limited)拉起,但进程链上的完整性已经落到了 Low。 +# Limited)拉起,但进程链上的完整性已经落到了 Low。仅重定向目录仍会产生带 Low +# 标签的 exe;用户运行它时也会变成 Low,NSIS 在执行 .onInit 前就无法写 TEMP。 +# job 开始时检查进程级别;打包前修正输出目录与 exe 标签;上传前只读复查。 # # ── 三个平台都显式写 --target,这不是啰嗦 ──────────────────────────────────── # desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用; @@ -368,12 +370,9 @@ jobs: env: TARGET: x86_64-pc-windows-msvc CARGO_BUILD_TARGET: x86_64-pc-windows-msvc - # 见文件头第 5 条:这台 runner 的 job 跑在**低完整性**(Low Mandatory Level, - # S-1-16-4096)上,而 Windows 不允许低完整性往中完整性写 —— 用户目录全中招: - # ~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA% 下新建任何东西都是 - # "Access denied (os error 5)",ACL 再宽松也没用(没有 Deny ACE,是完整性 - # 级别拦的)。所以 CI 用到的每个"家目录"都挪到 runner 自己的工作目录下, - # 那几处在 job 内的写探测里实测可写。换一台 Windows runner 时改这几行。 + # 这些隔离缓存路径沿用原 runner 配置。见文件头第 5 条:此前通过重定向 + # 目录绕过 Low 进程的写入限制,但产物也会带 Low 标签。现在 job 开始时 + # 要求进程达到 Medium;目录重定向仅用于缓存隔离,不能替代完整性检查。 RUSTUP_HOME: C:\Users\admin\runner\windows\cache\ci-rustup CARGO_HOME: C:\Users\admin\runner\windows\cache\ci-cargo # LOCALAPPDATA/APPDATA 也得改:pnpm 12 的 store 锁与 cache/state 都挂在 @@ -391,6 +390,10 @@ jobs: with: fetch-depth: 0 + - name: 检查 Windows 打包进程完整性 + shell: pwsh + run: ./desktop/scripts/prepare-windows-bundle.ps1 -CheckProcessOnly + # 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe # —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是 # 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。 @@ -497,6 +500,16 @@ jobs: working-directory: desktop run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis + - name: 检查安装包与主程序完整性标签 + working-directory: desktop + shell: pwsh + run: ./scripts/prepare-windows-bundle.ps1 -CheckOnly + + - name: Windows 安装包权限回归(不安装应用) + working-directory: desktop + shell: pwsh + run: ./scripts/test-windows-bundle.ps1 + - name: 上传产物 uses: actions/upload-artifact@v3 with: diff --git a/README.md b/README.md index d517fc2..79f5b6d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **黑龙江大学 602 实验室 · Simba Robotics** -面向机器人对抗赛的局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量,桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。 +本项目面向机器人对抗赛,提供局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量。桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。 [![Firmware: ESP32-S3](https://img.shields.io/badge/Firmware-ESP32--S3-00979D)](docs/setup/firmware.md) [![Armor: ESP32-C3](https://img.shields.io/badge/Armor-ESP32--C3-00979D)](docs/hardware/armor.md) @@ -59,7 +59,7 @@ flowchart LR | 网络项 | 配置 | 说明 | | :--- | :--- | :--- | -| 图传板 Wi-Fi | 2.4 GHz | 板卡与客户端需处于可互通的局域网 | +| 图传板 Wi-Fi | 2.4 GHz | 裁判系统与客户端需处于可互通的局域网 | | 视频与发现 | UDP `5600` | Hello、Subscribe 与 JPEG 分片 | | 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 | | 本地界面通道 | 回环 WebSocket + Tauri IPC | JPEG 由 WebView 解码,网络接收线程不做视频解码 | @@ -71,13 +71,13 @@ flowchart LR | 模块 | 已有功能 | 使用条件与边界 | | :--- | :--- | :--- | -| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、板卡与画质下需复测[^performance] | +| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、裁判系统与画质下需复测[^performance] | | 用户端 | 本队画面、双方装甲条、瞄准器、偏移校准、命中反馈 | 先绑定图传板与装甲板;改绑、切换模式和退出受密码限制 | | 裁判端 | 双路画面、队名编辑、装甲板发现、槽位分配、截图、录制与回放 | 录制需要 ffmpeg;图传槽位可手动指定 | | 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 | | 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 | -发送端在有限预算内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限约束的是本机积压,**不代表相机到屏幕的端到端延迟**。 +发送端在限定时间内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限只约束本机积压,**不代表相机到屏幕的端到端延迟**。 > [!WARNING] > 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。 @@ -86,7 +86,7 @@ flowchart LR ## 快速开始 -### 1. 无板卡:先看界面 +### 1. 无裁判系统:先看界面 安装 Node.js 和 pnpm 后,在仓库根目录执行: @@ -96,12 +96,12 @@ pnpm install --frozen-lockfile pnpm dev ``` -打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或板卡。 +打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或裁判系统。 > [!TIP] > 检查首次启动流程时,可使用 `?preview=first-run` 和 `?preview=password`。需要测试实际接收、重组和录制路径时,使用原生客户端的 `--demo` 模式;两种方式显示的都是合成数据。 -### 2. 连接板卡:运行桌面端 +### 2. 连接裁判系统:运行桌面端 先按[客户端说明](docs/desktop/client.md#快速开始)安装宿主平台依赖。Windows x64 需要 Rust stable MSVC、Visual Studio C++ Build Tools、Windows SDK 和 WebView2;录制另需 ffmpeg。 @@ -141,7 +141,7 @@ pnpm app --target x86_64-unknown-linux-gnu -- --locked --config 'unstable.build- -首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。 +首次启动时选择用户端或裁判端。用户端需要设置现场密码并绑定本车裁判系统;裁判端自动分配图传槽位,并按装甲板上报的队伍分配装甲槽位。对应关系不符时,可在设置中手动指定。 ### 3. 编译图传或装甲固件 @@ -162,9 +162,12 @@ if (-not (Test-Path -LiteralPath .env)) { Copy-Item -LiteralPath .env.example -Destination .env } # 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。 +$env:CARGO_TARGET_DIR = 'C:\s3vt' .\scripts\build.ps1 -Package ``` +Windows 必须使用可写的短输出目录,且不要与其他项目共用。`esp-idf-sys` 检查完整的构建输出路径,超过 88 字符会报 `Too long output directory`;仓库位于用户目录时,默认的 `target/` 通常会超限。这里将构建缓存放到 `C:\s3vt`,无需移动源码;`subst` 不能绕过该限制。固件 ZIP 仍输出到仓库的 `target/firmware/`。该环境变量仅供固件构建使用,切回桌面端前执行 `Remove-Item Env:CARGO_TARGET_DIR`。 + Linux / macOS(Bash): ```bash @@ -246,7 +249,7 @@ cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86 cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]' ``` -录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、板卡与网络行为。 +录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、裁判系统与网络行为。 @@ -261,7 +264,7 @@ node scripts/version.mjs --check ## 文档与后续工作 -文档沿用 Firefox 仓库的分类与索引思路:根目录保留项目入口和协作约定,详细说明集中到 [`docs/index.md`](docs/index.md)。 +文档参照 Firefox 仓库的分类与索引方式组织。根目录保留项目入口和协作约定,详细说明从 [`docs/index.md`](docs/index.md) 查阅。 | 要做什么 | 从哪里开始 | | :--- | :--- | diff --git a/desktop/.gitignore b/desktop/.gitignore index 64786d4..13af5d6 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -18,3 +18,6 @@ dist/ # 测试运行时被 XDG_CONFIG_HOME 相对路径写出来的配置,不是仓库内容。 src-tauri/.config/ + +# Installer hotspot guidance is local configuration. +/src-tauri/installer/network.local.nsh diff --git a/desktop/scripts/generate-brand-assets.py b/desktop/scripts/generate-brand-assets.py new file mode 100644 index 0000000..cd9e1cd --- /dev/null +++ b/desktop/scripts/generate-brand-assets.py @@ -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("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() diff --git a/desktop/scripts/prepare-windows-bundle.ps1 b/desktop/scripts/prepare-windows-bundle.ps1 new file mode 100644 index 0000000..b4bcae2 --- /dev/null +++ b/desktop/scripts/prepare-windows-bundle.ps1 @@ -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/. + # 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)." diff --git a/desktop/scripts/test-windows-bundle.ps1 b/desktop/scripts/test-windows-bundle.ps1 new file mode 100644 index 0000000..aafee44 --- /dev/null +++ b/desktop/scripts/test-windows-bundle.ps1 @@ -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 } +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index e1e21f5..204c42d 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index d4ee6ca..337987f 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index b52f961..5595062 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index d55c947..1388b6c 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index d4cebc9..34bbe16 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index aa7676c..08d5829 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg index c6153c4..715f8bf 100644 --- a/desktop/src-tauri/icons/icon.svg +++ b/desktop/src-tauri/icons/icon.svg @@ -1,9 +1,9 @@ - - + + + + + RMIC + ::2026 + diff --git a/desktop/src-tauri/installer/hooks.nsh b/desktop/src-tauri/installer/hooks.nsh new file mode 100644 index 0000000..5a6fc3a --- /dev/null +++ b/desktop/src-tauri/installer/hooks.nsh @@ -0,0 +1,47 @@ +; Included by Tauri before its Modern UI pages. Keep the upstream installer +; template, upgrade handling and current-user installation behavior. +!ifndef RMIC_INSTALLER_THEME +!define RMIC_INSTALLER_THEME + +; CJK dialog units have a different width/height ratio from the bitmap. +!define MUI_WELCOMEFINISHPAGE_BITMAP_STRETCH AspectFitHeight +; Header art is narrower than the standard 150px canvas so height-based +; scaling stays within the image area of the 9pt Chinese dialog. +!define MUI_HEADERIMAGE_BITMAP_STRETCH AspectFitHeight + +!define MUI_WELCOMEPAGE_TITLE "欢迎安装$\r$\n裁判系统客户端" +!define MUI_WELCOMEPAGE_TEXT "本程序运行内存需求约为 400MB。$\r$\n$\r$\n点击“下一步”选择安装位置。" +!define MUI_DIRECTORYPAGE_TEXT_TOP "软件仅为当前 Windows 用户安装。请选择有写入权限的文件夹。" +!define MUI_FINISHPAGE_TITLE "安装完成" +; MUI reserves 60 dialog units above the two checkboxes: keep six short lines. +; Optional local guidance is excluded from version control. +!if /FileExists "${__FILEDIR__}/network.local.nsh" + !include "${__FILEDIR__}/network.local.nsh" +!endif +!ifdef RMIC_INSTALLER_WIFI_SSID & RMIC_INSTALLER_WIFI_PASS +!define MUI_FINISHPAGE_TEXT "裁判系统客户端已安装完成。$\r$\n首次启动请选择模式并绑定裁判系统。$\r$\n裁判系统会自动连接以下热点;请将$\r$\n本机也连接到同一网络。$\r$\n名称(SSID):${RMIC_INSTALLER_WIFI_SSID}$\r$\n密码:${RMIC_INSTALLER_WIFI_PASS}" +!else +!define MUI_FINISHPAGE_TEXT "裁判系统客户端已安装完成。$\r$\n首次启动请选择模式并绑定裁判系统。$\r$\n请让本机与裁判系统连接到同一网络。$\r$\n网络名称和密码请向现场工作人员确认。" +!endif +!define MUI_FINISHPAGE_TEXT_LARGE +!define MUI_FINISHPAGE_RUN_TEXT "启动裁判系统客户端" +!define MUI_INSTFILESPAGE_COLORS "E7ECF5 080B13" + +!macro NSIS_HOOK_PREINSTALL + DetailPrint "正在安装 RMIC::2026 ${VERSION}。" + DetailPrint "安装位置:$INSTDIR" +!macroend + +!macro NSIS_HOOK_POSTINSTALL + DetailPrint "程序文件与快捷方式已创建。请点击“下一步”完成安装。" +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + DetailPrint "正在移除 RMIC::2026 的程序文件与快捷方式。" +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + DetailPrint "RMIC::2026 卸载步骤已完成。" +!macroend + +!endif diff --git a/desktop/src-tauri/installer/logo.svg b/desktop/src-tauri/installer/logo.svg new file mode 100644 index 0000000..0748851 --- /dev/null +++ b/desktop/src-tauri/installer/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/desktop/src-tauri/installer/network.example.nsh b/desktop/src-tauri/installer/network.example.nsh new file mode 100644 index 0000000..2d3caef --- /dev/null +++ b/desktop/src-tauri/installer/network.example.nsh @@ -0,0 +1,4 @@ +; Copy to network.local.nsh and fill in the event's hotspot information. +; network.local.nsh is ignored by Git and must not be committed. +!define RMIC_INSTALLER_WIFI_SSID "现场热点名称" +!define RMIC_INSTALLER_WIFI_PASS "现场热点密码" diff --git a/desktop/src-tauri/installer/sidebar.bmp b/desktop/src-tauri/installer/sidebar.bmp new file mode 100644 index 0000000..5d4b723 Binary files /dev/null and b/desktop/src-tauri/installer/sidebar.bmp differ diff --git a/desktop/src-tauri/installer/sidebar.png b/desktop/src-tauri/installer/sidebar.png new file mode 100644 index 0000000..3966d19 Binary files /dev/null and b/desktop/src-tauri/installer/sidebar.png differ diff --git a/desktop/src-tauri/installer/sidebar.svg b/desktop/src-tauri/installer/sidebar.svg new file mode 100644 index 0000000..96b2f07 --- /dev/null +++ b/desktop/src-tauri/installer/sidebar.svg @@ -0,0 +1,14 @@ + + + + SIMBA ROBOTICS + RMIC + :: + 2026 + + + + 裁判系统客户端 + 仅供第九届机甲大师校内赛使用 + 黑龙江大学 Simba 战队 + diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 66e419f..2475c92 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -40,8 +40,8 @@ "active": true, "targets": "all", "category": "Utility", - "shortDescription": "RMIC::2026 —— 视野链路终端", - "longDescription": "RMIC::2026 视野链路终端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。", + "shortDescription": "RMIC::2026 裁判系统客户端", + "longDescription": "RMIC::2026 裁判系统客户端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src-tauri/tauri.windows.conf.json b/desktop/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..d54a46c --- /dev/null +++ b/desktop/src-tauri/tauri.windows.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBuildCommand": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-windows-bundle.ps1 && pnpm build", + "beforeBundleCommand": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-windows-bundle.ps1" + }, + "bundle": { + "windows": { + "nsis": { + "installMode": "currentUser", + "languages": ["SimpChinese"], + "displayLanguageSelector": false, + "customLanguageFiles": { + "SimpChinese": "installer/SimpChinese.nsh" + }, + "installerHooks": "installer/hooks.nsh", + "installerIcon": "icons/icon.ico", + "uninstallerIcon": "icons/icon.ico", + "headerImage": "installer/header.bmp", + "sidebarImage": "installer/sidebar.bmp", + "uninstallerHeaderImage": "installer/header.bmp" + } + } + } +} diff --git a/desktop/src/components/Boot.tsx b/desktop/src/components/Boot.tsx index 9af94f0..abd3c6d 100644 --- a/desktop/src/components/Boot.tsx +++ b/desktop/src/components/Boot.tsx @@ -54,7 +54,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) { RMIC::2026 - 视野链路终端 · SIMBA ROBOTICS + 裁判系统客户端 · SIMBA ROBOTICS diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx index fb676fd..25e229c 100644 --- a/desktop/src/components/SettingsPanel.tsx +++ b/desktop/src/components/SettingsPanel.tsx @@ -350,7 +350,7 @@ export function SettingsPanel({ {cameraOptions.length === 0 && (

- 尚未发现图传板。板卡每 2 秒广播一次;请检查供电,确认与本机处于 + 尚未发现图传板。裁判系统每 2 秒广播一次;请检查供电,确认与本机处于 同一网络后稍候。

)} diff --git a/desktop/src/components/TopBar.tsx b/desktop/src/components/TopBar.tsx index bbebe35..6a9dea7 100644 --- a/desktop/src/components/TopBar.tsx +++ b/desktop/src/components/TopBar.tsx @@ -63,7 +63,7 @@ export function TopBar({