const { app, BrowserWindow, ipcMain, net, shell, dialog } = require('electron'); const path = require('path'); const url = require('url'); const fs = require('fs'); const { spawn, execFile } = require('child_process'); // 用于启动子进程 const sevenBin = require('7zip-bin'); const AdmZip = require('adm-zip'); const os = require('os'); let store = null; let storeReady = (async () => { try { const mod = await import('electron-store'); const StoreLib = mod && (mod.default || mod); if (!StoreLib) throw new Error('Failed to load electron-store'); store = new StoreLib(); return store; } catch (err) { console.error('Failed to dynamically import electron-store:', err); return null; } })(); let iconv = null; // 用来在 Windows 上正确解码 7za 使用系统编码输出的中文信息 try { iconv = require('iconv-lite'); } catch (e) { iconv = null; } let win = null; let pendingEep = null; // 如果在窗口未创建前收到文件,先缓存 // 创建窗口 const createWindow = () => { win = new BrowserWindow({ // width: 1200, // height: 800, show: false, // 是否显示窗口 autoHideMenuBar: true, // 隐藏菜单栏 webPreferences: { nodeIntegration: true, // 是否集成 Node.js // enableRemoteModule: true, // 是否启用 remote 模块 webSecurity: false, // 是否禁用同源策略 preload: path.join(__dirname, 'preload.js'), // 预加载脚本 }, }); win.loadURL( url.format({ pathname: path.join(__dirname, './dist', 'index.html'), protocol: 'file:', slashes: true, // true: file://, false: file: hash: '/select_eep', // 默认打开选择文件页面 }), ); win.once('ready-to-show', () => { win.maximize(); win.show(); }); // 拦截当前窗口的导航,防止外部链接在应用内打开 win.webContents.on('will-navigate', (event, url) => { if (url.startsWith('http://') || url.startsWith('https://')) { event.preventDefault(); shell.openExternal(url); } }); }; // 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法 app.whenReady().then(() => { createWindow(); // 处理首次启动时命令行传入的 .eep 文件 const args = process.argv.slice(1); const encPkgPath = args.find((arg) => arg && arg.toLowerCase().endsWith('.eep')); if (encPkgPath && fs.existsSync(encPkgPath)) { // 如果窗口已经创建,通知渲染进程 if (win) { win.webContents.once('did-finish-load', () => { win.webContents.send('open-eep', encPkgPath); }); } } else if (pendingEep) { // 如果之前 second-instance 缓存了文件,在窗口创建后处理 if (win) { win.webContents.once('did-finish-load', () => { win.webContents.send('open-eep', pendingEep); pendingEep = null; }); } } else { // 未指定加密包文件,加载默认页面 app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); } }); /** * 获取应用数据 * @param {string} key 键 * @returns {any} 值 */ ipcMain.handle('store:get', async (event, key) => { await storeReady; if (!store) throw new Error('electron-store not initialized'); return store.get(key); }); /** * 设置应用数据 * @param {string} key 键 * @param {any} value 值 */ ipcMain.handle('store:set', async (event, key, value) => { await storeReady; if (!store) throw new Error('electron-store not initialized'); store.set(key, value); }); /** * 删除应用数据 * @param {string} key 键 */ ipcMain.handle('store:delete', async (event, key) => { await storeReady; if (!store) throw new Error('electron-store not initialized'); store.delete(key); }); /** * 得到 7za 可执行路径,优先使用 seven-bin 提供的路径;若被打包进 app.asar,则尝试 app.asar.unpacked 路径 */ function get7zaPath() { let sevenPath = sevenBin.path7za; // 如果 sevenPath 在 app.asar 内,映射到 app.asar.unpacked 的对应相对路径 const asarToken = `${path.sep}app.asar${path.sep}`; if (typeof sevenPath === 'string' && sevenPath.indexOf(asarToken) !== -1) { const rel = sevenPath.split(asarToken)[1]; // 例如: node_modules/7zip-bin/win/x64/7za.exe const unpackedCandidate = path.join(process.resourcesPath, 'app.asar.unpacked', rel); if (fs.existsSync(unpackedCandidate)) { sevenPath = unpackedCandidate; } } // 若上面没有找到,尝试常见的 unpacked 路径(兼容不同包结构与架构) if (!fs.existsSync(sevenPath)) { const candidates = [ path.join( process.resourcesPath, 'app.asar.unpacked', 'node_modules', '7zip-bin', 'win', 'x64', path.basename(sevenPath), ), path.join( process.resourcesPath, 'app.asar.unpacked', 'node_modules', '7zip-bin', 'win', 'x86', path.basename(sevenPath), ), path.join( process.resourcesPath, 'app.asar.unpacked', 'node_modules', '7zip-bin', 'bin', path.basename(sevenPath), ), ]; for (const c of candidates) { if (fs.existsSync(c)) { sevenPath = c; break; } } } return sevenPath; } /** * 使用 7z 解压(支持 -p 密码),返回临时目录与条目列表 * @param {string} filePath EEP 文件路径 * @param {string} password 密码 * @returns {Promise<{tempDir: string, entries: Array<{entryName: string, isDirectory: boolean}>}>} 解压结果 */ let _tempDirs = ''; ipcMain.handle('extract-zip', async (event, { filePath, password }) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eep-')); _tempDirs = tempDir; const sevenPath = sevenBin.path7za; // 可跨平台 const args = ['x', filePath, `-o${tempDir}`, '-y']; if (password) args.push(`-p${password}`); await new Promise((resolve, reject) => { execFile(sevenPath, args, (err, stdout, stderr) => { if (err) { const output = `${String(stderr || '')}\n${String(stdout || '')}`; // 常见密码错误/提示匹配 if (/wrong password|password incorrect|incorrect password|bad password|wrong mac|crc failed/i.test(output)) { const e = new Error('密码不正确'); return reject(e); } if (/password required|enter password|is encrypted|requires a password|can not open encrypted/i.test(output)) { const e = new Error('需要密码'); return reject(e); } const e = new Error(`解压失败: ${err.message || String(err)}`); return reject(e); } resolve(); }); }); // 使用 AdmZip 读取压缩包内条目列表(只用于列出,不做解密) const zip = new AdmZip(filePath); const entries = zip.getEntries().map((e) => ({ entryName: e.entryName, isDirectory: e.isDirectory, })); return { tempDir, entries }; }); /** * 使用 7za 更新 zip 内的单个条目(替换或添加) * opts: { archivePath, entryPath, content, encoding?, password? } * - archivePath: 本地 zip 路径 * - entryPath: zip 内相对路径,例如 'dir/file.txt'(使用 / 分隔) * - content: Buffer 或 string(若为 string 则使用 encoding 解码,默认 utf8) * - password: 可选,提供则传给 7z 的 -p 参数 */ ipcMain.handle('zip:update-entry-with-7z', async (evt, opts) => { const sender = evt.sender; if (!opts || !opts.archivePath || !opts.entryPath || !opts.content) { throw new Error('missing required opts: archivePath, entryPath, content'); } const archivePath = path.resolve(opts.archivePath); const entryPath = opts.entryPath.replace(/\\/g, '/'); // 统一使用 / 作为 zip 内路径分隔符 if (!fs.existsSync(archivePath)) { throw new Error(`archive not found: ${archivePath}`); } // 准备内容 Buffer let contentBuf = null; if (Buffer.isBuffer(opts.content)) { contentBuf = opts.content; } else if (typeof opts.content === 'string') { contentBuf = Buffer.from(opts.content, opts.encoding || 'utf8'); } else if (opts.content && opts.content.data) { // 处理类似 Uint8Array 之类的对象 contentBuf = Buffer.from(opts.content); } else { throw new Error('unsupported content type'); } let sevenPath = sevenBin.path7za; try { sevenPath = get7zaPath(); } catch (err) { console.error('Error resolving 7za path:', err); } if (!fs.existsSync(sevenPath)) throw new Error(`7za executable not found: ${sevenPath}`); // 准备临时文件夹,将内容写入对应路径以供 7z 使用 const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'zipupdate-')); const entryParts = entryPath.split('/').filter(Boolean); const targetLocal = path.join(tmpBase, ...entryParts); try { fs.mkdirSync(path.dirname(targetLocal), { recursive: true }); fs.writeFileSync(targetLocal, contentBuf); } catch (e) { // 清理临时目录 try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (e2) {} throw e; } // 准备 7z 命令参数 const args = ['u', archivePath, entryPath, '-y']; if (opts.password) args.splice(2, 0, `-p${opts.password}`); return await new Promise((resolve, reject) => { let stdoutAll = ''; let stderrAll = ''; const child = spawn(sevenPath, args, { cwd: tmpBase, windowsHide: true }); child.stdout.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); let s = ''; if (process.platform === 'win32' && iconv) { try { s = iconv.decode(buf, 'cp936'); } catch (e) { s = buf.toString('utf8'); } } else s = buf.toString('utf8'); stdoutAll += s; sender.send('zip-update-stdout', s); }); child.stderr.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); let s = ''; if (process.platform === 'win32' && iconv) { try { s = iconv.decode(buf, 'cp936'); } catch (e) { s = buf.toString('utf8'); } } else s = buf.toString('utf8'); stderrAll += s; sender.send('zip-update-stderr', s); }); child.on('error', (err) => { try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (e) {} reject(err); }); child.on('close', (code) => { // 清理临时目录 try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (e) {} if (code === 0) { resolve({ success: true, archivePath, entryPath, stdout: stdoutAll, stderr: stderrAll }); } else { const errMsg = `7z exited with code ${code} \ncommand: ${JSON.stringify({ sevenPath, args })}\nstdout:\n${stdoutAll}\nstderr:\n${stderrAll}`; const e = new Error(errMsg); e.code = code; e.stdout = stdoutAll; e.stderr = stderrAll; e.cmd = { sevenPath, args }; reject(e); } }); }); }); /** * 打开文件对话框 * @param {Object} options 对话框选项 * @param {string} [options.title] 对话框标题 * @param {Array} [options.properties] 对话框属性,如 ['openFile', 'multiSelections'] * @param {Array<{name: string, extensions: Array}>} [options.filters] 过滤器 * @return {Promise<{canceled: boolean, filePaths: string[]}>} 文件路径 */ ipcMain.handle('dialog:openFiles', async (event, options = {}) => { const result = await dialog.showOpenDialog({ title: options.title || '选择.eep文件', properties: options.properties || ['openFile'], filters: options.filters || [{ name: '智慧梧桐数字教材离线包', extensions: ['eep'] }], }); // result.canceled: 是否取消; result.filePaths: 字符串数组 return { canceled: result.canceled, filePaths: result.filePaths }; }); // ===== 保证单实例并处理第二实例传入的文件 ===== const gotLock = app.requestSingleInstanceLock(); if (gotLock) { app.on('second-instance', (event, argv /*, workingDir */) => { // Windows: 被二次激活时,文件路径通常会包含在 argv 中 const encPkgPath = argv.find((arg) => arg && arg.toLowerCase().endsWith('.eep')); if (encPkgPath && fs.existsSync(encPkgPath)) { // 如果窗口已存在,恢复并发送路径;否则缓存,等待创建窗口后使用 if (win) { if (win.isMinimized()) win.restore(); win.focus(); win.webContents.send('open-eep', encPkgPath); } else { pendingEep = encPkgPath; } } }); } else { // 如果无法获取锁,说明已有实例在运行,直接退出新进程 app.quit(); } // ===== 保证单实例并处理第二实例传入的文件结束 ===== // 检查更新 ipcMain.handle('check-update', async () => { const apiUrl = 'https://your-api.com/api/app/latest'; // <-- 替换为真实接口 const currentVersion = app.getVersion(); return new Promise((resolve, reject) => { const req = net.request(apiUrl); let body = ''; req.on('response', (res) => { res.on('data', (chunk) => (body += chunk)); res.on('end', () => { try { const latest = JSON.parse(body); const update = latest.version && latest.version !== currentVersion; resolve({ update, latest, currentVersion }); } catch (e) { reject(e); } }); }); req.on('error', (err) => reject(err)); req.end(); }); }); // 下载更新(渲染进程发起),主进程负责流式保存并推送进度 ipcMain.on('download-update', (event, downloadUrl) => { const win = BrowserWindow.getAllWindows()[0]; const filename = path.basename(downloadUrl).split('?')[0] || `update-${Date.now()}.exe`; const tmpPath = path.join(app.getPath('temp'), filename); const fileStream = fs.createWriteStream(tmpPath); const req = net.request(downloadUrl); let received = 0; let total = 0; req.on('response', (res) => { total = parseInt(res.headers['content-length'] || res.headers['Content-Length'] || '0'); res.on('data', (chunk) => { received += chunk.length; fileStream.write(chunk); win.webContents.send('update-download-progress', { received, total }); }); res.on('end', () => { fileStream.end(); win.webContents.send('update-downloaded', { path: tmpPath }); }); }); req.on('error', (err) => { win.webContents.send('update-error', { message: err.message || String(err) }); }); req.end(); }); // 安装更新(渲染进程确认安装时调用) ipcMain.on('install-update', (event, filePath) => { if (!fs.existsSync(filePath)) { event.sender.send('update-error', { message: '安装文件不存在' }); return; } if (process.platform === 'win32') { // Windows:直接执行安装程序(根据你的安装包参数添加静默/等待参数) try { spawn(filePath, [], { detached: true, stdio: 'ignore' }).unref(); app.quit(); } catch (e) { event.sender.send('update-error', { message: e.message }); } } else if (process.platform === 'darwin') { // macOS:打开 dmg 或者打开 .pkg shell.openPath(filePath).then(() => app.quit()); } else { // linux:按照需要实现 shell.openPath(filePath).then(() => app.quit()); } }); // 当所有窗口都已关闭时退出 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { // 清理临时目录 if (_tempDirs) { try { fs.rmSync(_tempDirs, { recursive: true, force: true }); } catch (e) { console.error(`Failed to remove temp directory ${_tempDirs}:`, e); } _tempDirs = ''; } app.quit(); } }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } });