main.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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 win = null;
  10. let pendingEep = null; // 如果在窗口未创建前收到文件,先缓存
  11. // 创建窗口
  12. const createWindow = () => {
  13. win = new BrowserWindow({
  14. // width: 1200,
  15. // height: 800,
  16. show: false, // 是否显示窗口
  17. autoHideMenuBar: true, // 隐藏菜单栏
  18. webPreferences: {
  19. nodeIntegration: true, // 是否集成 Node.js
  20. // enableRemoteModule: true, // 是否启用 remote 模块
  21. webSecurity: false, // 是否禁用同源策略
  22. preload: path.join(__dirname, 'preload.js'), // 预加载脚本
  23. },
  24. });
  25. // 全局拦截会话级别的网络请求,禁止所有外部网络访问(离线包专用)
  26. try {
  27. const ses = win.webContents.session;
  28. // 拦截所有协议的请求,允许本地资源协议通过
  29. ses.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
  30. const u = details && details.url ? String(details.url) : '';
  31. // 允许本地文件和 data/ about 等内部资源
  32. if (
  33. u.startsWith('file:') ||
  34. u.startsWith('data:') ||
  35. u.startsWith('about:') ||
  36. u.startsWith('chrome-devtools://')
  37. ) {
  38. return callback({ cancel: false });
  39. }
  40. // 其它所有网络请求全部取消 (http/https/ftp/...)。
  41. return callback({ cancel: true });
  42. });
  43. } catch (e) {
  44. console.error('Failed to set webRequest blocker:', e);
  45. }
  46. win.loadURL(
  47. url.format({
  48. pathname: path.join(__dirname, './dist', 'index.html'),
  49. protocol: 'file:',
  50. slashes: true, // true: file://, false: file:
  51. hash: '/select_eep', // 默认打开选择文件页面
  52. }),
  53. );
  54. win.once('ready-to-show', () => {
  55. win.maximize();
  56. win.show();
  57. });
  58. // 拦截当前窗口的导航,防止外部链接在应用内打开
  59. win.webContents.on('will-navigate', (event, url) => {
  60. if (url.startsWith('http://') || url.startsWith('https://')) {
  61. event.preventDefault();
  62. shell.openExternal(url);
  63. }
  64. });
  65. };
  66. // 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法
  67. app.whenReady().then(() => {
  68. createWindow();
  69. // 处理首次启动时命令行传入的 .eep 文件
  70. const args = process.argv.slice(1);
  71. const encPkgPath = args.find((arg) => arg && arg.toLowerCase().endsWith('.eep'));
  72. if (encPkgPath && fs.existsSync(encPkgPath)) {
  73. // 如果窗口已经创建,通知渲染进程
  74. if (win) {
  75. win.webContents.once('did-finish-load', () => {
  76. win.webContents.send('open-eep', encPkgPath);
  77. });
  78. }
  79. } else if (pendingEep) {
  80. // 如果之前 second-instance 缓存了文件,在窗口创建后处理
  81. if (win) {
  82. win.webContents.once('did-finish-load', () => {
  83. win.webContents.send('open-eep', pendingEep);
  84. pendingEep = null;
  85. });
  86. }
  87. } else {
  88. // 未指定加密包文件,加载默认页面
  89. app.on('activate', () => {
  90. if (BrowserWindow.getAllWindows().length === 0) {
  91. createWindow();
  92. }
  93. });
  94. }
  95. });
  96. /**
  97. * 使用 7z 解压(支持 -p 密码),返回临时目录与条目列表
  98. * @param {string} filePath EEP 文件路径
  99. * @param {string} password 密码
  100. * @returns {Promise<{tempDir: string, entries: Array<{entryName: string, isDirectory: boolean}>}>} 解压结果
  101. */
  102. let _tempDirs = '';
  103. ipcMain.handle('extract-zip', async (event, { filePath, password }) => {
  104. const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eep-'));
  105. _tempDirs = tempDir;
  106. const sevenPath = sevenBin.path7za; // 可跨平台
  107. const args = ['x', filePath, `-o${tempDir}`, '-y'];
  108. if (password) args.push(`-p${password}`);
  109. await new Promise((resolve, reject) => {
  110. execFile(sevenPath, args, (err, stdout, stderr) => {
  111. if (err) {
  112. const output = `${String(stderr || '')}\n${String(stdout || '')}`;
  113. // 常见密码错误/提示匹配
  114. if (/wrong password|password incorrect|incorrect password|bad password|wrong mac|crc failed/i.test(output)) {
  115. const e = new Error('密码不正确');
  116. return reject(e);
  117. }
  118. if (/password required|enter password|is encrypted|requires a password|can not open encrypted/i.test(output)) {
  119. const e = new Error('需要密码');
  120. return reject(e);
  121. }
  122. const e = new Error(`解压失败: ${err.message || String(err)}`);
  123. return reject(e);
  124. }
  125. resolve();
  126. });
  127. });
  128. // 使用 AdmZip 读取压缩包内条目列表(只用于列出,不做解密)
  129. const zip = new AdmZip(filePath);
  130. const entries = zip.getEntries().map((e) => ({
  131. entryName: e.entryName,
  132. isDirectory: e.isDirectory,
  133. }));
  134. return { tempDir, entries };
  135. });
  136. /**
  137. * 打开文件对话框
  138. * @param {Object} options 对话框选项
  139. * @param {string} [options.title] 对话框标题
  140. * @param {Array<string>} [options.properties] 对话框属性,如 ['openFile', 'multiSelections']
  141. * @param {Array<{name: string, extensions: Array<string>}>} [options.filters] 过滤器
  142. * @return {Promise<{canceled: boolean, filePaths: string[]}>} 文件路径
  143. */
  144. ipcMain.handle('dialog:openFiles', async (event, options = {}) => {
  145. const result = await dialog.showOpenDialog({
  146. title: options.title || '选择.eep文件',
  147. properties: options.properties || ['openFile'],
  148. filters: options.filters || [{ name: '智慧梧桐数字教材离线包', extensions: ['eep'] }],
  149. });
  150. // result.canceled: 是否取消; result.filePaths: 字符串数组
  151. return { canceled: result.canceled, filePaths: result.filePaths };
  152. });
  153. // ===== 保证单实例并处理第二实例传入的文件 =====
  154. const gotLock = app.requestSingleInstanceLock();
  155. if (gotLock) {
  156. app.on('second-instance', (event, argv /*, workingDir */) => {
  157. // Windows: 被二次激活时,文件路径通常会包含在 argv 中
  158. const encPkgPath = argv.find((arg) => arg && arg.toLowerCase().endsWith('.eep'));
  159. if (encPkgPath && fs.existsSync(encPkgPath)) {
  160. // 如果窗口已存在,恢复并发送路径;否则缓存,等待创建窗口后使用
  161. if (win) {
  162. if (win.isMinimized()) win.restore();
  163. win.focus();
  164. win.webContents.send('open-eep', encPkgPath);
  165. } else {
  166. pendingEep = encPkgPath;
  167. }
  168. }
  169. });
  170. } else {
  171. // 如果无法获取锁,说明已有实例在运行,直接退出新进程
  172. app.quit();
  173. }
  174. // ===== 保证单实例并处理第二实例传入的文件结束 =====
  175. // 检查更新
  176. ipcMain.handle('check-update', async () => {
  177. const apiUrl = 'https://your-api.com/api/app/latest'; // <-- 替换为真实接口
  178. const currentVersion = app.getVersion();
  179. return new Promise((resolve, reject) => {
  180. const req = net.request(apiUrl);
  181. let body = '';
  182. req.on('response', (res) => {
  183. res.on('data', (chunk) => (body += chunk));
  184. res.on('end', () => {
  185. try {
  186. const latest = JSON.parse(body);
  187. const update = latest.version && latest.version !== currentVersion;
  188. resolve({ update, latest, currentVersion });
  189. } catch (e) {
  190. reject(e);
  191. }
  192. });
  193. });
  194. req.on('error', (err) => reject(err));
  195. req.end();
  196. });
  197. });
  198. // 下载更新(渲染进程发起),主进程负责流式保存并推送进度
  199. ipcMain.on('download-update', (event, downloadUrl) => {
  200. const win = BrowserWindow.getAllWindows()[0];
  201. const filename = path.basename(downloadUrl).split('?')[0] || `update-${Date.now()}.exe`;
  202. const tmpPath = path.join(app.getPath('temp'), filename);
  203. const fileStream = fs.createWriteStream(tmpPath);
  204. const req = net.request(downloadUrl);
  205. let received = 0;
  206. let total = 0;
  207. req.on('response', (res) => {
  208. total = parseInt(res.headers['content-length'] || res.headers['Content-Length'] || '0');
  209. res.on('data', (chunk) => {
  210. received += chunk.length;
  211. fileStream.write(chunk);
  212. win.webContents.send('update-download-progress', { received, total });
  213. });
  214. res.on('end', () => {
  215. fileStream.end();
  216. win.webContents.send('update-downloaded', { path: tmpPath });
  217. });
  218. });
  219. req.on('error', (err) => {
  220. win.webContents.send('update-error', { message: err.message || String(err) });
  221. });
  222. req.end();
  223. });
  224. // 安装更新(渲染进程确认安装时调用)
  225. ipcMain.on('install-update', (event, filePath) => {
  226. if (!fs.existsSync(filePath)) {
  227. event.sender.send('update-error', { message: '安装文件不存在' });
  228. return;
  229. }
  230. if (process.platform === 'win32') {
  231. // Windows:直接执行安装程序(根据你的安装包参数添加静默/等待参数)
  232. try {
  233. spawn(filePath, [], { detached: true, stdio: 'ignore' }).unref();
  234. app.quit();
  235. } catch (e) {
  236. event.sender.send('update-error', { message: e.message });
  237. }
  238. } else if (process.platform === 'darwin') {
  239. // macOS:打开 dmg 或者打开 .pkg
  240. shell.openPath(filePath).then(() => app.quit());
  241. } else {
  242. // linux:按照需要实现
  243. shell.openPath(filePath).then(() => app.quit());
  244. }
  245. });
  246. // 当所有窗口都已关闭时退出
  247. app.on('window-all-closed', () => {
  248. if (process.platform !== 'darwin') {
  249. // 清理临时目录
  250. if (_tempDirs) {
  251. try {
  252. fs.rmSync(_tempDirs, { recursive: true, force: true });
  253. } catch (e) {
  254. console.error(`Failed to remove temp directory ${_tempDirs}:`, e);
  255. }
  256. _tempDirs = '';
  257. }
  258. app.quit();
  259. }
  260. });
  261. app.on('activate', () => {
  262. if (BrowserWindow.getAllWindows().length === 0) {
  263. createWindow();
  264. }
  265. });