diff --git a/electron/electron-preload.js b/electron/electron-preload.js index 4300d33e8..d032682c9 100644 --- a/electron/electron-preload.js +++ b/electron/electron-preload.js @@ -1,73 +1,66 @@ -const {contextBridge, ipcRenderer} = require("electron") +const { + contextBridge, + ipcRenderer +} = require("electron"); -let reqId = 1 -let reqInfo = {} -let msgChangedListeners = {} -let fileChangedListeners = {} +let reqId = 1; +let reqInfo = {}; +let fileChangedListeners = {}; ipcRenderer.on('mainResp', (event, resp) => { - let callbacks = reqInfo[resp.reqId] + let callbacks = reqInfo[resp.reqId]; if (resp.error) { - callbacks.error(resp.msg, resp.e) + callbacks.error(resp.msg, resp.e); } else { - callbacks.callback(resp.data) + callbacks.callback(resp.data); } - delete reqInfo[resp.reqId] -}) + delete reqInfo[resp.reqId]; +}); ipcRenderer.on('fileChanged', (event, resp) => { - let listener = fileChangedListeners[resp.path] + let listener = fileChangedListeners[resp.path]; if (listener) { - listener(resp.curr, resp.prev) + listener(resp.curr, resp.prev); } -}) +}); contextBridge.exposeInMainWorld( 'electron', { request: (msg, callback, error) => { - msg.reqId = reqId++ - reqInfo[msg.reqId] = {callback: callback, error: error} + msg.reqId = reqId++; + reqInfo[msg.reqId] = {callback: callback, error: error}; + //TODO Maybe a special function for this better than this hack? + //File watch special case where the callback is called multiple times if (msg.action == 'watchFile') { - fileChangedListeners[msg.path] = msg.listener - delete msg.listener + fileChangedListeners[msg.path] = msg.listener; + delete msg.listener; } - ipcRenderer.send('rendererReq', msg) + ipcRenderer.send('rendererReq', msg); }, - registerMsgListener: (action, callback) => { - msgChangedListeners[action] = (event, args) => { - callback(args) - } - ipcRenderer.on(action, msgChangedListeners[action]) + registerMsgListener: function (action, callback) { + ipcRenderer.on(action, function (event, args) { + callback(args); + }); }, - registerMsgListenOnce: (action, callback) => { - msgChangedListeners[action] = (event, args) => { - callback(args) - } - ipcRenderer.once(action, msgChangedListeners[action]) + sendMessage: function (action, args) { + ipcRenderer.send(action, args); }, - removeMsgListener: (action) => { - if (typeof msgChangedListeners[action] === "function") { - ipcRenderer.removeListener(action, msgChangedListeners[action]) - delete msgChangedListeners[action] - } - }, - sendMessage: (action, args) => { - ipcRenderer.send(action, args) - }, - sendSyncMessage: (action, args) => { - ipcRenderer.sendSync(action, args) + listenOnce: function (action, callback) { + ipcRenderer.once(action, function (event, args) { + callback(args); + }); } } -) +); contextBridge.exposeInMainWorld( 'process', { type: process.type, versions: process.versions } -) +); diff --git a/electron/electron.js b/electron/electron.js index d80835e10..3ec4e539a 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -11,6 +11,14 @@ const crc = require('crc'); const zlib = require('zlib'); const utils = require('./utils'); const config = require('./package.json'); +const spawn = require("child_process").spawn; + +const isMac = process.platform === 'darwin' +const isWin = process.platform === 'win32' +const allowedUrls = /^(?:https?|mailto|tel|callto):/i; +let enableStoreBkp = true; +let dialogOpen = false; +let enablePlugins = false; let mainWindow = null, mainTray = null, @@ -605,6 +613,7 @@ ipcMain.on('updateQuitAndInstall', (event) => { //================================================================ 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; @@ -727,7 +736,6 @@ function writePngWithText(origBuff, key, text, compressed, base64encoded) { //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, @@ -829,7 +837,7 @@ function exportDiagram(event, args, directFinalize) { preload: path.join(__dirname, 'electron-preload.js'), backgroundThrottling: false, contextIsolation: true, - nativeWindowOpen: true + disableBlinkFeatures: 'Auxclick' // Is this needed? }, show: false, frame: false, @@ -837,7 +845,15 @@ function exportDiagram(event, args, directFinalize) { transparent: args.format == 'png' && (args.bg == null || args.bg == 'none'), }); - browser.loadURL(`file://${__dirname}/export3.html`); + if (devloadUrl) { + browser.loadURL(devloadUrl + 'drawio/webapp/export3.html').then(_ => { + + }) + } else { + browser.loadFile('./public/drawio/webapp/export3.html').then(_ => { + + }) + } const contents = browser.webContents; let pageByPage = (args.format == 'pdf' && !args.print), from, to, pdfs; @@ -887,22 +903,18 @@ function exportDiagram(event, args, directFinalize) { //A workaround to detect errors in the input file or being empty file hasError = true; } else { - //Chrome generates Pdf files larger than requested pixels size and requires scaling - let fixingScale = 0.959; - - let w = Math.ceil(bounds.width * fixingScale); - - // +0.1 fixes cases where adding 1px below is not enough - // Increase this if more cropped PDFs have extra empty pages - let h = Math.ceil(bounds.height * fixingScale + 0.1); - pdfOptions = { printBackground: true, pageSize: { - width: w * MICRON_TO_PIXEL, - height: (h + 2) * MICRON_TO_PIXEL //the extra 2 pixels to prevent adding an extra empty page + width: bounds.width / PIXELS_PER_INCH, + height: (bounds.height + 2) / PIXELS_PER_INCH //the extra 2 pixels to prevent adding an extra empty page }, - marginsType: 1 // no margin + margins: { + top: 0, + bottom: 0, + left: 0, + right: 0 + } // no margin } } @@ -1035,12 +1047,188 @@ ipcMain.on('export', exportDiagram); // Renderer Helper functions //================================================================ -const {COPYFILE_EXCL} = fs.constants; -const DRAFT_PREFEX = '~$'; +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 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; } @@ -1067,6 +1255,25 @@ async function getFileDrafts(fileObject) { 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]); @@ -1084,75 +1291,126 @@ async function getFileDrafts(fileObject) { } async function saveDraft(fileObject, data) { - if (data == null || data.length == 0) { - throw new Error('empty 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 () { - if (data == null || data.length == 0) { - throw new Error('empty data'); - } else { - let writeEnc = defEnc || fileObject.encoding; + let fh; - await fsProm.writeFile(fileObject.path, data, writeEnc); - let stat2 = await fsProm.stat(fileObject.path); - let writtenData = await fsProm.readFile(fileObject.path, writeEnc); + 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(); + } - if (data != writtenData) { - retryCount++; + 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 (retryCount < 3) { - return await writeFile(); - } else { - throw new Error('all saving trials failed'); - } + if (data != writtenData) { + retryCount++; + + if (retryCount < 3) { + return await writeFile(); } else { - if (backupCreated) { - fs.unlink(bkpPath, (err) => { - }); //Ignore errors! - } - - return stat2; + 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() { - try { - await fsProm.copyFile(fileObject.path, bkpPath, COPYFILE_EXCL); - backupCreated = true; - } catch (e) { - } //Ignore + async function doSaveFile(isNew) { + if (enableStoreBkp && !isNew) { + //Copy file to backup 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(); + return await doSaveFile(true); } else { let stat = fs.existsSync(fileObject.path) ? await fsProm.stat(fileObject.path) : null; if (stat && isConflict(origStat, stat)) { - new Error('conflict'); + throw new Error('conflict'); } else { - return await doSaveFile(); + return await doSaveFile(stat == null); } } } async function writeFile(path, data, enc) { - return await fsProm.writeFile(path, data, enc); + if (!checkFileContent(data, enc)) { + throw new Error('Invalid file data'); + } else { + return await fsProm.writeFile(path, data, enc); + } } function getAppDataFolder() { @@ -1173,6 +1431,7 @@ function getAppDataFolder() { } function getDocumentsFolder() { + //On windows, misconfigured Documents folder cause an exception try { return app.getPath('documents'); } catch (e) { @@ -1187,7 +1446,9 @@ function checkFileExists(pathParts) { } async function showOpenDialog(defaultPath, filters, properties) { - return dialog.showOpenDialogSync({ + let win = BrowserWindow.getFocusedWindow(); + + return dialog.showOpenDialog(win, { defaultPath: defaultPath, filters: filters, properties: properties @@ -1195,13 +1456,17 @@ async function showOpenDialog(defaultPath, filters, properties) { } async function showSaveDialog(defaultPath, filters) { - return dialog.showSaveDialogSync({ + 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)) { @@ -1220,13 +1485,25 @@ async function installPlugin(filePath) { return {pluginName: pluginName, selDir: path.dirname(filePath)}; } -function uninstallPlugin(plugin) { - let pluginsFile = path.join(getAppDataFolder(), '/plugins', plugin); +function getPluginFile(plugin) { + if (!enablePlugins) return null; - if (fs.existsSync(pluginsFile)) { - fs.unlinkSync(pluginsFile); + const prefix = path.join(getAppDataFolder(), '/plugins/'); + const pluginFile = path.join(prefix, plugin); + + if (pluginFile.startsWith(prefix) && fs.existsSync(pluginFile)) { + return pluginFile; + } + + return null; +} + +function uninstallPlugin(plugin) { + const pluginFile = getPluginFile(plugin); + + if (pluginFile != null) { + fs.unlinkSync(pluginFile); } - return null } function dirname(path_p) { @@ -1234,7 +1511,13 @@ function dirname(path_p) { } async function readFile(filename, encoding) { - return await fsProm.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) { @@ -1265,7 +1548,15 @@ function clipboardAction(method, data) { } async function deleteFile(file) { - await fsProm.unlink(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); + } } function windowAction(method) { @@ -1289,8 +1580,13 @@ function windowAction(method) { } function openExternal(url) { - shell.openExternal(url).then(() => {}).catch(() => {}); - return null + //Only open http(s), mailto, tel, and callto links + if (allowedUrls.test(url)) { + shell.openExternal(url); + return true; + } + + return false; } function watchFile(path) { @@ -1308,12 +1604,14 @@ function watchFile(path) { } // Ignore }); } - return null } function unwatchFile(path) { fs.unwatchFile(path); - return null +} + +function getCurDir() { + return __dirname; } ipcMain.on("rendererReq", async (event, args) => { @@ -1333,20 +1631,23 @@ ipcMain.on("rendererReq", async (event, args) => { case 'getFileDrafts': ret = await getFileDrafts(args.fileObject); break; - case 'getAppDataFolder': - ret = getAppDataFolder(); - break; case 'getDocumentsFolder': ret = await getDocumentsFolder(); break; case 'checkFileExists': - ret = checkFileExists(args.pathParts); + ret = await 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); @@ -1354,14 +1655,20 @@ ipcMain.on("rendererReq", async (event, args) => { case 'uninstallPlugin': ret = await uninstallPlugin(args.plugin); break; + case 'getPluginFile': + ret = await getPluginFile(args.plugin); + break; + case 'isPluginsEnabled': + ret = enablePlugins; + break; case 'dirname': - ret = dirname(args.path); + ret = await dirname(args.path); break; case 'readFile': ret = await readFile(args.filename, args.encoding); break; case 'clipboardAction': - ret = clipboardAction(args.method, args.data); + ret = await clipboardAction(args.method, args.data); break; case 'deleteFile': ret = await deleteFile(args.file); @@ -1373,7 +1680,7 @@ ipcMain.on("rendererReq", async (event, args) => { ret = await isFileWritable(args.file); break; case 'windowAction': - ret = windowAction(args.method); + ret = await windowAction(args.method); break; case 'openExternal': ret = await openExternal(args.url); @@ -1384,6 +1691,9 @@ ipcMain.on("rendererReq", async (event, args) => { case 'unwatchFile': ret = await unwatchFile(args.path); break; + case 'getCurDir': + ret = await getCurDir(); + break; } event.reply('mainResp', {success: true, data: ret, reqId: args.reqId}); diff --git a/electron/package.json b/electron/package.json index ba6a934b9..62240e1e4 100644 --- a/electron/package.json +++ b/electron/package.json @@ -32,7 +32,7 @@ "@electron-forge/maker-squirrel": "^6.0.4", "@electron-forge/maker-zip": "^6.0.4", "dotenv": "^16.0.3", - "electron": "^22.2.0", + "electron": "^23.0.0", "electron-builder": "^23.6.0", "electron-notarize": "^1.2.2", "form-data": "^4.0.0",