feat(console): 开机自启默认关闭,服务管理窗口加手动开关
- DEFAULTS.startup.openAtLogin 与随包 config.json 默认改为 false;
applyOpenAtLogin 只有显式 true 才注册登录项(缺省/null/false 都不注册)
- config.js 新增 updateConfig():只写回文件里已有的键 + 目标键,不把 DEFAULTS 落盘
- 主进程 setOpenAtLogin():改内存 → 写回 config.json → setLoginItemSettings,
任一失败都回滚,返回 {ok, openAtLogin};状态快照新增 openAtLogin / openAtLoginSupported
- 渲染层:服务端状态卡片新增「开机自启」滑块(纯 CSS,无依赖),
切换即时生效并有本地意图守卫,避免被随后到达的陈旧快照冲掉
- 文档:console/README.md 与 docs/部署指南.md 同步
注意:旧包 config.json 里已显式写 true 的用户升级后仍会自启(不悄悄改既有行为),
在窗口里关一次即可;全新安装默认不注册。
This commit is contained in:
1 parent
7c59dba9b9
commit
1b78c38bb8
9 files changed
+204
-12
No files matched your search
+3
-3
@@ -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:<port>/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` | 日志缓冲与文件 |
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"backgroundColor": "#0b0f1a"
|
||||
},
|
||||
"startup": {
|
||||
"openAtLogin": true
|
||||
"openAtLogin": false
|
||||
},
|
||||
"admin": {
|
||||
"width": 1040,
|
||||
|
||||
+14
-3
@@ -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 }
|
||||
+51
-3
@@ -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()
|
||||
|
||||
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
<dt>防熄屏</dt>
|
||||
<dd id="k-power">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开机自启</dt>
|
||||
<dd>
|
||||
<label class="switch">
|
||||
<input id="sw-open-at-login" type="checkbox" />
|
||||
<span id="sw-open-at-login-label" class="muted">—</span>
|
||||
</label>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>服务端程序</dt>
|
||||
<dd id="k-exe" class="mono">—</dd>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user