#!/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/ 生成,**必须在这里现场重新生成一次**:
# 只拷现成的,包里就会带上"上一次生成时的旧说明书"——已经踩过一次:
# 包内「升级说明.txt」停在 dev_0.5.2,而源码已经走到 0.5.11。
# 生成失败**不阻断**构建(Linux 包不该因为说明书而失败),但打印一行警告,别静默发旧文档。
if [ -f "$ROOT/deploy/prepare-extra.mjs" ] && command -v node >/dev/null 2>&1; then
if ! (cd "$ROOT" && node deploy/prepare-extra.mjs >/dev/null 2>&1); then
echo " ⚠️ 随包说明书重新生成失败(node deploy/prepare-extra.mjs),包里可能仍是旧版" >&2
fi
fi
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"