import { app, BrowserWindow, dialog, Menu, ipcMain, powerSaveBlocker, screen, session, shell, nativeTheme, net, protocol, clipboard } from 'electron' import path from 'path' import cp from 'child_process' import { IpcChannels, DBActions, SyncEvents } from '../constants' import baseHandlers from '../datastores/handlers/base' import { extractExpiryTimestamp, ImageCache } from './ImageCache' import { existsSync } from 'fs' import packageDetails from '../../package.json' if (process.argv.includes('--version')) { app.exit() } else { runApp() } function runApp() { require('electron-context-menu')({ showSearchWithGoogle: false, showSaveImageAs: true, showCopyImageAddress: true, showSelectAll: false, showCopyLink: false, prepend: (defaultActions, parameters, browserWindow) => [ { label: 'Show / Hide Video Statistics', visible: parameters.mediaType === 'video', click: () => { browserWindow.webContents.send('showVideoStatistics') } }, { label: 'Open in a New Window', // Only show the option for in-app URLs and not external ones visible: parameters.linkURL.split('#')[0] === browserWindow.webContents.getURL().split('#')[0], click: () => { createWindow({ replaceMainWindow: false, windowStartupUrl: parameters.linkURL, showWindowNow: true }) } }, // Only show select all in text fields { label: 'Select All', enabled: parameters.editFlags.canSelectAll, visible: parameters.isEditable, click: () => { browserWindow.webContents.selectAll() } } ], // only show the copy link entry for external links and the /playlist, /channel and /watch in-app URLs // the /playlist, /channel and /watch in-app URLs get transformed to their equivalent YouTube or Invidious URLs append: (defaultActions, parameters, browserWindow) => { let visible = false const urlParts = parameters.linkURL.split('#') const isInAppUrl = urlParts[0] === browserWindow.webContents.getURL().split('#')[0] if (parameters.linkURL.length > 0) { if (isInAppUrl) { const path = urlParts[1] if (path) { visible = ['/channel', '/watch'].some(p => path.startsWith(p)) || // Only show copy link entry for non user playlists (path.startsWith('/playlist') && !/playlistType=user/.test(path)) } } else { visible = true } } const copy = (url) => { if (parameters.linkText) { clipboard.write({ bookmark: parameters.linkText, text: url }) } else { clipboard.writeText(url) } } const transformURL = (toYouTube) => { let origin if (toYouTube) { origin = 'https://www.youtube.com' } else { origin = 'https://redirect.invidious.io' } const [path, query] = urlParts[1].split('?') const [route, id] = path.split('/').filter(p => p) switch (route) { case 'playlist': return `${origin}/playlist?list=${id}` case 'channel': return `${origin}/channel/${id}` case 'watch': { let url if (toYouTube) { url = new URL(`https://youtu.be/${id}`) } else { url = new URL(`https://redirect.invidious.io/watch?v=${id}`) } if (query) { const params = new URLSearchParams(query) const newParams = new URLSearchParams(url.search) let hasParams = false if (params.has('playlistId') && params.get('playlistType') !== 'user') { newParams.set('list', params.get('playlistId')) hasParams = true } if (params.has('timestamp')) { newParams.set('t', params.get('timestamp')) hasParams = true } if (hasParams) { url.search = newParams.toString() } } return url.toString() } } } return [ { label: 'Copy Lin&k', visible: visible && !isInAppUrl, click: () => { copy(parameters.linkURL) } }, { label: 'Copy YouTube Link', visible: visible && isInAppUrl, click: () => { copy(transformURL(true)) } }, { label: 'Copy Invidious Link', visible: visible && isInAppUrl, click: () => { copy(transformURL(false)) } } ] } }) // disable electron warning process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' const isDebug = process.argv.includes('--debug') let mainWindow let startupUrl if (process.platform === 'linux') { // Enable hardware acceleration via VA-API // https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/gpu/vaapi.md app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecodeLinuxGL') } // command line switches need to be added before the app ready event first // that means we can't use the normal settings system as that is asynchronous, // doing it synchronously ensures that we add it before the event fires const replaceHttpCache = existsSync(`${app.getPath('userData')}/experiment-replace-http-cache`) if (replaceHttpCache) { // the http cache causes excessive disk usage during video playback // we've got a custom image cache to make up for disabling the http cache // experimental as it increases RAM use in favour of reduced disk use app.commandLine.appendSwitch('disable-http-cache') } // See: https://stackoverflow.com/questions/45570589/electron-protocol-handler-not-working-on-windows // remove so we can register each time as we run the app. app.removeAsDefaultProtocolClient('freetube') // If we are running a non-packaged version of the app && on windows if (process.env.NODE_ENV === 'development' && process.platform === 'win32') { // Set the path of electron.exe and your app. // These two additional parameters are only available on windows. app.setAsDefaultProtocolClient('freetube', process.execPath, [path.resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient('freetube') } if (process.env.NODE_ENV !== 'development') { // Only allow single instance of the application const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() } app.on('second-instance', (_, commandLine, __) => { // Someone tried to run a second instance, we should focus our window if (mainWindow && typeof commandLine !== 'undefined') { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() const url = getLinkUrl(commandLine) if (url) { mainWindow.webContents.send('openUrl', url) } } }) } app.on('ready', async (_, __) => { let docArray try { docArray = await baseHandlers.settings._findAppReadyRelatedSettings() } catch (err) { console.error(err) app.exit() return } let disableSmoothScrolling = false let useProxy = false let proxyProtocol = 'socks5' let proxyHostname = '127.0.0.1' let proxyPort = '9050' if (docArray?.length > 0) { docArray.forEach((doc) => { switch (doc._id) { case 'disableSmoothScrolling': disableSmoothScrolling = doc.value break case 'useProxy': useProxy = doc.value break case 'proxyProtocol': proxyProtocol = doc.value break case 'proxyHostname': proxyHostname = doc.value break case 'proxyPort': proxyPort = doc.value break } }) } if (disableSmoothScrolling) { app.commandLine.appendSwitch('disable-smooth-scrolling') } else { app.commandLine.appendSwitch('enable-smooth-scrolling') } if (useProxy) { session.defaultSession.setProxy({ proxyRules: `${proxyProtocol}://${proxyHostname}:${proxyPort}` }) } const fixedUserAgent = session.defaultSession.getUserAgent() .split(' ') .filter(part => !part.includes('Electron') && !part.includes(packageDetails.productName)) .join(' ') session.defaultSession.setUserAgent(fixedUserAgent) // Set CONSENT cookie on reasonable domains const consentCookieDomains = [ 'https://www.youtube.com', 'https://youtube.com' ] consentCookieDomains.forEach(url => { session.defaultSession.cookies.set({ url: url, name: 'CONSENT', value: 'YES+', sameSite: 'no_restriction' }) }) session.defaultSession.cookies.set({ url: 'https://www.youtube.com', name: 'SOCS', value: 'CAI', sameSite: 'no_restriction', }) // make InnerTube requests work with the fetch function // InnerTube rejects requests if the referer isn't YouTube or empty const innertubeAndMediaRequestFilter = { urls: ['https://www.youtube.com/youtubei/*', 'https://*.googlevideo.com/videoplayback?*'] } session.defaultSession.webRequest.onBeforeSendHeaders(innertubeAndMediaRequestFilter, ({ requestHeaders, url, resourceType }, callback) => { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' if (url.startsWith('https://www.youtube.com/youtubei/')) { requestHeaders['Sec-Fetch-Site'] = 'same-origin' } else { // YouTube doesn't send the Content-Type header for the media requests, so we shouldn't either delete requestHeaders['Content-Type'] } // YouTube throttles the adaptive formats if you request a chunk larger than 10MiB. // For the DASH formats we are fine as video.js doesn't seem to ever request chunks that big. // The legacy formats don't have any chunk size limits. // For the audio formats we need to handle it ourselves, as the browser requests the entire audio file, // which means that for most videos that are longer than 10 mins, we get throttled, as the audio track file sizes surpass that 10MiB limit. // This code checks if the file is larger than the limit, by checking the `clen` query param, // which YouTube helpfully populates with the content length for us. // If it does surpass that limit, it then checks if the requested range is larger than the limit // (seeking right at the end of the video, would result in a small enough range to be under the chunk limit) // if that surpasses the limit too, it then limits the requested range to 10MiB, by setting the range to `start-${start + 10MiB}`. if (resourceType === 'media' && url.includes('&mime=audio') && requestHeaders.Range) { const TEN_MIB = 10 * 1024 * 1024 const contentLength = parseInt(new URL(url).searchParams.get('clen')) if (contentLength > TEN_MIB) { const [startStr, endStr] = requestHeaders.Range.split('=')[1].split('-') const start = parseInt(startStr) // handle open ended ranges like `0-` and `1234-` const end = endStr.length === 0 ? contentLength : parseInt(endStr) if (end - start > TEN_MIB) { const newEnd = start + TEN_MIB requestHeaders.Range = `bytes=${start}-${newEnd}` } } } // eslint-disable-next-line n/no-callback-literal callback({ requestHeaders }) }) // when we create a real session on the watch page, youtube returns tracking cookies, which we definitely don't want const trackingCookieRequestFilter = { urls: ['https://www.youtube.com/sw.js_data', 'https://www.youtube.com/iframe_api'] } session.defaultSession.webRequest.onHeadersReceived(trackingCookieRequestFilter, ({ responseHeaders }, callback) => { if (responseHeaders) { delete responseHeaders['set-cookie'] } // eslint-disable-next-line n/no-callback-literal callback({ responseHeaders }) }) if (replaceHttpCache) { // in-memory image cache const imageCache = new ImageCache() protocol.handle('imagecache', (request) => { return new Promise((resolve, reject) => { const url = decodeURIComponent(request.url.substring(13)) if (imageCache.has(url)) { const cached = imageCache.get(url) resolve(new Response(cached.data, { headers: { 'content-type': cached.mimeType } })) return } const newRequest = net.request({ method: request.method, url }) // Electron doesn't allow certain headers to be set: // https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value // also blacklist Origin and Referrer as we don't want to let YouTube know about them const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer'] for (const header of Object.keys(request.headers)) { if (!blacklistedHeaders.includes(header.toLowerCase())) { newRequest.setHeader(header, request.headers[header]) } } newRequest.on('response', (response) => { const chunks = [] response.on('data', (chunk) => { chunks.push(chunk) }) response.on('end', () => { const data = Buffer.concat(chunks) const expiryTimestamp = extractExpiryTimestamp(response.headers) const mimeType = response.headers['content-type'] imageCache.add(url, mimeType, data, expiryTimestamp) resolve(new Response(data, { headers: { 'content-type': mimeType } })) }) response.on('error', (error) => { console.error('image cache error', error) reject(error) }) }) newRequest.on('error', (err) => { console.error(err) }) newRequest.end() }) }) const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'] } session.defaultSession.webRequest.onBeforeRequest(imageRequestFilter, (details, callback) => { // the requests made by the imagecache:// handler to fetch the image, // are allowed through, as their resourceType is 'other' if (details.resourceType === 'image') { // eslint-disable-next-line n/no-callback-literal callback({ redirectURL: `imagecache://${encodeURIComponent(details.url)}` }) } else { // eslint-disable-next-line n/no-callback-literal callback({}) } }) // --- end of `if experimentsDisableDiskCache` --- } await createWindow() if (process.env.NODE_ENV === 'development') { installDevTools() } if (isDebug) { mainWindow.webContents.openDevTools() } }) async function installDevTools() { try { /* eslint-disable */ require('vue-devtools').install() /* eslint-enable */ } catch (err) { console.error(err) } } async function createWindow( { replaceMainWindow = true, windowStartupUrl = null, showWindowNow = false, searchQueryText = null } = { }) { // Syncing new window background to theme choice. const windowBackground = await baseHandlers.settings._findTheme().then((setting) => { if (!setting) { return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' } switch (setting.value) { case 'dark': return '#212121' case 'light': return '#f1f1f1' case 'black': return '#000000' case 'dracula': return '#282a36' case 'catppuccin-mocha': return '#1e1e2e' case 'pastel-pink': return '#ffd1dc' case 'hot-pink': return '#de1c85' case 'nordic': return '#2b2f3a' case 'system': default: return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' } }).catch((error) => { console.error(error) // Default to nativeTheme settings if nothing is found. return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' }) /** * Initial window options */ const commonBrowserWindowOptions = { backgroundColor: windowBackground, darkTheme: nativeTheme.shouldUseDarkColors, icon: process.env.NODE_ENV === 'development' ? path.join(__dirname, '../../_icons/iconColor.png') /* eslint-disable-next-line n/no-path-concat */ : `${__dirname}/_icons/iconColor.png`, autoHideMenuBar: true, // useContentSize: true, webPreferences: { nodeIntegration: true, nodeIntegrationInWorker: false, webSecurity: false, backgroundThrottling: false, contextIsolation: false } } const newWindow = new BrowserWindow( Object.assign( { // It will be shown later when ready via `ready-to-show` event show: showWindowNow }, commonBrowserWindowOptions ) ) // region Ensure child windows use same options since electron 14 // https://github.com/electron/electron/blob/14-x-y/docs/api/window-open.md#native-window-example newWindow.webContents.setWindowOpenHandler((details) => { createWindow({ replaceMainWindow: false, showWindowNow: true, windowStartupUrl: details.url }) return { action: 'deny' } }) // endregion Ensure child windows use same options since electron 14 if (replaceMainWindow) { mainWindow = newWindow } newWindow.setBounds({ width: 1200, height: 800 }) const boundsDoc = await baseHandlers.settings._findBounds() if (typeof boundsDoc?.value === 'object') { const { maximized, fullScreen, ...bounds } = boundsDoc.value const windowVisible = screen.getAllDisplays().some(display => { const { x, y, width, height } = display.bounds return !(bounds.x > x + width || bounds.x + bounds.width < x || bounds.y > y + height || bounds.y + bounds.height < y) }) if (windowVisible) { newWindow.setBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }) } if (maximized) { newWindow.maximize() } if (fullScreen) { newWindow.setFullScreen(true) } } // If called multiple times // Duplicate menu items will be added if (replaceMainWindow) { // eslint-disable-next-line setMenu() } // load root file/url if (process.env.NODE_ENV === 'development') { let devStartupURL = 'http://localhost:9080' if (windowStartupUrl != null) { devStartupURL = windowStartupUrl } newWindow.loadURL(devStartupURL) } else { if (windowStartupUrl != null) { newWindow.loadURL(windowStartupUrl) } else { /* eslint-disable-next-line n/no-path-concat */ newWindow.loadFile(`${__dirname}/index.html`) } } if (typeof searchQueryText === 'string' && searchQueryText.length > 0) { ipcMain.once('searchInputHandlingReady', () => { newWindow.webContents.send('updateSearchInputText', searchQueryText) }) } // Show when loaded newWindow.once('ready-to-show', () => { if (newWindow.isVisible()) { // only open the dev tools if they aren't already open if (process.env.NODE_ENV === 'development' && !newWindow.webContents.isDevToolsOpened()) { newWindow.webContents.openDevTools({ activate: false }) } return } newWindow.show() newWindow.focus() if (process.env.NODE_ENV === 'development') { newWindow.webContents.openDevTools({ activate: false }) } }) newWindow.once('close', async () => { if (BrowserWindow.getAllWindows().length !== 1) { return } const value = { ...newWindow.getNormalBounds(), maximized: newWindow.isMaximized(), fullScreen: newWindow.isFullScreen() } await baseHandlers.settings._updateBounds(value) }) newWindow.once('closed', () => { const allWindows = BrowserWindow.getAllWindows() if (allWindows.length !== 0 && newWindow === mainWindow) { // Replace mainWindow to avoid accessing `mainWindow.webContents` // Which raises "Object has been destroyed" error mainWindow = allWindows[0] } }) } ipcMain.once('appReady', () => { if (startupUrl) { mainWindow.webContents.send('openUrl', startupUrl) } }) ipcMain.once('relaunchRequest', () => { if (process.env.NODE_ENV === 'development') { app.exit(parseInt(process.env.FREETUBE_RELAUNCH_EXIT_CODE)) return } // The AppImage and Windows portable formats must be accounted for // because `process.execPath` points at the temporarily extracted // executables, not the executables themselves // // It's possible to detect these formats and identify their // executables' paths by checking the environmental variables const { env: { APPIMAGE, PORTABLE_EXECUTABLE_FILE } } = process if (!APPIMAGE) { // If it's a Windows portable, PORTABLE_EXECUTABLE_FILE will // hold a value. // Otherwise, `process.execPath` should be used instead. app.relaunch({ args: process.argv.slice(1), execPath: PORTABLE_EXECUTABLE_FILE || process.execPath }) } else { // If it's an AppImage, things must be done the "hard way" // `app.relaunch` doesn't work because of FUSE limitations // Spawn a new process using the APPIMAGE env variable const subprocess = cp.spawn(APPIMAGE, { detached: true, stdio: 'ignore' }) subprocess.unref() } app.quit() }) nativeTheme.on('updated', () => { const allWindows = BrowserWindow.getAllWindows() allWindows.forEach((window) => { window.webContents.send(IpcChannels.NATIVE_THEME_UPDATE, nativeTheme.shouldUseDarkColors) }) }) ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { session.defaultSession.setProxy({ proxyRules: url }) session.defaultSession.closeAllConnections() }) ipcMain.on(IpcChannels.DISABLE_PROXY, () => { session.defaultSession.setProxy({}) session.defaultSession.closeAllConnections() }) ipcMain.on(IpcChannels.OPEN_EXTERNAL_LINK, (_, url) => { if (typeof url === 'string') shell.openExternal(url) }) ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, () => { // we should switch to getPreferredSystemLanguages at some point and iterate through until we find a supported locale return app.getSystemLocale() }) ipcMain.handle(IpcChannels.GET_USER_DATA_PATH, () => { return app.getPath('userData') }) ipcMain.on(IpcChannels.GET_USER_DATA_PATH_SYNC, (event) => { event.returnValue = app.getPath('userData') }) ipcMain.handle(IpcChannels.GET_PICTURES_PATH, () => { return app.getPath('pictures') }) ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async ({ sender }, options) => { const senderWindow = findSenderWindow(sender) if (senderWindow) { return await dialog.showOpenDialog(senderWindow, options) } return await dialog.showOpenDialog(options) }) ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async ({ sender }, options) => { const senderWindow = findSenderWindow(sender) if (senderWindow) { return await dialog.showSaveDialog(senderWindow, options) } return await dialog.showSaveDialog(options) }) function findSenderWindow(sender) { return BrowserWindow.getAllWindows().find((window) => { return window.webContents.id === sender.id }) } ipcMain.on(IpcChannels.STOP_POWER_SAVE_BLOCKER, (_, id) => { powerSaveBlocker.stop(id) }) ipcMain.handle(IpcChannels.START_POWER_SAVE_BLOCKER, (_) => { return powerSaveBlocker.start('prevent-display-sleep') }) ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, (_e, { windowStartupUrl = null, searchQueryText = null } = { }) => { createWindow({ replaceMainWindow: false, showWindowNow: true, windowStartupUrl: windowStartupUrl, searchQueryText: searchQueryText }) }) ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, (_, payload) => { const child = cp.spawn(payload.executable, payload.args, { detached: true, stdio: 'ignore' }) child.unref() }) // ************************************************* // // DB related IPC calls // *********** // // Settings ipcMain.handle(IpcChannels.DB_SETTINGS, async (event, { action, data }) => { try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.settings.find() case DBActions.GENERAL.UPSERT: await baseHandlers.settings.upsert(data._id, data.value) syncOtherWindows( IpcChannels.SYNC_SETTINGS, event, { event: SyncEvents.GENERAL.UPSERT, data } ) switch (data._id) { // Update app menu on related setting update case 'hideTrendingVideos': case 'hidePopularVideos': case 'backendFallback': case 'backendPreference': case 'hidePlaylists': await setMenu() break default: // Do nothing for unmatched settings } return null default: // eslint-disable-next-line no-throw-literal throw 'invalid settings db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // History ipcMain.handle(IpcChannels.DB_HISTORY, async (event, { action, data }) => { try { switch (action) { case DBActions.GENERAL.FIND: return await baseHandlers.history.find() case DBActions.GENERAL.UPSERT: await baseHandlers.history.upsert(data) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.HISTORY.UPDATE_WATCH_PROGRESS: await baseHandlers.history.updateWatchProgress(data.videoId, data.watchProgress) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.HISTORY.UPDATE_WATCH_PROGRESS, data } ) return null case DBActions.HISTORY.UPDATE_PLAYLIST: await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId, data.lastViewedPlaylistType, data.lastViewedPlaylistItemId) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.HISTORY.UPDATE_PLAYLIST, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.history.delete(data) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.history.deleteAll() syncOtherWindows( IpcChannels.SYNC_HISTORY, event, { event: SyncEvents.GENERAL.DELETE_ALL } ) return null case DBActions.GENERAL.PERSIST: baseHandlers.history.persist() return null default: // eslint-disable-next-line no-throw-literal throw 'invalid history db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // Profiles ipcMain.handle(IpcChannels.DB_PROFILES, async (event, { action, data }) => { try { switch (action) { case DBActions.GENERAL.CREATE: { const newProfile = await baseHandlers.profiles.create(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.CREATE, data: newProfile } ) return newProfile } case DBActions.GENERAL.FIND: return await baseHandlers.profiles.find() case DBActions.GENERAL.UPSERT: await baseHandlers.profiles.upsert(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.profiles.delete(data) syncOtherWindows( IpcChannels.SYNC_PROFILES, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.GENERAL.PERSIST: baseHandlers.profiles.persist() return null default: // eslint-disable-next-line no-throw-literal throw 'invalid profile db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // // Playlists // ! NOTE: A lot of these actions are currently not used for anything // As such, only the currently used actions have synchronization implemented // The remaining should have it implemented only when playlists // get fully implemented into the app ipcMain.handle(IpcChannels.DB_PLAYLISTS, async (event, { action, data }) => { try { switch (action) { case DBActions.GENERAL.CREATE: await baseHandlers.playlists.create(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.CREATE, data } ) return null case DBActions.GENERAL.FIND: return await baseHandlers.playlists.find() case DBActions.GENERAL.UPSERT: await baseHandlers.playlists.upsert(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.UPSERT, data } ) return null case DBActions.PLAYLISTS.UPSERT_VIDEO: await baseHandlers.playlists.upsertVideoByPlaylistId(data._id, data.videoData) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.UPSERT_VIDEO, data } ) return null case DBActions.PLAYLISTS.UPSERT_VIDEOS: await baseHandlers.playlists.upsertVideosByPlaylistId(data._id, data.videos) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.UPSERT_VIDEOS, data } ) return null case DBActions.GENERAL.DELETE: await baseHandlers.playlists.delete(data) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.GENERAL.DELETE, data } ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_ID: await baseHandlers.playlists.deleteVideoIdByPlaylistId({ _id: data._id, videoId: data.videoId, playlistItemId: data.playlistItemId, }) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, { event: SyncEvents.PLAYLISTS.DELETE_VIDEO, data } ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_IDS: await baseHandlers.playlists.deleteVideoIdsByPlaylistId(data._id, data.videoIds) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.PLAYLISTS.DELETE_ALL_VIDEOS: await baseHandlers.playlists.deleteAllVideosByPlaylistId(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.GENERAL.DELETE_MULTIPLE: await baseHandlers.playlists.deleteMultiple(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.GENERAL.DELETE_ALL: await baseHandlers.playlists.deleteAll() // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null default: // eslint-disable-next-line no-throw-literal throw 'invalid playlist db action' } } catch (err) { if (typeof err === 'string') throw err else throw err.toString() } }) // *********** // function syncOtherWindows(channel, event, payload) { const otherWindows = BrowserWindow.getAllWindows().filter((window) => { return window.webContents.id !== event.sender.id }) for (const window of otherWindows) { window.webContents.send(channel, payload) } } // ************************************************* // let resourcesCleanUpDone = false app.on('window-all-closed', () => { // Clean up resources (datastores' compaction + Electron cache and storage data clearing) cleanUpResources().finally(() => { if (process.platform !== 'darwin') { app.quit() } }) }) if (process.platform === 'darwin') { // `window-all-closed` doesn't fire for Cmd+Q // https://www.electronjs.org/docs/latest/api/app#event-window-all-closed // This is also fired when `app.quit` called // Not using `before-quit` since that one is fired before windows are closed app.on('will-quit', e => { // Let app quit when the cleanup is finished if (resourcesCleanUpDone) { return } e.preventDefault() cleanUpResources().finally(() => { // Quit AFTER the resources cleanup is finished // Which calls the listener again, which is why we have the variable app.quit() }) }) } async function cleanUpResources() { if (resourcesCleanUpDone) { return } await Promise.allSettled([ baseHandlers.compactAllDatastores(), session.defaultSession.clearCache(), session.defaultSession.clearStorageData({ storages: [ 'appcache', 'cookies', 'filesystem', 'indexdb', 'shadercache', 'websql', 'serviceworkers', 'cachestorage' ] }) ]) resourcesCleanUpDone = true } // MacOS event // https://www.electronjs.org/docs/latest/api/app#event-activate-macos app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) /* * Callback when processing a freetube:// link (macOS) */ app.on('open-url', (event, url) => { event.preventDefault() if (mainWindow && mainWindow.webContents) { mainWindow.webContents.send('openUrl', baseUrl(url)) } else { startupUrl = baseUrl(url) } }) /* * Check if an argument was passed and send it over to the GUI (Linux / Windows). * Remove freetube:// protocol if present */ const url = getLinkUrl(process.argv) if (url) { startupUrl = url } function baseUrl(arg) { let newArg = arg.replace('freetube://', '') // add support for authority free url .replace('freetube:', '') // fix for Qt URL, like `freetube://https//www.youtube.com/watch?v=...` // For details see https://github.com/FreeTubeApp/FreeTube/pull/3119 if (newArg.startsWith('https') && newArg.charAt(5) !== ':') { newArg = 'https:' + newArg.substring(5) } return newArg } function getLinkUrl(argv) { if (argv.length > 1) { return baseUrl(argv[argv.length - 1]) } else { return null } } /* * Auto Updater * * Uncomment the following code below and install `electron-updater` to * support auto updating. Code Signing with a valid certificate is required. * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating */ /* import { autoUpdater } from 'electron-updater' autoUpdater.on('update-downloaded', () => { autoUpdater.quitAndInstall() }) app.on('ready', () => { if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() }) */ function navigateTo(path, browserWindow) { if (browserWindow == null) { return } browserWindow.webContents.send( 'change-view', { route: path } ) } async function setMenu() { const sidenavSettings = baseHandlers.settings._findSidenavSettings() const hideTrendingVideos = (await sidenavSettings.hideTrendingVideos)?.value const hidePopularVideos = (await sidenavSettings.hidePopularVideos)?.value const backendFallback = (await sidenavSettings.backendFallback)?.value const backendPreference = (await sidenavSettings.backendPreference)?.value const hidePlaylists = (await sidenavSettings.hidePlaylists)?.value const template = [ { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+N', click: (_menuItem, _browserWindow, _event) => { createWindow({ replaceMainWindow: false, showWindowNow: true }) }, type: 'normal' }, { type: 'separator' }, { label: 'Preferences', accelerator: 'CmdOrCtrl+,', click: (_menuItem, browserWindow, _event) => { navigateTo('/settings', browserWindow) }, type: 'normal' }, { type: 'separator' }, { role: 'quit' } ] }, { label: 'Edit', submenu: [ { role: 'cut' }, { role: 'copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, { role: 'paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, { role: 'pasteandmatchstyle' }, { role: 'delete' }, { role: 'selectall' } ] }, { label: 'View', submenu: [ { role: 'reload' }, { role: 'forcereload', accelerator: 'CmdOrCtrl+Shift+R' }, { role: 'toggledevtools' }, { role: 'toggledevtools', accelerator: 'f12', visible: false }, { label: 'Enter Inspect Element Mode', accelerator: 'CmdOrCtrl+Shift+C', click: (_, window) => { if (window.webContents.isDevToolsOpened()) { window.devToolsWebContents.executeJavaScript('DevToolsAPI.enterInspectElementMode()') } else { window.webContents.once('devtools-opened', () => { window.devToolsWebContents.executeJavaScript('DevToolsAPI.enterInspectElementMode()') }) window.webContents.openDevTools() } } }, { type: 'separator' }, { role: 'resetzoom' }, { role: 'resetzoom', accelerator: 'CmdOrCtrl+num0', visible: false }, { role: 'zoomin', accelerator: 'CmdOrCtrl+Plus' }, { role: 'zoomin', accelerator: 'CmdOrCtrl+=', visible: false }, { role: 'zoomin', accelerator: 'CmdOrCtrl+numadd', visible: false }, { role: 'zoomout' }, { role: 'zoomout', accelerator: 'CmdOrCtrl+numsub', visible: false }, { type: 'separator' }, { role: 'togglefullscreen' }, { type: 'separator' }, { label: 'Back', accelerator: 'Alt+Left', click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.send( 'history-back', ) }, type: 'normal', }, { label: 'Forward', accelerator: 'Alt+Right', click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } browserWindow.webContents.send( 'history-forward', ) }, type: 'normal', }, ] }, { label: 'Navigate', submenu: [ { label: 'Subscriptions', click: (_menuItem, browserWindow, _event) => { navigateTo('/subscriptions', browserWindow) }, type: 'normal' }, { label: 'Channels', click: (_menuItem, browserWindow, _event) => { navigateTo('/subscribedchannels', browserWindow) }, type: 'normal' }, !hideTrendingVideos && { label: 'Trending', click: (_menuItem, browserWindow, _event) => { navigateTo('/trending', browserWindow) }, type: 'normal' }, (!hidePopularVideos && (backendFallback || backendPreference === 'invidious')) && { label: 'Most Popular', click: (_menuItem, browserWindow, _event) => { navigateTo('/popular', browserWindow) }, type: 'normal' }, !hidePlaylists && { label: 'Playlists', click: (_menuItem, browserWindow, _event) => { navigateTo('/userplaylists', browserWindow) }, type: 'normal' }, { label: 'History', // MacOS: Command + Y // Other OS: Ctrl + H accelerator: process.platform === 'darwin' ? 'Cmd+Y' : 'Ctrl+H', click: (_menuItem, browserWindow, _event) => { navigateTo('/history', browserWindow) }, type: 'normal' }, { label: 'Profile Manager', click: (_menuItem, browserWindow, _event) => { navigateTo('/settings/profile/', browserWindow) }, type: 'normal' }, ].filter((v) => v !== false), }, { role: 'window', submenu: [ { role: 'minimize' }, { role: 'close' } ] } ] if (process.platform === 'darwin') { template.unshift({ label: app.getName(), submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }) template.push( { role: 'window' }, { role: 'help' }, { role: 'services' } ) } const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } }