main.js 20 KB

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