fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
@@ -15,7 +15,12 @@ const DEFAULTS = {
|
||||
healthIntervalMs: 5000,
|
||||
restartDelayMs: 3000,
|
||||
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
|
||||
maxRestarts: 0
|
||||
maxRestarts: 0,
|
||||
// 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号
|
||||
shutdownPath: '/api/shutdown',
|
||||
shutdownTimeoutMs: 12000,
|
||||
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
|
||||
healthyResetMs: 60000
|
||||
},
|
||||
display: {
|
||||
// all-secondary | all | specified
|
||||
|
||||
@@ -52,7 +52,7 @@ class DisplayManager {
|
||||
} else if (selection === SELECTION.SPECIFIED) {
|
||||
picked = this.pickSpecified(all)
|
||||
} else {
|
||||
picked = all.filter((item) => item.id !== primary.id)
|
||||
picked = this.pickSecondary(all, primary)
|
||||
}
|
||||
|
||||
// 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景,
|
||||
@@ -61,6 +61,15 @@ class DisplayManager {
|
||||
return { single: false, displays: picked }
|
||||
}
|
||||
|
||||
// 默认策略是「除内置屏外的每块屏」(doc 06 §6.2)。用 display.internal 判断,
|
||||
// 而不是拿主屏当替身:现场常把主屏设成外接 HDMI,用主屏排除会把 kiosk 开到笔记本屏。
|
||||
// 全部屏都报 internal=false 时(纯台式机)退回「排除主屏」的旧口径。
|
||||
pickSecondary(all, primary) {
|
||||
const external = all.filter((item) => item.internal === false)
|
||||
if (external.length && external.length < all.length) return external
|
||||
return all.filter((item) => item.id !== primary.id)
|
||||
}
|
||||
|
||||
// displays 配置里可以写 display.id,也可以写 0 起的序号
|
||||
pickSpecified(all) {
|
||||
const wanted = Array.isArray(this.display.displays) ? this.display.displays : []
|
||||
|
||||
+20
-12
@@ -7,8 +7,7 @@ const {
|
||||
Menu,
|
||||
Tray,
|
||||
ipcMain,
|
||||
powerSaveBlocker,
|
||||
shell
|
||||
powerSaveBlocker
|
||||
} = require('electron')
|
||||
|
||||
const { loadConfig, resolveExecutable } = require('./config')
|
||||
@@ -242,6 +241,18 @@ function main() {
|
||||
tray.on('double-click', () => showAdmin())
|
||||
}
|
||||
|
||||
// 开机自启:只对打包后的 Windows/macOS 程序有意义,开发态注册的是 electron 可执行文件
|
||||
function applyOpenAtLogin() {
|
||||
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'darwin')) return
|
||||
const wanted = !cfg.values.startup || cfg.values.startup.openAtLogin !== false
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: wanted })
|
||||
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭')
|
||||
} catch (err) {
|
||||
log.push('console', `设置开机自启失败:${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
ipcMain.handle('service:get-status', () => statusSnapshot())
|
||||
ipcMain.handle('service:start', () => {
|
||||
@@ -265,14 +276,6 @@ function main() {
|
||||
if (displays && displaysEnabled) displays.relayout()
|
||||
return statusSnapshot()
|
||||
})
|
||||
ipcMain.handle('app:open-external', (_event, url) => {
|
||||
// 只放行 http/https,避免管理窗口被注入的链接拉去执行本地协议
|
||||
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
|
||||
shell.openExternal(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
ipcMain.handle('app:open-admin-window', () => {
|
||||
showAdmin()
|
||||
return true
|
||||
@@ -308,8 +311,6 @@ function main() {
|
||||
})
|
||||
}
|
||||
|
||||
app.on('second-instance', () => showAdmin())
|
||||
|
||||
app.whenReady().then(() => {
|
||||
cfg = loadConfig(resolveConfigPath())
|
||||
log = new LogBus({
|
||||
@@ -367,6 +368,13 @@ function main() {
|
||||
createTray()
|
||||
registerIpc()
|
||||
|
||||
// 单实例锁在 whenReady 之前就生效,但回调要用 cfg;注册放在这里,
|
||||
// 避免第二个实例在配置加载完成前触发 showAdmin() 读到 null
|
||||
app.on('second-instance', () => showAdmin())
|
||||
|
||||
// 现场无人值守,Console 需要随系统启动(doc 10 §4.3)
|
||||
applyOpenAtLogin()
|
||||
|
||||
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
|
||||
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
|
||||
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
|
||||
|
||||
@@ -22,7 +22,14 @@ class LogBus {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) {
|
||||
fs.renameSync(file, `${file}.1`)
|
||||
// Windows 上 rename 到已存在的目标会失败,先删掉上一份轮转文件
|
||||
const rotated = `${file}.1`
|
||||
try {
|
||||
fs.rmSync(rotated, { force: true })
|
||||
} catch {
|
||||
// 删不掉就让 rename 失败,落到下面的「仅内存日志」兜底
|
||||
}
|
||||
fs.renameSync(file, rotated)
|
||||
}
|
||||
this.stream = fs.createWriteStream(file, { flags: 'a' })
|
||||
} catch (err) {
|
||||
|
||||
+107
-18
@@ -14,6 +14,17 @@ const STATE_LABEL = {
|
||||
crashed: '异常退出'
|
||||
}
|
||||
|
||||
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
|
||||
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
|
||||
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
|
||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
|
||||
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
|
||||
// 握手不可用时信号已能结束进程,不必再等满超时
|
||||
const FALLBACK_FORCE_KILL_MS = 1500
|
||||
// 连续健康这么久才认为崩溃计数可以清零
|
||||
const HEALTHY_RESET_MS = 60000
|
||||
|
||||
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
|
||||
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
|
||||
class ServiceSupervisor extends EventEmitter {
|
||||
@@ -33,6 +44,7 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.lastError = null
|
||||
|
||||
this.stopRequested = false
|
||||
this.healthySince = null
|
||||
this.healthTimer = null
|
||||
this.restartTimer = null
|
||||
this.forceKillTimer = null
|
||||
@@ -90,6 +102,7 @@ class ServiceSupervisor extends EventEmitter {
|
||||
const cwd = path.dirname(this.exePath)
|
||||
|
||||
this.lastError = null
|
||||
this.healthySince = null
|
||||
this.startedAt = new Date().toISOString()
|
||||
this.setState('starting')
|
||||
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
|
||||
@@ -140,6 +153,7 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.child = null
|
||||
this.pid = null
|
||||
this.healthy = false
|
||||
this.healthySince = null
|
||||
|
||||
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
|
||||
if (this.stopRequested) {
|
||||
@@ -187,25 +201,80 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.log.push('console', '正在停止服务端…')
|
||||
|
||||
child.once('exit', () => resolve())
|
||||
try {
|
||||
child.kill()
|
||||
} catch (err) {
|
||||
this.log.push('console', `发送停止信号失败:${err.message}`)
|
||||
}
|
||||
// Go 服务端有 10s 优雅关闭窗口,超过 5s 仍未退出就强杀,避免 Console 退不干净
|
||||
this.forceKillTimer = setTimeout(() => {
|
||||
if (this.child && this.pid) {
|
||||
this.log.push('console', '服务端未在 5 秒内退出,强制结束')
|
||||
try {
|
||||
this.child.kill('SIGKILL')
|
||||
} catch {
|
||||
// 进程可能已经没了
|
||||
}
|
||||
|
||||
const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||
// 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
|
||||
this.armForceKill(timeoutMs)
|
||||
|
||||
this.requestShutdown().then((accepted) => {
|
||||
if (this.child !== child) return
|
||||
if (accepted) {
|
||||
this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
|
||||
return
|
||||
}
|
||||
}, 5000)
|
||||
// 握手不可用:服务端还没实现该接口,或进程已无响应。
|
||||
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
|
||||
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
|
||||
this.log.push('console', '关闭接口不可用,退回进程信号')
|
||||
this.armForceKill(FALLBACK_FORCE_KILL_MS)
|
||||
try {
|
||||
child.kill()
|
||||
} catch (err) {
|
||||
this.log.push('console', `发送停止信号失败:${err.message}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
|
||||
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
|
||||
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
|
||||
requestShutdown() {
|
||||
return new Promise((resolve) => {
|
||||
const body = Buffer.from('{}')
|
||||
const request = http.request(
|
||||
{
|
||||
host: this.healthHost(),
|
||||
port: this.server.port,
|
||||
path: this.server.shutdownPath || DEFAULT_SHUTDOWN_PATH,
|
||||
method: 'POST',
|
||||
timeout: SHUTDOWN_REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length,
|
||||
Connection: 'close'
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
response.resume()
|
||||
const accepted = response.statusCode >= 200 && response.statusCode < 300
|
||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
|
||||
resolve(accepted)
|
||||
}
|
||||
)
|
||||
request.on('timeout', () => request.destroy(new Error('请求超时')))
|
||||
request.on('error', (err) => {
|
||||
this.log.push('console', `关闭接口请求失败:${err.message}`)
|
||||
resolve(false)
|
||||
})
|
||||
request.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
armForceKill(ms) {
|
||||
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
|
||||
this.forceKillTimer = setTimeout(() => {
|
||||
this.forceKillTimer = null
|
||||
if (!this.child || !this.pid) return
|
||||
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
|
||||
try {
|
||||
this.child.kill('SIGKILL')
|
||||
} catch {
|
||||
// 进程可能已经没了
|
||||
}
|
||||
}, ms)
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.stop()
|
||||
this.restarts = 0
|
||||
@@ -247,10 +316,30 @@ class ServiceSupervisor extends EventEmitter {
|
||||
}
|
||||
|
||||
setHealthy(ok) {
|
||||
if (this.healthy === ok) return
|
||||
const changed = this.healthy !== ok
|
||||
if (ok) {
|
||||
if (!this.healthySince) this.healthySince = Date.now()
|
||||
} else {
|
||||
this.healthySince = null
|
||||
}
|
||||
this.healthy = ok
|
||||
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
|
||||
this.emitStatus()
|
||||
if (changed) {
|
||||
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
|
||||
}
|
||||
|
||||
// 连续健康一段时间后清零崩溃计数:否则长时间赛事里历史崩溃会耗尽
|
||||
// maxRestarts 配额,之后真崩了反而不自动拉起
|
||||
let reset = false
|
||||
if (ok && this.restarts > 0) {
|
||||
const resetAfter = Number(this.server.healthyResetMs) || HEALTHY_RESET_MS
|
||||
if (Date.now() - this.healthySince >= resetAfter) {
|
||||
this.restarts = 0
|
||||
reset = true
|
||||
this.log.push('console', '服务端持续健康,崩溃计数已清零')
|
||||
}
|
||||
}
|
||||
|
||||
if (changed || reset) this.emitStatus()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
Generated
+2413
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,6 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
||||
getLog: () => ipcRenderer.invoke('log:tail'),
|
||||
clearLog: () => ipcRenderer.invoke('log:clear'),
|
||||
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
||||
openExternal: (url) => ipcRenderer.invoke('app:open-external', url),
|
||||
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
|
||||
Reference in new issue
Block a user