diff --git a/console/README.md b/console/README.md index b72a9e4..8405238 100644 --- a/console/README.md +++ b/console/README.md @@ -6,7 +6,7 @@ 2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏; 3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏; 4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单; -5. 打包后随系统启动,现场断电重启后无需人工干预; +5. 默认不随系统启动,可在服务管理窗口的「开机自启」开关里开启; 6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。 规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。 @@ -82,7 +82,7 @@ scoreboard-windows-amd64/ | 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 | | 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | -| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 | +| 开机自启 | 默认关闭;在服务管理窗口的「开机自启」开关里控制(`startup.openAtLogin`),仅打包后的 Windows/macOS 生效,开发态不注册 | | 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) | | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | | 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | @@ -111,7 +111,7 @@ scoreboard-windows-amd64/ | `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | -| `startup.openAtLogin` | `true` | 打包后随系统启动(开发态不注册) | +| `startup.openAtLogin` | `false` | 默认不随系统启动;在服务管理窗口「开机自启」开关里控制(开发态不注册) | | `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | diff --git a/console/config.json b/console/config.json index 5ad8976..5fae8b2 100644 --- a/console/config.json +++ b/console/config.json @@ -24,7 +24,7 @@ "backgroundColor": "#0b0f1a" }, "startup": { - "openAtLogin": true + "openAtLogin": false }, "admin": { "width": 1040, diff --git a/console/main/config.js b/console/main/config.js index 4c13358..f92cd41 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -39,8 +39,8 @@ const DEFAULTS = { backgroundColor: '#0b0f1a' }, startup: { - // 打包后随系统启动(doc 10 §4.3);开发态不注册 - openAtLogin: true + // 默认不随系统启动;服务管理窗口里有开关,打开后写入登录项 + openAtLogin: false }, admin: { width: 1040, @@ -86,6 +86,17 @@ function loadConfig(configPath) { } } +// 就地改配置并写回文件:只动 raw 里已有的键,不把 DEFAULTS 全量落盘, +// 否则升级新增的默认值会被老文件冻结成显式值。 +function updateConfig(configPath, mutate) { + const { values } = readJson(configPath) + const raw = isPlainObject(values) ? values : {} + mutate(raw) + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf8') + return raw +} + // 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同: // 开发时是 console/ 的上级,打包后是 exe 所在目录。逐个候选位置探测,避免写死。 function resolveExecutable(exe, configPath) { @@ -104,4 +115,4 @@ function resolveExecutable(exe, configPath) { return { path: found || candidates[0], candidates: [...new Set(candidates)] } } -module.exports = { loadConfig, resolveExecutable } +module.exports = { loadConfig, updateConfig, resolveExecutable } diff --git a/console/main/index.js b/console/main/index.js index 672dac4..d4cdc4b 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -11,7 +11,7 @@ const { powerSaveBlocker } = require('electron') -const { loadConfig, resolveExecutable } = require('./config') +const { loadConfig, updateConfig, resolveExecutable } = require('./config') const { LogBus } = require('./log-bus') const { ServiceSupervisor, healthHost } = require('./service') const { DisplayManager } = require('./displays') @@ -64,6 +64,8 @@ function main() { configWarning: cfg ? cfg.warning : null, displays: displays ? displays.getInfo() : [], powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false, + openAtLogin: cfg ? cfg.values.startup.openAtLogin === true : false, + openAtLoginSupported: app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin'), appVersion: app.getVersion() } } @@ -296,7 +298,8 @@ function main() { // 开机自启:只对打包后的 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 + // 只有显式 true 才注册;缺省 / null / false 一律不写登录项 + const wanted = cfg.values.startup?.openAtLogin === true try { app.setLoginItemSettings({ openAtLogin: wanted }) log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭') @@ -305,6 +308,50 @@ function main() { } } + // 配置为准:把开关值落到 config.json(只动 startup.openAtLogin) + function writeOpenAtLogin(value) { + updateConfig(cfg.path, (raw) => { + raw.startup = { ...(raw.startup || {}), openAtLogin: value } + }) + } + + // 服务管理窗口的「开机自启」开关。Windows 的 Run 项没有「已注册但禁用」的 + // 可靠中间态(任务管理器那种禁用走 StartupApproved 私有键,不可靠), + // 所以「不启动」= 不写登录项:打开时写入,关闭时删除。 + function setOpenAtLogin(enabled) { + if (!cfg) return { ok: false, message: '配置尚未加载' } + const wanted = enabled === true + const previous = cfg.values.startup?.openAtLogin === true + + cfg.values.startup = { ...(cfg.values.startup || {}), openAtLogin: wanted } + try { + writeOpenAtLogin(wanted) + } catch (err) { + cfg.values.startup.openAtLogin = previous + log.push('console', `写入开机自启配置失败:${err.message}`) + return { ok: false, message: `保存配置失败:${err.message}` } + } + + if (app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin')) { + try { + app.setLoginItemSettings({ openAtLogin: wanted }) + } catch (err) { + // 登录项没写成功,配置也回滚,避免界面与系统状态不一致 + cfg.values.startup.openAtLogin = previous + try { + writeOpenAtLogin(previous) + } catch (rollbackErr) { + log.push('console', `回滚开机自启配置失败:${rollbackErr.message}`) + } + log.push('console', `设置开机自启失败:${err.message}`) + return { ok: false, message: `写入登录项失败:${err.message}` } + } + } + + log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭') + return { ok: true, openAtLogin: wanted } + } + // 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号 function passwdConfigArg() { const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : [] @@ -400,6 +447,7 @@ function main() { return true }) ipcMain.handle('display:instances-get', () => pollDisplayInstances()) + ipcMain.handle('startup:set-open-at-login', (event, enabled) => setOpenAtLogin(enabled)) // 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道 ipcMain.handle('window:minimize', (event) => { @@ -488,7 +536,7 @@ function main() { // 避免第二个实例在配置加载完成前触发 showAdmin() 读到 null app.on('second-instance', () => showAdmin()) - // 现场无人值守,Console 需要随系统启动(doc 10 §4.3) + // 开机自启默认关闭,按 config.json 的 startup.openAtLogin 决定是否注册 applyOpenAtLogin() // 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠 diff --git a/console/renderer/app.js b/console/renderer/app.js index acf80ef..fee23b1 100644 --- a/console/renderer/app.js +++ b/console/renderer/app.js @@ -9,6 +9,8 @@ const el = { pid: document.getElementById('k-pid'), restarts: document.getElementById('k-restarts'), power: document.getElementById('k-power'), + openAtLogin: document.getElementById('sw-open-at-login'), + openAtLoginLabel: document.getElementById('sw-open-at-login-label'), exe: document.getElementById('k-exe'), config: document.getElementById('k-config'), configWarning: document.getElementById('config-warning'), @@ -56,6 +58,7 @@ function renderStatus(status) { el.pid.textContent = status.pid || '—' el.restarts.textContent = status.restarts ? `${status.restarts} 次` : '0 次' el.power.textContent = status.powerSaveBlocked ? '已启用(阻止显示器休眠)' : '未启用' + renderOpenAtLogin(status) el.exe.textContent = status.exePath || '—' el.config.textContent = status.configPath || '—' @@ -74,6 +77,66 @@ function renderStatus(status) { renderDisplays(status.displays || []) } +// 切换后状态快照可能还停在旧值(轮询与写配置存在时间差),用它兜底: +// 等快照追上新值再交还轮询,避免刚点下的开关被旧快照冲回去。 +let openAtLoginPending = null +let openAtLoginPendingTimer = null + +function setOpenAtLoginPending(value) { + openAtLoginPending = value + if (openAtLoginPendingTimer) clearTimeout(openAtLoginPendingTimer) + openAtLoginPendingTimer = null + if (value === null) return + openAtLoginPendingTimer = setTimeout(() => { + openAtLoginPending = null + openAtLoginPendingTimer = null + }, 5000) +} + +function renderOpenAtLogin(status) { + const supported = status.openAtLoginSupported === true + let enabled = status.openAtLogin === true + if (openAtLoginPending !== null) { + if (enabled === openAtLoginPending) setOpenAtLoginPending(null) + else enabled = openAtLoginPending + } + el.openAtLogin.checked = enabled + el.openAtLogin.disabled = !supported + el.openAtLogin.title = supported ? '' : '仅打包后的 Windows 版本生效' + el.openAtLoginLabel.textContent = enabled ? '已开启' : '已关闭' +} + +async function toggleOpenAtLogin() { + const wanted = el.openAtLogin.checked + // 写配置期间锁住开关,避免连点触发多次写入 + el.openAtLogin.disabled = true + setOpenAtLoginPending(wanted) + el.openAtLoginLabel.textContent = wanted ? '已开启' : '已关闭' + + let result + try { + result = await window.consoleApi.setOpenAtLogin(wanted) + } catch (err) { + result = { ok: false, message: err.message } + } + + if (result && result.ok) { + // 用返回值即时更新,不必等下一次状态快照 + const applied = result.openAtLogin === true + setOpenAtLoginPending(applied) + el.openAtLogin.checked = applied + el.openAtLoginLabel.textContent = applied ? '已开启' : '已关闭' + } else { + // 失败:回滚勾选;原因由主进程写进运行日志,这里再给个 title 提示 + setOpenAtLoginPending(null) + el.openAtLogin.checked = !wanted + el.openAtLoginLabel.textContent = !wanted ? '已开启' : '已关闭' + el.openAtLogin.title = (result && result.message) || '设置开机自启失败' + } + + el.openAtLogin.disabled = false +} + function renderDisplays(displays) { el.displayList.textContent = '' if (!displays.length) { @@ -185,6 +248,7 @@ function bindActions() { }) el.btnOpenAdmin.addEventListener('click', () => window.consoleApi.openServerAdmin()) el.btnResetAccount.addEventListener('click', resetAccount) + el.openAtLogin.addEventListener('change', toggleOpenAtLogin) } let accountStreaming = false diff --git a/console/renderer/index.html b/console/renderer/index.html index 8ae1002..a2540d2 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -79,6 +79,15 @@
防熄屏
—
+
+
开机自启
+
+ +
+
服务端程序
—
diff --git a/console/renderer/preload.js b/console/renderer/preload.js index 62b8c08..28aef51 100644 --- a/console/renderer/preload.js +++ b/console/renderer/preload.js @@ -18,6 +18,7 @@ contextBridge.exposeInMainWorld('consoleApi', { clearLog: () => ipcRenderer.invoke('log:clear'), relayoutDisplays: () => ipcRenderer.invoke('display:relayout'), getDisplayInstances: () => ipcRenderer.invoke('display:instances-get'), + setOpenAtLogin: (enabled) => ipcRenderer.invoke('startup:set-open-at-login', enabled), openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'), resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload), minimize: () => ipcRenderer.invoke('window:minimize'), diff --git a/console/renderer/styles.css b/console/renderer/styles.css index f2fc8c2..6c1e787 100644 --- a/console/renderer/styles.css +++ b/console/renderer/styles.css @@ -349,6 +349,65 @@ h2 { cursor: pointer; } +/* 滑块式开关:勾选后轨道变强调色,不依赖任何第三方样式 */ +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.switch input { + appearance: none; + -webkit-appearance: none; + position: relative; + flex: none; + width: 34px; + height: 18px; + margin: 0; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-2); + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.switch input::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--muted); + transition: + transform 0.15s ease, + background 0.15s ease; +} + +.switch input:checked { + border-color: var(--accent); + background: var(--accent); +} + +.switch input:checked::after { + transform: translateX(16px); + background: #06202b; +} + +.switch input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.switch input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .form-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/docs/部署指南.md b/docs/部署指南.md index 060d01e..2a0f8be 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -65,7 +65,7 @@ deploy\build.bat - 打开服务端管理面板窗口; - 把大屏页面投到扩展屏; - 启用防熄屏; -- 注册开机自启(仅打包后的 Windows 版本)。 +- 不注册开机自启;需要随系统启动时,在服务管理窗口的「开机自启」开关里开启(仅打包后的 Windows 版本生效)。 部署目录结构(安装或解压后): @@ -216,7 +216,7 @@ scoreboard.exe passwd -config config.yaml | `display.reloadEveryHours` | `24` | 定时重载兜底,`0` = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时,最多等这么久也先把大屏开出来 | | `display.backgroundColor` | `#0b0f1a` | 大屏窗口底色,减少加载白闪 | -| `startup.openAtLogin` | `true` | 打包后随系统启动 | +| `startup.openAtLogin` | `false` | 默认不随系统启动;在服务管理窗口「开机自启」开关里控制 | | `admin.width` / `admin.height` | `1040` / `760` | 启动管理窗口初始尺寸 | | `log.maxLines` | `2000` | 内存中保留的日志行数 | | `log.file` | `logs/console.log` | 日志文件,相对 `config.json` 所在目录 |