diff --git a/electron/main.cjs b/electron/main.cjs index c48ffd6..43613b6 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,9 +1,10 @@ // Electron 主进程:窗口生命周期、应用菜单、后端服务器地址配置、外链拦截 // 加载策略:开发模式(未打包)优先连 Vite 开发服务器(5173),连不上回退到本地 dist 构建产物; // 打包后固定加载 asar 内的 dist。渲染进程通过 preload 注入的 window.desktop 获取服务器地址 -const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog, net } = require('electron') +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 @@ -62,102 +63,12 @@ ipcMain.handle('nlg:set-server-base', (_e, url) => { 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)) +// 版本检查 / 应用内下载安装(见 update.cjs) +updater.init({ + getServerBase: () => readCfg().serverBase, + getMainWindow: () => mainWin, + setQuitting: (v) => { isQuitting = v }, +}) // --------------------------------------------------------------------- // 服务器设置窗口(极简内嵌页面,深色风格与主站一致) @@ -244,7 +155,7 @@ function createTray() { tray.setToolTip('像素游戏厅') tray.setContextMenu(Menu.buildFromTemplate([ { label: '显示主窗口', click: () => showMainWindow() }, - { label: '检查更新…', click: () => checkUpdate(false) }, + { label: '检查更新…', click: () => updater.checkUpdate(false) }, { type: 'separator' }, { label: '退出', @@ -268,7 +179,7 @@ function buildMenu() { label: '应用', submenu: [ { label: '服务器设置…', accelerator: 'CmdOrCtrl+,', click: openSettings }, - { label: '检查更新…', click: () => checkUpdate(false) }, + { label: '检查更新…', click: () => updater.checkUpdate(false) }, { type: 'separator' }, { label: '刷新', role: 'reload' }, { label: '强制刷新(忽略缓存)', role: 'forceReload' }, @@ -356,7 +267,7 @@ function createWindow() { if (cfg.winMax) mainWin.maximize() mainWin.show() // 启动后 3 秒静默检查一次更新(已是最新版不弹窗,发现新版本才提示) - setTimeout(() => { checkUpdate(true).catch(() => {}) }, 3000) + setTimeout(() => { updater.checkUpdate(true).catch(() => {}) }, 3000) }) // 点关闭:记住窗口状态并隐藏到托盘(冒烟模式 / 真正退出时直接关) mainWin.on('close', (e) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index a4a10ed..75ba6c3 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld('desktop', { setServerBase: (url) => ipcRenderer.invoke('nlg:set-server-base', url), // 检查桌面端新版本(silent=true 时已是最新版不弹窗,用于启动自动检查) checkUpdate: (silent = false) => ipcRenderer.invoke('nlg:check-update', silent), + // 取消正在进行的更新下载 + cancelUpdateDownload: () => ipcRenderer.invoke('nlg:cancel-update-download'), // 运行环境版本信息 versions: { electron: process.versions.electron, diff --git a/electron/update.cjs b/electron/update.cjs new file mode 100644 index 0000000..6f5e066 --- /dev/null +++ b/electron/update.cjs @@ -0,0 +1,385 @@ +// 桌面端检查更新:拉取 /api/version/latest,应用内下载安装包并启动安装程序 +const { app, BrowserWindow, dialog, net, ipcMain, shell } = require('electron') +const { spawn } = require('child_process') +const path = require('path') +const fs = require('fs') + +const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000 + +let deps = { + getServerBase: () => '', + getMainWindow: () => null, + setQuitting: () => {}, +} + +let busy = false +let abortDownload = null + +function init(next) { + deps = { ...deps, ...next } + ipcMain.handle('nlg:check-update', (_e, silent) => checkUpdate(!!silent)) + ipcMain.handle('nlg:cancel-update-download', () => { + if (abortDownload) abortDownload() + return true + }) +} + +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 +} + +function resolveDownloadUrl(url) { + const base = String(deps.getServerBase() || '').replace(/\/+$/, '') + if (!url) return '' + try { + const u = new URL(url, base + '/') + const m = String(u.pathname || '').match(/\/(?:api\/)?download\/([^/]+)$/i) + if (m) { + return base + '/api/download/' + encodeURIComponent(decodeURIComponent(m[1])) + } + return u.href + } catch { + return url + } +} + +function formatBytes(n) { + n = Number(n) || 0 + if (n < 1024) return n + ' B' + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' + return (n / 1024 / 1024).toFixed(1) + ' MB' +} + +function headerVal(headers, key) { + const raw = headers[key] || headers[key.toLowerCase()] || headers[key.toUpperCase()] + return Array.isArray(raw) ? raw[0] : (raw || '') +} + +function fetchLatestVersion() { + return new Promise((resolve) => { + const base = String(deps.getServerBase() || '').replace(/\/+$/, '') + if (!base) return resolve({ ok: false, msg: '未配置服务器地址' }) + let settled = false + const done = (v) => { + if (settled) return + settled = true + resolve(v) + } + const req = net.request({ + method: 'GET', + url: base + '/api/version/latest', + redirect: 'follow', + }) + let body = '' + req.on('response', (resp) => { + const code = resp.statusCode || 0 + if (code < 200 || code >= 300) { + done({ ok: false, msg: '服务器返回 ' + code }) + return + } + resp.on('data', (chunk) => { body += chunk.toString('utf8') }) + resp.on('end', () => { + try { + const parsed = JSON.parse(body) + if (parsed && typeof parsed.code === 'number' && parsed.code !== 0) { + done({ ok: false, msg: parsed.msg || '检查更新失败' }) + return + } + const data = parsed && parsed.data ? parsed.data : parsed + done({ ok: true, data }) + } catch { + done({ ok: false, msg: '解析版本信息失败' }) + } + }) + }) + req.on('error', (err) => done({ ok: false, msg: '网络错误:' + err.message })) + const timer = setTimeout(() => { + try { req.abort() } catch { /* ignore */ } + done({ ok: false, msg: '请求超时' }) + }, 10000) + req.on('close', () => clearTimeout(timer)) + req.end() + }) +} + +function downloadInstaller(url, dest, onProgress) { + return new Promise((resolve, reject) => { + let settled = false + const finish = (err, val) => { + if (settled) return + settled = true + abortDownload = null + if (err) reject(err) + else resolve(val) + } + const req = net.request({ method: 'GET', url, redirect: 'follow' }) + abortDownload = () => { + try { req.abort() } catch { /* ignore */ } + const e = new Error('已取消') + e.cancelled = true + finish(e) + } + const timer = setTimeout(() => { + try { req.abort() } catch { /* ignore */ } + finish(new Error('下载超时,请检查网络后重试')) + }, DOWNLOAD_TIMEOUT_MS) + + req.on('response', (resp) => { + const code = resp.statusCode || 0 + if (code < 200 || code >= 300) { + clearTimeout(timer) + finish(new Error('下载失败(HTTP ' + code + ')')) + return + } + const ct = String(headerVal(resp.headers, 'content-type')) + if (/text\/html/i.test(ct)) { + clearTimeout(timer) + finish(new Error('服务器返回了网页而不是安装包。请确认后台已上传安装包。')) + return + } + const total = parseInt(headerVal(resp.headers, 'content-length'), 10) || 0 + fs.mkdirSync(path.dirname(dest), { recursive: true }) + const ws = fs.createWriteStream(dest) + let loaded = 0 + resp.on('data', (chunk) => { + loaded += chunk.length + if (!ws.write(chunk) && typeof resp.pause === 'function') { + resp.pause() + ws.once('drain', () => { if (typeof resp.resume === 'function') resp.resume() }) + } + if (onProgress) onProgress(loaded, total) + }) + resp.on('end', () => { ws.end() }) + resp.on('error', (err) => { + clearTimeout(timer) + try { ws.destroy() } catch { /* ignore */ } + finish(err) + }) + ws.on('finish', () => { + clearTimeout(timer) + finish(null, { size: loaded, total }) + }) + ws.on('error', (err) => { + clearTimeout(timer) + finish(err) + }) + }) + req.on('error', (err) => { + clearTimeout(timer) + finish(err) + }) + req.end() + }) +} + +function verifyInstaller(filePath, expectSize) { + let st + try { + st = fs.statSync(filePath) + } catch { + return '安装包不存在' + } + // 网页被当成 exe 保存时通常只有几 KB;正式安装包约 100MB + if (st.size < 64 * 1024) { + return '文件过小,不是完整安装包(常见原因:下载地址被网站首页顶替)' + } + if (expectSize && Math.abs(st.size - expectSize) > 2048) { + return `文件大小不符(已下 ${formatBytes(st.size)},期望 ${formatBytes(expectSize)})` + } + const ext = path.extname(filePath).toLowerCase() + if (ext === '.exe') { + const buf = Buffer.alloc(2) + const fd = fs.openSync(filePath, 'r') + fs.readSync(fd, buf, 0, 2, 0) + fs.closeSync(fd) + if (buf[0] !== 0x4d || buf[1] !== 0x5a) { + return '安装包损坏(不是有效的 Windows 程序)' + } + } + return '' +} + +function launchInstaller(filePath) { + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + // start 脱离本进程,避免 app.quit() 把安装程序一起带走 + const cmd = process.env.ComSpec || 'cmd.exe' + const child = spawn(cmd, ['/c', 'start', '', filePath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) + child.once('error', reject) + child.unref() + setTimeout(resolve, 400) + return + } + shell.openPath(filePath).then((err) => { + if (err) reject(new Error(err)) + else resolve() + }).catch(reject) + }) +} + +function openProgressWin(version) { + const parent = deps.getMainWindow() + const win = new BrowserWindow({ + width: 440, + height: 230, + parent: parent && !parent.isDestroyed() ? parent : undefined, + modal: !!(parent && !parent.isDestroyed()), + resizable: false, + minimizable: false, + maximizable: false, + autoHideMenuBar: true, + backgroundColor: '#12142a', + title: '正在下载更新', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + }, + }) + const html = `下载更新 + +

