diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index fa5d16db56c0d..15d9b356b842e 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -18,12 +18,14 @@ const web = process.argv.indexOf('--web') !== -1 let mainConfig let rendererConfig +let botGuardScriptConfig let webConfig let SHAKA_LOCALES_TO_BE_BUNDLED if (!web) { mainConfig = require('./webpack.main.config') rendererConfig = require('./webpack.renderer.config') + botGuardScriptConfig = require('./webpack.botGuardScript.config') SHAKA_LOCALES_TO_BE_BUNDLED = rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED delete rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED @@ -98,6 +100,14 @@ function setupNotifyLocaleUpdate(compiler, devServer) { }) } +function startBotGuardScript() { + webpack(botGuardScriptConfig, (err) => { + if (err) console.error(err) + + console.log(`\nCompiled ${botGuardScriptConfig.name} script!`) + }) +} + function startMain() { const compiler = webpack(mainConfig) const { name } = compiler @@ -196,7 +206,10 @@ function startWeb () { }) } if (!web) { - startRenderer(startMain) + startRenderer(() => { + startBotGuardScript() + startMain() + }) } else { startWeb() } diff --git a/_scripts/injectAllowedPaths.mjs b/_scripts/injectAllowedPaths.mjs index 2310bf49839b1..e5c1ce2293232 100644 --- a/_scripts/injectAllowedPaths.mjs +++ b/_scripts/injectAllowedPaths.mjs @@ -21,6 +21,8 @@ const paths = readdirSync(distDirectory, { // disallow the renderer process/browser windows to read the main.js file dirent.name !== 'main.js' && dirent.name !== 'main.js.LICENSE.txt' && + // disallow the renderer process/browser windows to read the botGuardScript.js file + dirent.name !== 'botGuardScript.js' && // filter out any web build files, in case the dist directory contains a web build !dirent.parentPath.startsWith(webDirectory) }) diff --git a/_scripts/webpack.botGuardScript.config.js b/_scripts/webpack.botGuardScript.config.js new file mode 100644 index 0000000000000..9b66ffc370f21 --- /dev/null +++ b/_scripts/webpack.botGuardScript.config.js @@ -0,0 +1,23 @@ +const path = require('path') + +/** @type {import('webpack').Configuration} */ +module.exports = { + name: 'botGuardScript', + // Always use production mode, as we use the output as a function body and the debug output doesn't work for that + mode: 'production', + devtool: false, + target: 'web', + entry: { + botGuardScript: path.join(__dirname, '../src/botGuardScript.js'), + }, + output: { + filename: '[name].js', + path: path.join(__dirname, '../dist'), + library: { + type: 'modern-module' + } + }, + experiments: { + outputModule: true + } +} diff --git a/package.json b/package.json index 1b5ba57d8176e..6b9313784d29d 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,11 @@ "lint-style": "stylelint \"**/*.{css,scss}\"", "lint-style-fix": "stylelint --fix \"**/*.{css,scss}\"", "lint-yml": "eslint --config eslint.config.mjs \"**/*.yml\" \"**/*.yaml\"", - "pack": "run-p pack:main pack:renderer && node _scripts/injectAllowedPaths.mjs", + "pack": "run-p pack:main pack:renderer pack:botGuardScript && node _scripts/injectAllowedPaths.mjs", "pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js", "pack:renderer": "webpack --mode=production --node-env=production --config _scripts/webpack.renderer.config.js", "pack:web": "webpack --mode=production --node-env=production --config _scripts/webpack.web.config.js", + "pack:botGuardScript": "webpack --config _scripts/webpack.botGuardScript.config.js", "postinstall": "run-s --silent rebuild:electron patch-shaka", "prettier": "prettier --write \"{src,_scripts}/**/*.{js,vue}\"", "rebuild:electron": "electron-builder install-app-deps", @@ -61,6 +62,7 @@ "@fortawesome/vue-fontawesome": "^2.0.10", "@seald-io/nedb": "^4.0.4", "autolinker": "^4.0.1", + "bgutils-js": "^3.1.0", "electron-context-menu": "^4.0.4", "lodash.debounce": "^4.0.8", "marked": "^15.0.4", diff --git a/src/botGuardScript.js b/src/botGuardScript.js new file mode 100644 index 0000000000000..11da8855a6472 --- /dev/null +++ b/src/botGuardScript.js @@ -0,0 +1,37 @@ +import { BG } from 'bgutils-js' + +// This script has it's own webpack config, as it gets passed as a string to Electron's evaluateJavaScript function +// in src/main/poTokenGenerator.js +export default async function(visitorData) { + const requestKey = 'O43z0dpjhgX20SCx4KAo' + + const bgConfig = { + fetch: (input, init) => fetch(input, init), + requestKey, + globalObj: window, + identifier: visitorData + } + + const challenge = await BG.Challenge.create(bgConfig) + + if (!challenge) { + throw new Error('Could not get challenge') + } + + const interpreterJavascript = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue + + if (interpreterJavascript) { + // eslint-disable-next-line no-new-func + new Function(interpreterJavascript)() + } else { + console.warn('Unable to load VM.') + } + + const poTokenResult = await BG.PoToken.generate({ + program: challenge.program, + globalName: challenge.globalName, + bgConfig + }) + + return poTokenResult.poToken +} diff --git a/src/constants.js b/src/constants.js index 80e0c0010ca49..e313136b168e2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -42,7 +42,9 @@ const IpcChannels = { PLAYER_CACHE_GET: 'player-cache-get', PLAYER_CACHE_SET: 'player-cache-set', - SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization' + SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization', + + GENERATE_PO_TOKEN: 'generate-po-token', } const DBActions = { @@ -116,19 +118,37 @@ const SyncEvents = { }, } -// note: the multi-key shortcut values are currently just for display use in action titles +/* + DEV NOTE: Duplicate any and all changes made here to our [official documentation site here](https://github.com/FreeTubeApp/FreeTube-Docs/blob/master/usage/keyboard-shortcuts.md) + to have them reflect on the [keyboard shortcut reference webpage](https://docs.freetubeapp.io/usage/keyboard-shortcuts). + Please also update the [keyboard shortcut modal](src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue) +*/ const KeyboardShortcuts = { APP: { GENERAL: { + SHOW_SHORTCUTS: 'shift+?', HISTORY_BACKWARD: 'alt+arrowleft', HISTORY_FORWARD: 'alt+arrowright', - NEW_WINDOW: 'ctrl+N', + FULLSCREEN: 'f11', NAVIGATE_TO_SETTINGS: 'ctrl+,', NAVIGATE_TO_HISTORY: 'ctrl+H', NAVIGATE_TO_HISTORY_MAC: 'cmd+Y', + NEW_WINDOW: 'ctrl+N', + MINIMIZE_WINDOW: 'ctrl+M', + CLOSE_WINDOW: 'ctrl+W', + RESTART_WINDOW: 'ctrl+R', + FORCE_RESTART_WINDOW: 'ctrl+shift+R', + TOGGLE_DEVTOOLS: 'ctrl+shift+I', + FOCUS_SEARCH: 'alt+D', + SEARCH_IN_NEW_WINDOW: 'shift+enter', + RESET_ZOOM: 'ctrl+0', + ZOOM_IN: 'ctrl+plus', + ZOOM_OUT: 'ctrl+-' + }, SITUATIONAL: { - REFRESH: 'r' + REFRESH: 'r', + FOCUS_SECONDARY_SEARCH: 'ctrl+F' }, }, VIDEO_PLAYER: { @@ -152,10 +172,11 @@ const KeyboardShortcuts = { SMALL_FAST_FORWARD: 'arrowright', DECREASE_VIDEO_SPEED: 'o', INCREASE_VIDEO_SPEED: 'p', - LAST_FRAME: ',', - NEXT_FRAME: '.', + SKIP_N_TENTHS: '0..9', LAST_CHAPTER: 'ctrl+arrowleft', NEXT_CHAPTER: 'ctrl+arrowright', + LAST_FRAME: ',', + NEXT_FRAME: '.', } }, } diff --git a/src/main/index.js b/src/main/index.js index 6814db3ee807c..a6b89b903d481 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -22,6 +22,7 @@ import { brotliDecompress } from 'zlib' import contextMenu from 'electron-context-menu' import packageDetails from '../../package.json' +import { generatePoToken } from './poTokenGenerator' const brotliDecompressAsync = promisify(brotliDecompress) @@ -427,24 +428,9 @@ function runApp() { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' - // Make iOS requests work and look more realistic - if (requestHeaders['x-youtube-client-name'] === '5') { - delete requestHeaders.Referer - delete requestHeaders.Origin - delete requestHeaders['Sec-Fetch-Site'] - delete requestHeaders['Sec-Fetch-Mode'] - delete requestHeaders['Sec-Fetch-Dest'] - delete requestHeaders['sec-ch-ua'] - delete requestHeaders['sec-ch-ua-mobile'] - delete requestHeaders['sec-ch-ua-platform'] - - requestHeaders['User-Agent'] = requestHeaders['x-user-agent'] - delete requestHeaders['x-user-agent'] - } else { - requestHeaders['Sec-Fetch-Site'] = 'same-origin' - requestHeaders['Sec-Fetch-Mode'] = 'same-origin' - requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' - } + requestHeaders['Sec-Fetch-Site'] = 'same-origin' + requestHeaders['Sec-Fetch-Mode'] = 'same-origin' + requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' } else if (urlObj.origin.endsWith('.googlevideo.com') && urlObj.pathname === '/videoplayback') { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' @@ -884,6 +870,10 @@ function runApp() { }) }) + ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, visitorData) => { + return generatePoToken(visitorData) + }) + ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { session.defaultSession.setProxy({ proxyRules: url diff --git a/src/main/poTokenGenerator.js b/src/main/poTokenGenerator.js new file mode 100644 index 0000000000000..5871d7483975d --- /dev/null +++ b/src/main/poTokenGenerator.js @@ -0,0 +1,140 @@ +import { session, WebContentsView } from 'electron' +import { readFile } from 'fs/promises' +import { join } from 'path' + +/** + * Generates a poToken (proof of origin token) using `bgutils-js`. + * The script to generate it is `src/botGuardScript.js` + * + * This is intentionally split out into it's own thing, with it's own temporary in-memory session, + * as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests. + * So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data. + * @param {string} visitorData + * @returns {Promise} + */ +export async function generatePoToken(visitorData) { + const sessionUuid = crypto.randomUUID() + + const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false }) + + theSession.setPermissionCheckHandler(() => false) + // eslint-disable-next-line n/no-callback-literal + theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false)) + + theSession.setUserAgent( + theSession.getUserAgent() + .split(' ') + .filter(part => !part.includes('Electron')) + .join(' ') + ) + + const webContentsView = new WebContentsView({ + webPreferences: { + backgroundThrottling: false, + safeDialogs: true, + sandbox: true, + v8CacheOptions: 'none', + session: theSession, + offscreen: true + } + }) + + webContentsView.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + + webContentsView.webContents.setAudioMuted(true) + webContentsView.setBounds({ + x: 0, + y: 0, + width: 1920, + height: 1080 + }) + + webContentsView.webContents.debugger.attach() + + await webContentsView.webContents.loadURL('data:text/html,', { + baseURLForDataURL: 'https://www.youtube.com' + }) + + await webContentsView.webContents.debugger.sendCommand('Emulation.setUserAgentOverride', { + userAgent: theSession.getUserAgent(), + acceptLanguage: 'en-US', + platform: 'Win32', + userAgentMetadata: { + brands: [ + { + brand: 'Not/A)Brand', + version: '99' + }, + { + brand: 'Chromium', + version: process.versions.chrome.split('.')[0] + } + ], + fullVersionList: [ + { + brand: 'Not/A)Brand', + version: '99.0.0.0' + }, + { + brand: 'Chromium', + version: process.versions.chrome + } + ], + platform: 'Windows', + platformVersion: '10.0.0', + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false + } + }) + + await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { + width: 1920, + height: 1080, + deviceScaleFactor: 1, + mobile: false, + screenWidth: 1920, + screenHeight: 1080, + positionX: 0, + positionY: 0, + screenOrientation: { + type: 'landscapePrimary', + angle: 0 + } + }) + + const script = await getScript(visitorData) + + const response = await webContentsView.webContents.executeJavaScript(script) + + webContentsView.webContents.close({ waitForBeforeUnload: false }) + await theSession.closeAllConnections() + + return response +} + +let cachedScript + +/** + * @param {string} visitorData + */ +async function getScript(visitorData) { + if (!cachedScript) { + const pathToScript = process.env.NODE_ENV === 'development' + ? join(__dirname, '../../dist/botGuardScript.js') + /* eslint-disable-next-line n/no-path-concat */ + : `${__dirname}/botGuardScript.js` + + const content = await readFile(pathToScript, 'utf-8') + + const match = content.match(/export{(\w+) as default};/) + + const functionName = match[1] + + cachedScript = content.replace(match[0], `;${functionName}("FT_VISITOR_DATA")`) + } + + return cachedScript.replace('FT_VISITOR_DATA', visitorData) +} diff --git a/src/renderer/App.js b/src/renderer/App.js index d8134e14b46d0..c56a7394f2625 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -10,6 +10,7 @@ import FtToast from './components/ft-toast/ft-toast.vue' import FtProgressBar from './components/FtProgressBar/FtProgressBar.vue' import FtPlaylistAddVideoPrompt from './components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.vue' import FtCreatePlaylistPrompt from './components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue' +import FtKeyboardShortcutPrompt from './components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue' import FtSearchFilters from './components/FtSearchFilters/FtSearchFilters.vue' import { marked } from 'marked' import { IpcChannels } from '../constants' @@ -32,7 +33,8 @@ export default defineComponent({ FtProgressBar, FtPlaylistAddVideoPrompt, FtCreatePlaylistPrompt, - FtSearchFilters + FtSearchFilters, + FtKeyboardShortcutPrompt, }, data: function () { return { @@ -71,6 +73,9 @@ export default defineComponent({ checkForBlogPosts: function () { return this.$store.getters.getCheckForBlogPosts }, + isKeyboardShortcutPromptShown: function () { + return this.$store.getters.getIsKeyboardShortcutPromptShown + }, showAddToPlaylistPrompt: function () { return this.$store.getters.getShowAddToPlaylistPrompt }, @@ -348,6 +353,10 @@ export default defineComponent({ }, handleKeyboardShortcuts: function (event) { + if (event.shiftKey && event.key === '?') { + this.$store.commit('setIsKeyboardShortcutPromptShown', !this.isKeyboardShortcutPromptShown) + } + if (event.altKey) { switch (event.key) { case 'D': @@ -578,6 +587,8 @@ export default defineComponent({ 'fetchInvidiousInstancesFromFile', 'setRandomCurrentInvidiousInstance', 'setupListenersToSyncWindows', + 'hideKeyboardShortcutPrompt', + 'showKeyboardShortcutPrompt', 'updateBaseTheme', 'updateMainColor', 'updateSecColor', diff --git a/src/renderer/App.vue b/src/renderer/App.vue index a0235a5854898..f4ac948e85e41 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -50,6 +50,9 @@ + diff --git a/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css new file mode 100644 index 0000000000000..9f2f71216d56e --- /dev/null +++ b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css @@ -0,0 +1,100 @@ + + +.keyboardShortcutPrompt { + max-inline-size: 80%; +} + +.titleAndCloseButton { + display: flex; + justify-content: space-between; + margin-block: 20px; + margin-inline-start: 10px; +} + +h2 { + font-size: 25px; +} + +h3 { + font-size: 20px; +} + +h2, +h3 { + margin-block: 0.25em; + inline-size: fit-content; + font-weight: bold; +} + +.center { + text-align: center; +} + +.primarySection { + inline-size: 100%; +} + +.primarySections, +.primarySection { + display: flex; + flex-flow: row wrap; + gap: 30px; + justify-content: space-evenly; +} + +.secondarySection { + flex: 0 0 500px; +} + +.labelsAndShortcuts { + display: table; + table-layout: auto; + border-collapse: collapse; +} + +.labelAndShortcut { + display: table-row; + font-size: 16px; +} + +.labelAndShortcut + .labelAndShortcut { + border-block-start: 1px solid var(--primary-shadow-color); +} + +.label, +.shortcut { + padding-block: 0.25em; +} + +.label { + display: table-cell; + padding-inline-end: 2em; + inline-size: 300px; + min-inline-size: 300px; +} + +.shortcut { + font-family: monospace; + display: table-cell; + vertical-align: middle; + text-transform: capitalize; +} + +:deep(.promptCard) { + overflow-x: hidden; +} + +@media only screen and (width <= 600px) { + .label { + min-inline-size: 200px; + inline-size: 200px; + } + + .secondarySection { + flex: 0; + } + + .primarySection { + justify-content: flex-start; + } +} diff --git a/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue new file mode 100644 index 0000000000000..409f0d5ef9575 --- /dev/null +++ b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue @@ -0,0 +1,185 @@ + + + + +