main.js 17 KB

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