正在下载 ${String(version || '').replace(/[<>]/g, '')}

+
+
正在连接服务器…
+
+ +` + win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) + return win +} + +function setProgressUI(win, pct, text) { + if (!win || win.isDestroyed()) return + const js = `window.__setProgress && window.__setProgress(${Number(pct) || 0}, ${JSON.stringify(text)})` + win.webContents.executeJavaScript(js).catch(() => {}) +} + +async function downloadAndInstall({ version, url, size, filename }) { + const destName = (filename && path.basename(String(filename))) || ('PixelArcade-Setup-' + version + '.exe') + const dest = path.join(app.getPath('temp'), destName) + try { fs.unlinkSync(dest) } catch { /* ignore */ } + + const win = openProgressWin(version) + win.on('closed', () => { + if (abortDownload) abortDownload() + }) + + try { + setProgressUI(win, 0, '正在连接服务器…') + const onProg = (loaded, total) => { + const t = total || size || 0 + const pct = t ? Math.min(99, Math.round((loaded / t) * 100)) : 0 + setProgressUI(win, pct, `已下载 ${formatBytes(loaded)}${t ? ' / ' + formatBytes(t) : ''}`) + } + let r + try { + r = await downloadInstaller(url, dest, onProg) + } catch (e) { + if (e && e.cancelled) throw e + const alt = String(url).replace('/api/download/', '/download/') + if (alt === url) throw e + setProgressUI(win, 0, '正在尝试备用下载地址…') + r = await downloadInstaller(alt, dest, onProg) + } + setProgressUI(win, 99, '正在校验安装包…') + const bad = verifyInstaller(dest, size || r.total || 0) + if (bad) throw new Error(bad) + setProgressUI(win, 100, '下载完成,即将启动安装程序…') + await launchInstaller(dest) + deps.setQuitting(true) + app.quit() + } catch (e) { + try { if (!win.isDestroyed()) win.close() } catch { /* ignore */ } + if (e && e.cancelled) return + try { fs.unlinkSync(dest) } catch { /* ignore */ } + const parent = deps.getMainWindow() + const { response } = await dialog.showMessageBox(parent && !parent.isDestroyed() ? parent : undefined, { + type: 'error', + title: '下载更新失败', + message: '无法下载或安装更新', + detail: (e && e.message) || String(e), + buttons: ['在浏览器中打开', '确定'], + defaultId: 0, + cancelId: 1, + noLink: true, + }) + if (response === 0 && url) shell.openExternal(url) + } +} + +async function checkUpdate(silent = false) { + if (busy) return + const local = app.getVersion() + const parent = () => { + const w = deps.getMainWindow() + return w && !w.isDestroyed() ? w : undefined + } + const r = await fetchLatestVersion() + if (!r.ok) { + if (!silent) dialog.showErrorBox('检查更新失败', r.msg || '无法连接服务器') + return + } + const { version, download_url, release_notes: notes, force, size, filename } = r.data || {} + if (compareVer(version, local) <= 0) { + if (!silent) { + dialog.showMessageBox(parent(), { + type: 'info', + title: '已是最新版本', + message: '当前已是最新版本', + detail: `版本 ${local}`, + }) + } + return + } + const url = resolveDownloadUrl(download_url) + const buttons = url ? ['立即更新', force ? '确定' : '稍后提醒'] : [force ? '确定' : '稍后提醒'] + const { response } = await dialog.showMessageBox(parent(), { + type: force ? 'warning' : 'info', + title: '发现新版本', + message: `发现新版本 ${version}`, + detail: notes || '将下载安装包并启动安装程序,安装时请先关闭本应用。', + buttons, + defaultId: 0, + cancelId: buttons.length - 1, + noLink: true, + }) + if (response === 0 && url) { + busy = true + try { + await downloadAndInstall({ version, url, size, filename }) + } finally { + busy = false + } + } +} + +module.exports = { init, checkUpdate, compareVer, resolveDownloadUrl } diff --git a/src/App.vue b/src/App.vue index faf9548..aa90a7d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -74,4 +74,23 @@ body.wide-page .app-main { flex: 1; min-height: 0; } +/* 饥荒移动端页内横屏:竖屏手机上把整页旋转成横屏布局,不要求用户转手机 */ +body.wide-page.starve-page-landscape { + overflow: hidden; + width: 100vw; + height: 100vh; +} +body.wide-page.starve-page-landscape .app-shell { + position: fixed; + top: 0; + left: 0; + width: var(--pl-w, 100vh); + height: var(--pl-h, 100vw); + min-height: 0; + transform: rotate(-90deg) translateX(-100%); + transform-origin: 0 0; +} +body.wide-page.starve-page-landscape .app-main { + padding: 0; +} diff --git a/src/components/battle/DdzTable.vue b/src/components/battle/DdzTable.vue index 67da03f..242a499 100644 --- a/src/components/battle/DdzTable.vue +++ b/src/components/battle/DdzTable.vue @@ -1,7 +1,7 @@ - +