Files
Scoreboard/deploy/prepare-extra.mjs
T
AskaEth 9fee6f2da5 fix(installer): 安装器脚本不能带 BOM —— 它会拼进大脚本中间让 makensis 报 Invalid command
构建机上真实失败:`Invalid command: "?;"` / `Error in script "<stdin>" on line 83`。
本机单编这个脚本一切正常,只有 electron-builder 出包才炸。

根因:electron-builder 把「它的共享头部 + 我们的脚本」拼成一个整脚本、经 stdin 喂给 makensis,
而 Node 的 readFileSync(…, 'utf8') **不会**吃掉文件开头的 BOM —— 那个 U+FEFF 落在拼接结果
中间某一行的行首,makensis 就把它当成命令名的一部分(控制台显示成问号)。

- 去掉 console/installer/installer.nsi 的 UTF-8 BOM
- deploy/prepare-extra.mjs 的检查反向:从「必须有 BOM」改成「必须没有 BOM」,并把原因写进注释,
  避免下一个人再按直觉加回去;中文编码改由 makensis 的 -INPUTCHARSET UTF8 保证
  (electron-builder 本来就会传这个参数,本机手工编译也要带)

验证:无 BOM → prepare-extra 退出 0;临时加回 BOM → 退出 1 且提示准确。
2026-09-13 15:25:01 +08:00

