Files
nl-game/electron/main.cjs
2026-08-18 12:29:50 +08:00

373 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Electron 主进程:窗口生命周期、应用菜单、后端服务器地址配置、外链拦截
// 加载策略:开发模式(未打包)优先连 Vite 开发服务器5173连不上回退到本地 dist 构建产物;
// 打包后固定加载 asar 内的 dist。渲染进程通过 preload 注入的 window.desktop 获取服务器地址
const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
const updater = require('./update.cjs')
// 后端服务器默认地址(可在「菜单 → 服务器设置」中修改,保存在用户数据目录)
// 生产桌面端默认连线上;本机调试可在菜单里改回 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%/<app>/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
}
// ---------------------------------------------------------------------
// IPCpreload 同步取服务器地址;设置窗口保存新地址后重载主窗口
// ---------------------------------------------------------------------
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 }
})
// 版本检查 / 应用内下载安装(见 update.cjs
updater.init({
getServerBase: () => readCfg().serverBase,
getMainWindow: () => mainWin,
setQuitting: (v) => { isQuitting = v },
})
// ---------------------------------------------------------------------
// 服务器设置窗口(极简内嵌页面,深色风格与主站一致)
// ---------------------------------------------------------------------
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 = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>服务器设置</title>
<style>
body{margin:0;background:#12142a;color:#e8e9f5;font:14px/1.6 "Microsoft YaHei",sans-serif;padding:22px 26px}
h3{margin:0 0 4px;font-size:15px}
p{margin:0 0 12px;font-size:12px;color:#8d90b3}
input{width:100%;box-sizing:border-box;background:#1c1f3d;border:1px solid #34386b;color:#e8e9f5;
border-radius:8px;padding:9px 12px;font-size:13px;outline:none}
input:focus{border-color:#6d6ff0}
.row{display:flex;gap:10px;justify-content:flex-end;margin-top:16px}
button{border:0;border-radius:8px;padding:8px 22px;font-size:13px;cursor:pointer}
.ok{background:linear-gradient(135deg,#6d6ff0,#9b6df0);color:#fff}
.cancel{background:#262a52;color:#c6c8e8}
.err{color:#ff7b8a;font-size:12px;margin-top:8px;min-height:16px}
</style></head><body>
<h3>后端服务器地址</h3>
<p>桌面端连接的游戏服务器(修改后自动重新加载)</p>
<input id="url" value="${cur.replace(/"/g, '&quot;')}" placeholder="https://game.nailaoyun.cn" />
<div class="err" id="err"></div>
<div class="row">
<button class="cancel" onclick="window.close()">取消</button>
<button class="ok" id="save">保存</button>
</div>
<script>
document.getElementById('save').onclick = async () => {
const r = await window.desktop.setServerBase(document.getElementById('url').value)
if (!r.ok) document.getElementById('err').textContent = r.msg
}
document.getElementById('url').addEventListener('keyup', (e) => {
if (e.key === 'Enter') document.getElementById('save').click()
})
</script>
</body></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: () => updater.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: () => updater.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(() => { updater.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 })
}