feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- 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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
|
||||
}
|
||||
Reference in New Issue
Block a user