main.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. const { app, BrowserWindow, ipcMain, net, shell, dialog } = require('electron');
  2. const path = require('path');
  3. const url = require('url');
  4. const fs = require('fs');
  5. const { spawn, execFile } = require('child_process'); // 用于启动子进程
  6. const sevenBin = require('7zip-bin');
  7. const AdmZip = require('adm-zip');
  8. const os = require('os');
  9. let store = null;
  10. let storeReady = (async () => {
  11. try {
  12. const mod = await import('electron-store');
  13. const StoreLib = mod && (mod.default || mod);
  14. if (!StoreLib) throw new Error('Failed to load electron-store');
  15. store = new StoreLib();
  16. return store;
  17. } catch (err) {
  18. console.error('Failed to dynamically import electron-store:', err);
  19. return null;
  20. }
  21. })();
  22. let iconv = null; // 用来在 Windows 上正确解码 7za 使用系统编码输出的中文信息
  23. try {
  24. iconv = require('iconv-lite');
  25. } catch (e) {
  26. iconv = null;
  27. }
  28. let win = null;
  29. let pendingEep = null; // 如果在窗口未创建前收到文件,先缓存
  30. // 创建窗口
  31. const createWindow = () => {
  32. win = new BrowserWindow({
  33. // width: 1200,
  34. // height: 800,
  35. show: false, // 是否显示窗口
  36. autoHideMenuBar: true, // 隐藏菜单栏
  37. webPreferences: {
  38. nodeIntegration: true, // 是否集成 Node.js
  39. // enableRemoteModule: true, // 是否启用 remote 模块
  40. webSecurity: false, // 是否禁用同源策略
  41. preload: path.join(__dirname, 'preload.js'), // 预加载脚本
  42. },
  43. });
  44. win.loadURL(
  45. url.format({
  46. pathname: path.join(__dirname, './dist', 'index.html'),
  47. protocol: 'file:',
  48. slashes: true, // true: file://, false: file:
  49. hash: '/select_eep', // 默认打开选择文件页面
  50. }),
  51. );
  52. win.once('ready-to-show', () => {
  53. win.maximize();
  54. win.show();
  55. });
  56. // 拦截当前窗口的导航,防止外部链接在应用内打开
  57. win.webContents.on('will-navigate', (event, url) => {
  58. if (url.startsWith('http://') || url.startsWith('https://')) {
  59. event.preventDefault();
  60. shell.openExternal(url);
  61. }
  62. });
  63. };
  64. // 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法
  65. app.whenReady().then(() => {
  66. createWindow();
  67. // 处理首次启动时命令行传入的 .eep 文件
  68. const args = process.argv.slice(1);
  69. const encPkgPath = args.find((arg) => arg && arg.toLowerCase().endsWith('.eep'));
  70. if (encPkgPath && fs.existsSync(encPkgPath)) {
  71. // 如果窗口已经创建,通知渲染进程
  72. if (win) {
  73. win.webContents.once('did-finish-load', () => {
  74. win.webContents.send('open-eep', encPkgPath);
  75. });
  76. }
  77. } else if (pendingEep) {
  78. // 如果之前 second-instance 缓存了文件,在窗口创建后处理
  79. if (win) {
  80. win.webContents.once('did-finish-load', () => {
  81. win.webContents.send('open-eep', pendingEep);
  82. pendingEep = null;
  83. });
  84. }
  85. } else {
  86. // 未指定加密包文件,加载默认页面
  87. app.on('activate', () => {
  88. if (BrowserWindow.getAllWindows().length === 0) {
  89. createWindow();
  90. }
  91. });
  92. }
  93. });
  94. /**
  95. * 获取应用数据
  96. * @param {string} key 键
  97. * @returns {any} 值
  98. */
  99. ipcMain.handle('store:get', async (event, key) => {
  100. await storeReady;
  101. if (!store) throw new Error('electron-store not initialized');
  102. return store.get(key);
  103. });
  104. /**
  105. * 设置应用数据
  106. * @param {string} key 键
  107. * @param {any} value 值
  108. */
  109. ipcMain.handle('store:set', async (event, key, value) => {
  110. await storeReady;
  111. if (!store) throw new Error('electron-store not initialized');
  112. store.set(key, value);
  113. });
  114. /**
  115. * 删除应用数据
  116. * @param {string} key 键
  117. */
  118. ipcMain.handle('store:delete', async (event, key) => {
  119. await storeReady;
  120. if (!store) throw new Error('electron-store not initialized');
  121. store.delete(key);
  122. });
  123. /**
  124. * 得到 7za 可执行路径,优先使用 seven-bin 提供的路径;若被打包进 app.asar,则尝试 app.asar.unpacked 路径
  125. */
  126. function get7zaPath() {
  127. let sevenPath = sevenBin.path7za;
  128. // 如果 sevenPath 在 app.asar 内,映射到 app.asar.unpacked 的对应相对路径
  129. const asarToken = `${path.sep}app.asar${path.sep}`;
  130. if (typeof sevenPath === 'string' && sevenPath.indexOf(asarToken) !== -1) {
  131. const rel = sevenPath.split(asarToken)[1]; // 例如: node_modules/7zip-bin/win/x64/7za.exe
  132. const unpackedCandidate = path.join(process.resourcesPath, 'app.asar.unpacked', rel);
  133. if (fs.existsSync(unpackedCandidate)) {
  134. sevenPath = unpackedCandidate;
  135. }
  136. }
  137. // 若上面没有找到,尝试常见的 unpacked 路径(兼容不同包结构与架构)
  138. if (!fs.existsSync(sevenPath)) {
  139. const candidates = [
  140. path.join(
  141. process.resourcesPath,
  142. 'app.asar.unpacked',
  143. 'node_modules',
  144. '7zip-bin',
  145. 'win',
  146. 'x64',
  147. path.basename(sevenPath),
  148. ),
  149. path.join(
  150. process.resourcesPath,
  151. 'app.asar.unpacked',
  152. 'node_modules',
  153. '7zip-bin',
  154. 'win',
  155. 'x86',
  156. path.basename(sevenPath),
  157. ),
  158. path.join(
  159. process.resourcesPath,
  160. 'app.asar.unpacked',
  161. 'node_modules',
  162. '7zip-bin',
  163. 'bin',
  164. path.basename(sevenPath),
  165. ),
  166. ];
  167. for (const c of candidates) {
  168. if (fs.existsSync(c)) {
  169. sevenPath = c;
  170. break;
  171. }
  172. }
  173. }
  174. return sevenPath;
  175. }
  176. /**
  177. * 使用 7z 解压(支持 -p 密码),返回临时目录与条目列表
  178. * @param {string} filePath EEP 文件路径
  179. * @param {string} password 密码
  180. * @returns {Promise<{tempDir: string, entries: Array<{entryName: string, isDirectory: boolean}>}>} 解压结果
  181. */
  182. let _tempDirs = '';
  183. ipcMain.handle('extract-zip', async (event, { filePath, password }) => {
  184. const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eep-'));
  185. _tempDirs = tempDir;
  186. const sevenPath = sevenBin.path7za; // 可跨平台
  187. const args = ['x', filePath, `-o${tempDir}`, '-y'];
  188. if (password) args.push(`-p${password}`);
  189. await new Promise((resolve, reject) => {
  190. execFile(sevenPath, args, (err, stdout, stderr) => {
  191. if (err) {
  192. const output = `${String(stderr || '')}\n${String(stdout || '')}`;
  193. // 常见密码错误/提示匹配
  194. if (/wrong password|password incorrect|incorrect password|bad password|wrong mac|crc failed/i.test(output)) {
  195. const e = new Error('密码不正确');
  196. return reject(e);
  197. }
  198. if (/password required|enter password|is encrypted|requires a password|can not open encrypted/i.test(output)) {
  199. const e = new Error('需要密码');
  200. return reject(e);
  201. }
  202. const e = new Error(`解压失败: ${err.message || String(err)}`);
  203. return reject(e);
  204. }
  205. resolve();
  206. });
  207. });
  208. // 使用 AdmZip 读取压缩包内条目列表(只用于列出,不做解密)
  209. const zip = new AdmZip(filePath);
  210. const entries = zip.getEntries().map((e) => ({
  211. entryName: e.entryName,
  212. isDirectory: e.isDirectory,
  213. }));
  214. return { tempDir, entries };
  215. });
  216. /**
  217. * 使用 7za 更新 zip 内的单个条目(替换或添加)
  218. * opts: { archivePath, entryPath, content, encoding?, password? }
  219. * - archivePath: 本地 zip 路径
  220. * - entryPath: zip 内相对路径,例如 'dir/file.txt'(使用 / 分隔)
  221. * - content: Buffer 或 string(若为 string 则使用 encoding 解码,默认 utf8)
  222. * - password: 可选,提供则传给 7z 的 -p 参数
  223. */
  224. ipcMain.handle('zip:update-entry-with-7z', async (evt, opts) => {
  225. const sender = evt.sender;
  226. if (!opts || !opts.archivePath || !opts.entryPath || !opts.content) {
  227. throw new Error('missing required opts: archivePath, entryPath, content');
  228. }
  229. const archivePath = path.resolve(opts.archivePath);
  230. const entryPath = opts.entryPath.replace(/\\/g, '/'); // 统一使用 / 作为 zip 内路径分隔符
  231. if (!fs.existsSync(archivePath)) {
  232. throw new Error(`archive not found: ${archivePath}`);
  233. }
  234. // 准备内容 Buffer
  235. let contentBuf = null;
  236. if (Buffer.isBuffer(opts.content)) {
  237. contentBuf = opts.content;
  238. } else if (typeof opts.content === 'string') {
  239. contentBuf = Buffer.from(opts.content, opts.encoding || 'utf8');
  240. } else if (opts.content && opts.content.data) {
  241. // 处理类似 Uint8Array 之类的对象
  242. contentBuf = Buffer.from(opts.content);
  243. } else {
  244. throw new Error('unsupported content type');
  245. }
  246. let sevenPath = sevenBin.path7za;
  247. try {
  248. sevenPath = get7zaPath();
  249. } catch (err) {
  250. console.error('Error resolving 7za path:', err);
  251. }
  252. if (!fs.existsSync(sevenPath)) throw new Error(`7za executable not found: ${sevenPath}`);
  253. // 准备临时文件夹,将内容写入对应路径以供 7z 使用
  254. const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'zipupdate-'));
  255. const entryParts = entryPath.split('/').filter(Boolean);
  256. const targetLocal = path.join(tmpBase, ...entryParts);
  257. try {
  258. fs.mkdirSync(path.dirname(targetLocal), { recursive: true });
  259. fs.writeFileSync(targetLocal, contentBuf);
  260. } catch (e) {
  261. // 清理临时目录
  262. try {
  263. fs.rmSync(tmpBase, { recursive: true, force: true });
  264. } catch (e2) {}
  265. throw e;
  266. }
  267. // 准备 7z 命令参数
  268. const args = ['u', archivePath, entryPath, '-y'];
  269. if (opts.password) args.splice(2, 0, `-p${opts.password}`);
  270. return await new Promise((resolve, reject) => {
  271. let stdoutAll = '';
  272. let stderrAll = '';
  273. const child = spawn(sevenPath, args, { cwd: tmpBase, windowsHide: true });
  274. child.stdout.on('data', (chunk) => {
  275. const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
  276. let s = '';
  277. if (process.platform === 'win32' && iconv) {
  278. try {
  279. s = iconv.decode(buf, 'cp936');
  280. } catch (e) {
  281. s = buf.toString('utf8');
  282. }
  283. } else s = buf.toString('utf8');
  284. stdoutAll += s;
  285. sender.send('zip-update-stdout', s);
  286. });
  287. child.stderr.on('data', (chunk) => {
  288. const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
  289. let s = '';
  290. if (process.platform === 'win32' && iconv) {
  291. try {
  292. s = iconv.decode(buf, 'cp936');
  293. } catch (e) {
  294. s = buf.toString('utf8');
  295. }
  296. } else s = buf.toString('utf8');
  297. stderrAll += s;
  298. sender.send('zip-update-stderr', s);
  299. });
  300. child.on('error', (err) => {
  301. try {
  302. fs.rmSync(tmpBase, { recursive: true, force: true });
  303. } catch (e) {}
  304. reject(err);
  305. });
  306. child.on('close', (code) => {
  307. // 清理临时目录
  308. try {
  309. fs.rmSync(tmpBase, { recursive: true, force: true });
  310. } catch (e) {}
  311. if (code === 0) {
  312. resolve({ success: true, archivePath, entryPath, stdout: stdoutAll, stderr: stderrAll });
  313. } else {
  314. const errMsg = `7z exited with code ${code} \ncommand: ${JSON.stringify({ sevenPath, args })}\nstdout:\n${stdoutAll}\nstderr:\n${stderrAll}`;
  315. const e = new Error(errMsg);
  316. e.code = code;
  317. e.stdout = stdoutAll;
  318. e.stderr = stderrAll;
  319. e.cmd = { sevenPath, args };
  320. reject(e);
  321. }
  322. });
  323. });
  324. });
  325. /**
  326. * 打开文件对话框
  327. * @param {Object} options 对话框选项
  328. * @param {string} [options.title] 对话框标题
  329. * @param {Array<string>} [options.properties] 对话框属性,如 ['openFile', 'multiSelections']
  330. * @param {Array<{name: string, extensions: Array<string>}>} [options.filters] 过滤器
  331. * @return {Promise<{canceled: boolean, filePaths: string[]}>} 文件路径
  332. */
  333. ipcMain.handle('dialog:openFiles', async (event, options = {}) => {
  334. const result = await dialog.showOpenDialog({
  335. title: options.title || '选择.eep文件',
  336. properties: options.properties || ['openFile'],
  337. filters: options.filters || [{ name: '智慧梧桐数字教材离线包', extensions: ['eep'] }],
  338. });
  339. // result.canceled: 是否取消; result.filePaths: 字符串数组
  340. return { canceled: result.canceled, filePaths: result.filePaths };
  341. });
  342. // ===== 保证单实例并处理第二实例传入的文件 =====
  343. const gotLock = app.requestSingleInstanceLock();
  344. if (gotLock) {
  345. app.on('second-instance', (event, argv /*, workingDir */) => {
  346. // Windows: 被二次激活时,文件路径通常会包含在 argv 中
  347. const encPkgPath = argv.find((arg) => arg && arg.toLowerCase().endsWith('.eep'));
  348. if (encPkgPath && fs.existsSync(encPkgPath)) {
  349. // 如果窗口已存在,恢复并发送路径;否则缓存,等待创建窗口后使用
  350. if (win) {
  351. if (win.isMinimized()) win.restore();
  352. win.focus();
  353. win.webContents.send('open-eep', encPkgPath);
  354. } else {
  355. pendingEep = encPkgPath;
  356. }
  357. }
  358. });
  359. } else {
  360. // 如果无法获取锁,说明已有实例在运行,直接退出新进程
  361. app.quit();
  362. }
  363. // ===== 保证单实例并处理第二实例传入的文件结束 =====
  364. // 检查更新
  365. ipcMain.handle('check-update', async () => {
  366. const apiUrl = 'https://your-api.com/api/app/latest'; // <-- 替换为真实接口
  367. const currentVersion = app.getVersion();
  368. return new Promise((resolve, reject) => {
  369. const req = net.request(apiUrl);
  370. let body = '';
  371. req.on('response', (res) => {
  372. res.on('data', (chunk) => (body += chunk));
  373. res.on('end', () => {
  374. try {
  375. const latest = JSON.parse(body);
  376. const update = latest.version && latest.version !== currentVersion;
  377. resolve({ update, latest, currentVersion });
  378. } catch (e) {
  379. reject(e);
  380. }
  381. });
  382. });
  383. req.on('error', (err) => reject(err));
  384. req.end();
  385. });
  386. });
  387. // 下载更新(渲染进程发起),主进程负责流式保存并推送进度
  388. ipcMain.on('download-update', (event, downloadUrl) => {
  389. const win = BrowserWindow.getAllWindows()[0];
  390. const filename = path.basename(downloadUrl).split('?')[0] || `update-${Date.now()}.exe`;
  391. const tmpPath = path.join(app.getPath('temp'), filename);
  392. const fileStream = fs.createWriteStream(tmpPath);
  393. const req = net.request(downloadUrl);
  394. let received = 0;
  395. let total = 0;
  396. req.on('response', (res) => {
  397. total = parseInt(res.headers['content-length'] || res.headers['Content-Length'] || '0');
  398. res.on('data', (chunk) => {
  399. received += chunk.length;
  400. fileStream.write(chunk);
  401. win.webContents.send('update-download-progress', { received, total });
  402. });
  403. res.on('end', () => {
  404. fileStream.end();
  405. win.webContents.send('update-downloaded', { path: tmpPath });
  406. });
  407. });
  408. req.on('error', (err) => {
  409. win.webContents.send('update-error', { message: err.message || String(err) });
  410. });
  411. req.end();
  412. });
  413. // 安装更新(渲染进程确认安装时调用)
  414. ipcMain.on('install-update', (event, filePath) => {
  415. if (!fs.existsSync(filePath)) {
  416. event.sender.send('update-error', { message: '安装文件不存在' });
  417. return;
  418. }
  419. if (process.platform === 'win32') {
  420. // Windows:直接执行安装程序(根据你的安装包参数添加静默/等待参数)
  421. try {
  422. spawn(filePath, [], { detached: true, stdio: 'ignore' }).unref();
  423. app.quit();
  424. } catch (e) {
  425. event.sender.send('update-error', { message: e.message });
  426. }
  427. } else if (process.platform === 'darwin') {
  428. // macOS:打开 dmg 或者打开 .pkg
  429. shell.openPath(filePath).then(() => app.quit());
  430. } else {
  431. // linux:按照需要实现
  432. shell.openPath(filePath).then(() => app.quit());
  433. }
  434. });
  435. // 当所有窗口都已关闭时退出
  436. app.on('window-all-closed', () => {
  437. if (process.platform !== 'darwin') {
  438. // 清理临时目录
  439. if (_tempDirs) {
  440. try {
  441. fs.rmSync(_tempDirs, { recursive: true, force: true });
  442. } catch (e) {
  443. console.error(`Failed to remove temp directory ${_tempDirs}:`, e);
  444. }
  445. _tempDirs = '';
  446. }
  447. app.quit();
  448. }
  449. });
  450. app.on('activate', () => {
  451. if (BrowserWindow.getAllWindows().length === 0) {
  452. createWindow();
  453. }
  454. });