const { app, BrowserWindow, ipcMain, Tray, Menu, dialog, shell, screen, globalShortcut, nativeImage } = require('electron'); const path = require('path'); const { spawn, exec } = require('child_process'); const { autoUpdater } = require('electron-updater'); const log = require('electron-log'); const fss = require('fs'); const os = require('os'); const { promisify } = require('util'); const { createHash } = require('crypto'); const { default: { fromBase64: toBase64URL } } = require('base64url'); const { URL } = require('url'); const UserAgent = require('user-agents'); const nsfw = require('./nsfw'); const napi = require('./native/napi.node'); // console.log(napi.fibonacci(10)); const fs = fss.promises; let mainWindow; let tray; let watcher; const discordRPC = require('./discordRPC'); const gotTheLock = app.requestSingleInstanceLock(); const isDev = process.env.NODE_ENV === 'development'; // Prevent multiple instances if (gotTheLock) { app.on('second-instance', (e, argv) => { if (process.platform === 'win32') { const args = process.argv.slice(1); const args1 = argv.slice(1); log.log([...args, ...args1]); if (mainWindow) { mainWindow.webContents.send('custom-protocol-event', [ ...args, ...args1 ]); } } if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); } else { app.quit(); } if (!app.isDefaultProtocolClient('gdlauncher')) { app.setAsDefaultProtocolClient('gdlauncher'); } // This gets rid of this: https://github.com/electron/electron/issues/13186 process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = true; // app.commandLine.appendSwitch("disable-web-security"); app.commandLine.appendSwitch('disable-gpu-vsync=gpu'); app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors'); const edit = [ ...(process.platform === 'darwin' ? [ { label: 'GDLauncher', submenu: [ { label: 'About GDLauncher', role: 'about' }, { type: 'separator' }, { label: 'Services', role: 'services', submenu: [] }, { type: 'separator' }, { label: 'Hide GDLauncher', accelerator: 'Command+H', role: 'hide' }, { label: 'Hide Others', accelerator: 'Command+Alt+H', role: 'hideOthers' }, { label: 'Show All', role: 'unhide' }, { type: 'separator' }, { label: 'Quit GDLauncher', accelerator: 'Command+Q', click: () => { app.quit(); } } ] } ] : []), { label: 'Edit', submenu: [ { label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' }, { label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, { label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, { label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }, { type: 'separator' }, { label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' }, { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' } ] } ]; let navPlatform = null; switch (os.platform()) { case 'win32': navPlatform = 'Win32'; break; case 'darwin': navPlatform = 'MacIntel'; break; case 'linux': default: navPlatform = 'Linux x86_64'; } const userAgent = new UserAgent({ platform: navPlatform, deviceCategory: 'desktop' }).toString(); // app.allowRendererProcessReuse = true; Menu.setApplicationMenu(Menu.buildFromTemplate(edit)); const baseUserPath = path.join(app.getPath('appData'), 'gdlauncher_next'); if (!fss.existsSync(baseUserPath)) { fss.mkdirSync(baseUserPath); } app.setPath('userData', baseUserPath); let allowUnstableReleases = false; const releaseChannelExists = fss.existsSync( path.join(app.getPath('userData'), 'rChannel') ); if (releaseChannelExists) { const releaseChannelConfig = fss.readFileSync( path.join(app.getPath('userData'), 'rChannel') ); const releaseId = parseInt(releaseChannelConfig.toString(), 10); if (releaseId === 1) { allowUnstableReleases = true; } } else if (!releaseChannelExists && app.getVersion().includes('beta')) { fss.writeFileSync(path.join(app.getPath('userData'), 'rChannel'), '1'); allowUnstableReleases = true; } if ( process.env.REACT_APP_RELEASE_TYPE === 'portable' && process.platform !== 'linux' ) { app.setPath('userData', path.join(path.dirname(app.getPath('exe')), 'data')); } else { const overrideExists = fss.existsSync( path.join(app.getPath('userData'), 'override.data') ); if (overrideExists) { const override = fss.readFileSync( path.join(app.getPath('userData'), 'override.data') ); app.setPath('userData', override.toString()); } } // Read config and eventually use new path try { const configPath = path.join(app.getPath('userData'), 'config.json'); const configFile = fss.readFileSync(configPath); const config = JSON.parse(configFile); const requiredArgs = [ '-Dfml.ignorePatchDiscrepancies=true', '-Dfml.ignoreInvalidMinecraftCertificates=true' ]; const javaArgs = config.settings.java.args; if (config.settings.java.args) { const cleanedJavaArgs = javaArgs .split(' ') .filter(arg => !requiredArgs.includes(arg)) .join(' '); if (cleanedJavaArgs.split(' ').length !== javaArgs.split(' ').length) { config.settings.java.args = cleanedJavaArgs; fss.writeFileSync(configPath, JSON.stringify(config)); } } } catch { // Do nothing } log.log(process.env.REACT_APP_RELEASE_TYPE, app.getVersion()); const get7zPath = async () => { let baseDir = path.dirname(app.getPath('exe')); if (isDev) { baseDir = path.resolve(baseDir, '../../'); if (process.platform === 'win32') { baseDir = path.join(baseDir, '7zip-bin/win/x64'); } else if (process.platform === 'linux') { baseDir = path.join(baseDir, '7zip-bin/linux', process.arch); } else if (process.platform === 'darwin') { baseDir = path.resolve(baseDir, '../../../', '7zip-bin/mac/x64'); } } if (process.platform === 'linux') { return path.join(baseDir, '7za'); } if (process.platform === 'darwin') { return path.resolve(baseDir, isDev ? '' : '../', '7za'); } return path.join(baseDir, '7za.exe'); }; async function patchSevenZip() { try { if (process.platform === 'linux' || process.platform === 'darwin') { const sevenZipPath = await get7zPath(); await promisify(exec)(`chmod +x "${sevenZipPath}"`); await promisify(exec)(`chmod 755 "${sevenZipPath}"`); } } catch (e) { log.error(e); } } patchSevenZip(); function createWindow() { mainWindow = new BrowserWindow({ width: 1100, height: 700, minWidth: 1100, minHeight: 700, show: true, frame: false, backgroundColor: '#1B2533', webPreferences: { experimentalFeatures: true, nodeIntegration: true, contextIsolation: false, enableRemoteModule: true, sandbox: false, // Disable in dev since I think hot reload is messing with it webSecurity: !isDev } }); if (isDev) { globalShortcut.register('CommandOrControl+R', () => { mainWindow.reload(); }); globalShortcut.register('F5', () => { mainWindow.reload(); }); } mainWindow.webContents.session.webRequest.onHeadersReceived( { urls: ['http://*/*', 'https://*/*'] }, (details, callback) => { // eslint-disable-next-line delete details.responseHeaders['Access-Control-Allow-Origin']; // eslint-disable-next-line delete details.responseHeaders['access-control-allow-origin']; if (details.url.includes('www.google-analytics.com')) { // eslint-disable-next-line details.responseHeaders['Access-Control-Allow-Origin'] = [ 'http://localhost:3000' ]; } else { // eslint-disable-next-line details.responseHeaders['Access-Control-Allow-Origin'] = ['*']; } callback({ cancel: false, responseHeaders: details.responseHeaders }); } ); mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { // Use a header to skip sending Origin on request. const { 'X-Skip-Origin': xSkipOrigin, Origin: _origin, ...requestHeaders } = details.requestHeaders; if (xSkipOrigin !== 'skip') { requestHeaders.Origin = 'https://gdevs.io'; } callback({ cancel: false, requestHeaders }); } ); const RESOURCE_DIR = isDev ? __dirname : path.join(__dirname, '../build'); const iconPath = path.join(RESOURCE_DIR, 'logo_32x32.png'); const nimage = nativeImage.createFromPath(iconPath); tray = new Tray(nimage); const trayMenuTemplate = [ { label: 'GDLauncher', enabled: false }, { label: 'Show Dev Tools', click: () => mainWindow.webContents.openDevTools() } ]; const trayMenu = Menu.buildFromTemplate(trayMenuTemplate); tray.setContextMenu(trayMenu); tray.setToolTip('GDLauncher'); tray.on('double-click', () => mainWindow.show()); mainWindow.loadURL( isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`, { userAgent: 'GDLauncher' } ); if (isDev) { mainWindow.webContents.openDevTools(); } mainWindow.on('maximize', () => { mainWindow.webContents.send('window-maximized'); }); mainWindow.on('unmaximize', () => { mainWindow.webContents.send('window-minimized'); }); const handleRedirect = (e, url) => { if (url !== mainWindow.webContents.getURL()) { e.preventDefault(); shell.openExternal(url); } }; mainWindow.webContents.on('will-navigate', handleRedirect); mainWindow.webContents.on('new-window', handleRedirect); } app.on('ready', createWindow); app.on('window-all-closed', async () => { if (watcher) { await watcher.stop(); watcher = null; } app.quit(); }); app.on('before-quit', async () => { if (watcher) { await watcher.stop(); watcher = null; } if (mainWindow) { mainWindow.removeAllListeners('close'); } mainWindow = null; }); app.on('activate', () => { if (mainWindow === null) { createWindow(); } }); ipcMain.handle( 'msLoginOAuth', (_event, clientId, codeVerifier, redirectUrl) => new Promise((resolve, reject) => { const codeChallenge = toBase64URL( createHash('sha256').update(codeVerifier).digest('base64') ); const msAuthorizeUrl = new URL( 'https://login.live.com/oauth20_authorize.srf' ); msAuthorizeUrl.searchParams.set('client_id', clientId); msAuthorizeUrl.searchParams.set('redirect_uri', redirectUrl); msAuthorizeUrl.searchParams.set('code_challenge', codeChallenge); msAuthorizeUrl.searchParams.set('code_challenge_method', 'S256'); msAuthorizeUrl.searchParams.set('response_type', 'code'); msAuthorizeUrl.searchParams.set( 'scope', 'offline_access xboxlive.signin xboxlive.offline_access' ); msAuthorizeUrl.searchParams.set( 'cobrandid', '8058f65d-ce06-4c30-9559-473c9275a65d' ); const handleRedirect = (url, authWindow) => { const rdUrl = new URL(url); const orUrl = new URL(redirectUrl); if ( rdUrl.origin === orUrl.origin && rdUrl.pathname === orUrl.pathname ) { const redirectCode = rdUrl.searchParams.get('code'); const redirectError = rdUrl.searchParams.get('error'); authWindow.destroy(); // Will not trigger 'close' if (redirectCode) { return resolve(redirectCode); } return reject(redirectError); } }; const oAuthWindow = new BrowserWindow({ title: 'Sign in to your Microsoft account', show: false, parent: mainWindow, autoHideMenuBar: true, webPreferences: { nodeIntegration: false } }); oAuthWindow.webContents.session.clearStorageData(); // Remove Origin oAuthWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { const { requestHeaders: { Origin, ...requestHeaders } } = details; callback({ cancel: false, requestHeaders }); } ); oAuthWindow.on('close', () => reject(new Error('User closed login window')) ); oAuthWindow.webContents.on('will-navigate', (_e, url) => handleRedirect(url, oAuthWindow) ); oAuthWindow.webContents.on('will-redirect', (_e, url) => handleRedirect(url, oAuthWindow) ); oAuthWindow.show(); return oAuthWindow .loadURL(msAuthorizeUrl.toString()) .catch(error => reject(error)); }) ); ipcMain.handle('update-progress-bar', (event, p) => { mainWindow.setProgressBar(p / 100); }); ipcMain.handle('hide-window', () => { if (mainWindow) { mainWindow.hide(); } }); ipcMain.handle('min-max-window', () => { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow.maximize(); } }); ipcMain.handle('minimize-window', () => { mainWindow.minimize(); }); ipcMain.handle('show-window', () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); ipcMain.handle('quit-app', async () => { if (watcher) { log.log('Stopping listener'); await watcher.stop(); watcher = null; } mainWindow.close(); mainWindow = null; }); ipcMain.handle('isAppImage', () => { return process.env.APPIMAGE; }); ipcMain.handle('getAppdataPath', () => { return app.getPath('appData'); }); // Returns path to app.asar ipcMain.handle('getAppPath', () => { return app.getAppPath(); }); ipcMain.handle('getUserData', () => { return app.getPath('userData'); }); ipcMain.handle('getSentryDsn', () => { return process.env.SENTRY_DSN; }); ipcMain.handle('getExecutablePath', () => { return path.dirname(app.getPath('exe')); }); ipcMain.handle('getAppVersion', () => { return app.getVersion(); }); ipcMain.handle('getIsWindowMaximized', () => { return !mainWindow.maximizable; }); ipcMain.handle('openFolder', (e, folderPath) => { shell.openPath(folderPath); }); ipcMain.handle('open-devtools', () => { mainWindow.webContents.openDevTools({ mode: 'undocked' }); }); ipcMain.handle('openFolderDialog', (e, defaultPath) => { return dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath: path.dirname(defaultPath) }); }); ipcMain.handle('openFileDialog', (e, filters) => { return dialog.showOpenDialog({ properties: ['openFile'], filters }); }); ipcMain.handle('appRestart', async () => { log.log('Restarting app'); if (watcher) { log.log('Stopping listener'); await watcher.stop(); watcher = null; } app.relaunch(); mainWindow.close(); }); ipcMain.handle('getAllDisplaysBounds', () => { return screen.getAllDisplays().map(v => v.bounds); }); ipcMain.handle('init-discord-rpc', () => { discordRPC.initRPC(); }); ipcMain.handle('update-discord-rpc', (event, p) => { discordRPC.update(p); }); ipcMain.handle('reset-discord-rpc', () => { discordRPC.reset(); }); ipcMain.handle('shutdown-discord-rpc', () => { discordRPC.shutdownRPC(); }); const removeOriginHeader = (details, callback) => { // Use a header to skip sending Origin on request. const { 'X-Skip-Origin': xSkipOrigin, Origin: _origin, ...requestHeaders } = details.requestHeaders; delete requestHeaders.Origin; callback({ cancel: false, requestHeaders }); }; ipcMain.handle('download-optedout-mod', async (e, { url, filePath }) => { let win = new BrowserWindow(); await win.webContents.session.clearCache(); // await win.webContents.session.clearStorageData(); win.webContents.session.webRequest.onBeforeSendHeaders(removeOriginHeader); const mainWindowListener = () => { if (win) { win.removeAllListeners(); win.close(); win = null; } }; win.once('close', () => { win = null; }); let cleanupFn = null; win.once('closed', () => { mainWindow.webContents.send('opted-out-window-closed-unexpected'); if (cleanupFn) cleanupFn(`Window has been closed unexpectedly`); mainWindow.removeListener('closed', mainWindowListener); }); mainWindow.on('closed', mainWindowListener); if (!win) return; try { // eslint-disable-next-line no-loop-func await new Promise((resolve, reject) => { win.webContents.session.webRequest.onCompleted( { urls: [url] }, details => { if (details.statusCode === 404) { mainWindow.webContents.send('opted-out-download-mod-status', { error: new Error('Error 404, Mod page not found') }); reject(); } } ); win.loadURL(url, { userAgent }); cleanupFn = async err => { reject(new Error(err)); // eslint-disable-next-line promise/param-names await new Promise(r => setTimeout(r, 50)); mainWindowListener(); }; const timer = setTimeout( () => cleanupFn(`Download for ${url} timed out`), 60 * 1000 * 10 ); win.webContents.session.once('will-download', (_, item) => { item.setSavePath(filePath); item.once('updated', () => { clearTimeout(timer); }); item.once('done', (event, state) => { if (state === 'completed') { resolve(); } else { reject(new Error(`Download for ${url} failed`)); } }); }); }); if (mainWindow?.webContents) { // send success event to front end mainWindow.webContents.send('opted-out-download-mod-status', { error: false }); } } catch (err) { if (mainWindow?.webContents && win) { // send error event to front end mainWindow.webContents.send('opted-out-download-mod-status', { error: err }); } } if (win) { win.removeAllListeners(); win.close(); win.once('closed', () => { win = null; }); } }); ipcMain.handle('download-optedout-mods', async (e, { mods, instancePath }) => { let win = new BrowserWindow(); await win.webContents.session.clearCache(); // await win.webContents.session.clearStorageData(); win.webContents.session.webRequest.onBeforeSendHeaders(removeOriginHeader); const mainWindowListener = () => { if (win) { win.removeAllListeners(); win.close(); win = null; } }; win.once('close', () => { win = null; }); let cleanupFn = null; win.once('closed', () => { mainWindow.webContents.send('opted-out-window-closed-unexpected'); if (cleanupFn) cleanupFn(`Window has been closed unexpectedly`); mainWindow.removeListener('closed', mainWindowListener); }); mainWindow.on('closed', mainWindowListener); for (const mod of mods) { if (!win) return; const { modManifest, addon } = mod; try { // eslint-disable-next-line no-loop-func await new Promise((resolve, reject) => { const urlDownloadPage = `${addon.links.websiteUrl}/download/${modManifest.id}`; win.webContents.session.webRequest.onCompleted( { urls: [urlDownloadPage] }, details => { if (details.statusCode === 404) { resolve(); mainWindow.webContents.send('opted-out-download-mod-status', { modId: modManifest.id, error: false, warning: true }); } else if (details.statusCode > 400) { /** * Check for Cloudflare blocking automated downloads. * * Sometimes, Cloudflare prevents the internal browser from navigating to the * Curseforge mod download page and starting the download. The HTTP status code * it returns is (generally) either 403 or 503. The code below retrieves the * HTML of the page returned to the browser and checks for the title and some * content on the page to determine if the returned page is Cloudflare. * Unfortunately using the `webContents.getTitle()` returns an empty string. */ details.webContents .executeJavaScript( ` function getHTML () { return new Promise((resolve, reject) => { resolve(document.documentElement.innerHTML); }); } getHTML(); ` ) .then(content => { const isCloudflare = content.includes('Just a moment...') && content.includes( 'needs to review the security of your connection before proceeding.' ); if (isCloudflare) { resolve(); mainWindow.webContents.send( 'opted-out-download-mod-status', { modId: modManifest.id, error: false, warning: true, cloudflareBlock: true } ); } return null; }) .catch(() => { // no-op }); } } ); win.loadURL(urlDownloadPage, { userAgent }); cleanupFn = async err => { reject(new Error(err)); // eslint-disable-next-line promise/param-names await new Promise(r => setTimeout(r, 50)); mainWindowListener(); }; const timer = setTimeout( () => cleanupFn(`Download for ${modManifest.fileName} timed out`), 60 * 1000 * 10 ); const isResourcePack = addon.classId === 12; const modDestFile = path.join( instancePath, isResourcePack ? 'resourcepacks' : 'mods' ); win.webContents.session.once('will-download', (_, item) => { item.setSavePath(path.join(modDestFile, modManifest.fileName)); item.once('updated', () => { clearTimeout(timer); }); item.once('done', (event, state) => { if (state === 'completed') { resolve(); } else { reject(new Error(`Download for ${modManifest.fileName} failed`)); } }); }); }); if (mainWindow?.webContents) { // send success event to front end mainWindow.webContents.send('opted-out-download-mod-status', { modId: modManifest.id, error: false }); } } catch (err) { if (mainWindow?.webContents && win) { // send error event to front end mainWindow.webContents.send('opted-out-download-mod-status', { modId: modManifest.id, error: err }); } } } if (win) { win.removeAllListeners(); win.close(); win.once('closed', () => { win = null; }); } }); ipcMain.handle('start-listener', async (e, dirPath) => { try { log.log('Trying to start listener'); if (watcher) { await watcher.stop(); watcher = null; } watcher = await nsfw(dirPath, events => { log.log(`Detected ${events.length} events from listener`); mainWindow.webContents.send('listener-events', events); }); log.log('Started listener'); return watcher.start(); } catch (err) { log.error(err); return Promise.reject(err); } }); ipcMain.handle('stop-listener', async () => { if (watcher) { log.log('Stopping listener'); await watcher.stop(); watcher = null; } }); ipcMain.handle('calculateMurmur2FromPath', async (e, filePath) => { const res = await napi.computePathMurmur(filePath); return res.toString(); }); // AutoUpdater if (process.env.REACT_APP_RELEASE_TYPE === 'setup') { autoUpdater.autoDownload = false; autoUpdater.allowDowngrade = !allowUnstableReleases && app.getVersion().includes('beta'); autoUpdater.allowPrerelease = allowUnstableReleases; autoUpdater.setFeedURL({ owner: 'gorilla-devs', repo: 'GDLauncher', provider: 'github' }); autoUpdater.on('update-available', () => { autoUpdater.downloadUpdate(); }); autoUpdater.on('update-downloaded', () => { mainWindow.webContents.send('updateAvailable'); }); ipcMain.handle('checkForUpdates', () => { autoUpdater.checkForUpdates(); }); } ipcMain.handle('installUpdateAndQuitOrRestart', async (e, quitAfterInstall) => { const tempFolder = path.join( path.dirname(app.getPath('exe')), 'data', 'temp' ); if (process.env.REACT_APP_RELEASE_TYPE === 'setup') { autoUpdater.quitAndInstall(true, !quitAfterInstall); } else { const updaterVbs = 'updater.vbs'; const updaterBat = 'updateLauncher.bat'; await fs.writeFile( path.join(tempFolder, updaterBat), `ping 127.0.0.1 -n 1 > nul & robocopy "${path.join( tempFolder, 'update' )}" "." /MOV /E${ quitAfterInstall ? '' : ` & start "" "${app.getPath('exe')}"` } DEL "${path.join(tempFolder, updaterVbs)}" DEL "%~f0" ` ); await fs.writeFile( path.join(tempFolder, updaterVbs), `Set WshShell = CreateObject("WScript.Shell") WshShell.Run chr(34) & "${path.join( tempFolder, updaterBat )}" & Chr(34), 0 Set WshShell = Nothing ` ); const updateSpawn = spawn(path.join(tempFolder, updaterVbs), { cwd: path.dirname(app.getPath('exe')), detached: true, shell: true, stdio: [ 'ignore' /* stdin */, 'ignore' /* stdout */, 'ignore' /* stderr */ ] }); updateSpawn.unref(); mainWindow.close(); } });