'use strict'; const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('node:path'); const clockModel = require('./src/clock-model'); const RENDERER_DIR = path.join(__dirname, 'renderer'); const PRELOAD = path.join(__dirname, 'preload.js'); const SELF_TEST = process.argv.includes('--selftest'); const MIN_SIZE = { width: 260, height: 150 }; const DEFAULT_CLOCK_SIZE = { width: 520, height: 280 }; const state = { clock: clockModel.create(Date.now()), style: 'digital', alwaysOnTop: { clock: true, control: false } }; let controlWindow = null; let clockWindow = null; function isAlive(win) { return Boolean(win) && !win.isDestroyed(); } function publicState() { return { style: state.style, baseMs: state.clock.baseMs, baseRealMs: state.clock.baseRealMs, rate: state.clock.rate, lastRate: state.clock.lastRate, paused: state.clock.rate === 0, clockWindowOpen: isAlive(clockWindow), alwaysOnTop: { ...state.alwaysOnTop }, serverNow: Date.now() }; } function broadcast() { const snapshot = publicState(); for (const win of [controlWindow, clockWindow]) { if (isAlive(win)) win.webContents.send('state', snapshot); } } const WEB_PREFERENCES = { preload: PRELOAD, contextIsolation: true, nodeIntegration: false }; function createControlWindow() { controlWindow = new BrowserWindow({ width: 920, height: 720, minWidth: 780, minHeight: 560, title: 'ClockLab 控制台', backgroundColor: '#12151c', autoHideMenuBar: true, show: false, webPreferences: WEB_PREFERENCES }); controlWindow.setAlwaysOnTop(state.alwaysOnTop.control); controlWindow.loadFile(path.join(RENDERER_DIR, 'control.html')); controlWindow.once('ready-to-show', () => controlWindow.show()); controlWindow.on('closed', () => { controlWindow = null; // 控制窗口是唯一常驻入口,它被关掉就等于用户要退出整个程序(时钟窗口一起走) if (!SELF_TEST) app.quit(); }); } function createClockWindow() { if (isAlive(clockWindow)) return clockWindow; clockWindow = new BrowserWindow({ width: DEFAULT_CLOCK_SIZE.width, height: DEFAULT_CLOCK_SIZE.height, minWidth: MIN_SIZE.width, minHeight: MIN_SIZE.height, frame: false, resizable: true, movable: true, maximizable: false, fullscreenable: false, backgroundColor: '#05070c', title: 'ClockLab 时钟', show: false, webPreferences: WEB_PREFERENCES }); // screen-saver 级别:现场投影时希望它压在其他全屏窗口之上,普通 alwaysOnTop 在 Windows 上不够硬 clockWindow.setAlwaysOnTop(state.alwaysOnTop.clock, 'screen-saver'); clockWindow.loadFile(path.join(RENDERER_DIR, 'clock.html')); clockWindow.once('ready-to-show', () => { clockWindow.show(); broadcast(); }); clockWindow.on('closed', () => { clockWindow = null; broadcast(); }); return clockWindow; } function closeClockWindow() { if (isAlive(clockWindow)) clockWindow.close(); } // 自定义拖边缘改大小最终落到这里:夹住最小尺寸,避免渲染进程算出 NaN 时把窗口搞坏 function applyBounds(next) { if (!isAlive(clockWindow)) return null; const current = clockWindow.getBounds(); const bounds = { x: Number.isFinite(next.x) ? Math.round(next.x) : current.x, y: Number.isFinite(next.y) ? Math.round(next.y) : current.y, width: Math.max(MIN_SIZE.width, Number.isFinite(next.width) ? Math.round(next.width) : current.width), height: Math.max(MIN_SIZE.height, Number.isFinite(next.height) ? Math.round(next.height) : current.height) }; clockWindow.setBounds(bounds); return clockWindow.getBounds(); } // 所有会改动时钟/窗口的动作都集中在这里:IPC 和自检脚本共用同一条代码路径, // 自检才不会测到一份「和线上不一样」的逻辑。 const actions = { setRate(rate) { const value = Number(rate); if (Number.isFinite(value)) { clockModel.setRate(state.clock, Date.now(), value); broadcast(); } return publicState(); }, togglePause() { const now = Date.now(); const target = state.clock.rate === 0 ? state.clock.lastRate || 1 : 0; clockModel.setRate(state.clock, now, target); broadcast(); return publicState(); }, fastForward(deltaMs) { const delta = Number(deltaMs); if (Number.isFinite(delta)) { clockModel.shift(state.clock, Date.now(), delta); broadcast(); } return publicState(); }, setTime(h, m, s) { const parts = [Number(h), Number(m), Number(s)]; if (parts.every(Number.isFinite)) { clockModel.setTimeOfDay(state.clock, Date.now(), parts[0], parts[1], parts[2]); broadcast(); } return publicState(); }, setStyle(style) { if (style === 'digital' || style === 'analog') { state.style = style; broadcast(); } return publicState(); }, setAlwaysOnTop(target, value) { const key = target === 'control' ? 'control' : 'clock'; state.alwaysOnTop[key] = Boolean(value); const win = key === 'clock' ? clockWindow : controlWindow; // 时钟窗口用 screen-saver 级别,普通置顶在 Windows 上会被某些全屏窗口压住 if (isAlive(win)) win.setAlwaysOnTop(state.alwaysOnTop[key], key === 'clock' ? 'screen-saver' : 'normal'); broadcast(); return publicState(); }, toggleClockWindow() { if (isAlive(clockWindow)) closeClockWindow(); else createClockWindow(); broadcast(); return publicState(); }, closeClockWindow() { closeClockWindow(); return publicState(); } }; function registerIpc() { ipcMain.handle('state:get', () => publicState()); ipcMain.handle('rate:set', (_event, rate) => actions.setRate(rate)); ipcMain.handle('pause:toggle', () => actions.togglePause()); ipcMain.handle('fast-forward', (_event, deltaMs) => actions.fastForward(deltaMs)); ipcMain.handle('time:set', (_event, payload) => actions.setTime(payload?.h, payload?.m, payload?.s)); ipcMain.handle('style:set', (_event, style) => actions.setStyle(style)); ipcMain.handle('always-on-top:set', (_event, payload) => actions.setAlwaysOnTop(payload?.target, payload?.value)); ipcMain.handle('clock-window:toggle', () => actions.toggleClockWindow()); ipcMain.handle('clock-window:close', () => actions.closeClockWindow()); ipcMain.handle('control-window:close', () => { if (isAlive(controlWindow)) controlWindow.close(); return publicState(); }); ipcMain.on('bounds:get-sync', (event) => { event.returnValue = isAlive(clockWindow) ? clockWindow.getBounds() : null; }); ipcMain.on('bounds:set', (_event, bounds) => { if (bounds && typeof bounds === 'object') applyBounds(bounds); }); } app.whenReady().then(() => { registerIpc(); createControlWindow(); if (SELF_TEST) { require('./src/selftest')({ app, state, clockModel, actions, publicState, applyBounds, createClockWindow, getClockWindow: () => clockWindow, getControlWindow: () => controlWindow }); } }); app.on('window-all-closed', () => { if (!SELF_TEST) app.quit(); });