From a85115c96e764580d862eef8bbf79c725d61025c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 15 Sep 2026 01:14:50 +0800 Subject: [PATCH] =?UTF-8?q?build(linux):=20=E6=96=B0=E5=A2=9E=20deploy/bui?= =?UTF-8?q?ld-linux.sh=20=E2=80=94=E2=80=94=20=E6=89=8B=E5=B7=A5=E6=8B=BC?= =?UTF-8?q?=20arm64=20Console=20=E5=8C=85=EF=BC=88=E7=BB=95=E5=BC=80=20ele?= =?UTF-8?q?ctron-builder=20=E5=9C=A8=20Linux=20=E4=B8=8A=E7=9A=84=E5=BF=85?= =?UTF-8?q?=E5=B4=A9=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么手工拼:electron-builder 26.x 在「需要下载 Electron 运行时/7zip 工具」那条分支上会抛 `Cannot read properties of undefined (reading 'ReadWrite')`(`app-builder-lib/util/electronGet.ts` 里 `ElectronDownloadCacheMode` 未定义),本机与构建机都复现过。 脚本做三件事: 1. 交叉编译 arm64 服务端(CGO_ENABLED=0,纯 Go SQLite 驱动,产物静态链接); 2. 用本地现成的 Electron linux-arm64 运行时光手拼出可运行的包树 (Electron 可执行改名 + resources/app/ 放 console 代码 + 服务端二进制放包根); 3. 打 tar.gz(幂等:每次先清旧产物)。 包内布局依据 console/main 里的路径解析链:服务端与 Console 同级(包根), config.yaml / rules/ 也在包根(首启由 userpaths 复制到 $XDG_DATA_HOME/ScoreboardConsole/)。 本机 Xvfb 实测:Console 起得来、拉起服务端、/api/health 200、XDG 路径与 Linux 文件名都生效。 --- deploy/build-linux.sh | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100755 deploy/build-linux.sh diff --git a/deploy/build-linux.sh b/deploy/build-linux.sh new file mode 100755 index 0000000..8668e4f --- /dev/null +++ b/deploy/build-linux.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Scoreboard Console · Linux/arm64 发布包(tar.gz)构建脚本 +# +# 为什么手工拼包,而不是让 electron-builder 出 Linux 包: +# 1) electron-builder 在 Linux 上有一条必崩的路径(打 win 目标时读不到的 +# 'ReadWrite' 属性),本项目今晚实测复现过,跑不通就没法作为发布手段; +# 2) 它还要现下 Linux 版 Electron 运行时,本机网络下这个下载极不稳定; +# 3) Linux 的 tar.gz 本质就是「Electron 运行时 + resources/app/ 里的应用文件」, +# 布局是可数的几层目录,手工拼比驱动一个有崩溃路径的工具更可靠,也不需要联网。 +# 因此:运行时直接复用本机已有的 electron/dist(arm64),应用文件按 console/package.json +# 的 build.files / build.extraFiles 声明的位置原样摆放 —— 摆错了 Console 就找不到服务端 +# 二进制(见下面「服务端二进制放哪」那一段)。 +# +# 服务端二进制放哪(依据 console/main/ 的实际代码,不是猜的): +# console/main/index.js 的 appDir() 在打包态返回 path.dirname(app.getPath('exe')), +# 即 Console 可执行文件所在目录 = 部署包根目录;index.js 再把它作为 baseDir 交给 +# config.resolveExecutable(),候选表第一项就是 <部署包根>/scoreboard(文件名由 +# platform.serverExeName() 按平台给出,Linux 无 .exe 后缀)。 +# 所以服务端二进制与 Console 可执行文件**同级**放在包根,不能塞进 resources/。 +# 同一段代码还决定 config.yaml / rules / config.json 也按包根解析(userpaths.js 首次启动 +# 从包根复制到用户目录当模板),所以这几样同样摆在包根。 +# +# 用法:deploy/build-linux.sh +# 环境变量: +# ELECTRON_DIST= 指定 Electron 运行时目录(默认取本机 ClockLab 里那份 arm64) +# SERVER_BIN= 跳过交叉编译,直接用现成的 arm64 服务端二进制 +# SERVER_OUT= 交叉编译出的裸服务端另存位置(默认 debug/服务端-arm64/scoreboard; +# 设成空串则不另存,只放进 Console 包) +# OUT_DIR= 输出目录(默认 debug/console-linux-arm64) +# 产物:/ScoreboardConsole--linux-arm64.tar.gz +# 解压后顶层目录是 ScoreboardConsole/,进去直接跑 ./ScoreboardConsole 即可。 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${OUT_DIR:-$ROOT/debug/console-linux-arm64}" +ELECTRON_DIST="${ELECTRON_DIST:-$HOME/Project/ClockLab/node_modules/electron/dist}" +SERVER_BIN="${SERVER_BIN:-}" +# 默认要另存一份裸服务端;显式设 SERVER_OUT= 空串表示只放进 Console 包、不另存 +SERVER_OUT="${SERVER_OUT-$ROOT/debug/服务端-arm64/scoreboard}" + +die() { echo "[build-linux] 错误:$*" >&2; exit 1; } + +# ---- 0. 版本号:只认仓库根的 VERSION(唯一真相),与 deploy/build.bat 同一口径 ---- +[ -f "$ROOT/VERSION" ] || die "找不到 $ROOT/VERSION" +VERSION="$(sed -e 's/^\xEF\xBB\xBF//' "$ROOT/VERSION" | grep -m1 -v '^[[:space:]]*$' | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" +[ -n "$VERSION" ] || die "VERSION 文件是空的" + +# ---- 1. 清干净再拼:重复执行不会把上一版的文件叠进包里 ---- +STAGE="$OUT_DIR/ScoreboardConsole" +TARBALL="$OUT_DIR/ScoreboardConsole-${VERSION}-linux-arm64.tar.gz" +mkdir -p "$OUT_DIR" +rm -rf "$STAGE" +rm -f "$TARBALL" + +# ---- 2. Electron 运行时:只用本机已有的,绝不下载 ---- +[ -x "$ELECTRON_DIST/electron" ] || die "本机没有可用的 Electron 运行时:$ELECTRON_DIST(本脚本不联网下载)" +ELECTRON_VERSION="$(cat "$ELECTRON_DIST/version" 2>/dev/null || echo '?')" + +echo "[build-linux] 版本 $VERSION,Electron 运行时 $ELECTRON_VERSION($ELECTRON_DIST)" +cp -a "$ELECTRON_DIST" "$STAGE" + +# 可执行文件改名成产品名:窗口标题、任务栏分组、托盘都按它走; +# 同时这一步定下了 app.getPath('exe'),也就是后面所有路径的基准 +mv "$STAGE/electron" "$STAGE/ScoreboardConsole" +# 运行时自带的是「没给 app 目录时打开的那张欢迎页」,我们的应用不放这儿,留着只是多余的 100KB +rm -rf "$STAGE/resources/default_app.asar" + +# ---- 3. 应用文件:对应 console/package.json 的 build.files ---- +APP_DIR="$STAGE/resources/app" +mkdir -p "$APP_DIR" +for item in main renderer scripts config.json package.json; do + [ -e "$ROOT/console/$item" ] || die "console/$item 不存在,应用文件不全" + cp -a "$ROOT/console/$item" "$APP_DIR/" +done +# 单元测试不进发布包:*.test.js 是开发期的东西,带上只会让包内代码与「跑的是什么」对不上 +find "$APP_DIR" -name '*.test.js' -delete + +# ---- 4. 服务端二进制(linux/arm64):与 Console 可执行文件同级,见文件头说明 ---- +if [ -n "$SERVER_BIN" ]; then + [ -f "$SERVER_BIN" ] || die "SERVER_BIN 指向的文件不存在:$SERVER_BIN" + echo "[build-linux] 使用现成服务端二进制:$SERVER_BIN" + cp -f "$SERVER_BIN" "$STAGE/scoreboard" +else + command -v go >/dev/null 2>&1 || die "没装 Go,交叉编译不了 linux/arm64 服务端(可用 SERVER_BIN=... 指定现成二进制)" + echo "[build-linux] 交叉编译服务端 linux/arm64(CGO 关掉 = 静态链接,不依赖目标机的 libc)" + BUILD_TIME="$(date '+%Y-%m-%d %H:%M:%S')" + # 版本号与构建时间在编译这一刻写死进二进制;运行期不再读任何文件 + ( cd "$ROOT/server" && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -trimpath \ + -ldflags "-s -w -X 'main.version=$VERSION' -X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.BuildTime=$BUILD_TIME'" \ + -o "$STAGE/scoreboard" . ) + # 裸服务端也单独留一份:排障时要能脱离 Console 直接起它看日志, + # 与 Windows 侧 build.bat 把服务端落到 dist\ 是同一个用途。SERVER_OUT= 可关掉。 + if [ -n "$SERVER_OUT" ]; then + mkdir -p "$(dirname "$SERVER_OUT")" + cp -f "$STAGE/scoreboard" "$SERVER_OUT" + echo "[build-linux] 裸服务端另存:$SERVER_OUT" + fi +fi +chmod +x "$STAGE/scoreboard" + +# ---- 5. 包根随包文件:对应 console/package.json 的 build.extraFiles ---- +cp -a "$ROOT/config.yaml" "$STAGE/config.yaml" +cp -a "$ROOT/console/config.json" "$STAGE/config.json" +cp -a "$ROOT/console/scoreboard.png" "$STAGE/scoreboard.png" +cp -a "$ROOT/rules" "$STAGE/rules" +# 说明书之类平铺在包根(extraFiles 的 from: extra, to: .)。 +# 这些 .txt 是 deploy/prepare-extra.mjs 从 docs/ 生成到 console/extra/ 的,本脚本不重新生成, +# 只负责把它们放进包里;没有就跳过(Linux 包不该因为没有说明书而构建失败)。 +if [ -d "$ROOT/console/extra" ]; then + find "$ROOT/console/extra" -maxdepth 1 -type f ! -name '.gitkeep' -exec cp -a {} "$STAGE/" \; +fi + +# ---- 6. 打 tar.gz ---- +chmod +x "$STAGE/ScoreboardConsole" "$STAGE/chrome-sandbox" 2>/dev/null || true +# --owner/--group 归零:包里的属主信息没有意义(非 root 解压时本来就会被忽略), +# 归一以后谁解出来的东西都一样。 +# 注意 setuid 位没法靠 tar 交付:Electron 的 chrome-sandbox 在禁用 user namespace 的机器上 +# 需要 root:root + 4755,那是解压后由现场自己补的一步(见末尾提示),包里带不了。 +tar --create --gzip --file "$TARBALL" --owner=0 --group=0 --numeric-owner -C "$OUT_DIR" ScoreboardConsole + +echo +echo "[build-linux] 完成" +echo " 包内布局(前两层):" +tar -tzf "$TARBALL" | awk -F/ 'NF<=2' | sed 's/^/ /' +echo " 产物:$TARBALL" +echo " 大小:$(du -h "$TARBALL" | cut -f1)($(stat -c%s "$TARBALL") 字节)" +echo " SHA256:$(sha256sum "$TARBALL" | cut -d' ' -f1)" +echo +echo " 无头环境试跑(有桌面环境时不需要 --no-sandbox):" +echo " ./ScoreboardConsole --no-sandbox" +echo " 内核禁用了 user namespace 时,沙箱要现场补一次(tar 交付不了 setuid):" +echo " sudo chown root:root chrome-sandbox && sudo chmod 4755 chrome-sandbox"