From c3f1a0d654a168538eb406232759941051c55ad0 Mon Sep 17 00:00:00 2001 From: PikachuEXE Date: Thu, 16 Oct 2025 06:01:43 +0800 Subject: [PATCH 1/4] Fix video playback by using video ID bound poToken (#8137) * ! Fix video playback by using video ID bound poToken * Clean up now unused session PO token code --------- Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> --- src/botGuardScript.js | 10 +- src/constants.js | 2 +- src/main/index.js | 4 +- src/main/poTokenGenerator.js | 14 +- src/preload/interface.js | 301 ++++++++++++++++++++++++++++++ src/renderer/helpers/api/local.js | 17 +- 6 files changed, 320 insertions(+), 28 deletions(-) create mode 100644 src/preload/interface.js diff --git a/src/botGuardScript.js b/src/botGuardScript.js index aac39ec88749d..c648703ac87c6 100644 --- a/src/botGuardScript.js +++ b/src/botGuardScript.js @@ -6,10 +6,9 @@ import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js' /** * Based on: https://github.com/LuanRT/BgUtils/blob/main/examples/node/innertube-challenge-fetcher-example.ts * @param {string} videoId - * @param {string} visitorData * @param {import('youtubei.js').Session['context']} context */ -export default async function (videoId, visitorData, context) { +export default async function (videoId, context) { const requestKey = 'O43z0dpjhgX20SCx4KAo' const challengeResponse = await fetch( @@ -19,7 +18,7 @@ export default async function (videoId, visitorData, context) { headers: { Accept: '*/*', 'Content-Type': 'application/json', - 'X-Goog-Visitor-Id': visitorData, + 'X-Goog-Visitor-Id': context.client.visitorData, 'X-Youtube-Client-Version': context.client.clientVersion, 'X-Youtube-Client-Name': '1' }, @@ -83,8 +82,5 @@ export default async function (videoId, visitorData, context) { const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput) - const contentPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(videoId) - const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(visitorData) - - return { contentPoToken, sessionPoToken } + return await integrityTokenBasedMinter.mintAsWebsafeString(videoId) } diff --git a/src/constants.js b/src/constants.js index 2271764192348..66a2f73751195 100644 --- a/src/constants.js +++ b/src/constants.js @@ -47,7 +47,7 @@ const IpcChannels = { SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization', - GENERATE_PO_TOKENS: 'generate-po-tokens', + GENERATE_PO_TOKEN: 'generate-po-token', WRITE_SCREENSHOT: 'write-screenshot', } diff --git a/src/main/index.js b/src/main/index.js index c7934becd7b53..04759f6e1f930 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -891,8 +891,8 @@ function runApp() { }) }) - ipcMain.handle(IpcChannels.GENERATE_PO_TOKENS, (_, videoId, visitorData, context) => { - return generatePoToken(videoId, visitorData, context, proxyUrl) + ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, videoId, context) => { + return generatePoToken(videoId, context, proxyUrl) }) ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { diff --git a/src/main/poTokenGenerator.js b/src/main/poTokenGenerator.js index 01211766aa27d..5a266b1abf47d 100644 --- a/src/main/poTokenGenerator.js +++ b/src/main/poTokenGenerator.js @@ -3,19 +3,18 @@ import { readFile } from 'fs/promises' import { join } from 'path' /** - * Generates a poToken (proof of origin token) using `bgutils-js`. + * Generates a content-bound 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} videoId - * @param {string} visitorData * @param {string} context * @param {string|undefined} proxyUrl - * @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>} + * @returns {Promise} */ -export async function generatePoToken(videoId, visitorData, context, proxyUrl) { +export async function generatePoToken(videoId, context, proxyUrl) { const sessionUuid = crypto.randomUUID() const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false }) @@ -96,7 +95,7 @@ export async function generatePoToken(videoId, visitorData, context, proxyUrl) { } }) - const script = await getScript(videoId, visitorData, context) + const script = await getScript(videoId, context) const response = await webContentsView.webContents.executeJavaScript(script) @@ -110,10 +109,9 @@ let cachedScript /** * @param {string} videoId - * @param {string} visitorData * @param {string} context */ -async function getScript(videoId, visitorData, context) { +async function getScript(videoId, context) { if (!cachedScript) { const pathToScript = process.env.NODE_ENV === 'development' ? join(__dirname, '../../dist/botGuardScript.js') @@ -129,5 +127,5 @@ async function getScript(videoId, visitorData, context) { cachedScript = content.replace(match[0], `;${functionName}(FT_PARAMS)`) } - return cachedScript.replace('FT_PARAMS', `"${videoId}","${visitorData}",${context}`) + return cachedScript.replace('FT_PARAMS', `"${videoId}",${context}`) } diff --git a/src/preload/interface.js b/src/preload/interface.js new file mode 100644 index 0000000000000..d9a2b919b88f5 --- /dev/null +++ b/src/preload/interface.js @@ -0,0 +1,301 @@ +import { ipcRenderer, webFrame } from 'electron/renderer' +import { IpcChannels } from '../constants.js' + +/** + * Linux fix for dynamically updating theme preference, this works on + * all systems running the electron app. + */ +ipcRenderer.on(IpcChannels.NATIVE_THEME_UPDATE, (_, shouldUseDarkColors) => { + webFrame.executeJavaScript(`document.body.dataset.systemTheme = "${shouldUseDarkColors ? 'dark' : 'light'}"`).catch() +}) + +let currentUpdateSearchInputTextListener + +export default { + /** + * @returns {Promise} + */ + getSystemLocale: () => { + return ipcRenderer.invoke(IpcChannels.GET_SYSTEM_LOCALE) + }, + + /** + * @param {string} path + * @param {Record | null | undefined} query + * @param {string | null | undefined} searchQueryText + */ + openInNewWindow: (path, query, searchQueryText) => { + ipcRenderer.send(IpcChannels.CREATE_NEW_WINDOW, path, query, searchQueryText) + }, + + /** + * @param {string} url + */ + enableProxy: (url) => { + ipcRenderer.send(IpcChannels.ENABLE_PROXY, url) + }, + + disableProxy: () => { + ipcRenderer.send(IpcChannels.DISABLE_PROXY) + }, + + /** + * @param {string} authorization + * @param {string} url + */ + setInvidiousAuthorization: (authorization, url) => { + ipcRenderer.send(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, authorization, url) + }, + + clearInvidiousAuthorization: () => { + ipcRenderer.send(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, null) + }, + + startPowerSaveBlocker: () => { + ipcRenderer.send(IpcChannels.START_POWER_SAVE_BLOCKER) + }, + + stopPowerSaveBlocker: () => { + ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER) + }, + + /** + * @returns {Promise} + */ + getReplaceHttpCache: () => { + return ipcRenderer.invoke(IpcChannels.GET_REPLACE_HTTP_CACHE) + }, + + toggleReplaceHttpCache: () => { + ipcRenderer.send(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE) + }, + + // Allows programmatic toggling of picture-in-picture mode without accompanying user interaction. + // See: https://developer.mozilla.org/en-US/docs/Web/Security/User_activation#transient_activation + requestPiP: () => { + webFrame.executeJavaScript('document.querySelector("video.player")?.ui.getControls().togglePiP()', true).catch() + }, + + // Allows programmatic toggling of fullscreen without accompanying user interaction. + // See: https://developer.mozilla.org/en-US/docs/Web/Security/User_activation#transient_activation + requestFullscreen: () => { + webFrame.executeJavaScript('document.querySelector("video.player")?.ui.getControls().toggleFullScreen()', true).catch() + }, + + /** + * @param {string} key + * @returns {Promise} + */ + playerCacheGet: (key) => { + return ipcRenderer.invoke(IpcChannels.PLAYER_CACHE_GET, key) + }, + + /** + * @param {string} key + * @param {ArrayBuffer} value + */ + playerCacheSet: async (key, value) => { + await ipcRenderer.invoke(IpcChannels.PLAYER_CACHE_SET, key, value) + }, + + /** + * @param {string} videoId + * @param {string} context + * @returns {Promise} + */ + generatePoToken: (videoId, context) => { + return ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, videoId, context) + }, + + /** + * @param {0 | 1} kind + */ + chooseDefaultFolder: (kind) => { + ipcRenderer.send(IpcChannels.CHOOSE_DEFAULT_FOLDER, kind) + }, + + /** + * @param {0 | 1} kind + * @param {string} filename + * @param {ArrayBuffer} contents + */ + writeToDefaultFolder: async (kind, filename, contents) => { + await ipcRenderer.invoke(IpcChannels.WRITE_TO_DEFAULT_FOLDER, kind, filename, contents) + }, + + /** + * @returns {Promise} + */ + getScreenshotFallbackFolder: () => { + return ipcRenderer.invoke(IpcChannels.GET_SCREENSHOT_FALLBACK_FOLDER) + }, + + relaunch: () => { + ipcRenderer.send(IpcChannels.RELAUNCH_REQUEST) + }, + + /** + * @param {string} executable + * @param {string} args + */ + openInExternalPlayer: (executable, args) => { + ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, executable, args) + }, + + /** + * @param {number} factor + */ + setZoomFactor: (factor) => { + if (typeof factor === 'number' && factor > 0) { + webFrame.setZoomFactor(factor) + } + }, + + /** + * @returns {Promise<{ label: string, value: number, active: boolean }[]>} + */ + getNavigationHistory: () => { + return ipcRenderer.invoke(IpcChannels.GET_NAVIGATION_HISTORY) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbSettings: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_SETTINGS, data ? { action, data } : { action }) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbHistory: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_HISTORY, data ? { action, data } : { action }) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbProfiles: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_PROFILES, data ? { action, data } : { action }) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbPlaylists: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_PLAYLISTS, data ? { action, data } : { action }) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbSearchHistory: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_SEARCH_HISTORY, data ? { action, data } : { action }) + }, + + /** + * @param {number} action + * @param {any} [data] + */ + dbSubscriptionCache: (action, data) => { + return ipcRenderer.invoke(IpcChannels.DB_SUBSCRIPTION_CACHE, data ? { action, data } : { action }) + }, + + /** + * @param {(route: string) => void} handler + */ + handleChangeView: (handler) => { + ipcRenderer.on(IpcChannels.CHANGE_VIEW, (_, route) => { + handler(route) + }) + }, + + /** + * @param {(url: string) => void} handler + */ + handleOpenUrl: (handler) => { + ipcRenderer.on(IpcChannels.OPEN_URL, (_, url) => { + handler(url) + }) + ipcRenderer.send(IpcChannels.APP_READY) + }, + + /** + * Pass `null` to clear the handler + * @param {(text: string) => void | null} handler + */ + handleUpdateSearchInputText: (handler) => { + if (currentUpdateSearchInputTextListener) { + ipcRenderer.off(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, currentUpdateSearchInputTextListener) + currentUpdateSearchInputTextListener = undefined + } + + if (handler) { + currentUpdateSearchInputTextListener = (_, text) => { + handler(text) + } + + ipcRenderer.on(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, currentUpdateSearchInputTextListener) + ipcRenderer.send(IpcChannels.SEARCH_INPUT_HANDLING_READY) + } + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncSettings: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_SETTINGS, (_, { event, data }) => { + handler(event, data) + }) + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncHistory: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_HISTORY, (_, { event, data }) => { + handler(event, data) + }) + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncSearchHistory: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_SEARCH_HISTORY, (_, { event, data }) => { + handler(event, data) + }) + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncProfiles: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_PROFILES, (_, { event, data }) => { + handler(event, data) + }) + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncPlaylists: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_PLAYLISTS, (_, { event, data }) => { + handler(event, data) + }) + }, + + /** + * @param {(event: number, data: any) => void} handler + */ + handleSyncSubscriptionCache: (handler) => { + ipcRenderer.on(IpcChannels.SYNC_SUBSCRIPTION_CACHE, (_, { event, data }) => { + handler(event, data) + }) + } +} diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 9eaa67a4a0cad..65f3c453be128 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -285,23 +285,20 @@ export async function getLocalSearchContinuation(continuationData) { export async function getLocalVideoInfo(id) { const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false }) - // based on the videoId (added to the body of the /player request and to caption URLs) + // based on the videoId let contentPoToken - // based on the visitor data (added to the streaming URLs) - let sessionPoToken if (process.env.IS_ELECTRON) { const { ipcRenderer } = require('electron') try { - ({ contentPoToken, sessionPoToken } = await ipcRenderer.invoke( - IpcChannels.GENERATE_PO_TOKENS, + contentPoToken = await ipcRenderer.invoke( + IpcChannels.GENERATE_PO_TOKEN, id, - webInnertube.session.context.client.visitorData, JSON.stringify(webInnertube.session.context) - )) + ) - webInnertube.session.player.po_token = sessionPoToken + webInnertube.session.player.po_token = contentPoToken } catch (error) { console.error('Local API, poToken generation failed', error) throw error @@ -400,9 +397,9 @@ export async function getLocalVideoInfo(id) { let url = info.streaming_data.dash_manifest_url if (url.includes('?')) { - url += `&pot=${encodeURIComponent(sessionPoToken)}&mpd_version=7` + url += `&pot=${encodeURIComponent(contentPoToken)}&mpd_version=7` } else { - url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(sessionPoToken)}/mpd_version/7` + url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(contentPoToken)}/mpd_version/7` } info.streaming_data.dash_manifest_url = url From 0931180dde05c14ae7ace2dcf5a1433f6588a7a4 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Thu, 16 Oct 2025 00:22:01 +0200 Subject: [PATCH 2/4] Add error handling to the deciphering code (#8139) --- src/index.ejs | 5 ++--- src/renderer/helpers/api/local.js | 32 +++++++++++++++++++------------ src/renderer/sigFrameScript.js | 17 +++++++++++----- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/index.ejs b/src/index.ejs index 7a3aa8aa92971..ecdc7f73fe4b9 100644 --- a/src/index.ejs +++ b/src/index.ejs @@ -13,7 +13,7 @@
- <% if (process.env.SUPPORTS_LOCAL_API) { %> + <% if (process.env.IS_ELECTRON) { %> - <% } %> - <% if (!process.env.IS_ELECTRON) { %> + <% } else { %>