feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (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

- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+31
View File
@@ -2,6 +2,7 @@
[CmdletBinding()]
param(
[string]$EnvFile = '.env',
[switch]$Package,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$CargoArgs = @()
)
@@ -42,12 +43,42 @@ try {
}
if ($null -eq $env:WIFI_PASS) { $env:WIFI_PASS = '' }
# bindgen 要加载 esp-clang 自带的 libclang.dll,而它依赖**同目录**的 libLLVM-21.dll、
# libclang-cpp.dll 等。LoadLibraryEx 默认不会去 DLL 自己的目录找依赖,所以必须把这个
# 目录放进 PATH,否则报的是一句毫不相干的话:
# Unable to find libclang: "...\libclang.dll could not be opened: LoadLibraryExW failed"
# (`espup` 的 shell 会替你导出这些变量,直接跑脚本的人不会。)
$espClang = Join-Path $env:USERPROFILE '.rustup\toolchains\esp-1.97.0.0\xtensa-esp32-elf-clang\esp-clang\bin'
if (Test-Path -LiteralPath (Join-Path $espClang 'libclang.dll')) {
$env:LIBCLANG_PATH = $espClang
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $espClang })) {
$env:PATH = $espClang + ';' + $env:PATH
}
}
$env:CARGO_WORKSPACE_DIR = (Get-Location).Path
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
& cargo build --release --locked @CargoArgs
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
if ($Package) {
$python = Get-Command python -ErrorAction SilentlyContinue
if ($null -eq $python) {
throw '-Package requires Python 3 on PATH (used by scripts/package_firmware.py).'
}
# 固件包的版本号与客户端同源:都读仓库根的 version.json(由 scripts/version.mjs
# 从 git 历史算出)。不传的话 make_bundle.py 会退回 BUILD-INFO.json 里的
# 变体名+提交号,那是另一套编号,装到客户端里会与界面上的版本对不上。
$versionArgs = @()
if (Test-Path -LiteralPath 'version.json') {
$info = Get-Content -LiteralPath 'version.json' -Raw | ConvertFrom-Json
$versionArgs = @('--version', "v$($info.version)+$($info.build)")
}
& python scripts/package_firmware.py --firmware target/firmware --name 'RMIC 图传固件' @versionArgs
if ($LASTEXITCODE -ne 0) { throw "package_firmware.py failed (exit $LASTEXITCODE)." }
}
} finally {
foreach ($name in $buildEnvironment.Keys) {
[Environment]::SetEnvironmentVariable($name, $buildEnvironment[$name], 'Process')
+18 -1
View File
@@ -23,4 +23,21 @@ fi
: "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}"
export WIFI_PASS="${WIFI_PASS:-}"
cargo build --release --locked "$@"
package=0
cargo_args=()
for arg in "$@"; do
case "$arg" in
--package) package=1 ;;
*) cargo_args+=("$arg") ;;
esac
done
cargo build --release --locked "${cargo_args[@]}"
if [ "$package" -eq 1 ]; then
if ! command -v python3 >/dev/null 2>&1; then
echo "error: --package requires python3 on PATH" >&2
exit 1
fi
python3 scripts/package_firmware.py --firmware target/firmware
fi
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""把一个已构建的固件目录封装成客户端认的官方 bundle。
官方 bundle 是一个目录,至少包含:
bundle.json
esp32s3-app.bin
partition-table.bin
bootloader.bin # 可选,客户端默认不写
客户端会先校验 bundle.json 里声明的 SHA256,再读设备当前分区表、选择空闲槽位、
写 App + 整段回读;只有回读通过才切换分区表。它不会按这里的 partition-table.bin
直接覆盖设备分区表——设备上的 NVS/PHY 布局以设备当前表为准。
用法(在仓库根目录):
python scripts/make_bundle.py --firmware target/firmware
可选参数:
--name "ESP32-S3 图传固件"
--version "..." 默认读 BUILD-INFO.json 的 build_variant+source_revision
--app-partition-size 0x140000
--flash-size 16mb --flash-mode dio --flash-frequency 40mhz
--notes "..."
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_build_info(firmware: Path) -> dict:
path = firmware / "BUILD-INFO.json"
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def main() -> int:
parser = argparse.ArgumentParser(description="生成官方固件 bundle.json")
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
parser.add_argument("--name", default="ESP32-S3 图传固件")
parser.add_argument("--version", default="")
parser.add_argument("--app", default="esp32s3-app.bin")
parser.add_argument("--partition-table", default="partition-table.bin")
parser.add_argument("--bootloader", default="bootloader.bin")
parser.add_argument("--app-partition-size", default="0x140000")
parser.add_argument("--flash-size", default="16mb")
parser.add_argument("--flash-mode", default="dio")
parser.add_argument("--flash-frequency", default="40mhz")
parser.add_argument("--chip", default="esp32s3")
parser.add_argument("--notes", default="官方 bundle:客户端只写 App 与 patch 后的分区表,不重写 bootloader;旧 App/分区表会先备份。")
args = parser.parse_args()
firmware = Path(args.firmware)
if not firmware.is_dir():
print(f"固件目录不存在:{firmware}", file=sys.stderr)
return 1
app = firmware / args.app
partition_table = firmware / args.partition_table
bootloader = firmware / args.bootloader
for required, label in ((app, "App"), (partition_table, "分区表")):
if not required.is_file():
print(f"{label}文件不存在:{required}", file=sys.stderr)
return 1
if partition_table.stat().st_size != 0xC00:
print(
f"分区表应为 0xC00 字节,实际 {partition_table.stat().st_size}",
file=sys.stderr,
)
return 1
build = read_build_info(firmware)
version = args.version
if not version:
variant = build.get("build_variant")
revision = build.get("source_revision")
if variant and revision:
version = f"{variant}+{revision[:7]}"
else:
version = "0.1.0"
manifest = {
"format": 1,
"name": args.name,
"version": version,
"chip": args.chip,
"app": args.app,
"appSha256": sha256(app),
"partitionTable": args.partition_table,
"partitionTableSha256": sha256(partition_table),
"appPartitionSize": args.app_partition_size,
"flashSize": args.flash_size,
"flashMode": args.flash_mode,
"flashFrequency": args.flash_frequency,
"notes": args.notes,
}
if bootloader.is_file():
manifest["bootloader"] = args.bootloader
manifest["bootloaderSha256"] = sha256(bootloader)
else:
print(f"提示:没有 {bootloader},bundle 将不包含 bootloader", file=sys.stderr)
output = firmware / "bundle.json"
output.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"已写入 {output}")
print(f" {manifest['name']} {manifest['version']}")
print(f" App {app.name} · {app.stat().st_size} 字节 · {manifest['appSha256']}")
print(f" App 分区 {manifest['appPartitionSize']} · Flash {manifest['flashSize']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""把编译后的固件目录打成客户端可直接读取的 .zip 官方包。
流程(在 cargo build 之后):
make_bundle.py → bundle.json(沿用现有清单)
package_firmware.py
├─ 读 bundle.json / BUILD-INFO.json
├─ 固定条目顺序与 mtime,生成可复现的 zip
└─ 写 <zip>.sha256
zip 根目录结构:
bundle.json
esp32s3-app.bin
partition-table.bin (0xC00 字节)
bootloader.bin (可选)
BUILD-INFO.json (可选)
客户端读取 zip 时不要求先解压;目录包仍然兼容。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from datetime import datetime
from pathlib import Path
def sanitize(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
return value.strip("-") or "firmware"
def parse_epoch(build: dict) -> int:
raw = os.environ.get("SOURCE_DATE_EPOCH")
if raw:
try:
return int(raw)
except ValueError:
pass
packaged = build.get("packaged_at")
if packaged:
try:
return int(datetime.fromisoformat(packaged).timestamp())
except (ValueError, TypeError):
pass
return int(time.time())
def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
"""编译后 App 镜像可能还没导出;优先用 espflash 从最新 ELF 重新导出。"""
elf = repo / "target" / "xtensa-esp32s3-espidf" / "release" / "esp32"
app = firmware / "esp32s3-app.bin"
if not elf.is_file():
return
if app.is_file() and app.stat().st_mtime >= elf.stat().st_mtime:
return
if app.is_file():
print(f"提示:{app} 早于 {elf},将尝试从最新 ELF 重新导出", file=sys.stderr)
else:
print(f"提示:缺少 {app},将尝试从最新 ELF 导出", file=sys.stderr)
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
if not espflash.is_file():
found = shutil.which("espflash")
espflash = Path(found) if found else Path("")
if not espflash.is_file():
message = "找不到 espflash,无法从最新 ELF 导出 App 镜像"
if allow_stale:
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"{message}。请先导出 target/firmware/esp32s3-app.bin,或加 --allow-stale。")
firmware.mkdir(parents=True, exist_ok=True)
print(f"导出 App:{elf} -> {app}")
result = subprocess.run(
[
str(espflash),
"save-image",
"--chip",
"esp32s3",
"--format",
"esp-idf",
str(elf),
str(app),
]
)
if result.returncode != 0:
raise SystemExit(f"espflash save-image 失败(exit {result.returncode})")
def main() -> int:
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
parser.add_argument("--name", default="", help="传递给 make_bundle.py 的名称")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本")
parser.add_argument("--skip-manifest", action="store_true", help="不重新生成 bundle.json")
parser.add_argument("--allow-stale", action="store_true", help="允许打包早于 ELF 的 App 镜像")
parser.add_argument("--output", default="", help="输出 zip 路径;默认 target/firmware/camfw-*.zip")
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
firmware = Path(args.firmware)
if not firmware.is_absolute():
firmware = (Path.cwd() / firmware).resolve()
if not firmware.is_dir():
print(f"固件目录不存在:{firmware}", file=sys.stderr)
return 1
try:
export_app_if_stale(repo, firmware, args.allow_stale)
except SystemExit as error:
print(error, file=sys.stderr)
return 1
if not args.skip_manifest:
make_bundle = repo / "scripts" / "make_bundle.py"
command = [sys.executable, str(make_bundle), "--firmware", str(firmware)]
if args.name:
command += ["--name", args.name]
if args.version:
command += ["--version", args.version]
result = subprocess.run(command, text=True)
if result.returncode != 0:
print("make_bundle.py 失败,未生成 zip", file=sys.stderr)
return result.returncode
bundle_path = firmware / "bundle.json"
if not bundle_path.is_file():
print(f"缺少 {bundle_path}", file=sys.stderr)
return 1
bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
build = {}
build_info_path = firmware / "BUILD-INFO.json"
if build_info_path.is_file():
try:
build = json.loads(build_info_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
build = {}
app = firmware / bundle["app"]
partition_table = firmware / bundle["partitionTable"]
if not app.is_file() or not partition_table.is_file():
print("bundle.json 里的 App/分区表文件不存在", file=sys.stderr)
return 1
if partition_table.stat().st_size != 0xC00:
print("partition-table.bin 必须是 0xC00 字节", file=sys.stderr)
return 1
bootloader_name = bundle.get("bootloader")
bootloader = firmware / bootloader_name if bootloader_name else None
if bootloader is not None and not bootloader.is_file():
print(f"bundle.json 声明的 bootloader 不存在:{bootloader}", file=sys.stderr)
return 1
epoch = parse_epoch(build)
stamp = time.gmtime(epoch)
date_time = (
stamp.tm_year,
stamp.tm_mon,
stamp.tm_mday,
stamp.tm_hour,
stamp.tm_min,
stamp.tm_sec,
)
date_text = f"{stamp.tm_year:04d}{stamp.tm_mon:02d}{stamp.tm_mday:02d}"
variant = sanitize(str(build.get("build_variant", "firmware")))
revision = sanitize(str(build.get("source_revision", ""))[:7])
chip = sanitize(str(bundle.get("chip", "esp32s3")))
# 文件名优先用**传进来的版本**(`--version`,来自仓库根的 version.json)。
#
# 原来只用 BUILD-INFO.json 里的变体名 + 提交号 + 日期,那是另一套编号:固件包叫
# `...-4794bed-20261018.zip`,而客户端里显示的是 `v0.2.0+34`,两个数对不上,
# 现场拿到一个 zip 没法说清它是不是界面上那一版。给了版本就用版本,没给才退回旧格式。
if args.version:
filename = f"camfw-{chip}-{sanitize(args.version)}.zip"
else:
suffix = f"-{revision}" if revision else ""
filename = f"camfw-{chip}-{variant}{suffix}-{date_text}.zip"
output = Path(args.output) if args.output else firmware / filename
if not output.is_absolute():
output = (Path.cwd() / output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
entries: list[tuple[str, bytes]] = [
("bundle.json", bundle_path.read_bytes()),
(app.name, app.read_bytes()),
(partition_table.name, partition_table.read_bytes()),
]
if bootloader is not None:
entries.append((bootloader.name, bootloader.read_bytes()))
if build_info_path.is_file():
entries.append(("BUILD-INFO.json", build_info_path.read_bytes()))
with zipfile.ZipFile(
output,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for name, data in entries:
info = zipfile.ZipInfo(filename=name, date_time=date_time)
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
info.external_attr = 0o100644 << 16
archive.writestr(info, data)
digest = hashlib.sha256(output.read_bytes()).hexdigest()
sidecar = output.with_name(output.name + ".sha256")
sidecar.write_text(f"{digest} {output.name}\n", encoding="utf-8")
print(f"已生成 {output}")
print(f" {output.stat().st_size} 字节 · {len(entries)} 项 · SHA256 {digest}")
print(f" {sidecar}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env node
/**
* 版本号与改动日志的计算器。
*
* # 为什么要有它
*
* 版本号与改动日志原来都是**手写**的:改一次代码要记得去改三处版本号,再想想这次该升哪
* 一位,最后在 CHANGELOG 里补一段。这三件事都会忘,而忘了之后没有任何东西会报错——
* 典型表现是装包里写着 0.1.0,实际是两周后的代码。
*
* 现在它们都从 **git 历史**算出来:
*
* - 版本号 = 上一个 tag + 这之间提交的语义(conventional commits);
* - 改动日志 = 同一批提交按类型分组;
* - 构建号 = `git rev-list --count HEAD`,单调递增,无冲突。
*
* 于是"这次该升哪一位"不再是一个判断,而是一次计算。
*
* # 用法
*
* node scripts/version.mjs --write # 计算并写回所有派生文件
* node scripts/version.mjs --check # 只校验:有未归档的提交就退 1
* node scripts/version.mjs --print # 把算出来的 version.json 打到 stdout
*
* # 约定
*
* 提交标题用 Conventional Commits:`type(scope): 说明`,可加 `!` 表示破坏性改动,
* 或在正文里写 `BREAKING CHANGE:`。规则:
*
* | 有 breaking | major |
* | 有 feat | minor |
* | 有 fix / perf | patch |
* | 其它 | 不动版本,只加构建号 |
*
* 正文里以 `- ` 开头、且本身写成 `type(scope): 说明` 的行,会被当作**额外的条目**收进
* 同一份日志。这样一次 squash 提交也能把几件事分别归类,而不必为了日志好看去拆提交。
*/
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/** 各类提交在日志里的标题与顺序。没列出的类型归入「其它」。 */
const GROUPS = [
["feat", "新增"],
["fix", "修复"],
["perf", "性能"],
["refactor", "重构"],
["build", "构建"],
["ci", "CI"],
["docs", "文档"],
["test", "测试"],
["style", "样式"],
["revert", "回滚"],
["chore", "杂项"],
["other", "其它"],
];
const TYPE_ORDER = GROUPS.map(([type]) => type);
// ---------------------------------------------------------------------------
// git
// ---------------------------------------------------------------------------
function git(args, { allowFail = false } = {}) {
try {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
} catch (err) {
if (allowFail) return "";
throw new Error(`git ${args.join(" ")} 失败:${err.message}`);
}
}
/** 最近的一个 `v*` 标签;没有标签时返回 null(第一次发布)。 */
function lastTag() {
const tag = git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], {
allowFail: true,
});
return tag || null;
}
/** `v1.2.3` → `[1,2,3]`。 */
function parseTag(tag) {
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag ?? "");
if (!match) return [0, 0, 0];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
/**
* 取 `since..HEAD` 的提交。
*
* 用 `%x1f` / `%x1e` 作分隔符:它们不会出现在提交信息里,比按空行切更稳。
*/
function commitsSince(since) {
const range = since ? `${since}..HEAD` : "HEAD";
const raw = git(["log", range, "--no-merges", "--pretty=format:%H%x1f%s%x1f%b%x1e"]);
if (!raw) return [];
return raw
.split("\x1e")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [hash, subject, body] = chunk.split("\x1f");
return { hash, subject: subject ?? "", body: body ?? "" };
});
}
// ---------------------------------------------------------------------------
// 解析
// ---------------------------------------------------------------------------
const SUBJECT = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s*(?<text>.+)$/;
/**
* 把一条提交拆成一个主条目 + 正文里的追加条目。
*
* 正文里的追加条目必须自己写成 `type(scope): 说明`,否则不猜——猜错的日志比没有更糟。
*/
function entriesOf(commit) {
const head = SUBJECT.exec(commit.subject);
const breaking = /(^|\n)BREAKING[ -]CHANGE:/.test(commit.body) || Boolean(head?.groups?.bang);
const entries = [];
if (head) {
entries.push({
type: head.groups.type,
scope: head.groups.scope ?? null,
text: head.groups.text.trim(),
breaking,
});
} else {
entries.push({ type: "other", scope: null, text: commit.subject, breaking });
}
for (const line of commit.body.split("\n")) {
const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
if (!bullet) continue;
const nested = SUBJECT.exec(bullet[1].trim());
if (!nested) continue;
entries.push({
type: nested.groups.type,
scope: nested.groups.scope ?? null,
text: nested.groups.text.trim(),
breaking: Boolean(nested.groups.bang),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// 计算
// ---------------------------------------------------------------------------
function bump(base, entries) {
if (entries.some((entry) => entry.breaking)) return [base[0] + 1, 0, 0];
if (entries.some((entry) => entry.type === "feat")) return [base[0], base[1] + 1, 0];
if (entries.some((entry) => entry.type === "fix" || entry.type === "perf")) {
return [base[0], base[1], base[2] + 1];
}
return base;
}
function compute() {
const tag = lastTag();
const base = parseTag(tag);
const commits = commitsSince(tag);
const entries = commits.flatMap(entriesOf);
const version = bump(base, entries);
const build = Number(git(["rev-list", "--count", "HEAD"]) || 0);
return {
tag,
base,
commits,
entries,
version,
build,
dirty: git(["status", "--porcelain"]).length > 0,
};
}
function metaOf(result) {
const [major, minor, patch] = result.version;
return {
version: `${major}.${minor}.${patch}`,
major,
minor,
patch,
build: result.build,
protocol: protocolVersion(),
// 生成日期取提交日期而不是"现在":同一次发布重跑两次,产物必须逐字节相同,
// 否则每次 --check 都会报一个并不存在的差异。
date: git(["log", "-1", "--pretty=format:%cs"]),
};
}
/** 线路协议版本以 protocol crate 里的常量为准,不从别处猜。 */
function protocolVersion() {
const source = readFileSync(join(ROOT, "protocol/src/lib.rs"), "utf8");
const match = /pub const PROTOCOL_VERSION: u16 = (\d+);/.exec(source);
if (!match) throw new Error("protocol/src/lib.rs 里找不到 PROTOCOL_VERSION");
return Number(match[1]);
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
function groupEntries(entries) {
const buckets = new Map(GROUPS.map(([type]) => [type, []]));
for (const entry of entries) {
const bucket = buckets.get(entry.type) ?? buckets.get("other");
bucket.push(entry);
}
return [...buckets.entries()]
.filter(([, list]) => list.length > 0)
.sort((a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]));
}
function renderChangelog(meta, result) {
const lines = [];
lines.push("# 改动记录");
lines.push("");
lines.push(
"> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。",
);
lines.push("> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。");
lines.push("> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。");
lines.push("");
if (result.entries.length === 0) {
lines.push(`## v${meta.version} — ${meta.date}`);
lines.push("");
lines.push("(自上一个发布以来没有面向使用者的改动。)");
lines.push("");
return lines.join("\n");
}
const span = result.tag ? `${result.tag} → v${meta.version}` : `初始 → v${meta.version}`;
lines.push(`## v${meta.version} — ${meta.date}`);
lines.push("");
lines.push(
`构建号 **${meta.build}** · 线路协议 **${meta.protocol}** · ` +
`${result.commits.length} 个提交(${span})`,
);
lines.push("");
for (const [type, list] of groupEntries(result.entries)) {
const heading = GROUPS.find(([key]) => key === type)?.[1] ?? "其它";
lines.push(`### ${heading}`);
lines.push("");
for (const entry of list) {
const scope = entry.scope ? `**${entry.scope}** · ` : "";
const bang = entry.breaking ? "(破坏性)" : "";
lines.push(`- ${scope}${entry.text}${bang}`);
}
lines.push("");
}
return lines.join("\n");
}
function renderJson(meta, result) {
return (
JSON.stringify(
{
...meta,
baseline: result.tag,
commits: result.commits.length,
groups: Object.fromEntries(
groupEntries(result.entries).map(([type, list]) => [
type,
list.map((entry) => ({
scope: entry.scope,
text: entry.text,
breaking: entry.breaking,
})),
]),
),
},
null,
2,
) + "\n"
);
}
function renderTs(meta) {
return `/**
* 构建信息。**由 \`scripts/version.mjs\` 生成,请勿手改。**
*
* 版本号与构建号都从 git 历史算出来(见该脚本的文件头),所以界面显示的一定就是
* 这一份二进制的来源,不会出现"装包里写着 0.1.0,其实是两周后的代码"。
*/
export const BUILD = {
version: "${meta.version}",
major: ${meta.major},
minor: ${meta.minor},
patch: ${meta.patch},
/** git 提交数,单调递增。 */
build: ${meta.build},
/** 线路协议版本。与固件自报的那个数直接比对。 */
protocol: ${meta.protocol},
/** 生成时的提交日期(\`YYYY-MM-DD\`)。 */
date: "${meta.date}",
} as const;
/** \`"0.2.0+57"\` —— 界面上放得下的那一行。 */
export const VERSION_LABEL = \`v\${BUILD.version}+\${BUILD.build}\`;
`;
}
// ---------------------------------------------------------------------------
// 写回
// ---------------------------------------------------------------------------
function writeIfChanged(path, content) {
const full = join(ROOT, path);
if (existsSync(full) && readFileSync(full, "utf8") === content) {
return false;
}
writeFileSync(full, content, "utf8");
return true;
}
/** 把版本写进 JSON 文件,保留原有排版(这些文件是给人看也给人改的)。 */
function syncJsonVersion(path, version) {
const full = join(ROOT, path);
if (!existsSync(full)) return false;
const source = readFileSync(full, "utf8");
const next = source.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`);
if (next === source) return false;
writeFileSync(full, next, "utf8");
return true;
}
function write(meta, result) {
const changed = [];
const put = (path, content) => {
if (writeIfChanged(path, content)) changed.push(path);
};
put(
"version.json",
JSON.stringify(
{
...meta,
baseline: result.tag,
note: "由 scripts/version.mjs 从 git 历史算出,请勿手改。",
},
null,
2,
) + "\n",
);
put("CHANGELOG.md", renderChangelog(meta, result));
put("CHANGELOG.json", renderJson(meta, result));
put("desktop/src/lib/version.ts", renderTs(meta));
if (syncJsonVersion("desktop/package.json", meta.version)) changed.push("desktop/package.json");
if (syncJsonVersion("desktop/src-tauri/tauri.conf.json", meta.version)) {
changed.push("desktop/src-tauri/tauri.conf.json");
}
return changed;
}
// ---------------------------------------------------------------------------
// 入口
// ---------------------------------------------------------------------------
const argv = new Set(process.argv.slice(2));
const result = compute();
const meta = metaOf(result);
if (argv.has("--print")) {
process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
process.exit(0);
}
if (argv.has("--check")) {
const committed = existsSync(join(ROOT, "version.json"))
? JSON.parse(readFileSync(join(ROOT, "version.json"), "utf8"))
: null;
if (!committed) {
console.error("version.json 不存在。先跑一次 `node scripts/version.mjs --write`。");
process.exit(1);
}
if (committed.version !== meta.version || committed.build !== meta.build) {
console.error(
`版本已过期:仓库里写的是 v${committed.version}+${committed.build},` +
`按 git 历史算出来应该是 v${meta.version}+${meta.build}。\n` +
"跑 `node scripts/version.mjs --write` 再提交。",
);
process.exit(1);
}
console.log(`版本一致:v${meta.version}+${meta.build}`);
process.exit(0);
}
const changed = write(meta, result);
console.log(
`v${meta.version}+${meta.build}(协议 ${meta.protocol},基线 ${result.tag ?? "无"},` +
`${result.commits.length} 个提交,${result.entries.length} 个条目)`,
);
if (changed.length > 0) {
console.log("已更新:" + changed.join("、"));
} else {
console.log("所有派生文件都是最新的。");
}
if (result.dirty) {
console.log("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
}