// Electron 主进程:窗口生命周期、应用菜单、后端服务器地址配置、外链拦截 // 加载策略:开发模式(未打包)优先连 Vite 开发服务器(5173),连不上回退到本地 dist 构建产物; // 打包后固定加载 asar 内的 dist。渲染进程通过 preload 注入的 window.desktop 获取服务器地址 const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog, net } = require('electron') const path = require('path') const fs = require('fs') // 后端服务器默认地址(可在「菜单 → 服务器设置」中修改,保存在用户数据目录) // 生产桌面端默认连线上;本机调试可在菜单里改回 http://127.0.0.1:8080 const DEFAULT_SERVER = 'https://game.nailaoyun.cn' const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173' // NLG_LOAD=dist 可在开发模式下强制加载 dist(用于模拟打包后的运行环境) const FORCE_DIST = process.env.NLG_LOAD === 'dist' // 冒烟自检模式:页面加载完成后截图保存并退出(用于自动化验证桌面端能正常启动) const SMOKE = process.argv.includes('--smoke') || process.env.NLG_SMOKE === '1' let mainWin = null let settingsWin = null let tray = null // 真正退出时置 true(托盘「退出」/ app.quit);否则点关闭按钮只隐藏到托盘 let isQuitting = false function appIconPath() { return path.join(__dirname, '../build/icon.png') } // --------------------------------------------------------------------- // 配置读写:%APPDATA%//config.json // --------------------------------------------------------------------- function cfgFile() { return path.join(app.getPath('userData'), 'config.json') } function readCfg() { try { return { serverBase: DEFAULT_SERVER, ...JSON.parse(fs.readFileSync(cfgFile(), 'utf8')) } } catch { return { serverBase: DEFAULT_SERVER } } } function writeCfg(patch) { const next = { ...readCfg(), ...patch } fs.mkdirSync(path.dirname(cfgFile()), { recursive: true }) fs.writeFileSync(cfgFile(), JSON.stringify(next, null, 2)) return next } // --------------------------------------------------------------------- // IPC:preload 同步取服务器地址;设置窗口保存新地址后重载主窗口 // --------------------------------------------------------------------- ipcMain.on('nlg:get-server-base', (e) => { e.returnValue = readCfg().serverBase }) ipcMain.handle('nlg:set-server-base', (_e, url) => { const clean = String(url || '').trim().replace(/\/+$/, '') if (!/^https?:\/\/.+/i.test(clean)) { return { ok: false, msg: '地址需以 http:// 或 https:// 开头' } } writeCfg({ serverBase: clean }) if (settingsWin && !settingsWin.isDestroyed()) settingsWin.close() // 重载后 preload 会重新读取配置,前端以新地址发起请求 if (mainWin && !mainWin.isDestroyed()) mainWin.reload() return { ok: true } }) // --------------------------------------------------------------------- // 版本检查:拉取后端 /api/version/latest 与本地 app.getVersion() 比较 // 后端返回 { version, download_url, release_notes, force } // --------------------------------------------------------------------- // 语义化版本比较:返回 -1/0/1(ab),非法版本视为 0.0.0 function compareVer(a, b) { const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0) const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0) const len = Math.max(pa.length, pb.length) for (let i = 0; i < len; i++) { const va = pa[i] || 0 const vb = pb[i] || 0 if (va > vb) return 1 if (va < vb) return -1 } return 0 } // 拉取一次最新版本信息(用 Electron 内置 net,自动走系统代理) function fetchLatestVersion() { return new Promise((resolve) => { const base = readCfg().serverBase.replace(/\/+$/, '') if (!base) return resolve({ ok: false, msg: '未配置服务器地址' }) const req = net.request({ method: 'GET', url: base + '/api/version/latest', redirect: 'follow', }) let body = '' req.on('response', (resp) => { // 3xx 已由 redirect:'follow' 处理;这里只关心 2xx 最终响应 resp.on('data', (chunk) => (body += chunk.toString('utf8'))) resp.on('end', () => { try { const parsed = JSON.parse(body) // 后端统一返回 { code, msg, data },code=0 表示成功 const data = parsed && parsed.data ? parsed.data : parsed resolve({ ok: true, data }) } catch { resolve({ ok: false, msg: '解析版本信息失败' }) } }) }) req.on('error', (err) => resolve({ ok: false, msg: '网络错误:' + err.message })) // 超时兜底(10s) setTimeout(() => { try { req.abort() } catch {} resolve({ ok: false, msg: '请求超时' }) }, 10000) req.end() }) } // 弹出更新对话框(silent=true 表示自动检查,已是最新版不弹窗) async function checkUpdate(silent = false) { const local = app.getVersion() const r = await fetchLatestVersion() if (!r.ok) { if (!silent) dialog.showErrorBox('检查更新失败', r.msg || '无法连接服务器') return } const { version, download_url: url, release_notes: notes, force } = r.data || {} if (compareVer(version, local) <= 0) { if (!silent) { dialog.showMessageBox(mainWin, { type: 'info', title: '已是最新版本', message: '当前已是最新版本', detail: `版本 ${local}`, }) } return } const buttons = url ? ['立即下载', force ? '确定' : '稍后提醒'] : [force ? '确定' : '稍后提醒'] const { response } = await dialog.showMessageBox(mainWin, { type: force ? 'warning' : 'info', title: '发现新版本', message: `发现新版本 ${version}`, detail: notes || '请下载最新版本安装包覆盖安装。', buttons, defaultId: 0, cancelId: buttons.length - 1, noLink: true, }) // 点了「立即下载」(按钮 0 且存在下载链接);相对路径拼服务器基址 if (response === 0 && url) { const abs = /^https?:\/\//i.test(url) ? url : readCfg().serverBase.replace(/\/+$/, '') + (url.startsWith('/') ? url : '/' + url) shell.openExternal(abs) } } // IPC:渲染进程可触发检查更新(预留菜单按钮 / 页面入口) ipcMain.handle('nlg:check-update', (_e, silent) => checkUpdate(silent)) // --------------------------------------------------------------------- // 服务器设置窗口(极简内嵌页面,深色风格与主站一致) // --------------------------------------------------------------------- function openSettings() { if (settingsWin && !settingsWin.isDestroyed()) { settingsWin.focus() return } settingsWin = new BrowserWindow({ width: 460, height: 240, parent: mainWin || undefined, modal: !!mainWin, resizable: false, minimizable: false, maximizable: false, autoHideMenuBar: true, backgroundColor: '#12142a', title: '服务器设置', webPreferences: { preload: path.join(__dirname, 'preload.cjs') }, }) const cur = readCfg().serverBase const html = `服务器设置

