373 lines
13 KiB
JavaScript
373 lines
13 KiB
JavaScript
// 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
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 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 }
|
||
})
|
||
|
||
// 版本检查 / 应用内下载安装(见 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, '"')}" 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 })
|
||
}
|