// Node.js 核心模块 const fs = require('fs') const os = require("os"); const path = require('path') const spawn = require("child_process").spawn; const fsProm = require('fs/promises'); const crc = require('crc'); const zlib = require('zlib'); // Web 服务相关 const express = require('express') const axios = require('axios'); // Electron 核心模块 const { app, ipcMain, dialog, clipboard, nativeImage, shell, globalShortcut, nativeTheme, Tray, Menu, WebContentsView, BrowserWindow } = require('electron') // 禁用渲染器后台化 app.commandLine.appendSwitch('disable-renderer-backgrounding'); app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); // Electron 扩展和工具 const {autoUpdater} = require("electron-updater") const Store = require("electron-store"); const loger = require("electron-log"); const electronConf = require('electron-config') const Screenshots = require("electron-screenshots-tool").Screenshots; // PDF 处理 const PDFDocument = require('pdf-lib').PDFDocument; // 本地模块和配置 const utils = require('./lib/utils'); const config = require('./package.json'); const electronDown = require("./electron-down"); const electronMenu = require("./electron-menu"); const { startMCPServer, stopMCPServer } = require("./lib/mcp"); // 实例初始化 const userConf = new electronConf() const store = new Store(); // 平台检测常量 const isMac = process.platform === 'darwin' const isWin = process.platform === 'win32' // URL 和调用验证正则 const allowedUrls = /^(?:https?|mailto|tel|callto):/i; const allowedCalls = /^(?:mailto|tel|callto):/i; // 路径和缓存配置 const cacheDir = path.join(os.tmpdir(), 'dootask-cache') const updaterLockFile = path.join(cacheDir, '.dootask_updater.lock'); // 应用状态标志 let enableStoreBkp = true, dialogOpen = false, enablePlugins = false, isReady = false, willQuitApp = false, isDevelopMode = false; // 服务器配置 let serverPort = 22223, mcpPort = 22224, serverPublicDir = path.join(__dirname, 'public'), serverUrl = "", serverTimer = null; // 截图相关变量 let screenshotObj = null, screenshotKey = null; // 窗口实例变量 let mainWindow = null, mainTray = null, preloadWindow = null, mediaWindow = null, webTabWindow = null; // 窗口数组和状态 let childWindow = [], webTabView = []; // 窗口配置和状态 let mediaType = null, webTabHeight = 40, webTabClosedByShortcut = false; // 开发模式路径 let devloadPath = path.resolve(__dirname, ".devload"); // 窗口显示状态管理 let showState = {}, onShowWindow = (win) => { try { if (typeof showState[win.webContents.id] === 'undefined') { showState[win.webContents.id] = true win.show(); } } catch (e) { // loger.error(e) } } // 开发模式加载 if (fs.existsSync(devloadPath)) { let devloadContent = fs.readFileSync(devloadPath, 'utf8') if (devloadContent.startsWith('http')) { serverUrl = devloadContent; isDevelopMode = true; } } // 缓存目录检查 if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } // 初始化下载 electronDown.initialize(() => { if (mainWindow) { mainWindow.webContents.send("openDownloadWindow", {}) } }) /** * 启动web服务 */ async function startWebServer(force = false) { if (serverUrl && !force) { return Promise.resolve(); } // 每次启动前清理缓存 utils.clearServerCache(); return new Promise((resolve, reject) => { // 创建Express应用 const expressApp = express(); // 健康检查 expressApp.head('/health', (req, res) => { res.status(200).send('OK'); }); // 使用express.static中间件提供静态文件服务 // Express内置了全面的MIME类型支持,无需手动配置 expressApp.use(express.static(serverPublicDir, { // 设置默认文件 index: ['index.html', 'index.htm'], // 启用etag缓存 etag: true, // 设置缓存时间(开发环境可以设置较短) maxAge: '1h', // 启用压缩 dotfiles: 'ignore', // 自定义头部 setHeaders: (res, path, stat) => { const ext = path.split('.').pop().toLowerCase(); // HTML、JS、CSS文件禁用缓存,方便开发调试 if (['html', 'js', 'css'].includes(ext)){ res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); } } })); // 404处理中间件 expressApp.use((req, res) => { res.status(404).send('File not found'); }); // 错误处理中间件 expressApp.use((err, req, res, next) => { // 不是ENOENT错误,记录error级别日志 if (err.code !== 'ENOENT') { loger.error('Server error:', err); res.status(500).send('Internal Server Error'); return; } // 没有path,说明是404错误 if (!err.path) { loger.warn('File not found:', req.url); res.status(404).send('File not found'); return; } // 不是临时文件错误,普通404 if (!err.path.includes('.com.dootask.task.')) { loger.warn('File not found:', err.path); res.status(404).send('File not found'); return; } // 防止死循环 - 如果已经是重定向请求,直接返回404 if (req.query._dt_restored) { const redirectTime = parseInt(req.query._dt_restored); const timeDiff = Date.now() - redirectTime; // 10秒内的重定向认为是死循环,直接返回404 if (timeDiff < 10000) { loger.warn('Recent redirect detected, avoiding loop:', timeDiff + 'ms ago'); res.status(404).send('File not found'); return; } } loger.warn('Temporary file cleaned up by system:', err.path, req.url); // 临时文件被系统清理,尝试从serverPublicDir重新读取并恢复 const requestedUrl = new URL(req.url, serverUrl); const requestedFile = path.join(serverPublicDir, requestedUrl.pathname === '/' ? '/index.html' : requestedUrl.pathname); try { // 检查文件是否存在于serverPublicDir fs.accessSync(requestedFile, fs.constants.F_OK); // 确保目标目录存在 const targetDir = path.dirname(err.path); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, {recursive: true}); } // 从ASAR文件中读取文件并写入到临时位置 fs.writeFileSync(err.path, fs.readFileSync(requestedFile)); // 文件恢复成功后,301重定向到带__redirect参数的URL requestedUrl.searchParams.set('_dt_restored', Date.now()); res.redirect(301, requestedUrl.toString()); } catch (accessErr) { // 文件不存在于serverPublicDir,返回404 loger.warn('Source file not found:', requestedFile, 'Error:', accessErr.message); res.status(404).send('File not found'); } }); // 启动服务器 const server = expressApp.listen(serverPort, 'localhost', () => { loger.info(`Express static file server running at http://localhost:${serverPort}/`); loger.info(`Serving files from: ${serverPublicDir}`); serverUrl = `http://localhost:${serverPort}/`; resolve(server); // 启动健康检查定时器 serverTimeout(); }); // 错误处理 server.on('error', (err) => { loger.error('Server error:', err); reject(err); }); }); } /** * 健康检查定时器 */ function serverTimeout() { clearTimeout(serverTimer) serverTimer = setTimeout(async () => { if (!serverUrl) { return; // 没有服务器URL,直接返回 } try { const res = await axios.head(serverUrl + 'health') if (res.status === 200) { serverTimeout() // 健康检查通过,重新设置定时器 return; } loger.error('Server health check failed with status: ' + res.status); } catch (err) { loger.error('Server health check error:', err); } // 如果健康检查失败,尝试重新启动服务器 try { await startWebServer(true) loger.info('Server restarted successfully'); } catch (error) { loger.error('Failed to restart server:', error); } }, 10000) } /** * 创建主窗口 */ function createMainWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 360, minHeight: 360, center: true, autoHideMenuBar: true, backgroundColor: utils.getDefaultBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, backgroundThrottling: false, } }) mainWindow.on('page-title-updated', (event, title) => { if (title == "index.html") { event.preventDefault() } }) mainWindow.on('focus', () => { mainWindow.webContents.send("browserWindowFocus", {}) }) mainWindow.on('blur', () => { mainWindow.webContents.send("browserWindowBlur", {}) }) mainWindow.on('close', event => { if (!willQuitApp) { utils.onBeforeUnload(event, mainWindow).then(() => { if (['darwin', 'win32'].includes(process.platform)) { if (mainWindow.isFullScreen()) { mainWindow.once('leave-full-screen', () => { mainWindow.hide(); }) mainWindow.setFullScreen(false) } else { mainWindow.hide(); } } else { app.quit(); } }) } }) // 设置 UA const originalUA = mainWindow.webContents.session.getUserAgent() || mainWindow.webContents.getUserAgent() mainWindow.webContents.setUserAgent(originalUA + " MainTaskWindow/" + process.platform + "/" + os.arch() + "/1.0"); // 新窗口处理 mainWindow.webContents.setWindowOpenHandler(({url}) => { if (allowedCalls.test(url)) { openExternal(url).catch(() => {}) } else { utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => { openExternal(url).catch(() => {}) }) } return {action: 'deny'} }) // 设置右键菜单 electronMenu.webContentsMenu(mainWindow.webContents) // 加载地址 utils.loadUrl(mainWindow, serverUrl) } /** * 创建更新程序子进程 */ function createUpdaterWindow(updateTitle) { // 检查平台是否支持 if (!['darwin', 'win32'].includes(process.platform)) { return; } try { // 构建updater应用路径 let updaterPath; if (isWin) { updaterPath = path.join(process.resourcesPath, 'updater', 'updater.exe'); } else { updaterPath = path.join(process.resourcesPath, 'updater', 'updater'); } // 检查updater应用是否存在 if (!fs.existsSync(updaterPath)) { loger.error('Updater not found:', updaterPath); return; } // 检查文件权限 try { fs.accessSync(updaterPath, fs.constants.X_OK); } catch (e) { if (isWin) { try { spawn('icacls', [updaterPath, '/grant', 'everyone:F'], { stdio: 'inherit', shell: true }); } catch (e) { loger.error('Failed to set executable permission:', e); } } else if (process.platform === 'darwin') { try { spawn('chmod', ['+x', updaterPath], {stdio: 'inherit'}); } catch (e) { loger.error('Failed to set executable permission:', e); } } } // 创建锁文件 fs.writeFileSync(updaterLockFile, Date.now().toString()); // 启动子进程,传入锁文件路径作为第一个参数 const child = spawn(updaterPath, [updaterLockFile], { detached: true, stdio: 'ignore', shell: isWin, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', UPDATER_TITLE: updateTitle || '' } }); child.unref(); child.on('error', (err) => { loger.error('Updater process error:', err); }); } catch (e) { loger.error('Failed to create updater process:', e); } } /** * 创建预窗口 */ function preCreateChildWindow() { if (preloadWindow) { return; } const browser = new BrowserWindow({ width: 360, height: 360, minWidth: 360, minHeight: 360, center: true, show: false, autoHideMenuBar: true, backgroundColor: utils.getDefaultBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, } }); // 关闭事件 browser.addListener('closed', () => { preloadWindow = null; }) // 设置 UA const originalUA = browser.webContents.session.getUserAgent() || browser.webContents.getUserAgent() browser.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0"); utils.loadUrl(browser, serverUrl, '/preload') preloadWindow = browser; } /** * 创建子窗口 * @param args {name, path, hash, force, userAgent, config, webPreferences} * - config: {title, titleFixed, ...BrowserWindowConstructorOptions} */ function createChildWindow(args) { if (!args) { return; } if (!utils.isJson(args)) { args = {path: args, config: {}} } const name = args.name || "auto_" + utils.randomString(6); const wind = childWindow.find(item => item.name == name); let browser = wind ? wind.browser : null; let isPreload = false; if (browser) { browser.focus(); if (args.force === false) { return; } } else { const config = args.config || {}; const webPreferences = args.webPreferences || {}; const options = Object.assign({ width: 1280, height: 800, minWidth: 360, minHeight: 360, center: true, show: false, autoHideMenuBar: true, backgroundColor: utils.getDefaultBackgroundColor(), webPreferences: Object.assign({ preload: path.join(__dirname, 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, }, webPreferences), }, config) options.width = utils.normalizeSize(options.width, 1280) options.height = utils.normalizeSize(options.height, 800) options.minWidth = utils.normalizeSize(options.minWidth, 360) options.minHeight = utils.normalizeSize(options.minHeight, 360) if (!options.webPreferences.contextIsolation) { delete options.webPreferences.preload; } if (options.parent) { options.parent = mainWindow } if (preloadWindow && Object.keys(webPreferences).length === 0) { // 使用预加载窗口 browser = preloadWindow; preloadWindow = null; isPreload = true; options.title && browser.setTitle(options.title); options.parent && browser.setParentWindow(options.parent); browser.setSize(options.width, options.height); browser.setMinimumSize(options.minWidth, options.minHeight); browser.center(); browser.setAutoHideMenuBar(options.autoHideMenuBar); browser.removeAllListeners("closed"); setTimeout(() => onShowWindow(browser), 300) process.nextTick(() => setTimeout(() => onShowWindow(browser), 50)); } else { // 创建新窗口 browser = new BrowserWindow(options) loger.info("create new window") } browser.on('page-title-updated', (event, title) => { if (title == "index.html" || options.titleFixed === true) { event.preventDefault() } }) browser.on('focus', () => { browser.webContents.send("browserWindowFocus", {}) }) browser.on('blur', () => { browser.webContents.send("browserWindowBlur", {}) }) browser.on('close', event => { if (!willQuitApp) { utils.onBeforeUnload(event, browser).then(() => { browser.hide() setTimeout(() => { browser.destroy() }, 100) }) } }) browser.on('closed', () => { const index = childWindow.findIndex(item => item.name == name); if (index > -1) { childWindow.splice(index, 1) } }) browser.once('ready-to-show', () => { onShowWindow(browser); }) browser.webContents.once('dom-ready', () => { onShowWindow(browser); }) childWindow.push({ name, browser }) } // 设置 UA const originalUA = browser.webContents.session.getUserAgent() || browser.webContents.getUserAgent() browser.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0" + (args.userAgent ? (" " + args.userAgent) : "")); // 新窗口处理 browser.webContents.setWindowOpenHandler(({url}) => { if (allowedCalls.test(url)) { openExternal(url).catch(() => {}) } else { utils.onBeforeOpenWindow(browser.webContents, url).then(() => { openExternal(url).catch(() => {}) }) } return {action: 'deny'} }) // 设置右键菜单 electronMenu.webContentsMenu(browser.webContents) // 加载地址 const hash = `${args.hash || args.path}`; if (/^https?:/i.test(hash)) { browser.loadURL(hash) .then(_ => { }) .catch(_ => { }) } else if (isPreload) { browser .webContents .executeJavaScript(`if(typeof window.__initializeApp === 'function'){window.__initializeApp('${hash}')}else{throw new Error('no function')}`, true) .catch(() => { utils.loadUrl(browser, serverUrl, hash) }); } else { utils.loadUrl(browser, serverUrl, hash) } // 预创建下一个窗口 preCreateChildWindow(); } /** * 更新子窗口 * @param browser * @param args */ function updateChildWindow(browser, args) { if (!args) { return; } if (!utils.isJson(args)) { args = {path: args, name: null} } const hash = args.hash || args.path; if (hash) { utils.loadUrl(browser, serverUrl, hash) } if (args.name) { const er = childWindow.find(item => item.browser == browser); if (er) { er.name = args.name; } } } /** * 创建媒体浏览器窗口 * @param args * @param type */ function createMediaWindow(args, type = 'image') { if (mediaWindow === null) { mediaWindow = new BrowserWindow({ width: args.width || 970, height: args.height || 700, minWidth: 360, minHeight: 360, autoHideMenuBar: true, webPreferences: { nodeIntegration: true, contextIsolation: false, webSecurity: false, plugins: true }, show: false }); // 监听关闭事件 mediaWindow.addListener('close', event => { if (!willQuitApp) { event.preventDefault() if (mediaWindow.isFullScreen()) { mediaWindow.once('leave-full-screen', () => { mediaWindow.hide(); }) mediaWindow.setFullScreen(false) } else { mediaWindow.webContents.send('on-close'); mediaWindow.hide(); } } }) // 监听关闭事件 mediaWindow.addListener('closed', () => { mediaWindow = null; mediaType = null; }) // 设置右键菜单 electronMenu.webContentsMenu(mediaWindow.webContents) } else { // 直接显示 mediaWindow.show(); } // 加载图片浏览器的HTML if (mediaType === type) { // 更新窗口 mediaWindow.webContents.send('load-media', args); } else { // 重置窗口 mediaType = type; let filePath = './render/viewer/index.html'; if (type === 'video') { filePath = './render/video/index.html'; } mediaWindow.loadFile(filePath, {}).then(_ => { }).catch(_ => { }) } // 窗口准备好后事件 mediaWindow.removeAllListeners("ready-to-show"); mediaWindow.addListener('ready-to-show', () => { mediaWindow.show(); mediaWindow.webContents.send('load-media', args); }); } /** * 创建内置浏览器 * @param args {url, ?} */ function createWebTabWindow(args) { if (!args) { return; } if (!utils.isJson(args)) { args = {url: args} } // 创建父级窗口 if (!webTabWindow) { const titleBarOverlay = { height: webTabHeight } if (nativeTheme.shouldUseDarkColors) { titleBarOverlay.color = '#3B3B3D' titleBarOverlay.symbolColor = '#C5C5C5' } webTabWindow = new BrowserWindow(Object.assign({ x: mainWindow.getBounds().x + webTabHeight, y: mainWindow.getBounds().y + webTabHeight, width: 1280, height: 800, minWidth: 360, minHeight: 360, center: true, show: false, autoHideMenuBar: true, titleBarStyle: 'hidden', titleBarOverlay, backgroundColor: nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF', webPreferences: { preload: path.join(__dirname, 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, }, }, userConf.get('webTabWindow') || {})) const originalClose = webTabWindow.close; webTabWindow.close = function() { webTabClosedByShortcut = true; return originalClose.apply(this, arguments); }; webTabWindow.on('resize', () => { resizeWebTab(0) }) webTabWindow.on('enter-full-screen', () => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'enter-full-screen', }).then(_ => { }) }) webTabWindow.on('leave-full-screen', () => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'leave-full-screen', }).then(_ => { }) }) webTabWindow.on('close', event => { if (webTabClosedByShortcut) { webTabClosedByShortcut = false if (!willQuitApp) { closeWebTab(0) event.preventDefault() return } } userConf.set('webTabWindow', webTabWindow.getBounds()) }) webTabWindow.on('closed', () => { webTabView.forEach(({view}) => { try { view.webContents.close() } catch (e) { // } }) webTabView = [] webTabWindow = null }) webTabWindow.once('ready-to-show', () => { onShowWindow(webTabWindow); }) webTabWindow.webContents.once('dom-ready', () => { onShowWindow(webTabWindow); }) webTabWindow.webContents.on('before-input-event', (event, input) => { if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') { reloadWebTab(0) event.preventDefault() } else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') { webTabClosedByShortcut = true } else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') { devToolsWebTab(0) } }) webTabWindow.loadFile('./render/tabs/index.html', {}).then(_ => { }).catch(_ => { }) } if (webTabWindow.isMinimized()) { webTabWindow.restore() } webTabWindow.focus(); webTabWindow.show(); // 创建 tab 子窗口 const viewOptions = args.config || {} viewOptions.webPreferences = Object.assign({ preload: path.join(__dirname, 'electron-preload.js'), nodeIntegration: true, contextIsolation: true }, args.webPreferences || {}) if (!viewOptions.webPreferences.contextIsolation) { delete viewOptions.webPreferences.preload; } const browserView = new WebContentsView(viewOptions) if (args.backgroundColor) { browserView.setBackgroundColor(args.backgroundColor) } else if (nativeTheme.shouldUseDarkColors) { browserView.setBackgroundColor('#575757') } else { browserView.setBackgroundColor('#FFFFFF') } browserView.setBounds({ x: 0, y: webTabHeight, width: webTabWindow.getContentBounds().width || 1280, height: (webTabWindow.getContentBounds().height || 800) - webTabHeight, }) browserView.webContents.on('destroyed', () => { closeWebTab(browserView.webContents.id) }) browserView.webContents.setWindowOpenHandler(({url}) => { if (allowedCalls.test(url)) { openExternal(url).catch(() => {}) } else { createWebTabWindow({url}) } return {action: 'deny'} }) browserView.webContents.on('page-title-updated', (event, title) => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'title', id: browserView.webContents.id, title: title, url: browserView.webContents.getURL(), }).then(_ => { }) }) browserView.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { if (!errorDescription) { return } // 主框架加载失败时,展示内置的错误页面 if (isMainFrame) { const originalUrl = validatedURL || args.url || '' const filePath = path.join(__dirname, 'render', 'tabs', 'error.html') browserView.webContents.loadFile(filePath, { query: { id: String(browserView.webContents.id), url: originalUrl, code: String(errorCode), desc: errorDescription, } }).then(_ => { }).catch(_ => { }) return } utils.onDispatchEvent(webTabWindow.webContents, { event: 'title', id: browserView.webContents.id, title: errorDescription, url: browserView.webContents.getURL(), }).then(_ => { }) }) browserView.webContents.on('page-favicon-updated', (event, favicons) => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'favicon', id: browserView.webContents.id, favicons }).then(_ => { }) }) browserView.webContents.on('did-start-loading', _ => { webTabView.forEach(({id: vid, view}) => { view.setVisible(vid === browserView.webContents.id) }) utils.onDispatchEvent(webTabWindow.webContents, { event: 'start-loading', id: browserView.webContents.id, }).then(_ => { }) }) browserView.webContents.on('did-stop-loading', _ => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'stop-loading', id: browserView.webContents.id, }).then(_ => { }) // 加载完成暗黑模式下把窗口背景色改成白色,避免透明网站背景色穿透 if (nativeTheme.shouldUseDarkColors) { browserView.setBackgroundColor('#FFFFFF') } }) browserView.webContents.on('before-input-event', (event, input) => { if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') { browserView.webContents.reload() event.preventDefault() } else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') { webTabClosedByShortcut = true } else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') { browserView.webContents.toggleDevTools() } }) const originalUA = browserView.webContents.session.getUserAgent() || browserView.webContents.getUserAgent() browserView.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0"); electronMenu.webContentsMenu(browserView.webContents, true) browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { }) browserView.setVisible(true) webTabWindow.contentView.addChildView(browserView) webTabView.push({ id: browserView.webContents.id, view: browserView }) utils.onDispatchEvent(webTabWindow.webContents, { event: 'create', id: browserView.webContents.id, url: args.url, }).then(_ => { }) activateWebTab(browserView.webContents.id) } /** * 获取当前内置浏览器标签 * @returns {Electron.WebContentsView|undefined} */ function currentWebTab() { // 第一:使用当前可见的标签 try { const item = webTabView.find(({view}) => view?.getVisible && view.getVisible()) if (item) { return item } } catch (e) {} // 第二:使用当前聚焦的 webContents try { const focused = webContents.getFocusedWebContents?.() if (focused) { const item = webTabView.find(it => it.id === focused.id) if (item) { return item } } } catch (e) {} // 兜底:根据 children 顺序选择最上层的可用视图 const children = webTabWindow.contentView.children || [] for (let i = children.length - 1; i >= 0; i--) { const id = children[i]?.webContents?.id const item = webTabView.find(it => it.id === id) if (item) { return item } } return undefined } /** * 重新加载内置浏览器标签 * @param id */ function reloadWebTab(id) { const item = id === 0 ? currentWebTab() : webTabView.find(item => item.id == id) if (!item) { return } item.view.webContents.reload() } /** * 内置浏览器标签打开开发者工具 * @param id */ function devToolsWebTab(id) { const item = id === 0 ? currentWebTab() : webTabView.find(item => item.id == id) if (!item) { return } item.view.webContents.toggleDevTools() } /** * 调整内置浏览器标签尺寸 * @param id */ function resizeWebTab(id) { const item = id === 0 ? currentWebTab() : webTabView.find(item => item.id == id) if (!item) { return } item.view.setBounds({ x: 0, y: webTabHeight, width: webTabWindow.getContentBounds().width || 1280, height: (webTabWindow.getContentBounds().height || 800) - webTabHeight, }) } /** * 切换内置浏览器标签 * @param id */ function activateWebTab(id) { const item = id === 0 ? currentWebTab() : webTabView.find(item => item.id == id) if (!item) { return } webTabView.forEach(({id: vid, view}) => { view.setVisible(vid === item.id) }) resizeWebTab(item.id) item.view.webContents.focus() utils.onDispatchEvent(webTabWindow.webContents, { event: 'switch', id: item.id, }).then(_ => { }) } /** * 关闭内置浏览器标签 * @param id */ function closeWebTab(id) { const item = id === 0 ? currentWebTab() : webTabView.find(item => item.id == id) if (!item) { return } if (webTabView.length === 1) { webTabWindow.hide() } webTabWindow.contentView.removeChildView(item.view) try { item.view.webContents.close() } catch (e) { // } const index = webTabView.findIndex(({id}) => item.id == id) if (index > -1) { webTabView.splice(index, 1) } utils.onDispatchEvent(webTabWindow.webContents, { event: 'close', id: item.id, }).then(_ => { }) if (webTabView.length === 0) { userConf.set('webTabWindow', webTabWindow.getBounds()) webTabWindow.destroy() } else { activateWebTab(0) } } /** * 监听主题变化 */ function monitorThemeChanges() { let currentTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; nativeTheme.on('updated', () => { const newTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; if (currentTheme === newTheme) { return } currentTheme = newTheme; // 更新背景 const backgroundColor = utils.getDefaultBackgroundColor() mainWindow?.setBackgroundColor(backgroundColor); preloadWindow?.setBackgroundColor(backgroundColor); mediaWindow?.setBackgroundColor(backgroundColor); childWindow.some(({browser}) => browser.setBackgroundColor(backgroundColor)) webTabWindow?.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF') // 通知所有窗口 BrowserWindow.getAllWindows().forEach(window => { window.webContents.send('systemThemeChanged', { theme: currentTheme, }); }); }) } const getTheLock = app.requestSingleInstanceLock() if (!getTheLock) { app.quit() } else { app.on('second-instance', () => { utils.setShowWindow(mainWindow) }) app.on('ready', async () => { isReady = true isWin && app.setAppUserModelId(config.appId) // 启动 Web 服务器 try { await startWebServer() } catch (error) { dialog.showErrorBox('启动失败', `Web 服务器启动失败:${error.message}`); app.quit(); return; } // SameSite utils.useCookie() // 创建主窗口 createMainWindow() // 预创建子窗口 preCreateChildWindow() // 监听主题变化 monitorThemeChanges() // 创建托盘 if (['darwin', 'win32'].includes(process.platform) && utils.isJson(config.trayIcon)) { mainTray = new Tray(path.join(__dirname, config.trayIcon[isDevelopMode ? 'dev' : 'prod'][process.platform === 'darwin' ? 'mac' : 'win'])); mainTray.on('click', () => { utils.setShowWindow(mainWindow) }) mainTray.setToolTip(config.name) if (process.platform === 'win32') { const trayMenu = Menu.buildFromTemplate([{ label: '显示', click: () => { utils.setShowWindow(mainWindow) } }, { label: '退出', click: () => { app.quit() } }]) mainTray.setContextMenu(trayMenu) } } // 删除updater锁文件(如果存在) if (fs.existsSync(updaterLockFile)) { try { fs.unlinkSync(updaterLockFile); } catch (e) { //忽略错误 } } // 截图对象 screenshotObj = new Screenshots({ singleWindow: true, mainWindow: mainWindow }) }) } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { if (isReady) { createMainWindow() } } else if (mainWindow) { if (!mainWindow.isVisible()) { mainWindow.show() } } }) app.on('window-all-closed', () => { if (willQuitApp || process.platform !== 'darwin') { app.quit() } }) app.on('before-quit', () => { willQuitApp = true }) app.on("will-quit", () => { globalShortcut.unregisterAll(); }) /** * 设置菜单语言包 * @param args {path} */ ipcMain.on('setMenuLanguage', (event, args) => { if (utils.isJson(args)) { electronMenu.setLanguage(args) } event.returnValue = "ok" }) /** * 打开文件 * @param args {path} */ ipcMain.on('openFile', (event, args) => { utils.openFile(args.path) event.returnValue = "ok" }) /** * 退出客户端 */ ipcMain.on('windowQuit', (event) => { event.returnValue = "ok" app.quit(); }) /** * 创建路由窗口 * @param args {path, ?} */ ipcMain.on('openChildWindow', (event, args) => { createChildWindow(args) event.returnValue = "ok" }) /** * 显示预加载窗口(用于调试) */ ipcMain.on('showPreloadWindow', (event) => { if (preloadWindow) { onShowWindow(preloadWindow) } event.returnValue = "ok" }) /** * 更新路由窗口 * @param args {?name, ?path} // name: 不是要更改的窗口名,是要把窗口名改成什么, path: 地址 */ ipcMain.on('updateChildWindow', (event, args) => { const browser = BrowserWindow.fromWebContents(event.sender); updateChildWindow(browser, args) event.returnValue = "ok" }) /** * 获取路由窗口信息 */ ipcMain.handle('getChildWindow', (event, args) => { let child; if (!args) { const browser = BrowserWindow.fromWebContents(event.sender); child = childWindow.find(({browser: win}) => win === browser) } else { child = childWindow.find(({name}) => name === args) } if (child) { return { name: child.name, id: child.browser.webContents.id, url: child.browser.webContents.getURL() } } return null; }); /** * 打开媒体浏览器 */ ipcMain.on('openMediaViewer', (event, args) => { createMediaWindow(args, ['image', 'video'].includes(args.type) ? args.type : 'image'); event.returnValue = "ok" }); /** * 内置浏览器 - 打开创建 * @param args {url, ?} */ ipcMain.on('openWebTabWindow', (event, args) => { createWebTabWindow(args) event.returnValue = "ok" }) /** * 内置浏览器 - 激活标签 * @param id */ ipcMain.on('webTabActivate', (event, id) => { activateWebTab(id) event.returnValue = "ok" }) /** * 内置浏览器 - 关闭标签 * @param id */ ipcMain.on('webTabClose', (event, id) => { closeWebTab(id) event.returnValue = "ok" }) /** * 内置浏览器 - 在外部浏览器打开 */ ipcMain.on('webTabExternal', (event) => { const item = currentWebTab() if (!item) { return } openExternal(item.view.webContents.getURL()).catch(() => {}) event.returnValue = "ok" }) /** * 内置浏览器 - 打开开发者工具 */ ipcMain.on('webTabOpenDevTools', (event) => { const item = currentWebTab() if (!item) { return } item.view.webContents.openDevTools() event.returnValue = "ok" }) /** * 内置浏览器 - 销毁所有标签及窗口 */ ipcMain.on('webTabDestroyAll', (event) => { if (webTabWindow) { webTabWindow.destroy() } event.returnValue = "ok" }) /** * 内置浏览器 - 后退 */ ipcMain.on('webTabGoBack', (event) => { const item = currentWebTab() if (!item) { return } if (item.view.webContents.canGoBack()) { item.view.webContents.goBack() // 导航后更新状态 setTimeout(() => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'navigation-state', id: item.id, canGoBack: item.view.webContents.canGoBack(), canGoForward: item.view.webContents.canGoForward() }).then(_ => { }) }, 100) } event.returnValue = "ok" }) /** * 内置浏览器 - 前进 */ ipcMain.on('webTabGoForward', (event) => { const item = currentWebTab() if (!item) { return } if (item.view.webContents.canGoForward()) { item.view.webContents.goForward() // 导航后更新状态 setTimeout(() => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'navigation-state', id: item.id, canGoBack: item.view.webContents.canGoBack(), canGoForward: item.view.webContents.canGoForward() }).then(_ => { }) }, 100) } event.returnValue = "ok" }) /** * 内置浏览器 - 刷新 */ ipcMain.on('webTabReload', (event) => { const item = currentWebTab() if (!item) { return } item.view.webContents.reload() // 刷新完成后会触发 did-stop-loading 事件,在那里会更新导航状态 event.returnValue = "ok" }) /** * 内置浏览器 - 停止加载 */ ipcMain.on('webTabStop', (event) => { const item = currentWebTab() if (!item) { return } item.view.webContents.stop() event.returnValue = "ok" }) /** * 内置浏览器 - 获取导航状态 */ ipcMain.on('webTabGetNavigationState', (event) => { const item = currentWebTab() if (!item) { return } const canGoBack = item.view.webContents.canGoBack() const canGoForward = item.view.webContents.canGoForward() utils.onDispatchEvent(webTabWindow.webContents, { event: 'navigation-state', id: item.id, canGoBack, canGoForward }).then(_ => { }) event.returnValue = "ok" }) /** * 隐藏窗口(mac、win隐藏,其他关闭) */ ipcMain.on('windowHidden', (event) => { if (['darwin', 'win32'].includes(process.platform)) { app.hide(); } else { app.quit(); } event.returnValue = "ok" }) /** * 关闭窗口 */ ipcMain.on('windowClose', (event) => { const win = BrowserWindow.fromWebContents(event.sender); win.close() event.returnValue = "ok" }) /** * 销毁窗口 */ ipcMain.on('windowDestroy', (event) => { const win = BrowserWindow.fromWebContents(event.sender); win.destroy() event.returnValue = "ok" }) /** * 关闭所有子窗口 */ ipcMain.on('childWindowCloseAll', (event) => { childWindow.some(({browser}) => { browser && browser.close() }) preloadWindow?.close() mediaWindow?.close() electronDown.close() event.returnValue = "ok" }) /** * 销毁所有子窗口 */ ipcMain.on('childWindowDestroyAll', (event) => { childWindow.some(({browser}) => { browser && browser.destroy() }) preloadWindow?.destroy() mediaWindow?.destroy() electronDown.destroy() event.returnValue = "ok" }) /** * 刷新预加载窗口(用于更换语言和主题时触发) */ ipcMain.on('reloadPreloadWindow', (event) => { if (preloadWindow) { preloadWindow.webContents.reload() } event.returnValue = "ok" }) /** * 设置窗口尺寸 * @param args {width, height, autoZoom, minWidth, minHeight, maxWidth, maxHeight} */ ipcMain.on('windowSize', (event, args) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { if (args.width || args.height) { let [w, h] = win.getSize() const width = args.width || w const height = args.height || h win.setSize(width, height, args.animate === true) // if (args.autoZoom === true) { let move = false let [x, y] = win.getPosition() if (Math.abs(width - w) > 10) { move = true x -= (width - w) / 2 } if (Math.abs(height - h) > 10) { move = true y -= (height - h) / 2 } if (move) { win.setPosition(Math.max(0, Math.floor(x)), Math.max(0, Math.floor(y))) } } } if (args.minWidth || args.minHeight) { win.setMinimumSize(args.minWidth || win.getMinimumSize()[0], args.minHeight || win.getMinimumSize()[1]) } if (args.maxWidth || args.maxHeight) { win.setMaximumSize(args.maxWidth || win.getMaximumSize()[0], args.maxHeight || win.getMaximumSize()[1]) } } event.returnValue = "ok" }) /** * 设置窗口最小尺寸 * @param args {minWidth, minHeight} */ ipcMain.on('windowMinSize', (event, args) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setMinimumSize(args.minWidth || win.getMinimumSize()[0], args.minHeight || win.getMinimumSize()[1]) } event.returnValue = "ok" }) /** * 设置窗口最大尺寸 * @param args {maxWidth, maxHeight} */ ipcMain.on('windowMaxSize', (event, args) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setMaximumSize(args.maxWidth || win.getMaximumSize()[0], args.maxHeight || win.getMaximumSize()[1]) } event.returnValue = "ok" }) /** * 窗口居中 */ ipcMain.on('windowCenter', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.center(); } event.returnValue = "ok" }) /** * 窗口最大化或恢复 */ ipcMain.on('windowMax', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win.isMaximized()) { win.restore(); } else { win.maximize(); } event.returnValue = "ok" }) /** * 给所有窗口广播指令(除了本身) * @param args {type, payload} */ ipcMain.on('broadcastCommand', (event, args) => { const channel = args.channel || args.command const payload = args.payload || args.data BrowserWindow.getAllWindows().forEach(window => { if (window.webContents.id !== event.sender.id) { window.webContents.send(channel, payload) } }) event.returnValue = "ok" }) /** * 设置Dock标记(window闪烁、macos标记) * @param args */ ipcMain.on('setDockBadge', (event, args) => { if (process.platform === 'win32') { // Window flash if (!mainWindow.isFocused()) { mainWindow.once('focus', () => mainWindow.flashFrame(false)) mainWindow.flashFrame(true) } return; } if (process.platform !== 'darwin') { // Mac only return; } let num = args; let tray = true; if (utils.isJson(args)) { num = args.num tray = !!args.tray } let text = typeof num === "string" ? num : (utils.runNum(num) > 0 ? String(num) : "") app.dock.setBadge(text) if (tray && mainTray) { mainTray.setTitle(text) } event.returnValue = "ok" }) /** * MCP 服务器状态切换 * @param args */ ipcMain.on('mcpServerToggle', (event, args) => { const { running } = args; if (running === 'running') { startMCPServer(mainWindow, mcpPort) } else { stopMCPServer() } }) /** * 复制Base64图片 * @param args */ ipcMain.on('copyBase64Image', (event, args) => { const { base64 } = args; if (base64) { const img = nativeImage.createFromDataURL(base64) clipboard.writeImage(img) } event.returnValue = "ok" }) /** * 复制图片根据坐标 * @param args */ ipcMain.on('copyImageAt', (event, args) => { try { event.sender.copyImageAt(args.x, args.y); } catch (e) { loger.error('copyImageAt error:', e) } event.returnValue = "ok" }) /** * 保存图片 * @param args */ ipcMain.on('saveImageAt', async (event, args) => { await electronMenu.saveImageAs(args.url, args.params) event.returnValue = "ok" }) /** * 绑定截图快捷键 * @param args */ ipcMain.on('bindScreenshotKey', (event, args) => { const { key } = args; if (screenshotKey !== key) { if (screenshotKey) { globalShortcut.unregister(screenshotKey) screenshotKey = null } if (key) { screenshotKey = key globalShortcut.register(key, () => { screenshotObj.startCapture().then(_ => { screenshotObj.view.webContents.executeJavaScript(`if(typeof window.__initializeShortcuts==='undefined'){window.__initializeShortcuts=true;document.addEventListener('keydown',function(e){console.log(e);if(e.keyCode===27){window.screenshots.cancel()}})}`, true).catch(() => {}); screenshotObj.view.webContents.focus() }) }) } } event.returnValue = "ok" }) /** * 执行截图 */ ipcMain.on('openScreenshot', (event) => { if (screenshotObj) { screenshotObj.startCapture().then(_ => {}) } event.returnValue = "ok" }) /** * 关闭截图 */ ipcMain.on('closeScreenshot', (event) => { if (screenshotObj && screenshotObj.window?.isFocused()) { screenshotObj.endCapture().then(_ => {}); } event.returnValue = "ok" }) /** * 通知 */ ipcMain.on('openNotification', (event, args) => { utils.showNotification(args, mainWindow) event.returnValue = "ok" }) /** * 保存缓存 */ ipcMain.on('setStore', (event, args) => { if (utils.isJson(args)) { store.set(args.key, args.value) } event.returnValue = "ok" }) /** * 获取缓存 */ ipcMain.handle('getStore', (event, args) => { return store.get(args) }); /** * 清理服务器缓存 */ ipcMain.on('clearServerCache', (event) => { utils.clearServerCache(); event.returnValue = "ok"; }); //================================================================ // Update //================================================================ let autoUpdating = 0 if (autoUpdater) { autoUpdater.logger = loger autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true autoUpdater.on('update-available', info => { mainWindow.webContents.send("updateAvailable", info) }) autoUpdater.on('update-downloaded', info => { mainWindow.webContents.send("updateDownloaded", info) }) } /** * 检查更新 */ ipcMain.on('updateCheckAndDownload', (event, args) => { event.returnValue = "ok" if (autoUpdating + 3600 > utils.dayjs().unix()) { return // 限制1小时仅执行一次 } if (!autoUpdater) { return } if (args.provider) { autoUpdater.setFeedURL(args) } autoUpdater.checkForUpdates().then(info => { if (!info) { return } if (utils.compareVersion(config.version, info.updateInfo.version) >= 0) { return } if (args.apiVersion) { if (utils.compareVersion(info.updateInfo.version, args.apiVersion) <= 0) { // 客户端版本 <= 接口版本 autoUpdating = utils.dayjs().unix() autoUpdater.downloadUpdate().then(_ => {}).catch(_ => {}) } } else { autoUpdating = utils.dayjs().unix() autoUpdater.downloadUpdate().then(_ => {}).catch(_ => {}) } }) }) /** * 将主窗口激活到顶层 */ ipcMain.on('mainWindowTop', (event) => { mainWindow.moveTop() event.returnValue = "ok" }) /** * 将主窗口激活 */ ipcMain.on('mainWindowActive', (event) => { if (!mainWindow.isVisible()) { mainWindow.show() } mainWindow.focus() event.returnValue = "ok" }) /** * 退出并安装更新 */ ipcMain.on('updateQuitAndInstall', (event, args) => { if (!utils.isJson(args)) { args = {} } event.returnValue = "ok" // 关闭所有子窗口 willQuitApp = true childWindow.some(({browser}) => { browser && browser.destroy() }) preloadWindow?.destroy() mediaWindow?.destroy() electronDown.destroy() // 启动更新子窗口 createUpdaterWindow(args.updateTitle) // 退出并安装更新 setTimeout(_ => { mainWindow.hide() autoUpdater?.quitAndInstall(true, true) }, 600) }) //================================================================ // Pdf export //================================================================ const MICRON_TO_PIXEL = 264.58 //264.58 micron = 1 pixel const PIXELS_PER_INCH = 100.117 // Usually it is 100 pixels per inch but this give better results const PNG_CHUNK_IDAT = 1229209940; const LARGE_IMAGE_AREA = 30000000; //NOTE: Key length must not be longer than 79 bytes (not checked) function writePngWithText(origBuff, key, text, compressed, base64encoded) { let isDpi = key == 'dpi'; let inOffset = 0; let outOffset = 0; let data = text; let dataLen = isDpi ? 9 : key.length + data.length + 1; //we add 1 zeros with non-compressed data, for pHYs it's 2 of 4-byte-int + 1 byte //prepare compressed data to get its size if (compressed) { data = zlib.deflateRawSync(encodeURIComponent(text)); dataLen = key.length + data.length + 2; //we add 2 zeros with compressed data } let outBuff = Buffer.allocUnsafe(origBuff.length + dataLen + 4); //4 is the header size "zTXt", "tEXt" or "pHYs" try { let magic1 = origBuff.readUInt32BE(inOffset); inOffset += 4; let magic2 = origBuff.readUInt32BE(inOffset); inOffset += 4; if (magic1 != 0x89504e47 && magic2 != 0x0d0a1a0a) { throw new Error("PNGImageDecoder0"); } outBuff.writeUInt32BE(magic1, outOffset); outOffset += 4; outBuff.writeUInt32BE(magic2, outOffset); outOffset += 4; } catch (e) { loger.error(e.message, {stack: e.stack}); throw new Error("PNGImageDecoder1"); } try { while (inOffset < origBuff.length) { let length = origBuff.readInt32BE(inOffset); inOffset += 4; let type = origBuff.readInt32BE(inOffset) inOffset += 4; if (type == PNG_CHUNK_IDAT) { // Insert zTXt chunk before IDAT chunk outBuff.writeInt32BE(dataLen, outOffset); outOffset += 4; let typeSignature = isDpi ? 'pHYs' : (compressed ? "zTXt" : "tEXt"); outBuff.write(typeSignature, outOffset); outOffset += 4; if (isDpi) { let dpm = Math.round(parseInt(text) / 0.0254) || 3937; //One inch is equal to exactly 0.0254 meters. 3937 is 100dpi outBuff.writeInt32BE(dpm, outOffset); outBuff.writeInt32BE(dpm, outOffset + 4); outBuff.writeInt8(1, outOffset + 8); outOffset += 9; data = Buffer.allocUnsafe(9); data.writeInt32BE(dpm, 0); data.writeInt32BE(dpm, 4); data.writeInt8(1, 8); } else { outBuff.write(key, outOffset); outOffset += key.length; outBuff.writeInt8(0, outOffset); outOffset++; if (compressed) { outBuff.writeInt8(0, outOffset); outOffset++; data.copy(outBuff, outOffset); } else { outBuff.write(data, outOffset); } outOffset += data.length; } let crcVal = 0xffffffff; crcVal = crc.crcjam(typeSignature, crcVal); crcVal = crc.crcjam(data, crcVal); // CRC outBuff.writeInt32BE(crcVal ^ 0xffffffff, outOffset); outOffset += 4; // Writes the IDAT chunk after the zTXt outBuff.writeInt32BE(length, outOffset); outOffset += 4; outBuff.writeInt32BE(type, outOffset); outOffset += 4; origBuff.copy(outBuff, outOffset, inOffset); // Encodes the buffer using base64 if requested return base64encoded ? outBuff.toString('base64') : outBuff; } outBuff.writeInt32BE(length, outOffset); outOffset += 4; outBuff.writeInt32BE(type, outOffset); outOffset += 4; origBuff.copy(outBuff, outOffset, inOffset, inOffset + length + 4);// +4 to move past the crc inOffset += length + 4; outOffset += length + 4; } } catch (e) { loger.error(e.message, {stack: e.stack}); throw e; } } //TODO Create a lightweight html file similar to export3.html for exporting to vsdx function exportVsdx(event, args, directFinalize) { let win = new BrowserWindow({ width: 1280, height: 800, show: false, webPreferences: { preload: path.join(__dirname, 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, }, }) let loadEvtCount = 0; function loadFinished() { loadEvtCount++; if (loadEvtCount == 2) { win.webContents.send('export-vsdx', args); ipcMain.once('export-vsdx-finished', (evt, data) => { let hasError = false; if (data == null) { hasError = true; } //Set finalize here since it is call in the reply below function finalize() { win.destroy(); } if (directFinalize === true) { event.finalize = finalize; } else { //Destroy the window after response being received by caller ipcMain.once('export-finalize', finalize); } if (hasError) { event.reply('export-error'); } else { event.reply('export-success', data); } }); } } //Order of these two events is not guaranteed, so wait for them async. //TOOD There is still a chance we catch another window 'app-load-finished' if user created multiple windows quickly ipcMain.once('app-load-finished', loadFinished); win.webContents.on('did-finish-load', loadFinished); } async function mergePdfs(pdfFiles, xml) { //Pass throgh single files if (pdfFiles.length == 1 && xml == null) { return pdfFiles[0]; } try { const pdfDoc = await PDFDocument.create(); pdfDoc.setCreator(config.name); if (xml != null) { //Embed diagram XML as file attachment await pdfDoc.attach(Buffer.from(xml).toString('base64'), config.name + '.xml', { mimeType: 'application/vnd.jgraph.mxfile', description: config.name + ' Content' }); } for (let i = 0; i < pdfFiles.length; i++) { const pdfFile = await PDFDocument.load(pdfFiles[i].buffer); const pages = await pdfDoc.copyPages(pdfFile, pdfFile.getPageIndices()); pages.forEach(p => pdfDoc.addPage(p)); } const pdfBytes = await pdfDoc.save(); return Buffer.from(pdfBytes); } catch (e) { throw new Error('Error during PDF combination: ' + e.message); } } //TODO Use canvas to export images if math is not used to speedup export (no capturePage). Requires change to export3.html also function exportDiagram(event, args, directFinalize) { if (args.format == 'vsdx') { exportVsdx(event, args, directFinalize); return; } let browser = null; try { browser = new BrowserWindow({ webPreferences: { preload: path.join(__dirname, 'electron-preload.js'), backgroundThrottling: false, contextIsolation: true, disableBlinkFeatures: 'Auxclick' // Is this needed? }, show: false, frame: false, enableLargerThanScreen: true, transparent: args.format == 'png' && (args.bg == null || args.bg == 'none'), }); if (serverUrl) { browser.loadURL(serverUrl + 'drawio/webapp/export3.html').then(_ => { }).catch(_ => { }) } else { browser.loadFile('./public/drawio/webapp/export3.html').then(_ => { }).catch(_ => { }) } const contents = browser.webContents; let pageByPage = (args.format == 'pdf' && !args.print), from, to, pdfs; if (pageByPage) { from = args.allPages ? 0 : parseInt(args.from || 0); to = args.allPages ? 1000 : parseInt(args.to || 1000) + 1; //The 'to' will be corrected later pdfs = []; args.from = from; args.to = from; args.allPages = false; } contents.on('did-finish-load', function () { //Set finalize here since it is call in the reply below function finalize() { browser.destroy(); } if (directFinalize === true) { event.finalize = finalize; } else { //Destroy the window after response being received by caller ipcMain.once('export-finalize', finalize); } function renderingFinishHandler(evt, renderInfo) { if (renderInfo == null) { event.reply('export-error'); return; } let pageCount = renderInfo.pageCount, bounds = null; //For some reason, Electron 9 doesn't send this object as is without stringifying. Usually when variable is external to function own scope try { bounds = JSON.parse(renderInfo.bounds); } catch (e) { bounds = null; } let pdfOptions = {pageSize: 'A4'}; let hasError = false; if (bounds == null || bounds.width < 5 || bounds.height < 5) //very small page size never return from printToPDF { //A workaround to detect errors in the input file or being empty file hasError = true; } else { pdfOptions = { printBackground: true, pageSize: { width: bounds.width / PIXELS_PER_INCH, height: (bounds.height + 2) / PIXELS_PER_INCH //the extra 2 pixels to prevent adding an extra empty page }, margins: { top: 0, bottom: 0, left: 0, right: 0 } // no margin } } let base64encoded = args.base64 == '1'; if (hasError) { event.reply('export-error'); } else if (args.format == 'png' || args.format == 'jpg' || args.format == 'jpeg') { //Adds an extra pixel to prevent scrollbars from showing let newBounds = { width: Math.ceil(bounds.width + bounds.x) + 1, height: Math.ceil(bounds.height + bounds.y) + 1 }; browser.setBounds(newBounds); //TODO The browser takes sometime to show the graph (also after resize it takes some time to render) // 1 sec is most probably enough (for small images, 5 for large ones) BUT not a stable solution setTimeout(function () { browser.capturePage().then(function (img) { //Image is double the given bounds, so resize is needed! let tScale = 1; //If user defined width and/or height, enforce it precisely here. Height override width if (args.h) { tScale = args.h / newBounds.height; } else if (args.w) { tScale = args.w / newBounds.width; } newBounds.width *= tScale; newBounds.height *= tScale; img = img.resize(newBounds); let data = args.format == 'png' ? img.toPNG() : img.toJPEG(args.jpegQuality || 90); if (args.dpi != null && args.format == 'png') { data = writePngWithText(data, 'dpi', args.dpi); } if (args.embedXml == "1" && args.format == 'png') { data = writePngWithText(data, "mxGraphModel", args.xml, true, base64encoded); } else { if (base64encoded) { data = data.toString('base64'); } } event.reply('export-success', data); }); }, bounds.width * bounds.height < LARGE_IMAGE_AREA ? 1000 : 5000); } else if (args.format == 'pdf') { if (args.print) { pdfOptions = { scaleFactor: args.pageScale, printBackground: true, pageSize: { width: args.pageWidth * MICRON_TO_PIXEL, //This height adjustment fixes the output. TODO Test more cases height: (args.pageHeight * 1.025) * MICRON_TO_PIXEL }, marginsType: 1 // no margin }; contents.print(pdfOptions, (success, errorType) => { //Consider all as success event.reply('export-success', {}); }); } else { contents.printToPDF(pdfOptions).then(async (data) => { pdfs.push(data); to = to > pageCount ? pageCount : to; from++; if (from < to) { args.from = from; args.to = from; ipcMain.once('render-finished', renderingFinishHandler); contents.send('render', args); } else { data = await mergePdfs(pdfs, args.embedXml == '1' ? args.xml : null); event.reply('export-success', data); } }) .catch((error) => { event.reply('export-error', error); }); } } else if (args.format == 'svg') { contents.send('get-svg-data'); ipcMain.once('svg-data', (evt, data) => { event.reply('export-success', data); }); } else { event.reply('export-error', 'Error: Unsupported format'); } } ipcMain.once('render-finished', renderingFinishHandler); if (args.format == 'xml') { ipcMain.once('xml-data', (evt, data) => { event.reply('export-success', data); }); ipcMain.once('xml-data-error', () => { event.reply('export-error'); }); } args.border = args.border || 0; args.scale = args.scale || 1; contents.send('render', args); }); } catch (e) { if (browser != null) { browser.destroy(); } event.reply('export-error', e); console.log('export-error', e); } } ipcMain.on('export', exportDiagram); //================================================================ // Renderer Helper functions //================================================================ const {O_SYNC, O_CREAT, O_WRONLY, O_TRUNC, O_RDONLY} = fs.constants; const DRAFT_PREFEX = '.$'; const OLD_DRAFT_PREFEX = '~$'; const DRAFT_EXT = '.dtmp'; const BKP_PREFEX = '.$'; const OLD_BKP_PREFEX = '~$'; const BKP_EXT = '.bkp'; /** * Checks the file content type * Confirm content is xml, pdf, png, jpg, svg, vsdx ... */ function checkFileContent(body, enc) { if (body != null) { let head, headBinay; if (typeof body === 'string') { if (enc == 'base64') { headBinay = Buffer.from(body.substring(0, 22), 'base64'); head = headBinay.toString(); } else { head = body.substring(0, 16); headBinay = Buffer.from(head); } } else { head = new TextDecoder("utf-8").decode(body.subarray(0, 16)); headBinay = body; } let c1 = head[0], c2 = head[1], c3 = head[2], c4 = head[3], c5 = head[4], c6 = head[5], c7 = head[6], c8 = head[7], c9 = head[8], c10 = head[9], c11 = head[10], c12 = head[11], c13 = head[12], c14 = head[13], c15 = head[14], c16 = head[15]; let cc1 = headBinay[0], cc2 = headBinay[1], cc3 = headBinay[2], cc4 = headBinay[3], cc5 = headBinay[4], cc6 = headBinay[5], cc7 = headBinay[6], cc8 = headBinay[7], cc9 = headBinay[8], cc10 = headBinay[9], cc11 = headBinay[10], cc12 = headBinay[11], cc13 = headBinay[12], cc14 = headBinay[13], cc15 = headBinay[14], cc16 = headBinay[15]; if (c1 == '<') { // text/html if (c2 == '!' || ((c2 == 'h' && (c3 == 't' && c4 == 'm' && c5 == 'l' || c3 == 'e' && c4 == 'a' && c5 == 'd') || (c2 == 'b' && c3 == 'o' && c4 == 'd' && c5 == 'y'))) || ((c2 == 'H' && (c3 == 'T' && c4 == 'M' && c5 == 'L' || c3 == 'E' && c4 == 'A' && c5 == 'D') || (c2 == 'B' && c3 == 'O' && c4 == 'D' && c5 == 'Y')))) { return true; } // application/xml if (c2 == '?' && c3 == 'x' && c4 == 'm' && c5 == 'l' && c6 == ' ') { return true; } // application/svg+xml if (c2 == 's' && c3 == 'v' && c4 == 'g' && c5 == ' ') { return true; } } // big and little (identical) endian UTF-8 encodings, with BOM // application/xml if (cc1 == 0xef && cc2 == 0xbb && cc3 == 0xbf) { if (c4 == '<' && c5 == '?' && c6 == 'x') { return true; } } // big and little endian UTF-16 encodings, with byte order mark // application/xml if (cc1 == 0xfe && cc2 == 0xff) { if (cc3 == 0 && c4 == '<' && cc5 == 0 && c6 == '?' && cc7 == 0 && c8 == 'x') { return true; } } // application/xml if (cc1 == 0xff && cc2 == 0xfe) { if (c3 == '<' && cc4 == 0 && c5 == '?' && cc6 == 0 && c7 == 'x' && cc8 == 0) { return true; } } // big and little endian UTF-32 encodings, with BOM // application/xml if (cc1 == 0x00 && cc2 == 0x00 && cc3 == 0xfe && cc4 == 0xff) { if (cc5 == 0 && cc6 == 0 && cc7 == 0 && c8 == '<' && cc9 == 0 && cc10 == 0 && cc11 == 0 && c12 == '?' && cc13 == 0 && cc14 == 0 && cc15 == 0 && c16 == 'x') { return true; } } // application/xml if (cc1 == 0xff && cc2 == 0xfe && cc3 == 0x00 && cc4 == 0x00) { if (c5 == '<' && cc6 == 0 && cc7 == 0 && cc8 == 0 && c9 == '?' && cc10 == 0 && cc11 == 0 && cc12 == 0 && c13 == 'x' && cc14 == 0 && cc15 == 0 && cc16 == 0) { return true; } } // application/pdf (%PDF-) if (cc1 == 37 && cc2 == 80 && cc3 == 68 && cc4 == 70 && cc5 == 45) { return true; } // image/png if ((cc1 == 137 && cc2 == 80 && cc3 == 78 && cc4 == 71 && cc5 == 13 && cc6 == 10 && cc7 == 26 && cc8 == 10) || (cc1 == 194 && cc2 == 137 && cc3 == 80 && cc4 == 78 && cc5 == 71 && cc6 == 13 //Our embedded PNG+XML && cc7 == 10 && cc8 == 26 && cc9 == 10)) { return true; } // image/jpeg if (cc1 == 0xFF && cc2 == 0xD8 && cc3 == 0xFF) { if (cc4 == 0xE0 || cc4 == 0xEE) { return true; } /** * File format used by digital cameras to store images. * Exif Format can be read by any application supporting * JPEG. Exif Spec can be found at: * http://www.pima.net/standards/it10/PIMA15740/Exif_2-1.PDF */ if ((cc4 == 0xE1) && (c7 == 'E' && c8 == 'x' && c9 == 'i' && c10 == 'f' && cc11 == 0)) { return true; } } // vsdx, vssx (also zip, jar, odt, ods, odp, docx, xlsx, pptx, apk, aar) if (cc1 == 0x50 && cc2 == 0x4B && cc3 == 0x03 && cc4 == 0x04) { return true; } else if (cc1 == 0x50 && cc2 == 0x4B && cc3 == 0x03 && cc4 == 0x06) { return true; } // mxfile, mxlibrary, mxGraphModel if (c1 == '<' && c2 == 'm' && c3 == 'x') { return true; } } return false; } function isConflict(origStat, stat) { return stat != null && origStat != null && stat.mtimeMs != origStat.mtimeMs; } function getDraftFileName(fileObject) { let filePath = fileObject.path; let draftFileName = '', counter = 1, uniquePart = ''; do { draftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT); uniquePart = '_' + counter++; } while (fs.existsSync(draftFileName)); return draftFileName; } async function getFileDrafts(fileObject) { let filePath = fileObject.path; let draftsPaths = [], drafts = [], draftFileName, counter = 1, uniquePart = ''; do { draftsPaths.push(draftFileName); draftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT); uniquePart = '_' + counter++; } while (fs.existsSync(draftFileName)); //TODO this assume continuous drafts names //Port old draft files to new prefex counter = 1; uniquePart = ''; let draftExists = false; do { draftFileName = path.join(path.dirname(filePath), OLD_DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT); draftExists = fs.existsSync(draftFileName); if (draftExists) { const newDraftFileName = path.join(path.dirname(filePath), DRAFT_PREFEX + path.basename(filePath) + uniquePart + DRAFT_EXT); await fsProm.rename(draftFileName, newDraftFileName); draftsPaths.push(newDraftFileName); } uniquePart = '_' + counter++; } while (draftExists); //TODO this assume continuous drafts names //Skip the first null element for (let i = 1; i < draftsPaths.length; i++) { try { let stat = await fsProm.lstat(draftsPaths[i]); drafts.push({ data: await fsProm.readFile(draftsPaths[i], 'utf8'), created: stat.ctimeMs, modified: stat.mtimeMs, path: draftsPaths[i] }); } catch (e) { } // Ignore } return drafts; } async function saveDraft(fileObject, data) { if (!checkFileContent(data)) { throw new Error('Invalid file data'); } else { let draftFileName = fileObject.draftFileName || getDraftFileName(fileObject); await fsProm.writeFile(draftFileName, data, 'utf8'); if (isWin) { try { // Add Hidden attribute: spawn('attrib', ['+h', draftFileName], {shell: true}); } catch (e) { } } return draftFileName; } } async function saveFile(fileObject, data, origStat, overwrite, defEnc) { if (!checkFileContent(data)) { throw new Error('Invalid file data'); } let retryCount = 0; let backupCreated = false; let bkpPath = path.join(path.dirname(fileObject.path), BKP_PREFEX + path.basename(fileObject.path) + BKP_EXT); const oldBkpPath = path.join(path.dirname(fileObject.path), OLD_BKP_PREFEX + path.basename(fileObject.path) + BKP_EXT); let writeEnc = defEnc || fileObject.encoding; let writeFile = async function () { let fh; try { // O_SYNC is for sync I/O and reduce risk of file corruption fh = await fsProm.open(fileObject.path, O_SYNC | O_CREAT | O_WRONLY | O_TRUNC); await fsProm.writeFile(fh, data, writeEnc); } finally { await fh?.close(); } let stat2 = await fsProm.stat(fileObject.path); // Workaround for possible writing errors is to check the written // contents of the file and retry 3 times before showing an error let writtenData = await fsProm.readFile(fileObject.path, writeEnc); if (data != writtenData) { retryCount++; if (retryCount < 3) { return await writeFile(); } else { throw new Error('all saving trials failed'); } } else { //We'll keep the backup file in case the original file is corrupted. TODO When should we delete the backup file? if (backupCreated) { //fs.unlink(bkpPath, (err) => {}); //Ignore errors! //Delete old backup file with old prefix if (fs.existsSync(oldBkpPath)) { fs.unlink(oldBkpPath, (err) => { }); //Ignore errors } } return stat2; } }; async function doSaveFile(isNew) { if (enableStoreBkp && !isNew) { //Copy file to back up file (after conflict and stat is checked) let bkpFh; try { //Use file read then write to open the backup file direct sync write to reduce the chance of file corruption let fileContent = await fsProm.readFile(fileObject.path, writeEnc); bkpFh = await fsProm.open(bkpPath, O_SYNC | O_CREAT | O_WRONLY | O_TRUNC); await fsProm.writeFile(bkpFh, fileContent, writeEnc); backupCreated = true; } catch (e) { if (__DEV__) { console.log('Backup file writing failed', e); //Ignore } } finally { await bkpFh?.close(); if (isWin) { try { // Add Hidden attribute: spawn('attrib', ['+h', bkpPath], {shell: true}); } catch (e) { } } } } return await writeFile(); } if (overwrite) { return await doSaveFile(true); } else { let stat = fs.existsSync(fileObject.path) ? await fsProm.stat(fileObject.path) : null; if (stat && isConflict(origStat, stat)) { throw new Error('conflict'); } else { return await doSaveFile(stat == null); } } } async function writeFile(path, data, enc) { if (!checkFileContent(data, enc)) { throw new Error('Invalid file data'); } else { return await fsProm.writeFile(path, data, enc); } } function getAppDataFolder() { try { let appDataDir = app.getPath('appData'); let drawioDir = appDataDir + '/' + config.name; if (!fs.existsSync(drawioDir)) //Usually this dir already exists { fs.mkdirSync(drawioDir); } return drawioDir; } catch (e) { } return '.'; } function getDocumentsFolder() { //On windows, misconfigured Documents folder cause an exception try { return app.getPath('documents'); } catch (e) { } return '.'; } function checkFileExists(pathParts) { let filePath = path.join(...pathParts); return {exists: fs.existsSync(filePath), path: filePath}; } async function showOpenDialog(defaultPath, filters, properties) { let win = BrowserWindow.getFocusedWindow(); return dialog.showOpenDialog(win, { defaultPath: defaultPath, filters: filters, properties: properties }); } async function showSaveDialog(defaultPath, filters) { let win = BrowserWindow.getFocusedWindow(); return dialog.showSaveDialog(win, { defaultPath: defaultPath, filters: filters }); } async function installPlugin(filePath) { if (!enablePlugins) return {}; let pluginsDir = path.join(getAppDataFolder(), '/plugins'); if (!fs.existsSync(pluginsDir)) { fs.mkdirSync(pluginsDir); } let pluginName = path.basename(filePath); let dstFile = path.join(pluginsDir, pluginName); if (fs.existsSync(dstFile)) { throw new Error('fileExists'); } else { await fsProm.copyFile(filePath, dstFile); } return {pluginName: pluginName, selDir: path.dirname(filePath)}; } function getPluginFile(plugin) { if (!enablePlugins) return null; const prefix = path.join(getAppDataFolder(), '/plugins/'); const pluginFile = path.join(prefix, plugin); if (pluginFile.startsWith(prefix) && fs.existsSync(pluginFile)) { return pluginFile; } return null; } async function uninstallPlugin(plugin) { const pluginFile = getPluginFile(plugin); if (pluginFile != null) { fs.unlinkSync(pluginFile); } } function dirname(path_p) { return path.dirname(path_p); } async function readFile(filename, encoding) { let data = await fsProm.readFile(filename, encoding); if (checkFileContent(data, encoding)) { return data; } throw new Error('Invalid file data'); } async function fileStat(file) { return await fsProm.stat(file); } async function isFileWritable(file) { try { await fsProm.access(file, fs.constants.W_OK); return true; } catch (e) { return false; } } function clipboardAction(method, data) { if (method == 'writeText') { clipboard.writeText(data); } else if (method == 'readText') { return clipboard.readText(); } else if (method == 'writeImage') { clipboard.write({ image: nativeImage.createFromDataURL(data.dataUrl), html: '' }); } } async function deleteFile(file) { // Reading the header of the file to confirm it is a file we can delete let fh = await fsProm.open(file, O_RDONLY); let buffer = Buffer.allocUnsafe(16); await fh.read(buffer, 0, 16); await fh.close(); if (checkFileContent(buffer)) { await fsProm.unlink(file); } } async function windowAction(method) { let win = BrowserWindow.getFocusedWindow(); if (win) { if (method == 'minimize') { win.minimize(); } else if (method == 'maximize') { win.maximize(); } else if (method == 'unmaximize') { win.unmaximize(); } else if (method == 'close') { win.close(); } else if (method == 'isMaximized') { return win.isMaximized(); } else if (method == 'removeAllListeners') { win.removeAllListeners(); } } } async function openExternal(url) { //Only open http(s), mailto, tel, and callto links if (allowedUrls.test(url)) { await shell.openExternal(url) } } async function watchFile(path) { let win = BrowserWindow.getFocusedWindow(); if (win) { fs.watchFile(path, (curr, prev) => { try { win.webContents.send('fileChanged', { path: path, curr: curr, prev: prev }); } catch (e) { // Ignore } }); } } async function unwatchFile(path) { fs.unwatchFile(path); } function getCurDir() { return __dirname; } ipcMain.on("rendererReq", async (event, args) => { try { let ret = null; switch (args.action) { case 'saveFile': ret = await saveFile(args.fileObject, args.data, args.origStat, args.overwrite, args.defEnc); break; case 'writeFile': ret = await writeFile(args.path, args.data, args.enc); break; case 'saveDraft': ret = await saveDraft(args.fileObject, args.data); break; case 'getFileDrafts': ret = await getFileDrafts(args.fileObject); break; case 'getDocumentsFolder': ret = await getDocumentsFolder(); break; case 'checkFileExists': ret = checkFileExists(args.pathParts); break; case 'showOpenDialog': dialogOpen = true; ret = await showOpenDialog(args.defaultPath, args.filters, args.properties); ret = ret.filePaths; dialogOpen = false; break; case 'showSaveDialog': dialogOpen = true; ret = await showSaveDialog(args.defaultPath, args.filters); ret = ret.canceled ? null : ret.filePath; dialogOpen = false; break; case 'installPlugin': ret = await installPlugin(args.filePath); break; case 'uninstallPlugin': ret = await uninstallPlugin(args.plugin); break; case 'getPluginFile': ret = getPluginFile(args.plugin); break; case 'isPluginsEnabled': ret = enablePlugins; break; case 'dirname': ret = await dirname(args.path); break; case 'readFile': ret = await readFile(args.filename, args.encoding); break; case 'clipboardAction': ret = clipboardAction(args.method, args.data); break; case 'deleteFile': ret = await deleteFile(args.file); break; case 'fileStat': ret = await fileStat(args.file); break; case 'isFileWritable': ret = await isFileWritable(args.file); break; case 'windowAction': ret = await windowAction(args.method); break; case 'openExternal': ret = await openExternal(args.url); break; case 'openDownloadWindow': ret = await electronDown.open(args.language || 'zh', args.theme || 'light'); break; case 'updateDownloadWindow': ret = await electronDown.updateWindow(args.language, args.theme); break; case 'createDownload': ret = await electronDown.createDownload(mainWindow, args.url, args.options || {}); break; case 'watchFile': ret = await watchFile(args.path); break; case 'unwatchFile': ret = await unwatchFile(args.path); break; case 'getCurDir': ret = getCurDir(); break; } event.reply('mainResp', {success: true, data: ret, reqId: args.reqId}); } catch (e) { event.reply('mainResp', {error: true, msg: e.message, e: e, reqId: args.reqId}); loger.error('Renderer request error', e.message, e.stack); } });