// main.js (ESM 版本) import { app, BrowserWindow, Menu, ipcMain, shell, dialog } from 'electron'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { writeLog } from './src/log/log-writer.js'; import { tmpdir } from 'os'; import { writeFileSync } from 'fs'; // -------------- 构造 ESM 版 __dirname -------------- const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const execAsync = promisify(exec); let win=null; // -------------- 单实例检查 ------------ // 1. 请求单实例锁 const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { // 抢锁失败 -> 立即退出 app.quit(); } // 2. 抢锁成功 -> 监听后续启动事件 app.on('second-instance', (ev, cmd, cwd, additionalData) => { // 把已存在的窗口提到最前 if (win) { if (win.isMinimized()) win.restore(); win.focus(); } // todo 也可以在这里解析 cmd 中的参数,做打开文件等操作 }); // -------------- 创建窗口 -------------- function createWindow() { const isMac = process.platform === 'darwin'; win = new BrowserWindow({ show: false, frame: false, titleBarStyle: 'hidden', webPreferences: { nodeIntegration: false, contextIsolation: true, preload: join(__dirname, 'preload.js'), }, }); // 去掉应用菜单栏 Menu.setApplicationMenu(null); if (!isMac) win.removeMenu(); win.maximize(); // 加载外置 H5 页面 win.loadFile(join(process.cwd(), 'h5/index.html')); win.once('ready-to-show', () => win.show()); //渲染进程死了,纪录日志 win.webContents.on('render-process-gone', (event, details) => { writeLog('error',`渲染进程崩溃 ${details}`); // writeLog('error', JSON.stringify(details)); }); } // -------------- 应用生命周期 -------------- app.whenReady().then(createWindow); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); // -------------- 系统操作函数 -------------- const getSystemCommands = () => { const platform = process.platform; switch (platform) { case 'win32': // Windows return { shutdown: 'shutdown /s /t 0' }; case 'darwin': // macOS return { shutdown: 'sudo shutdown -h now' }; case 'linux': // Linux return { shutdown: 'systemctl poweroff' }; default: return { shutdown: null }; } }; const executeSystemCommand = async (command, requiresAdmin = false) => { try { if (!command) { throw new Error('当前平台不支持该操作'); } // Windows平台的权限检查 if (process.platform === 'win32' && requiresAdmin) { // 检查是否以管理员身份运行 try { await execAsync('net session', { timeout: 5000 }); } catch (adminCheckError) { writeLog('warn', '操作可能需要管理员权限'); return { success: false, error: '该操作需要管理员权限,请以管理员身份运行程序后重试', requiresAdmin: true }; } } const { stdout, stderr } = await execAsync(command, { timeout: 10000 }); writeLog('info', `系统命令执行成功: ${command}`); if (stderr && stderr.trim()) { writeLog('warn', `系统命令有警告信息: ${stderr}`); } return { success: true, stdout, stderr }; } catch (error) { let errorMessage = error.message; // 特定错误类型的处理 if (error.code === 'EACCES') { errorMessage = '权限不足,请以管理员身份运行程序'; } else if (error.code === 'ENOENT') { errorMessage = '系统命令不存在或路径错误'; } else if (error.code === 'ETIMEDOUT') { errorMessage = '操作超时,请重试'; } writeLog('error', `系统命令执行失败: ${command}, 错误: ${errorMessage}`); return { success: false, error: errorMessage }; } }; // -------------- IPC -------------- ipcMain.handle('write-log', async (_, level, msg) => { writeLog(level, msg); }); // 退出应用 ipcMain.handle('exit-close', async () => { writeLog('info', '用户选择关闭应用程序'); app.quit(); return { success: true }; }); // 关机 ipcMain.handle('exit-shutdown', async () => { writeLog('info', '用户选择关机'); const commands = getSystemCommands(); // 关机通常需要管理员权限 const requiresAdmin = true; return await executeSystemCommand(commands.shutdown, requiresAdmin); }); // ========== 打印相关 IPC 处理器 ========== /** * 打印HTML内容 */ // 工具:一次性事件监听 const once = (emitter, name) => new Promise(resolve => emitter.once(name, resolve)); // 工具:生成不二次编码的 data URL const makeDataURL = html => `data:text/html;charset=utf-8,${Buffer.from(html, 'utf-8')}`; ipcMain.handle('print-film', async (event, options) => { let printWindow = null; try { writeLog('info', `开始打印,选项: ${JSON.stringify(options)}`); const { html, orientation, paperSize, silent, margins } = options; // 1. 创建隐藏窗口 printWindow = new BrowserWindow({ show: true, // true 仅调试时打开 webPreferences: { nodeIntegration: false, contextIsolation: false, //sandbox: true } }); // 2. 加载 HTML const loaded = once(printWindow.webContents, 'did-finish-load'); await printWindow.loadURL(makeDataURL(html)); await loaded; // 3. 打印参数 const printOpts = { silent: Boolean(silent), printBackground: true, color: true, margins: { marginType: 'custom', top: margins?.top ?? 10, bottom: margins?.bottom ?? 10, left: margins?.left ?? 10, right: margins?.right ?? 10 }, landscape: orientation === 'landscape', pageSize: paperSize || 'A4' }; // 4. 执行打印(带 60 秒超时) const [success, reason] = await Promise.race([ new Promise(res => { printWindow.webContents.print(printOpts, (ok, err) => res([ok, err])); }), new Promise((_, rej) => setTimeout(() => rej(new Error('打印超时(60s)')), 60_000) ) ]); // 5. 清理窗口 if (printWindow && !printWindow.isDestroyed()) { printWindow.close(); printWindow = null; } if (success) { writeLog('info', '打印成功'); return { success: true }; } throw new Error(reason || '打印失败'); } catch (err) { writeLog('error', `打印出错: ${err.message}`); // 确保异常时窗口也关掉 if (printWindow && !printWindow.isDestroyed()) { printWindow.close(); } return { success: false, error: err.message }; } }); /** * 打印HTML内容到文件(使用File System API) */ ipcMain.handle('print-film-to-file', async (event, options) => { let printWindow = null; const tempFiles = []; try { writeLog('info', `开始文件系统打印,选项: ${JSON.stringify(options)}`); const { html, imageDataList, orientation, paperSize, silent, margins } = options; // 1. 创建临时图片文件 const fs = await import('fs/promises'); const os = await import('os'); const path = await import('path'); const { fileURLToPath, pathToFileURL } = await import('url'); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-print-')); // 当前时间戳用于文件名 const timestamp = Date.now(); // 处理每个图片数据 const imageUrlMap = new Map(); for (let i = 0; i < imageDataList.length; i++) { const { canvasId, data: base64Data } = imageDataList[i]; // 将base64转换为buffer const buffer = Buffer.from(base64Data, 'base64'); // 生成临时文件路径 const tempFilePath = path.join(tempDir, `print-image-${timestamp}-${i}.png`); // 写入文件 await fs.writeFile(tempFilePath, buffer); tempFiles.push(tempFilePath); writeLog('info', `临时图片文件已保存: ${tempFilePath}`); // 使用Node.js内置方法构造file:// URL const fileUrl = pathToFileURL(tempFilePath).href; writeLog('info', `File URL构造: ${fileUrl}`); writeLog('info', `原始路径: ${tempFilePath}`); // 记录文件路径映射 imageUrlMap.set(canvasId, fileUrl); } // 2. 生成最终HTML,将占位符替换为文件URL let finalHtml = html; for (const [canvasId, fileUrl] of imageUrlMap.entries()) { finalHtml = finalHtml.replace( new RegExp(`]*>`, 'g'), `打印图片` ); } writeLog('info', `HTML占位符已替换完成,临时文件数量: ${tempFiles.length}`); // 3. 包装完整的HTML文档 const fullHtml = ` 打印胶片 235689
${finalHtml}
`; // 4. 创建隐藏窗口(调试时可以设为true查看窗口内容) printWindow = new BrowserWindow({ show: true, // 设为true调试内容 webPreferences: { nodeIntegration: false, contextIsolation: false, } }); writeLog('info', '打印窗口已创建,开始加载HTML'); writeLog('info',`把完整的html文件保存起来:${fullHtml}`) // 5. 加载 HTML const loaded = once(printWindow.webContents, 'did-finish-load'); // const htmlDataURL = makeDataURL(fullHtml); // await printWindow.loadURL(htmlDataURL); // 1. 把完整 HTML 写入临时文件 const tmpHtml = join(tmpdir(), `electron-print-${Date.now()}.html`); await writeFileSync(tmpHtml, fullHtml, 'utf8'); // 2. 用 file:// 协议加载 await printWindow.loadURL(`file://${tmpHtml.replace(/\\/g, '/')}`); await loaded; writeLog('info', 'HTML加载完成,等待图片加载...'); // 6. 等待所有图片加载完成(重要的是这个步骤) await new Promise((resolve, reject) => { let loadedCount = 0; const totalImages = imageDataList.length; if (totalImages === 0) { // 没有图片,直接继续 resolve(); return; } const checkLoaded = () => { loadedCount++; writeLog('info', `图片已加载 ${loadedCount}/${totalImages}`); if (loadedCount >= totalImages) { writeLog('info', '所有图片加载完成'); resolve(); } }; printWindow.webContents.on('console-message', (event, level, message) => { // 监控图片加载的调试信息 if (message.includes('图片加载完成')) { checkLoaded(); } }); // 设置超时(20秒) setTimeout(() => { writeLog('warn', `图片加载超时 (已加载 ${loadedCount}/${totalImages}),仍继续打印`); resolve(); }, 10000); }); // 7. 执行打印 const printOpts = { silent: false,//Boolean(silent), printBackground: true, color: true, margins: { marginType: 'custom', top: margins?.top ?? 10, bottom: margins?.bottom ?? 10, left: margins?.left ?? 10, right: margins?.right ?? 10 }, landscape: orientation === 'landscape', pageSize: paperSize || 'A4' }; writeLog('info', '开始执行打印'); const [success, reason] = await Promise.race([ new Promise( res => { // 使用 printDialog 显示对话框 // printWindow.webContents.printDialog(printOpts); printWindow.webContents.print(printOpts, (ok, err) => res([ok, err])); }), new Promise((_, rej) => setTimeout(() => rej(new Error('打印超时(60s)')), 60_000) ) ]); // 8. 清理临时文件 // try { // for (const tempFile of tempFiles) { // await fs.unlink(tempFile); // writeLog('info', `临时文件已删除: ${tempFile}`); // } // // 删除临时目录 // await fs.rmdir(tempDir, { recursive: true }); // writeLog('info', `临时目录已删除: ${tempDir}`); // } catch (cleanupError) { // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`); // } if (success) { writeLog('info', '文件系统打印成功'); return { success: true }; } throw new Error(reason || '打印失败'); } catch (err) { writeLog('error', `文件系统打印出错: ${err.message}`); // 清理资源 if (printWindow && !printWindow.isDestroyed()) { printWindow.close(); } // // 清理临时文件 // try { // const fs = await import('fs/promises'); // for (const tempFile of tempFiles) { // try { // await fs.unlink(tempFile); // } catch (e) { // // 忽略单个文件删除错误 // } // } // } catch (cleanupError) { // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`); // } return { success: false, error: err.message }; } }); /** * 导出为PDF */ ipcMain.handle('print-to-pdf', async (event, options) => { try { writeLog('info', `开始导出PDF,选项: ${JSON.stringify(options)}`); const { html, orientation, paperSize, margins } = options; // 创建隐藏窗口 const pdfWindow = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, }, }); // 加载HTML内容 await pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); // 等待加载完成 await new Promise(resolve => { pdfWindow.webContents.on('did-finish-load', resolve); }); // PDF选项 const pdfOptions = { printBackground: true, landscape: orientation === 'landscape', pageSize: paperSize || 'A4', margins: { top: margins?.top || 10, bottom: margins?.bottom || 10, left: margins?.left || 10, right: margins?.right || 10, }, }; // 生成PDF const data = await pdfWindow.webContents.printToPDF(pdfOptions); // 关闭窗口 pdfWindow.close(); // 显示保存对话框 const { filePath } = await dialog.showSaveDialog(win, { title: '保存PDF', defaultPath: `film-${Date.now()}.pdf`, filters: [ { name: 'PDF文件', extensions: ['pdf'] } ] }); if (filePath) { const fs = await import('fs/promises'); await fs.writeFile(filePath, data); writeLog('info', `PDF已保存到: ${filePath}`); // 询问是否打开文件 const { response } = await dialog.showMessageBox(win, { type: 'info', message: 'PDF导出成功', detail: `文件已保存到:\n${filePath}`, buttons: ['打开文件', '关闭'], defaultId: 0 }); if (response === 0) { shell.openPath(filePath); } return { success: true, filePath }; } return { success: false, error: '用户取消保存' }; } catch (error) { writeLog('error', `导出PDF出错: ${error.message}`); return { success: false, error: error.message }; } });