diff --git a/electron/electron.js b/electron/electron.js index abef2130c..ff8ace348 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -3,9 +3,6 @@ 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') @@ -18,7 +15,6 @@ const { dialog, clipboard, nativeImage, - shell, globalShortcut, nativeTheme, Tray, @@ -38,9 +34,6 @@ 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 navigation = require('./lib/navigation'); @@ -48,28 +41,20 @@ const config = require('./package.json'); const electronDown = require("./electron-down"); const electronMenu = require("./electron-menu"); const { startMCPServer, stopMCPServer } = require("./lib/mcp"); +const {onRenderer} = require("./lib/renderer"); +const {onExport} = require("./lib/pdf-export"); +const {allowedCalls, isWin} = require("./lib/other"); // 实例初始化 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, +let isReady = false, willQuitApp = false, isDevelopMode = false; @@ -971,7 +956,7 @@ function createWebTabWindow(args) { event: 'stop-loading', id: browserView.webContents.id, }).then(_ => { }) - + // 加载完成暗黑模式下把窗口背景色改成白色,避免透明网站背景色穿透 if (nativeTheme.shouldUseDarkColors) { browserView.setBackgroundColor('#FFFFFF') @@ -1487,17 +1472,17 @@ ipcMain.on('webTabGetNavigationState', (event) => { 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" }) @@ -1924,1098 +1909,8 @@ ipcMain.on('updateQuitAndInstall', (event, args) => { }) //================================================================ -// 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); - } -}); +onExport() +onRenderer(mainWindow) diff --git a/electron/lib/other.js b/electron/lib/other.js new file mode 100644 index 000000000..55e8403bb --- /dev/null +++ b/electron/lib/other.js @@ -0,0 +1,15 @@ +// 平台检测常量 +const isMac = process.platform === 'darwin' +const isWin = process.platform === 'win32' + +// URL 和调用验证正则 +const allowedUrls = /^(?:https?|mailto|tel|callto):/i; +const allowedCalls = /^(?:mailto|tel|callto):/i; + +module.exports = { + isMac, + isWin, + + allowedUrls, + allowedCalls +} diff --git a/electron/lib/pdf-export.js b/electron/lib/pdf-export.js new file mode 100644 index 000000000..e773d81a2 --- /dev/null +++ b/electron/lib/pdf-export.js @@ -0,0 +1,439 @@ +const path = require('path') +const {BrowserWindow, ipcMain} = require('electron') +const loger = require("electron-log"); +const crc = require("crc"); +const zlib = require('zlib'); + +const PDFDocument = require('pdf-lib').PDFDocument; +const config = require('../package.json'); + +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; + +const pdfExport = { + //NOTE: Key length must not be longer than 79 bytes (not checked) + 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 + 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 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); + } + }, + + exportDiagram(event, args, directFinalize) { + if (args.format == 'vsdx') { + pdfExport.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 = pdfExport.writePngWithText(data, 'dpi', args.dpi); + } + + if (args.embedXml == "1" && args.format == 'png') { + data = pdfExport.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 pdfExport.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); + } + }, +} + +const onExport = () => { + ipcMain.on('export', pdfExport.exportDiagram); +} + +module.exports = {pdfExport, onExport}; diff --git a/electron/lib/renderer.js b/electron/lib/renderer.js new file mode 100644 index 000000000..5e62e1286 --- /dev/null +++ b/electron/lib/renderer.js @@ -0,0 +1,675 @@ +const fs = require("fs"); +const {BrowserWindow, shell, app, dialog, clipboard, nativeImage, ipcMain} = require("electron"); +const fsProm = require("fs/promises"); +const path = require("path"); +const {spawn} = require("child_process"); +const config = require("../package.json"); +const electronDown = require("../electron-down"); +const loger = require("electron-log"); +const {allowedUrls, isWin} = require("./other"); + +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'; + +let enableStoreBkp = true, + dialogOpen = false, + enablePlugins = false; + +const renderer = { + 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; + }, + + isConflict(origStat, stat) { + return stat != null && origStat != null && stat.mtimeMs != origStat.mtimeMs; + }, + + 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 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 saveDraft(fileObject, data) { + if (!renderer.checkFileContent(data)) { + throw new Error('Invalid file data'); + } else { + let draftFileName = fileObject.draftFileName || renderer.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 saveFile(fileObject, data, origStat, overwrite, defEnc) { + if (!renderer.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, () => { + //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 && renderer.isConflict(origStat, stat)) { + throw new Error('conflict'); + } else { + return await doSaveFile(stat == null); + } + } + }, + + async writeFile(path, data, enc) { + if (!renderer.checkFileContent(data, enc)) { + throw new Error('Invalid file data'); + } else { + return await fsProm.writeFile(path, data, enc); + } + }, + + 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 '.'; + }, + + getDocumentsFolder() { + //On windows, misconfigured Documents folder cause an exception + try { + return app.getPath('documents'); + } catch (e) { + } + + return '.'; + }, + + checkFileExists(pathParts) { + let filePath = path.join(...pathParts); + return {exists: fs.existsSync(filePath), path: filePath}; + }, + + async showOpenDialog(defaultPath, filters, properties) { + let win = BrowserWindow.getFocusedWindow(); + + return dialog.showOpenDialog(win, { + defaultPath: defaultPath, + filters: filters, + properties: properties + }); + }, + + async showSaveDialog(defaultPath, filters) { + let win = BrowserWindow.getFocusedWindow(); + + return dialog.showSaveDialog(win, { + defaultPath: defaultPath, + filters: filters + }); + }, + + async installPlugin(filePath) { + if (!enablePlugins) return {}; + + let pluginsDir = path.join(renderer.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)}; + }, + + getPluginFile(plugin) { + if (!enablePlugins) return null; + + const prefix = path.join(renderer.getAppDataFolder(), '/plugins/'); + const pluginFile = path.join(prefix, plugin); + + if (pluginFile.startsWith(prefix) && fs.existsSync(pluginFile)) { + return pluginFile; + } + + return null; + }, + + async uninstallPlugin(plugin) { + const pluginFile = renderer.getPluginFile(plugin); + + if (pluginFile != null) { + fs.unlinkSync(pluginFile); + } + }, + + dirname(path_p) { + return path.dirname(path_p); + }, + + async readFile(filename, encoding) { + let data = await fsProm.readFile(filename, encoding); + + if (renderer.checkFileContent(data, encoding)) { + return data; + } + + throw new Error('Invalid file data'); + }, + + async fileStat(file) { + return await fsProm.stat(file); + }, + + async isFileWritable(file) { + try { + await fsProm.access(file, fs.constants.W_OK); + return true; + } catch (e) { + return false; + } + }, + + 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 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 (renderer.checkFileContent(buffer)) { + await fsProm.unlink(file); + } + }, + + async 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 openExternal(url) { + //Only open http(s), mailto, tel, and callto links + if (allowedUrls.test(url)) { + await shell.openExternal(url) + } + }, + + async 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 unwatchFile(path) { + fs.unwatchFile(path); + }, +} + +const onRenderer = (mainWindow) => { + ipcMain.on("rendererReq", async (event, args) => { + try { + let ret = null; + + switch (args.action) { + case 'saveFile': + ret = await renderer.saveFile(args.fileObject, args.data, args.origStat, args.overwrite, args.defEnc); + break; + case 'writeFile': + ret = await renderer.writeFile(args.path, args.data, args.enc); + break; + case 'saveDraft': + ret = await renderer.saveDraft(args.fileObject, args.data); + break; + case 'getFileDrafts': + ret = await renderer.getFileDrafts(args.fileObject); + break; + case 'getDocumentsFolder': + ret = await renderer.getDocumentsFolder(); + break; + case 'checkFileExists': + ret = renderer.checkFileExists(args.pathParts); + break; + case 'showOpenDialog': + dialogOpen = true; + ret = await renderer.showOpenDialog(args.defaultPath, args.filters, args.properties); + ret = ret.filePaths; + dialogOpen = false; + break; + case 'showSaveDialog': + dialogOpen = true; + ret = await renderer.showSaveDialog(args.defaultPath, args.filters); + ret = ret.canceled ? null : ret.filePath; + dialogOpen = false; + break; + case 'installPlugin': + ret = await renderer.installPlugin(args.filePath); + break; + case 'uninstallPlugin': + ret = await renderer.uninstallPlugin(args.plugin); + break; + case 'getPluginFile': + ret = renderer.getPluginFile(args.plugin); + break; + case 'isPluginsEnabled': + ret = enablePlugins; + break; + case 'dirname': + ret = await renderer.dirname(args.path); + break; + case 'readFile': + ret = await renderer.readFile(args.filename, args.encoding); + break; + case 'clipboardAction': + ret = renderer.clipboardAction(args.method, args.data); + break; + case 'deleteFile': + ret = await renderer.deleteFile(args.file); + break; + case 'fileStat': + ret = await renderer.fileStat(args.file); + break; + case 'isFileWritable': + ret = await renderer.isFileWritable(args.file); + break; + case 'windowAction': + ret = await renderer.windowAction(args.method); + break; + case 'openExternal': + ret = await renderer.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 renderer.watchFile(args.path); + break; + case 'unwatchFile': + ret = await renderer.unwatchFile(args.path); + break; + case 'getCurDir': + ret = __dirname; + 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); + } + }); +} + +module.exports = { renderer, onRenderer };