后端服务器地址

桌面端连接的游戏服务器(修改后自动重新加载)

` settingsWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) settingsWin.on('closed', () => { settingsWin = null }) } // --------------------------------------------------------------------- // 系统托盘:关闭窗口隐藏到托盘,右键可显示/检查更新/退出 // --------------------------------------------------------------------- function showMainWindow() { if (!mainWin || mainWin.isDestroyed()) { createWindow() return } if (mainWin.isMinimized()) mainWin.restore() mainWin.show() mainWin.focus() } function createTray() { if (tray) return const iconFile = appIconPath() let img = fs.existsSync(iconFile) ? nativeImage.createFromPath(iconFile) : nativeImage.createEmpty() // Windows 托盘建议 16×16;过大图标会被系统缩放发糊,主动缩一下 if (!img.isEmpty() && (img.getSize().width > 32 || img.getSize().height > 32)) { img = img.resize({ width: 16, height: 16 }) } tray = new Tray(img) tray.setToolTip('像素游戏厅') tray.setContextMenu(Menu.buildFromTemplate([ { label: '显示主窗口', click: () => showMainWindow() }, { label: '检查更新…', click: () => checkUpdate(false) }, { type: 'separator' }, { label: '退出', click: () => { isQuitting = true app.quit() }, }, ])) // 单击 / 双击托盘图标都拉起主窗口(Windows 习惯) tray.on('click', () => showMainWindow()) tray.on('double-click', () => showMainWindow()) } // --------------------------------------------------------------------- // 应用菜单 // --------------------------------------------------------------------- function buildMenu() { const template = [ { label: '应用', submenu: [ { label: '服务器设置…', accelerator: 'CmdOrCtrl+,', click: openSettings }, { label: '检查更新…', click: () => checkUpdate(false) }, { type: 'separator' }, { label: '刷新', role: 'reload' }, { label: '强制刷新(忽略缓存)', role: 'forceReload' }, { label: '开发者工具', role: 'toggleDevTools' }, { type: 'separator' }, { label: '退出', accelerator: 'CmdOrCtrl+Q', click: () => { isQuitting = true app.quit() }, }, ], }, { label: '编辑', submenu: [ { label: '撤销', role: 'undo' }, { label: '重做', role: 'redo' }, { type: 'separator' }, { label: '剪切', role: 'cut' }, { label: '复制', role: 'copy' }, { label: '粘贴', role: 'paste' }, { label: '全选', role: 'selectAll' }, ], }, { label: '视图', submenu: [ { label: '放大', role: 'zoomIn' }, { label: '缩小', role: 'zoomOut' }, { label: '实际大小', role: 'resetZoom' }, { type: 'separator' }, { label: '全屏', role: 'togglefullscreen' }, ], }, { label: '帮助', submenu: [ { label: '关于', click: () => { dialog.showMessageBox(mainWin, { type: 'info', title: '关于', message: '像素游戏厅 桌面版', detail: `版本 ${app.getVersion()}\nElectron ${process.versions.electron} / Chromium ${process.versions.chrome}\n服务器:${readCfg().serverBase}`, }) }, }, ], }, ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } // --------------------------------------------------------------------- // 主窗口 // --------------------------------------------------------------------- function createWindow() { // 窗口图标:打包后 exe 自带图标,这里主要供开发模式(electron .)使用 const iconPath = appIconPath() // 恢复上次的窗口大小与位置(若显示器变化导致越界,Electron 会自动拉回可见区域) const cfg = readCfg() const saved = cfg.winBounds || {} mainWin = new BrowserWindow({ width: saved.width || 1280, height: saved.height || 820, ...(Number.isFinite(saved.x) && Number.isFinite(saved.y) ? { x: saved.x, y: saved.y } : {}), minWidth: 960, minHeight: 640, show: false, backgroundColor: '#0f1220', title: '像素游戏厅', ...(fs.existsSync(iconPath) ? { icon: iconPath } : {}), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, spellcheck: false, }, }) mainWin.once('ready-to-show', () => { if (cfg.winMax) mainWin.maximize() mainWin.show() // 启动后 3 秒静默检查一次更新(已是最新版不弹窗,发现新版本才提示) setTimeout(() => { checkUpdate(true).catch(() => {}) }, 3000) }) // 点关闭:记住窗口状态并隐藏到托盘(冒烟模式 / 真正退出时直接关) mainWin.on('close', (e) => { try { writeCfg({ winBounds: mainWin.getNormalBounds(), winMax: mainWin.isMaximized() }) } catch {} if (!isQuitting && !SMOKE && tray) { e.preventDefault() mainWin.hide() } }) // 页面内打开新窗口(target=_blank 等)→ 交给系统浏览器 mainWin.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:\/\//i.test(url)) shell.openExternal(url) return { action: 'deny' } }) // 阻止主窗口导航离开应用(外部 http 链接转系统浏览器) mainWin.webContents.on('will-navigate', (e, url) => { const inApp = url.startsWith(DEV_URL) || url.startsWith('file://') if (!inApp) { e.preventDefault() if (/^https?:\/\//i.test(url)) shell.openExternal(url) } }) const distIndex = path.join(__dirname, '../dist/index.html') const loadDist = () => { if (!fs.existsSync(distIndex)) { dialog.showErrorBox('缺少构建产物', '未找到 dist/index.html,请先执行 npm run electron:dist') app.quit() return } mainWin.loadFile(distIndex) } if (!app.isPackaged && !FORCE_DIST) { // 开发模式:优先 Vite 开发服务器,连不上回退 dist mainWin.loadURL(DEV_URL) mainWin.webContents.once('did-fail-load', (_e, _code, _desc, _url, isMainFrame) => { if (isMainFrame) loadDist() }) } else { loadDist() } // 冒烟自检:渲染完成后截图退出,供自动化验证 if (SMOKE) { let done = false mainWin.webContents.on('did-finish-load', () => { setTimeout(async () => { if (done) return done = true try { const img = await mainWin.webContents.capturePage() const out = process.env.NLG_SHOT_PATH || path.join(process.cwd(), 'electron-smoke.png') fs.writeFileSync(out, img.toPNG()) console.log('[smoke] screenshot saved:', out) app.exit(0) } catch (err) { console.error('[smoke] capture failed:', err) app.exit(1) } }, 2200) }) // 兜底:25 秒仍未完成视为失败 setTimeout(() => { if (!done) { console.error('[smoke] timeout') app.exit(1) } }, 25000) } mainWin.on('closed', () => { mainWin = null }) } // 单实例锁:二次启动聚焦已有窗口 const gotLock = app.requestSingleInstanceLock() if (!gotLock) { app.quit() } else { app.on('second-instance', () => { showMainWindow() }) app.whenReady().then(() => { // Windows 任务栏/托盘分组 ID,避免与其他 Electron 应用挤在一起 if (process.platform === 'win32') { app.setAppUserModelId('cn.nailaoyun.pixelarcade') } buildMenu() createTray() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() else showMainWindow() }) }) // 有托盘时关窗不等于退出,不要在 window-all-closed 里 quit app.on('window-all-closed', () => { if (process.platform !== 'darwin' && !tray) app.quit() }) app.on('before-quit', () => { isQuitting = true }) }