mirror of https://github.com/FreeTubeApp/FreeTube
1411 lines
43 KiB
JavaScript
1411 lines
43 KiB
JavaScript
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')
|
|
}
|
|
|
|
// Work around for context menus in the devtools being displayed behind the window
|
|
// https://github.com/electron/electron/issues/38790
|
|
app.commandLine.appendSwitch('disable-features', 'WidgetLayering')
|
|
|
|
// 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)
|
|
}
|
|
}
|