main.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 } = require('child_process'); // 用于启动子进程
  6. const sevenBin = require('7zip-bin');
  7. let iconv = null; // 用来在 Windows 上正确解码 7za 使用系统编码输出的中文信息
  8. try {
  9. iconv = require('iconv-lite');
  10. } catch (e) {
  11. iconv = null;
  12. }
  13. let win = null;
  14. // 创建窗口
  15. const createWindow = () => {
  16. win = new BrowserWindow({
  17. // width: 1200,
  18. // height: 800,
  19. show: false, // 是否显示窗口
  20. autoHideMenuBar: true, // 隐藏菜单栏
  21. webPreferences: {
  22. nodeIntegration: true, // 是否集成 Node.js
  23. // enableRemoteModule: true, // 是否启用 remote 模块
  24. webSecurity: false, // 是否禁用同源策略
  25. preload: path.join(__dirname, 'preload.js'), // 预加载脚本
  26. },
  27. });
  28. // 每次启动时重置缩放为 100%
  29. win.webContents.setZoomLevel(0);
  30. win.loadURL(
  31. url.format({
  32. pathname: path.join(__dirname, './dist', 'index.html'),
  33. protocol: 'file:',
  34. slashes: true, // true: file://, false: file:
  35. hash: '/login', // 设置默认路由为 /login
  36. }),
  37. );
  38. // 页面加载完成后再次确保缩放为 100%(防止持久化缩放覆盖)
  39. win.webContents.on('did-finish-load', () => {
  40. // 立即重置一次
  41. win.webContents.setZoomLevel(0);
  42. });
  43. // 当窗口准备好显示时,最大化并显示窗口
  44. win.once('ready-to-show', () => {
  45. win.webContents.setZoomLevel(0);
  46. win.maximize();
  47. win.show();
  48. });
  49. // 拦截当前窗口的导航,防止外部链接在应用内打开
  50. win.webContents.on('will-navigate', (event, url) => {
  51. if (url.startsWith('http://') || url.startsWith('https://')) {
  52. event.preventDefault();
  53. shell.openExternal(url);
  54. }
  55. });
  56. };
  57. // 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法
  58. app.whenReady().then(() => {
  59. createWindow();
  60. app.on('activate', () => {
  61. if (BrowserWindow.getAllWindows().length === 0) {
  62. createWindow();
  63. }
  64. });
  65. });
  66. // 安装更新(渲染进程确认安装时调用)
  67. ipcMain.on('install-update', (event, filePath) => {
  68. if (!fs.existsSync(filePath)) {
  69. event.sender.send('update-error', { message: '安装文件不存在' });
  70. return;
  71. }
  72. if (process.platform === 'win32') {
  73. // Windows:直接执行安装程序(根据安装包参数添加静默/等待参数)
  74. try {
  75. spawn(filePath, [], { detached: true, stdio: 'ignore' }).unref();
  76. app.quit();
  77. } catch (e) {
  78. event.sender.send('update-error', { message: e.message });
  79. }
  80. } else if (process.platform === 'darwin') {
  81. // macOS:打开 dmg 或者打开 .pkg
  82. shell.openPath(filePath).then(() => app.quit());
  83. } else {
  84. shell.openPath(filePath).then(() => app.quit());
  85. }
  86. });
  87. /**
  88. * 使用 7z 压缩文件/目录,支持密码和进度反馈
  89. * @param {Object} opts 压缩选项
  90. * @param {Array<string>} opts.sources 待压缩的文件或目录列表
  91. * @param {string} opts.dest 目标压缩包路径
  92. * @param {string} [opts.format] 压缩格式,'7z' 或 'zip',默认 'zip'
  93. * @param {number} [opts.level] 压缩级别,0-9,默认 9
  94. * @param {boolean} [opts.recurse] 是否递归子目录,默认 true
  95. * @param {string} [opts.password] 压缩密码
  96. * @returns {Promise<{success: boolean, dest: string}>} 压缩结果
  97. */
  98. ipcMain.handle('compress-with-7z', async (evt, opts) => {
  99. const sender = evt.sender; // 用于发送进度消息
  100. const sources = Array.isArray(opts.sources) ? opts.sources : []; // 待压缩的文件或目录列表
  101. if (!sources.length) throw new Error('no sources');
  102. const dest = path.resolve(opts.dest); // 目标压缩包路径
  103. const format = opts.format === 'zip' ? 'zip' : '7z'; // 压缩格式
  104. const level = Number.isInteger(opts.level) ? Math.max(0, Math.min(9, opts.level)) : 9; // 压缩级别
  105. const recurse = opts.recurse !== false; // 是否递归子目录
  106. // 检查源文件/目录是否存在
  107. for (const s of sources) {
  108. if (!fs.existsSync(s)) throw new Error(`source not found: ${s}`);
  109. }
  110. // 7za 可执行路径,优先使用 seven-bin 提供的路径;若被打包进 app.asar,则尝试 app.asar.unpacked 路径
  111. let sevenPath = sevenBin.path7za;
  112. try {
  113. // 如果 sevenPath 在 app.asar 内,映射到 app.asar.unpacked 的对应相对路径
  114. const asarToken = `${path.sep}app.asar${path.sep}`;
  115. if (typeof sevenPath === 'string' && sevenPath.indexOf(asarToken) !== -1) {
  116. const rel = sevenPath.split(asarToken)[1]; // 例如: node_modules/7zip-bin/win/x64/7za.exe
  117. const unpackedCandidate = path.join(process.resourcesPath, 'app.asar.unpacked', rel);
  118. if (fs.existsSync(unpackedCandidate)) {
  119. sevenPath = unpackedCandidate;
  120. }
  121. }
  122. // 若上面没有找到,尝试常见的 unpacked 路径(兼容不同包结构与架构)
  123. if (!fs.existsSync(sevenPath)) {
  124. const candidates = [
  125. path.join(
  126. process.resourcesPath,
  127. 'app.asar.unpacked',
  128. 'node_modules',
  129. '7zip-bin',
  130. 'win',
  131. 'x64',
  132. path.basename(sevenPath),
  133. ),
  134. path.join(
  135. process.resourcesPath,
  136. 'app.asar.unpacked',
  137. 'node_modules',
  138. '7zip-bin',
  139. 'win',
  140. 'x86',
  141. path.basename(sevenPath),
  142. ),
  143. path.join(
  144. process.resourcesPath,
  145. 'app.asar.unpacked',
  146. 'node_modules',
  147. '7zip-bin',
  148. 'bin',
  149. path.basename(sevenPath),
  150. ),
  151. ];
  152. for (const c of candidates) {
  153. if (fs.existsSync(c)) {
  154. sevenPath = c;
  155. break;
  156. }
  157. }
  158. }
  159. } catch (err) {
  160. console.error('Error checking 7za path:', err);
  161. }
  162. if (!fs.existsSync(sevenPath)) {
  163. throw new Error(
  164. `7za executable not found: ${sevenPath}. If running from a packaged app, add "asarUnpack": ["node_modules/7zip-bin/**"] to build config so 7za is unpacked.`,
  165. );
  166. }
  167. const args = ['a', `-t${format}`, `-mx=${level}`, dest]; // 基本参数
  168. if (opts.password) {
  169. args.push(`-p${opts.password}`);
  170. // 仅当使用 7z 格式时才启用头部加密(zip 不支持 -mhe)
  171. if (format === '7z') args.push('-mhe=on'); // 加密文件名/头(仅 7z 支持)
  172. }
  173. if (recurse) args.push('-r'); // 递归
  174. // append sources (支持通配或单个路径)
  175. args.push(...sources);
  176. // 确保目标目录存在(避免 7z 无法写入导致的失败)
  177. try {
  178. fs.mkdirSync(path.dirname(dest), { recursive: true });
  179. } catch (e) {
  180. // 如果无法创建目标目录,尽早报错
  181. throw new Error(`failed to create dest directory: ${e.message}`);
  182. }
  183. return await new Promise((resolve, reject) => {
  184. let stdoutAll = '';
  185. let stderrAll = '';
  186. const child = spawn(sevenPath, args, { windowsHide: true });
  187. // 收集并转发 stdout/stderr,用作进度显示或日志
  188. child.stdout.on('data', (chunk) => {
  189. const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
  190. let s = '';
  191. // 7za 在 Windows 上输出常用 OEM/GBK 编码,优先用 iconv-lite 解码
  192. if (process.platform === 'win32' && iconv) {
  193. try {
  194. s = iconv.decode(buf, 'cp936');
  195. } catch (e) {
  196. s = buf.toString('utf8');
  197. }
  198. } else {
  199. s = buf.toString('utf8');
  200. }
  201. stdoutAll += s;
  202. sender.send('compress-progress', s);
  203. });
  204. child.stderr.on('data', (chunk) => {
  205. const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
  206. let s = '';
  207. if (process.platform === 'win32' && iconv) {
  208. try {
  209. s = iconv.decode(buf, 'cp936');
  210. } catch (e) {
  211. s = buf.toString('utf8');
  212. }
  213. } else {
  214. s = buf.toString('utf8');
  215. }
  216. stderrAll += s;
  217. sender.send('compress-stderr', s);
  218. });
  219. child.on('error', (err) => reject(err));
  220. child.on('close', (code) => {
  221. if (code === 0) {
  222. resolve({ success: true, dest });
  223. } else {
  224. // 包含 stdout/stderr 与命令信息以便排查
  225. const errMsg = `7z exited with code ${code}\ncommand: ${JSON.stringify({ sevenPath, args })}\nstdout:\n${stdoutAll}\nstderr:\n${stderrAll}`;
  226. const e = new Error(errMsg);
  227. e.code = code;
  228. e.stdout = stdoutAll;
  229. e.stderr = stderrAll;
  230. e.cmd = { sevenPath, args };
  231. return reject(e);
  232. }
  233. });
  234. });
  235. });
  236. /**
  237. * 下载文件
  238. * @param {string} url 文件 URL
  239. * @param {string} destPath 保存路径
  240. */
  241. ipcMain.handle('download-file', async (evt, { url, destPath }) => {
  242. return new Promise((resolve, reject) => {
  243. const fileStream = fs.createWriteStream(destPath); // 创建写入流
  244. const req = net.request(url);
  245. req.on('response', (res) => {
  246. const contentLength = Number(res.headers['content-length'] || '0');
  247. let bytesReceived = 0;
  248. res.on('data', (chunk) => {
  249. fileStream.write(chunk);
  250. bytesReceived += chunk.length;
  251. // 在主进程计算百分比并发送
  252. const percentage = contentLength > 0 ? Math.round((bytesReceived / contentLength) * 100) : 0;
  253. evt.sender.send('download-progress', {
  254. bytesReceived, // 已接收字节数
  255. contentLength, // 总字节数
  256. percentage, // 百分比
  257. });
  258. });
  259. res.on('end', () => {
  260. fileStream.end(() => {
  261. // 结束时确保发送 100%(若已知总长度)
  262. const finalPercentage = contentLength > 0 ? 100 : 0;
  263. evt.sender.send('download-progress', {
  264. url,
  265. bytesReceived,
  266. contentLength,
  267. percentage: finalPercentage,
  268. });
  269. resolve(destPath);
  270. });
  271. });
  272. res.on('aborted', () => {
  273. reject(new Error('response aborted'));
  274. });
  275. });
  276. req.on('error', (err) => {
  277. reject(err);
  278. });
  279. fileStream.on('error', (err) => {
  280. reject(err);
  281. });
  282. req.end();
  283. });
  284. });
  285. /**
  286. * 打开文件对话框
  287. * @param {Object} opts 对话框选项
  288. * @param {string} [opts.title] 对话框标题
  289. * @param {Array<string>} [opts.properties] 对话框属性数组,默认 ['openDirectory']
  290. * @return {Promise<{canceled: boolean, filePaths: string[]}>} 文件路径 canceled 表示是否取消选择 filePaths 表示选择的文件路径数组
  291. */
  292. ipcMain.handle('dialog:openFiles', async (event, options = {}) => {
  293. const result = await dialog.showOpenDialog({
  294. title: options.title || '选择文件夹',
  295. properties: options.properties || ['openDirectory'],
  296. });
  297. return { canceled: result.canceled, filePaths: result.filePaths };
  298. });
  299. // 当所有窗口都已关闭时退出
  300. app.on('window-all-closed', () => {
  301. if (process.platform !== 'darwin') {
  302. app.quit();
  303. }
  304. });
  305. app.on('activate', () => {
  306. if (BrowserWindow.getAllWindows().length === 0) {
  307. createWindow();
  308. }
  309. });