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

386 lines
13 KiB
JavaScript
Raw 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.
// 桌面端检查更新:拉取 /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 = `<!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 10px;font-size:15px}
.bar{height:8px;border-radius:99px;background:#262a52;overflow:hidden;margin:12px 0 8px}
.bar i{display:block;height:100%;width:0;background:linear-gradient(135deg,#6d6ff0,#9b6df0);transition:width .15s}
.msg{font-size:12px;color:#8d90b3;min-height:18px}
.row{display:flex;justify-content:flex-end;margin-top:16px}
button{border:0;border-radius:8px;padding:7px 18px;font-size:13px;cursor:pointer;background:#262a52;color:#c6c8e8}
</style></head><body>
<h3>正在下载 ${String(version || '').replace(/[<>]/g, '')}</h3>
<div class="bar"><i id="bar"></i></div>
<div class="msg" id="msg">正在连接服务器…</div>
<div class="row"><button id="cancel">取消</button></div>
<script>
window.__setProgress = (pct, text) => {
document.getElementById('bar').style.width = Math.max(0, Math.min(100, pct)) + '%'
if (text) document.getElementById('msg').textContent = text
}
document.getElementById('cancel').onclick = async () => {
try { await window.desktop.cancelUpdateDownload() } catch (e) {}
window.close()
}
</script>
</body></html>`
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 }