236 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 构建前准备三样东西:
// 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录;
// 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件)
// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)
// + 按安装模式放行/回收防火墙入站 19540(「为所有用户安装」那一趟本来就提了权,顺手做掉,
// 省得现场再拿管理员 cmd 手工敲 netsh);
// 3) 读仓库根 VERSION 文件:生成 deploy/version.gen.bat(供 build.bat 注入 Go 二进制)
// 并同步 console/package.json 的 version(决定安装包/免安装包的文件名)。
// 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
const root = dirname(import.meta.dirname)
// ---- 1. usage docs ----------------------------------------------------------
// 都暂存成 .txt:部署包根目录里给现场双击打开,.md 在记事本里全是符号。
// 源文件优先取 docs/ 下同名 .txt(便于临时替换),否则用仓库里的 .md。
const extraDir = join(root, 'console', 'extra')
mkdirSync(extraDir, { recursive: true })
const docsToStage = [
{ target: '使用说明.txt', sources: ['使用说明.txt', '使用手册.md'], label: 'usage doc' },
{ target: '报名名单导入规范.txt', sources: ['报名名单导入规范.md'], label: 'entry import spec' },
// 升级须知也要随包走:现场覆盖安装前能在安装目录里先看到「这一版改了什么、要补什么」。
{ target: '升级说明.txt', sources: ['升级说明.md'], label: 'upgrade notes' },
]
for (const doc of docsToStage) {
const source = doc.sources.map((name) => join(root, 'docs', name)).find((file) => existsSync(file))
if (!source) {
console.log(` [docs] no ${doc.label}, skip`)
continue
}
copyFileSync(source, join(extraDir, doc.target))
console.log(` ${doc.label} staged from ${source}`)
}
// ---- 1b. 自绘安装器脚本的编码检查 -------------------------------------------
// 这条检查要求**没有** BOM —— 方向反直觉,但踩过一次真实的构建失败:
// electron-builder 把「它自己的共享头部 + 我们的脚本」拼成一个整脚本、经 stdin 喂给 makensis,
// 而 Node 的 readFileSync(…, 'utf8') **不会**吃掉文件开头的 BOM:那个 U+FEFF 会落在拼接结果
// 中间某一行的行首,makensis 报 `Invalid command: "?;"`(BOM 被控制台显示成问号),
// 且报的是拼接后的行号 —— 本地单编这个脚本时一切正常,只有构建机才炸,极难定位。
// 中文编码由 makensis 的 `-INPUTCHARSET UTF8` 保证(electron-builder 会传这个参数);
// 本机手工编译时同样带上它,**不要靠加 BOM**。
const installerScript = join(root, 'console', 'installer', 'installer.nsi')
if (existsSync(installerScript)) {
const head = readFileSync(installerScript).subarray(0, 3)
if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
console.error(` [installer] ${installerScript} 带了 UTF-8 BOM:它会被拼进大脚本中间,`)
console.error(' [installer] 构建时 makensis 报 Invalid command: "?;"。请另存为不带 BOM 的 UTF-8。')
process.exit(1)
}
console.log(' installer script has no BOM (required by the build)')
}
// ---- 2. installer custom script ---------------------------------------------
// electron-builder 的 nsis.include 只收一个文件,所以许可页文案、安装目录记忆与防火墙规则写在同一个 .nsh 里。
const signatureFile = join(root, 'deploy', 'signature.local.txt')
const nshDir = join(root, 'console', 'resources')
const nshFile = join(nshDir, 'installer-custom.nsh')
const legacyNshFile = join(nshDir, 'installer-signature.nsh')
mkdirSync(nshDir, { recursive: true })
let topText = '请阅读并接受许可协议'
if (existsSync(signatureFile)) {
const signature = readFileSync(signatureFile, 'utf8').trim()
if (signature !== '') topText = `内部署名:${signature}`
}
// 换行用 CRLF:.gitattributes 里 *.nsh 就是 CRLF,NSIS 在 Windows 上按CRLF 读最稳。
const nsh = [
'!include "LogicLib.nsh"',
'',
'; 安装器自定义脚本:由 deploy/prepare-extra.mjs 生成,不要手改。',
'; 内容:许可页文案、上次安装目录记忆、防火墙入站规则(装时放行 / 卸时回收)、卸载钩子。',
'',
'; 自己记的安装目录(per-user 落 HKCU,per-all-users 落 HKLM)。',
'; electron-builder 自带的 ${INSTALL_REGISTRY_KEY} 在卸载时会被整键删掉,',
'; 「先卸载再装新版」或换过安装模式就会退回系统默认路径,所以另存一份不会被卸载器清理的。',
'!define SCOREBOARD_INSTALL_KEY "Software\\Scoreboard Console"',
'',
'; 防火墙入站规则的规则名:安装与卸载共用这一个字面量,改它两处一起改(名字对不上就删不掉)。',
'; 端口写死安装包的默认端口:现场改过 config.yaml 里的 server.port 时,这条规则对不上实际端口,',
'; 得按实际端口现场再放行一次(见 docs/部署指南.md 7.5)。',
'!define SCOREBOARD_FIREWALL_RULE "Scoreboard Console (TCP 19540)"',
'!define SCOREBOARD_FIREWALL_PORT "19540"',
'',
'!macro customHeader',
` !define MUI_LICENSEPAGE_TEXT_TOP "${topText}"`,
'!macroend',
'',
'!macro customInit',
' ; 在 .onInit 里、initMultiUser 之后执行:此时改 $INSTDIR 还来得及影响后面的目录页。',
' ReadRegStr $R9 HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' ; 安装模式页(per-user / per-all-users)离开时,electron-builder 会用自己那个键',
' ; 重新算一遍 $INSTDIR;卸载后它已经被删了,把记忆值补写回同一个 hive 里。',
' ; 不跨 hive 写:否则会把 per-machine 装的机器悄悄带成默认 per-user 模式(反之亦然)。',
' WriteRegStr HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
' ${Else}',
' ReadRegStr $R9 HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' WriteRegStr HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
' ${Else}',
' ; 没有自己的记忆时,退回 electron-builder 的键(老版本装过、没卸载过的情况)',
' ReadRegStr $R9 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
' ${If} $R9 == ""',
' ReadRegStr $R9 HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
' ${EndIf}',
' ; 只认非空值;旧目录已经被删掉也照样用(这就是「记住我的路径」的语义)。',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' ${EndIf}',
' ${EndIf}',
' ${EndIf}',
' ClearErrors',
'!macroend',
'',
'!macro customInstall',
' ; SHELL_CONTEXT 跟随安装模式:per-user → HKCU,per-all-users → HKLM。',
' WriteRegStr SHELL_CONTEXT "${SCOREBOARD_INSTALL_KEY}" "InstallLocation" "$INSTDIR"',
'',
' ; 放行防火墙入站端口,只在「为所有用户安装」这一路做。依据:',
' ; $installMode 由 electron-builder 的安装模式页写定(multiUser.nsh 里就是 "all" / "CurrentUser");',
' ; 选「为所有用户安装」时安装器先弹 UAC 提权、提权成功的那个进程才继续跑安装段',
' ; (multiUserUi.nsh 里对「all users」单选钮的处理:UAC_RunElevated,外层进程直接 Quit),',
' ; 所以走到这里时进程已经是管理员,netsh 才有权限改防火墙。',
' ; 选「仅为我安装」全程不提权(installer.nsi 里 RequestExecutionLevel user),netsh 必然失败,',
' ; 所以这里连试都不试:省一次注定失败的调用,也不往安装日志里塞看不懂的报错。',
' ; UAC_IsAdmin 再确认一次当前确实是提权进程(宏来自 UAC.nsh,multiUser.nsh 已经 include 过),',
' ; 两个条件都成立才动手,任何情况下都不会拿普通用户权限去调 netsh。',
' ${If} $installMode == "all"',
' ${AndIf} ${UAC_IsAdmin}',
' ; 先删后加:覆盖安装/重装时把上一条同名规则清掉,免得同名规则越积越多。',
' ; ExecToLog = 隐藏执行、不弹黑窗,输出只进安装日志(装完展开「显示细节」才看得到)。',
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
' Pop $0',
' ; 失败一律忽略:netsh 返回非 0(被组策略禁用、杀软拦截、防火墙服务被拆……)就只留一行日志,',
' ; 绝不能因为防火墙这一步让安装失败——规则没加上,现场还能按 docs/部署指南.md 7.5 手工补。',
' nsExec::ExecToLog \'netsh advfirewall firewall add rule name="${SCOREBOARD_FIREWALL_RULE}" dir=in action=allow protocol=TCP localport=${SCOREBOARD_FIREWALL_PORT}\'',
' Pop $0',
' ClearErrors',
' ${EndIf}',
'!macroend',
'',
'!macro customUnInstall',
' ; 安装目录记忆故意不删:需求就是「卸载后重装仍沿用上次的目录」。',
' ; 若改成卸载即遗忘,在这里补两行(卸载时 SHELL_CONTEXT 不一定已设定,要写死根键):',
' ; DeleteRegValue HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ; DeleteRegValue HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
'',
' ; 回收安装时加的那条入站规则。卸载走哪条路要看当初是怎么装的:',
' ; 按「为所有用户安装」装的:卸载器进卸载段之前会先 UAC 提权',
' ; (multiUserUi.nsh 里发现只有 per-machine 安装、且当前不是管理员时 UAC_RunElevated),',
' ; 当前进程是管理员,netsh 能删掉;',
' ; 按「仅为我安装」装的:全程不提权,netsh 必然失败——需求是「失败就算了」。',
' ; 所以这里只判断「是不是管理员」,不判断安装模式:非管理员时连试都不试,不报错、不留提示,',
' ; 免得卸载日志里多出一行用户看不懂的 netsh 报错。',
' ${If} ${UAC_IsAdmin}',
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
' Pop $0',
' ClearErrors',
' ${EndIf}',
'!macroend',
'',
].join('\r\n')
writeFileSync(nshFile, nsh, 'utf8')
// 改名前生成的旧脚本已经没人引用,构建机上顺手清掉,免得看起来还在用。
rmSync(legacyNshFile, { force: true })
console.log(` installer custom script ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`)
// ---- 3. version -------------------------------------------------------------
// 版本号只维护仓库根的 VERSION 文件(唯一真相):这里读出来分两路用——
// deploy/version.gen.bat 给 build.bat,编译期用 -X main.version 写进二进制;
// console/package.json electron-builder 据此给安装包/免安装包命名。
// 二进制里的版本在编译那一刻定死,运行期不再读任何文件。
const versionFile = join(root, 'VERSION')
if (!existsSync(versionFile)) {
console.error(' [version] missing VERSION file at repo root')
process.exit(1)
}
const version = (readFileSync(versionFile, 'utf8')
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.find((line) => line.trim() !== '') ?? '').trim()
if (version === '') {
console.error(' [version] VERSION file is empty')
process.exit(1)
}
// ---- 3b. 安装器素材的版本号核对 ---------------------------------------------
// 安装界面的版本号是「烘焙进位图」的(console/installer/art/*/brand.bmp 的品牌栏),
// 位图里读不出文本,所以 gen_art.py 另外写了一份 art/version.txt 作旁证。
// 不核对的话会出现「安装界面写着 v0.5.1、装的其实是 0.5.2」——这种错只在装完之后
// 被人一眼看到,而且很容易随包发出去;宁可构建直接失败,也不要发错版本号。
const artDir = join(root, 'console', 'installer', 'art')
if (existsSync(artDir)) {
const artVersionFile = join(artDir, 'version.txt')
const artVersion = existsSync(artVersionFile) ? readFileSync(artVersionFile, 'utf8').trim() : ''
const wantVersion = version.replace(/^dev_/, '')
if (artVersion !== wantVersion) {
console.error(` [art] 安装器素材里的版本号与 VERSION 对不上:素材=${artVersion || '(缺失)'},VERSION=${wantVersion}`)
console.error(' [art] 修复:跑 python3 console/installer/art-gen/gen_art.py 重新生成素材,然后提交')
process.exit(1)
}
console.log(` installer art version matches (${wantVersion})`)
}
// 必须纯 ASCII:cmd.exe 会错解 .bat 里的非 ASCII 文本(见本文件头部说明)。
// 用 CRLF:与 .gitattributes 里 *.bat 的约定一致,cmd 在 Windows 上按 CRLF 读最稳。
// 只 set 不加 setlocal:build.bat 用 call 调它,变量要留在调用方的环境里。
const genBatFile = join(root, 'deploy', 'version.gen.bat')
const genBat = [
'@echo off',
'REM Generated by deploy/prepare-extra.mjs from the VERSION file - do not edit.',
`set "SCOREBOARD_VERSION=${version}"`,
'',
].join('\r\n')
writeFileSync(genBatFile, genBat, 'utf8')
console.log(` version ${version} -> deploy/version.gen.bat`)
// console/package.json 的 version 是 semver,不带 dev_ 前缀(dev_ 只用于二进制版本号)。
// 逐字替换而不是 JSON.parse/stringify:保住原有缩进与末尾换行,改动最小。
const pkgVersion = version.replace(/^dev_/, '')
const pkgFile = join(root, 'console', 'package.json')
const pkgRaw = readFileSync(pkgFile, 'utf8')
const pkgNext = pkgRaw.replace(/^(\s*"version"\s*:\s*)"[^"]*"/m, `$1"${pkgVersion}"`)
if (pkgNext === pkgRaw) {
console.log(` console/package.json version is ${pkgVersion}`)
} else {
writeFileSync(pkgFile, pkgNext, 'utf8')
console.log(` console/package.json version -> ${pkgVersion}`)
}