// Modules to control application life and create native browser window const {app, ipcMain, dialog, BrowserWindow, Menu, MenuItem } = require('electron') const level = require('level') const path = require('path') const open = require('open') const fs = require('mz/fs') const {VM} = require('vm2') const zlib = require('zlib') const uuid = require('uuid/v4') const { translations } = require('./strings') const settings = require('./settings') // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let mainWindow let db const locale = app.getLocale(); const simpleLocale = locale.split('-')[0]; const keyPrefixFileMetadata = '00:'; const keyFileMetadata = x => `${keyPrefixFileMetadata}${x}`; const firstDatabaseId = 2; let strings; if(translations[locale]) { strings = translations[locale]; } else if(translations[simpleLocale]) { strings = translations[simpleLocale]; } else { strings = translations['en'] } // This is needed to avoid Electron logging a message every time one of these promises is rejected :( function wrapError(doTheThing) { // electron IPC cant pass null because it serializes everything as JSON and the serializer strips null :( const errorToErrorResult = (error) => { let stack = error != null ? error.stack : null; let errorString = stack ? stack : (typeof error.toString == 'function' ? error.toString() : String(error)); return { error: error == null ? '__electron-ipc-wrapped-null__' : error, errorString: errorString } } try { return doTheThing().then( result => { return {result: result == null ? '__electron-ipc-wrapped-null__' : result} }, errorToErrorResult ); } catch (error) { return Promise.resolve(errorToErrorResult(error)) } } const getAppSupportFolder = () => { let folder; if(process.platform == 'darwin') { folder = path.resolve(process.env.HOME, 'Library/Application Support/LibreBookKeeper') } else if(process.platform == 'win32') { folder = path.resolve(process.env.APPDATA, 'LibreBookKeeper') } else { folder = path.resolve(process.env.HOME, '.local/share/LibreBookKeeper') } return folder; }; const logFile = `${getAppSupportFolder()}/LibreBookKeeper.log`; let logStream = null; let logStreamOpen = false; let myLog = (message) => { console.log(message); }; fs.mkdir(getAppSupportFolder(), {recursive: true, mode: 0744}).then( (_) => false, (err) => err.code == 'EEXIST' ? false : true ).then(mightHaveFailed => { if(!mightHaveFailed) { logStream = fs.createWriteStream(logFile, {flags:'a'}); logStreamOpen = true; myLog = (message) => { logStream.write(`\n${new Date().toISOString()} ${message}`); console.log(message); }; myLog("log file opened"); } }); let currentDBPrefix = ''; let currentOpenFilePath = ''; let currentDatabaseId = ''; const setDatabaseOpened = (filePath, databaseId) => { currentOpenFilePath = filePath; currentDatabaseId = databaseId; // prevent currentDBPrefix from ever getting to be more than two digits. databaseId = databaseId % (36*36); currentDBPrefix = `${databaseId.toString(36)}:`; if(currentDBPrefix.length == 2) { currentDBPrefix = `0${currentDBPrefix}`; } myLog(`setDatabaseOpened: filePath=${filePath} databaseId=${databaseId} dbPrefix=${currentDBPrefix}`); }; ipcMain.handle('db-get', (event, key) => wrapError(() => db.get(`${currentDBPrefix}${key}`))); ipcMain.handle('db-put', (event, key, value) => wrapError(() => db.put(`${currentDBPrefix}${key}`, value))); ipcMain.handle('db-del', (event, key) => wrapError(() => db.del(`${currentDBPrefix}${key}`))); ipcMain.handle('db-batch', (event, batch) => wrapError(() => { const batchWithPrefixedKeys = batch.map(x => { x.key = `${currentDBPrefix}${x.key}`; return x; }); return db.batch(batchWithPrefixedKeys); })); const dbRange = (options) => new Promise((resolve, reject) => { const results = []; if(!settings.isProduction) { myLog(`dbRange ${JSON.stringify(options)}`); } db.createReadStream(options) .on('data', function (data) { // Make sure to remove the currentDBPrefix from the key here because the app doesnt need (or want) to know it exists data.key = data.key.replace(RegExp(`^${currentDBPrefix}`), ''); // const json = JSON.stringify(data.value); // console.log([data.key, ' ==> ' , json.substring(0, json.length > 30 ? 30 : json.length)].join('')); results.push(data); }) .on('error', function (err) { reject(err); }) .on('end', function () { // const foo = results.map(kv => { // const json = JSON.stringify(kv.value); // return [kv.key, ' ==> ' , json.substring(0, json.length > 30 ? 30 : json.length)].join(''); // }).join('\n'); resolve(results); }); }); ipcMain.handle('db-range', (event, options) => { if(options.gt != null) { options.gt = `${currentDBPrefix}${options.gt}`; } if(options.lt != null) { options.lt = `${currentDBPrefix}${options.lt}`; } if(options.gte != null) { options.gte = `${currentDBPrefix}${options.gte}`; } if(options.lte != null) { options.lte = `${currentDBPrefix}${options.lte}`; } return wrapError(() => dbRange(options)); }); // ipcMain.handle('db-put-raw', (event, key, value) => wrapError(() => db.put(`${key}`, value))); // ipcMain.handle('db-range-all', (event, options) => { // return wrapError(() => dbRange(options)); // }); ipcMain.handle('vm-exec', (event, javascriptString) => { return new Promise((resolve, reject) => { try { const result = new VM({ timeout: 1000, sandbox: {} }).run(javascriptString); resolve(result); } catch (err) { reject(err) } }); }); ipcMain.handle('open-database', () => { const leveldbFolder = `${getAppSupportFolder()}/leveldb`; return new Promise((resolve, reject) => { fs.access(leveldbFolder, fs.constants.F_OK | fs.constants.W_OK).then( (_) => { return null; }, err => { if(err.code !== 'ENOENT') { const fsAccess = `fs.access${err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`}`; return strings.errorMessage_XReturnedY(fsAccess, `"${err.message}"`); } return null; } ).then((accessError) => { if(accessError) { return accessError; } return fs.mkdir(leveldbFolder, {recursive: true, mode: 0744}).then( (_) => { return null; }, (err) => { if(err.code != 'EEXIST') { const fsMkdir = `fs.mkdir${err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`}`; return strings.errorMessage_XReturnedY(fsMkdir, `"${err.message}"`); } } ); }).then((error) => { if(error) { reject(error); } else { level(leveldbFolder, {valueEncoding: 'json'}, (err, newDb) => { if(err) { const pathIfNotThereAlready = err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`; reject(strings.errorMessage_XReturnedY(`leveldb${pathIfNotThereAlready}`, `"${err.message}"`)); } else { myLog(`LibreBookKeeper leveldb instance opened at ${leveldbFolder}`); db = newDb; dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => { Promise.all(fileMetadataKvs.map( x => fs.access(x.value.path, fs.constants.F_OK | fs.constants.W_OK) .then((_) => x.value, (_) => null) )).then(results => { const notNullFileNames = results.filter(x => x != null); const uniqueFileNames = Object.values( notNullFileNames.reduce((accum, x) => { if(!accum[x.path] || accum[x.path].lastModified < x.lastModified) { accum[x.path] = x; } return accum; }, {}) ); uniqueFileNames.sort((a, b) => a.lastModified < b.lastModified ? 1 : (a.lastModified > b.lastModified ? -1 : 0)); resolve({previouslyOpenedFiles: uniqueFileNames.slice(0, 3).map(x => x.path)}); }); }); } }); } }); }); }); ipcMain.on('close-database-and-quit', () => { myLog('close-database-and-quit'); if(logStreamOpen) { logStream.end(); logStreamOpen = false; } if(db) { db.close() db = null; } app.quit(); setTimeout(() => { process.exit(1); }, 2000); }); const myGzip = { compress: (buffer) => new Promise((resolve, reject) => { zlib.gzip(buffer, (err, result) => { if(err) { reject(err); } else { resolve(result); } }); }), decompress: (buffer) => new Promise((resolve, reject) => { zlib.gunzip(buffer, (err, result) => { if(err) { reject(err); } else { resolve(result); } }); }) }; const readFile = (filePath, resolve, reject) => { currentDBPrefix = ''; currentOpenFilePath = ''; currentDatabaseId = ''; fs.readFile(filePath).then( fileContentsGzip => { myGzip.decompress(fileContentsGzip).then( (fileContents) => { let file; try { file = JSON.parse(fileContents); // if(file.db && file.db[0].key) { // file.db = file.db.map(kv => [ kv.key, kv.value ]); // } // console.log(JSON.stringify(file, null, " ").substring(0,1000)); // } catch (err) { const jsonParse = `JSON.parse("${filePath}")`; reject(strings.errorMessage_XReturnedY(jsonParse, `"${err.message}".`)); return; } dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => { const metadataKv = fileMetadataKvs.filter(x => x.value.origin == file.origin)[0]; let databaseId; let syncNeeded; let writeMetadataPromise; if(!metadataKv) { databaseId = fileMetadataKvs.reduce( (accum, x) => accum < x.value.databaseId ? x.value.databaseId : accum, firstDatabaseId )+1; myLog(`${file.origin} (${filePath}) was not found in keyPrefixFileMetadata. `); myLog(`Syncing NEW database (id=${databaseId}) with file.`); syncNeeded = true; writeMetadataPromise = db.put(keyFileMetadata(file.origin), { origin: file.origin, version: file.version, lastModified: new Date().getTime(), path: filePath, databaseId: databaseId }); } else { databaseId = metadataKv.value.databaseId || file.databaseId; syncNeeded = metadataKv.value.version != file.version; if (metadataKv.value.path != filePath || file.path != filePath || databaseId != metadataKv.value.databaseId) { file.path = filePath; writeMetadataPromise = db.put(keyFileMetadata(file.origin), { origin: file.origin, version: file.version, lastModified: file.lastModified, path: filePath, databaseId: databaseId }); } else { writeMetadataPromise = Promise.resolve(); } if(syncNeeded) { myLog(`${file.origin} (${filePath}) has changed since the last time I remember opening it.`); myLog(`Syncing existing database (id=${databaseId}) with file.`); } else { myLog(`${file.origin} (${filePath}) has not changed since the last time I remember opening it.`); } } writeMetadataPromise.then( () => { setDatabaseOpened(filePath, databaseId); if(syncNeeded) { new Promise((resolve, reject) => { const batch = []; db.createKeyStream({ gt: currentDBPrefix, lt: currentDBPrefix + '\xff' }) .on('data', function (key) { batch.push({type: 'del', key: key}); }) .on('error', function (err) { reject(err); }) .on('end', function () { myLog(`deleting all keys for databaseId=${databaseId} (dbPrefix=${currentDBPrefix})`); db.batch(batch).then( resolve, reject ); }); }).then( () => { //console.log(JSON.stringify(file.db, null, " ")) const rewriteFileBatch = file.db.map(tuple => ({ type: 'put', key: `${currentDBPrefix}${tuple[0]}`, value: tuple[1] })); myLog(`copying keys from ${file.origin} (${filePath}) into databaseId=${databaseId} (dbPrefix=${currentDBPrefix}) `); db.batch(rewriteFileBatch).then( () => { myLog(`copying keys completed.`) resolve({recreateSearchIndex: true}); }, reject ); }, reject ); } else { resolve({recreateSearchIndex: false}); } }, reject ); }); }, (err) => { const zlibUnzip = `gzip.decompress("${filePath}")`; reject(strings.errorMessage_XReturnedY(zlibUnzip, `"${err.message}".`)); } ); }, err => { const readFile = `fs.readFile${err.message.includes(filePath) ? '' : `("${filePath}")`}`; reject(strings.errorMessage_XReturnedY(readFile, `"${err.message}"`)); } ); }; const writeFile = (filePath, file, resolve, reject) => { dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => { let databaseId = fileMetadataKvs.filter(x => x.value.origin == file.origin).map(x => x.value.databaseId)[0]; if(databaseId == null) { databaseId = fileMetadataKvs.reduce( (accum, x) => accum < x.value.databaseId ? x.value.databaseId : accum, firstDatabaseId )+1; } file.version = uuid(); file.databaseId = databaseId; setDatabaseOpened(filePath, databaseId); db.put(keyFileMetadata(file.origin), { origin: file.origin, version: file.version, lastModified: new Date().getTime(), path: filePath, databaseId: databaseId }).then( () => { myGzip.compress(JSON.stringify(file)).then( fileContentsGzip => { fs.writeFile(filePath, fileContentsGzip, { mode: 0744 }).then( (_) => { resolve({recreateSearchIndex: false}); }, err => { const writeFile = `fs.writeFile${err.message.includes(filePath) ? '' : `("${filePath}")`}`; reject(strings.errorMessage_XReturnedY(writeFile, `"${err.message}"`)); } ); }, err => { reject(strings.errorMessage_XReturnedY('gzip.compress', `"${err.message}".`)); } ); }, reject ); }); }; ipcMain.handle('request-file', (event, mode, filePath) => { if(!filePath) { if(mode == 'open') { filePath = dialog.showOpenDialogSync({ title: strings.title_OpenAnLBKFile, properties: ['openFile'] }); } if(mode == 'new') { filePath = dialog.showSaveDialogSync({ title: strings.title_WhereToSaveYourFile, defaultPath: strings.defaultFileName }); } } if(Array.isArray(filePath)) { filePath = filePath[0]; } return new Promise((resolve, reject) => { if(filePath != null) { fs.access(filePath, fs.constants.F_OK | fs.constants.W_OK).then( (_) => { return null; }, err => { if(mode == 'open' || err.code !== 'ENOENT') { const fsAccess = `fs.access${err.message.includes(filePath) ? '' : `("${filePath}")`}`; return strings.errorMessage_XReturnedY(fsAccess, `"${err.message}"`); } return null; } ).then(error => { if(error) { reject(error); } else { if(mode == 'open') { readFile(filePath, resolve, reject); } else { const newFileMetadata = { origin: uuid(), db: [], }; writeFile(filePath, newFileMetadata, resolve, reject); } } }); } else { reject('cancelled_symbol'); } }); }); ipcMain.handle('auto-save', (event) => { return wrapError( () => dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => { if((typeof currentDatabaseId) != 'number') { throw new Error(`Got auto-save when currentDatabaseId was not set.`); } const matchingKvs = fileMetadataKvs.filter(x => x.value.databaseId === currentDatabaseId); if(matchingKvs.length != 1) { throw new Error(`Expected exactly one file metadata record for this database. Found: ${JSON.stringify(matching)}.`); } let currentFile = matchingKvs[0].value; return dbRange({ gt: `${currentDBPrefix}${settings.keyPrefixStartOfSourceOfTruth}`, lt: `${currentDBPrefix}${settings.keyPrefixEndOfSourceOfTruth}` }).then(sourceOfTruthDatabaseKeyValues => { myLog(`auto-saving ${sourceOfTruthDatabaseKeyValues.length} keys from (databaseId=${currentFile.databaseId}) into ${currentFile.origin} (${currentFile.path})`) // dbRange() already strips the currentDBPrefix so no need to do it here currentFile.db = sourceOfTruthDatabaseKeyValues.map(kv => [ kv.key, kv.value ]); currentFile.version = uuid(); return new Promise((resolve, reject) => writeFile(currentFile.path, currentFile, resolve, reject)) .then(() => myLog(`writing ${currentFile.origin} (${currentFile.path}) completed!`)); }); }) ); }); function createWindow () { // Create the browser window. mainWindow = new BrowserWindow({ icon: 'electron-packager-stuff/icon/icon_256.png', width: settings.isProduction ? 800 : 1200, height: 600, webPreferences: { devTools: !settings.isProduction, // sandbox: true, // breaks preload enableRemoteModule: false, preload: path.join(__dirname, 'preload.js') } }); mainWindow.maximize(); // and load the index.html of the app. mainWindow.loadFile('client/index.html') if(!settings.isProduction) { mainWindow.webContents.openDevTools() } mainWindow.webContents.on('new-window', function(event, url){ event.preventDefault(); open(url); }); // Remove the help menu and the developer tools from the application menu let defaultMenu = Menu.getApplicationMenu() let newMenu = new Menu(); defaultMenu.items .filter(x => x.role != 'help') .forEach(x => { if(x.role == 'viewmenu' && settings.isProduction) { let newSubmenu = new Menu(); const removeFromViewMenu = [ 'toggledevtools', 'reload', 'forcereload' ]; x.submenu.items.filter(y => !removeFromViewMenu.includes(y.role)).forEach(y => newSubmenu.append(y)); //x.submenu.items.forEach(x => console.log(x.role)); x.submenu = newSubmenu; newMenu.append( new MenuItem({ type: x.type, label: x.label, submenu: newSubmenu }) ); } else { newMenu.append(x); } }); Menu.setApplicationMenu(newMenu); // Emitted when the window is closed. mainWindow.on('closed', function () { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. mainWindow = null }) } // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', createWindow) // Quit when all windows are closed. app.on('window-all-closed', function () { // On macOS it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform !== 'darwin') app.quit() }) app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (mainWindow === null) createWindow() })