main.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. // main.js (ESM 版本)
  2. import { app, BrowserWindow, Menu, ipcMain, shell, dialog } from 'electron';
  3. import { fileURLToPath } from 'url';
  4. import { dirname, join } from 'path';
  5. import { exec } from 'child_process';
  6. import { promisify } from 'util';
  7. import { writeLog } from './src/log/log-writer.js';
  8. import { tmpdir } from 'os';
  9. import { writeFileSync } from 'fs';
  10. import fs from 'fs';
  11. import path from 'path';
  12. // -------------- 全局错误处理 --------------
  13. process.on('uncaughtException', (error) => {
  14. writeLog('fatal', `未捕获的异常: ${error.message}\n${error.stack}`);
  15. // 在生产环境中,可以选择退出应用
  16. if (process.env.NODE_ENV === 'production') {
  17. app.quit();
  18. }
  19. });
  20. process.on('unhandledRejection', (reason, promise) => {
  21. writeLog('error', `未处理的Promise拒绝: ${reason}\nPromise: ${promise}`);
  22. });
  23. // 监听应用崩溃
  24. app.on('render-process-gone', (event, webContents, details) => {
  25. writeLog('fatal', `渲染进程崩溃: ${JSON.stringify(details)}`);
  26. });
  27. app.on('child-process-gone', (event, childProcess, details) => {
  28. writeLog('error', `子进程崩溃: ${JSON.stringify(details)}`);
  29. });
  30. // -------------- 构造 ESM 版 __dirname --------------
  31. const __filename = fileURLToPath(import.meta.url);
  32. const __dirname = dirname(__filename);
  33. const execAsync = promisify(exec);
  34. let win=null;
  35. // -------------- 单实例检查 ------------
  36. // 1. 请求单实例锁
  37. const gotTheLock = app.requestSingleInstanceLock();
  38. if (!gotTheLock) {
  39. // 抢锁失败 -> 立即退出
  40. app.quit();
  41. }
  42. // 2. 抢锁成功 -> 监听后续启动事件
  43. app.on('second-instance', (ev, cmd, cwd, additionalData) => {
  44. // 把已存在的窗口提到最前
  45. if (win) {
  46. if (win.isMinimized()) win.restore();
  47. win.focus();
  48. }
  49. // todo 也可以在这里解析 cmd 中的参数,做打开文件等操作
  50. });
  51. // -------------- 创建窗口 --------------
  52. function createWindow() {
  53. const isMac = process.platform === 'darwin';
  54. win = new BrowserWindow({
  55. show: false,
  56. frame: false,
  57. titleBarStyle: 'hidden',
  58. webPreferences: {
  59. nodeIntegration: false,
  60. contextIsolation: true,
  61. preload: join(__dirname, 'preload.js'),
  62. },
  63. });
  64. // 去掉应用菜单栏
  65. Menu.setApplicationMenu(null);
  66. if (!isMac) win.removeMenu();
  67. win.maximize();
  68. // 加载外置 H5 页面
  69. win.loadFile(join(process.cwd(), 'h5/index.html'));
  70. win.once('ready-to-show', () => win.show());
  71. //渲染进程死了,纪录日志
  72. win.webContents.on('render-process-gone', (event, details) => {
  73. writeLog('error',`渲染进程崩溃 ${details}`);
  74. // writeLog('error', JSON.stringify(details));
  75. });
  76. }
  77. // -------------- 应用生命周期 --------------
  78. app.whenReady().then(createWindow);
  79. app.on('window-all-closed', () => {
  80. if (process.platform !== 'darwin') app.quit();
  81. });
  82. app.on('activate', () => {
  83. if (BrowserWindow.getAllWindows().length === 0) createWindow();
  84. });
  85. // -------------- 系统操作函数 --------------
  86. const getSystemCommands = () => {
  87. const platform = process.platform;
  88. switch (platform) {
  89. case 'win32': // Windows
  90. return {
  91. shutdown: 'shutdown /s /t 0'
  92. };
  93. case 'darwin': // macOS
  94. return {
  95. shutdown: 'sudo shutdown -h now'
  96. };
  97. case 'linux': // Linux
  98. return {
  99. shutdown: 'systemctl poweroff'
  100. };
  101. default:
  102. return {
  103. shutdown: null
  104. };
  105. }
  106. };
  107. const executeSystemCommand = async (command, requiresAdmin = false) => {
  108. try {
  109. if (!command) {
  110. throw new Error('当前平台不支持该操作');
  111. }
  112. // Windows平台的权限检查
  113. if (process.platform === 'win32' && requiresAdmin) {
  114. // 检查是否以管理员身份运行
  115. try {
  116. await execAsync('net session', { timeout: 5000 });
  117. } catch (adminCheckError) {
  118. writeLog('warn', '操作可能需要管理员权限');
  119. return {
  120. success: false,
  121. error: '该操作需要管理员权限,请以管理员身份运行程序后重试',
  122. requiresAdmin: true
  123. };
  124. }
  125. }
  126. const { stdout, stderr } = await execAsync(command, { timeout: 10000 });
  127. writeLog('info', `系统命令执行成功: ${command}`);
  128. if (stderr && stderr.trim()) {
  129. writeLog('warn', `系统命令有警告信息: ${stderr}`);
  130. }
  131. return { success: true, stdout, stderr };
  132. } catch (error) {
  133. let errorMessage = error.message;
  134. // 特定错误类型的处理
  135. if (error.code === 'EACCES') {
  136. errorMessage = '权限不足,请以管理员身份运行程序';
  137. } else if (error.code === 'ENOENT') {
  138. errorMessage = '系统命令不存在或路径错误';
  139. } else if (error.code === 'ETIMEDOUT') {
  140. errorMessage = '操作超时,请重试';
  141. }
  142. writeLog('error', `系统命令执行失败: ${command}, 错误: ${errorMessage}`);
  143. return { success: false, error: errorMessage };
  144. }
  145. };
  146. // -------------- IPC --------------
  147. // 存储相关 IPC 处理器
  148. // 获取配置文件路径
  149. function getConfigPath() {
  150. const userDataPath = app.getPath('userData');
  151. return path.join(userDataPath, 'config.json');
  152. }
  153. // 确保配置文件所在目录存在
  154. function ensureConfigDir() {
  155. const configPath = getConfigPath();
  156. const dir = path.dirname(configPath);
  157. if (!fs.existsSync(dir)) {
  158. fs.mkdirSync(dir, { recursive: true });
  159. }
  160. }
  161. ipcMain.handle('storage-get', async (event, key) => {
  162. try {
  163. const configPath = getConfigPath();
  164. if (!fs.existsSync(configPath)) {
  165. return null;
  166. }
  167. const content = fs.readFileSync(configPath, 'utf-8');
  168. const data = JSON.parse(content);
  169. return data[key] || null;
  170. } catch (error) {
  171. console.error('读取配置文件失败:', error);
  172. return null;
  173. }
  174. });
  175. ipcMain.handle('storage-set', async (event, key, value) => {
  176. try {
  177. ensureConfigDir();
  178. const configPath = getConfigPath();
  179. let data = {};
  180. if (fs.existsSync(configPath)) {
  181. const content = fs.readFileSync(configPath, 'utf-8');
  182. data = JSON.parse(content);
  183. }
  184. data[key] = value;
  185. fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
  186. } catch (error) {
  187. console.error('保存配置文件失败:', error);
  188. throw error;
  189. }
  190. });
  191. ipcMain.handle('storage-remove', async (event, key) => {
  192. try {
  193. const configPath = getConfigPath();
  194. if (!fs.existsSync(configPath)) {
  195. return;
  196. }
  197. const content = fs.readFileSync(configPath, 'utf-8');
  198. const data = JSON.parse(content);
  199. delete data[key];
  200. fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
  201. } catch (error) {
  202. console.error('删除配置项失败:', error);
  203. throw error;
  204. }
  205. });
  206. // 日志功能
  207. ipcMain.handle('write-log', async (_, level, msg) => {
  208. writeLog(level, msg);
  209. });
  210. // 退出应用
  211. ipcMain.handle('exit-close', async () => {
  212. writeLog('info', '用户选择关闭应用程序');
  213. app.quit();
  214. return { success: true };
  215. });
  216. // 关机
  217. ipcMain.handle('exit-shutdown', async () => {
  218. writeLog('info', '用户选择关机');
  219. const commands = getSystemCommands();
  220. // 关机通常需要管理员权限
  221. const requiresAdmin = true;
  222. return await executeSystemCommand(commands.shutdown, requiresAdmin);
  223. });
  224. // ========== 打印相关 IPC 处理器 ==========
  225. /**
  226. * 打印HTML内容
  227. */
  228. // 工具:一次性事件监听
  229. const once = (emitter, name) =>
  230. new Promise(resolve => emitter.once(name, resolve));
  231. // 工具:生成不二次编码的 data URL
  232. const makeDataURL = html =>
  233. `data:text/html;charset=utf-8,${Buffer.from(html, 'utf-8')}`;
  234. ipcMain.handle('print-film', async (event, options) => {
  235. let printWindow = null;
  236. try {
  237. writeLog('info', `开始打印,选项: ${JSON.stringify(options)}`);
  238. const { html, orientation, paperSize, silent, margins } = options;
  239. // 1. 创建隐藏窗口
  240. printWindow = new BrowserWindow({
  241. show: true, // true 仅调试时打开
  242. webPreferences: {
  243. nodeIntegration: false,
  244. contextIsolation: false,
  245. //sandbox: true
  246. }
  247. });
  248. // 2. 加载 HTML
  249. const loaded = once(printWindow.webContents, 'did-finish-load');
  250. await printWindow.loadURL(makeDataURL(html));
  251. await loaded;
  252. // 3. 打印参数
  253. const printOpts = {
  254. silent: Boolean(silent),
  255. printBackground: true,
  256. color: true,
  257. margins: {
  258. marginType: 'custom',
  259. top: margins?.top ?? 10,
  260. bottom: margins?.bottom ?? 10,
  261. left: margins?.left ?? 10,
  262. right: margins?.right ?? 10
  263. },
  264. landscape: orientation === 'landscape',
  265. pageSize: paperSize || 'A4'
  266. };
  267. // 4. 执行打印(带 60 秒超时)
  268. const [success, reason] = await Promise.race([
  269. new Promise(res => {
  270. printWindow.webContents.print(printOpts, (ok, err) => res([ok, err]));
  271. }),
  272. new Promise((_, rej) =>
  273. setTimeout(() => rej(new Error('打印超时(60s)')), 60_000)
  274. )
  275. ]);
  276. // 5. 清理窗口
  277. if (printWindow && !printWindow.isDestroyed()) {
  278. printWindow.close();
  279. printWindow = null;
  280. }
  281. if (success) {
  282. writeLog('info', '打印成功');
  283. return { success: true };
  284. }
  285. throw new Error(reason || '打印失败');
  286. } catch (err) {
  287. writeLog('error', `打印出错: ${err.message}`);
  288. // 确保异常时窗口也关掉
  289. if (printWindow && !printWindow.isDestroyed()) {
  290. printWindow.close();
  291. }
  292. return { success: false, error: err.message };
  293. }
  294. });
  295. /**
  296. * 打印HTML内容到文件(使用File System API)
  297. */
  298. ipcMain.handle('print-film-to-file', async (event, options) => {
  299. let printWindow = null;
  300. const tempFiles = [];
  301. try {
  302. writeLog('info', `开始文件系统打印,选项: ${JSON.stringify(options)}`);
  303. const { html, imageDataList, orientation, paperSize, silent, margins } = options;
  304. // 1. 创建临时图片文件
  305. const fs = await import('fs/promises');
  306. const os = await import('os');
  307. const path = await import('path');
  308. const { fileURLToPath, pathToFileURL } = await import('url');
  309. const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-print-'));
  310. // 当前时间戳用于文件名
  311. const timestamp = Date.now();
  312. // 处理每个图片数据
  313. const imageUrlMap = new Map();
  314. for (let i = 0; i < imageDataList.length; i++) {
  315. const { canvasId, data: base64Data } = imageDataList[i];
  316. // 将base64转换为buffer
  317. const buffer = Buffer.from(base64Data, 'base64');
  318. // 生成临时文件路径
  319. const tempFilePath = path.join(tempDir, `print-image-${timestamp}-${i}.png`);
  320. // 写入文件
  321. await fs.writeFile(tempFilePath, buffer);
  322. tempFiles.push(tempFilePath);
  323. writeLog('info', `临时图片文件已保存: ${tempFilePath}`);
  324. // 使用Node.js内置方法构造file:// URL
  325. const fileUrl = pathToFileURL(tempFilePath).href;
  326. writeLog('info', `File URL构造: ${fileUrl}`);
  327. writeLog('info', `原始路径: ${tempFilePath}`);
  328. // 记录文件路径映射
  329. imageUrlMap.set(canvasId, fileUrl);
  330. }
  331. // 2. 生成最终HTML,将占位符替换为文件URL
  332. let finalHtml = html;
  333. for (const [canvasId, fileUrl] of imageUrlMap.entries()) {
  334. finalHtml = finalHtml.replace(
  335. new RegExp(`<img id="${canvasId}"[^>]*>`, 'g'),
  336. `<img id="${canvasId}" src="${fileUrl}" style="max-width: 100%;" alt="打印图片" />`
  337. );
  338. }
  339. writeLog('info', `HTML占位符已替换完成,临时文件数量: ${tempFiles.length}`);
  340. // 3. 包装完整的HTML文档
  341. const fullHtml = `
  342. <!DOCTYPE html>
  343. <html>
  344. <head>
  345. <meta charset="UTF-8">
  346. <title>打印胶片</title>
  347. <style>
  348. * {
  349. margin: 0;
  350. padding: 0;
  351. box-sizing: border-box;
  352. }
  353. body {
  354. width: 100%;
  355. height: 100%;
  356. background: white;
  357. }
  358. @page {
  359. size: ${paperSize} ${orientation};
  360. margin: ${margins?.top ?? 10}mm ${margins?.right ?? 10}mm ${margins?.bottom ?? 10}mm ${margins?.left ?? 10}mm;
  361. }
  362. .print-only {
  363. display: block !important;
  364. }
  365. .screen-only {
  366. display: none !important;
  367. }
  368. </style>
  369. </head>
  370. <body>
  371. 235689
  372. <div id="film-print-area">
  373. ${finalHtml}
  374. </div>
  375. </body>
  376. </html>
  377. `;
  378. // 4. 创建隐藏窗口(调试时可以设为true查看窗口内容)
  379. printWindow = new BrowserWindow({
  380. show: true, // 设为true调试内容
  381. webPreferences: {
  382. nodeIntegration: false,
  383. contextIsolation: false,
  384. }
  385. });
  386. writeLog('info', '打印窗口已创建,开始加载HTML');
  387. writeLog('info',`把完整的html文件保存起来:${fullHtml}`)
  388. // 5. 加载 HTML
  389. const loaded = once(printWindow.webContents, 'did-finish-load');
  390. // const htmlDataURL = makeDataURL(fullHtml);
  391. // await printWindow.loadURL(htmlDataURL);
  392. // 1. 把完整 HTML 写入临时文件
  393. const tmpHtml = join(tmpdir(), `electron-print-${Date.now()}.html`);
  394. await writeFileSync(tmpHtml, fullHtml, 'utf8');
  395. // 2. 用 file:// 协议加载
  396. await printWindow.loadURL(`file://${tmpHtml.replace(/\\/g, '/')}`);
  397. await loaded;
  398. writeLog('info', 'HTML加载完成,等待图片加载...');
  399. // 6. 等待所有图片加载完成(重要的是这个步骤)
  400. await new Promise((resolve, reject) => {
  401. let loadedCount = 0;
  402. const totalImages = imageDataList.length;
  403. if (totalImages === 0) {
  404. // 没有图片,直接继续
  405. resolve();
  406. return;
  407. }
  408. const checkLoaded = () => {
  409. loadedCount++;
  410. writeLog('info', `图片已加载 ${loadedCount}/${totalImages}`);
  411. if (loadedCount >= totalImages) {
  412. writeLog('info', '所有图片加载完成');
  413. resolve();
  414. }
  415. };
  416. printWindow.webContents.on('console-message', (event, level, message) => {
  417. // 监控图片加载的调试信息
  418. if (message.includes('图片加载完成')) {
  419. checkLoaded();
  420. }
  421. });
  422. // 设置超时(20秒)
  423. setTimeout(() => {
  424. writeLog('warn', `图片加载超时 (已加载 ${loadedCount}/${totalImages}),仍继续打印`);
  425. resolve();
  426. }, 10000);
  427. });
  428. // 7. 执行打印
  429. const printOpts = {
  430. silent: false,//Boolean(silent),
  431. printBackground: true,
  432. color: true,
  433. margins: {
  434. marginType: 'custom',
  435. top: margins?.top ?? 10,
  436. bottom: margins?.bottom ?? 10,
  437. left: margins?.left ?? 10,
  438. right: margins?.right ?? 10
  439. },
  440. landscape: orientation === 'landscape',
  441. pageSize: paperSize || 'A4'
  442. };
  443. writeLog('info', '开始执行打印');
  444. const [success, reason] = await Promise.race([
  445. new Promise( res => {
  446. // 使用 printDialog 显示对话框
  447. // printWindow.webContents.printDialog(printOpts);
  448. printWindow.webContents.print(printOpts, (ok, err) => res([ok, err]));
  449. }),
  450. new Promise((_, rej) =>
  451. setTimeout(() => rej(new Error('打印超时(60s)')), 60_000)
  452. )
  453. ]);
  454. // 8. 清理临时文件
  455. // try {
  456. // for (const tempFile of tempFiles) {
  457. // await fs.unlink(tempFile);
  458. // writeLog('info', `临时文件已删除: ${tempFile}`);
  459. // }
  460. // // 删除临时目录
  461. // await fs.rmdir(tempDir, { recursive: true });
  462. // writeLog('info', `临时目录已删除: ${tempDir}`);
  463. // } catch (cleanupError) {
  464. // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`);
  465. // }
  466. if (success) {
  467. writeLog('info', '文件系统打印成功');
  468. return { success: true };
  469. }
  470. throw new Error(reason || '打印失败');
  471. } catch (err) {
  472. writeLog('error', `文件系统打印出错: ${err.message}`);
  473. // 清理资源
  474. if (printWindow && !printWindow.isDestroyed()) {
  475. printWindow.close();
  476. }
  477. // // 清理临时文件
  478. // try {
  479. // const fs = await import('fs/promises');
  480. // for (const tempFile of tempFiles) {
  481. // try {
  482. // await fs.unlink(tempFile);
  483. // } catch (e) {
  484. // // 忽略单个文件删除错误
  485. // }
  486. // }
  487. // } catch (cleanupError) {
  488. // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`);
  489. // }
  490. return { success: false, error: err.message };
  491. }
  492. });
  493. /**
  494. * 保存 Blob 数据到文件
  495. * 通用的文件保存功能,支持任意二进制数据
  496. */
  497. ipcMain.handle('save-blob', async (event, options) => {
  498. try {
  499. const { data, defaultFilename, filters, title } = options;
  500. writeLog('info', `开始保存文件,默认文件名: ${defaultFilename}`);
  501. // 显示保存对话框
  502. const { filePath, canceled } = await dialog.showSaveDialog(win, {
  503. title: title || '保存文件',
  504. defaultPath: defaultFilename,
  505. filters: filters || [{ name: '所有文件', extensions: ['*'] }]
  506. });
  507. if (canceled || !filePath) {
  508. writeLog('info', '用户取消保存');
  509. return { success: false, canceled: true };
  510. }
  511. // 将 ArrayBuffer 转换为 Buffer
  512. const buffer = Buffer.from(data);
  513. // 保存文件
  514. const fs = await import('fs/promises');
  515. await fs.writeFile(filePath, buffer);
  516. writeLog('info', `文件已保存到: ${filePath}`);
  517. return { success: true, filePath };
  518. } catch (error) {
  519. writeLog('error', `保存文件出错: ${error.message}`);
  520. return { success: false, error: error.message };
  521. }
  522. });
  523. /**
  524. * 导出为PDF
  525. */
  526. ipcMain.handle('print-to-pdf', async (event, options) => {
  527. try {
  528. writeLog('info', `开始导出PDF,选项: ${JSON.stringify(options)}`);
  529. const { html, orientation, paperSize, margins } = options;
  530. // 创建隐藏窗口
  531. const pdfWindow = new BrowserWindow({
  532. show: false,
  533. webPreferences: {
  534. nodeIntegration: false,
  535. contextIsolation: true,
  536. },
  537. });
  538. // 加载HTML内容
  539. await pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
  540. // 等待加载完成
  541. await new Promise(resolve => {
  542. pdfWindow.webContents.on('did-finish-load', resolve);
  543. });
  544. // PDF选项
  545. const pdfOptions = {
  546. printBackground: true,
  547. landscape: orientation === 'landscape',
  548. pageSize: paperSize || 'A4',
  549. margins: {
  550. top: margins?.top || 10,
  551. bottom: margins?.bottom || 10,
  552. left: margins?.left || 10,
  553. right: margins?.right || 10,
  554. },
  555. };
  556. // 生成PDF
  557. const data = await pdfWindow.webContents.printToPDF(pdfOptions);
  558. // 关闭窗口
  559. pdfWindow.close();
  560. // 显示保存对话框
  561. const { filePath } = await dialog.showSaveDialog(win, {
  562. title: '保存PDF',
  563. defaultPath: `film-${Date.now()}.pdf`,
  564. filters: [
  565. { name: 'PDF文件', extensions: ['pdf'] }
  566. ]
  567. });
  568. if (filePath) {
  569. const fs = await import('fs/promises');
  570. await fs.writeFile(filePath, data);
  571. writeLog('info', `PDF已保存到: ${filePath}`);
  572. // 询问是否打开文件
  573. const { response } = await dialog.showMessageBox(win, {
  574. type: 'info',
  575. message: 'PDF导出成功',
  576. detail: `文件已保存到:\n${filePath}`,
  577. buttons: ['打开文件', '关闭'],
  578. defaultId: 0
  579. });
  580. if (response === 0) {
  581. shell.openPath(filePath);
  582. }
  583. return { success: true, filePath };
  584. }
  585. return { success: false, error: '用户取消保存' };
  586. } catch (error) {
  587. writeLog('error', `导出PDF出错: ${error.message}`);
  588. return { success: false, error: error.message };
  589. }
  590. });