// 桌面端检查更新:拉取 /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 = `