import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config"; import { createVoteClient } from "../common/vote-client"; import { createBrowserCredentialStore } from "./src/vote-client-adapter"; const apiUrl = getApiUrl(); const voteDisabledIconName = config.voteDisabledIconName; const defaultIconName = config.defaultIconName; let api; const CHANGELOG_STORAGE_KEY = "lastShownChangelogVersion"; const PENDING_CHANGELOG_STORAGE_KEY = "pendingChangelogVersion"; /** stores extension's global config */ let extConfig = { ...config.defaultExtConfig }; if (isChrome()) api = chrome; else if (isFirefox()) api = browser; const voteClient = createVoteClient({ apiBaseUrl: apiUrl, fetchImpl: (...args) => fetch(...args), credentialStore: createBrowserCredentialStore(api.storage.sync, () => api.runtime?.lastError), cryptoImpl: globalThis.crypto, }); initExtConfig(); voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error)); function broadcastPatreonStatus(authenticated, user, sessionToken) { chrome.tabs.query({}, (tabs) => { tabs .filter((tab) => tab.url && tab.url.includes("youtube.com")) .forEach((tab) => { const maybePromise = chrome.tabs.sendMessage( tab.id, { message: "patreon_status_changed", authenticated, user: authenticated ? user : null, sessionToken: authenticated ? sessionToken : null, }, () => { if (chrome.runtime.lastError) { console.debug("Patreon status broadcast skipped:", chrome.runtime.lastError.message); } }, ); if (maybePromise && typeof maybePromise.catch === "function") { maybePromise.catch((error) => { console.debug("Patreon status broadcast skipped:", error?.message ?? error); }); } }); }); } function handlePatreonAuthComplete(user, sessionToken, done) { if (!user) { done?.(); return; } chrome.storage.sync.set( { patreonAuthenticated: true, patreonUser: user, patreonSessionToken: sessionToken, }, () => { broadcastPatreonStatus(true, user, sessionToken); done?.(); }, ); } function getIdentityApi() { if (isFirefox() && browser.identity) return browser.identity; if (isChrome() && chrome.identity) return chrome.identity; return null; } function launchWebAuthFlow(url) { try { if (isFirefox() && browser.identity && typeof browser.identity.launchWebAuthFlow === "function") { return browser.identity.launchWebAuthFlow({ url, interactive: true }); } } catch (_) {} return new Promise((resolve, reject) => { if (!isChrome() || !chrome.identity || typeof chrome.identity.launchWebAuthFlow !== "function") { reject(new Error("identity API not available")); return; } chrome.identity.launchWebAuthFlow({ url, interactive: true }, (responseUrl) => { const err = chrome.runtime && chrome.runtime.lastError; if (err) reject(err); else resolve(responseUrl); }); }); } function extractOAuthParams(responseUrl) { try { const u = new URL(responseUrl); let code = u.searchParams.get("code"); let state = u.searchParams.get("state"); if (!code && u.hash) { const hashParams = new URLSearchParams(u.hash.startsWith("#") ? u.hash.substring(1) : u.hash); code = hashParams.get("code"); state = state || hashParams.get("state"); } return { code, state }; } catch (_) { return { code: null, state: null }; } } api.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.message === "get_auth_token") { chrome.identity.getAuthToken({ interactive: true }, function (token) { console.log(token); chrome.identity.getProfileUserInfo(function (userInfo) { console.log(JSON.stringify(userInfo)); }); }); } else if (request.message === "log_off") { // chrome.identity.clearAllCachedAuthTokens(() => console.log("logged off")); } else if (request.message === "patreon_auth_complete") { handlePatreonAuthComplete(request.user, request.sessionToken); } else if (request.message === "patreon_logout") { // Clear Patreon authentication chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], () => { broadcastPatreonStatus(false, null, null); }); } else if (request.message === "ryd_open_tab") { const targetUrl = typeof request?.url === "string" ? request.url : null; if (!targetUrl) { sendResponse?.({ success: false, error: "invalid_url" }); return; } try { if (api?.tabs?.create) { api.tabs.create({ url: targetUrl }, () => { if (api.runtime?.lastError) { console.debug("Tab open failed:", api.runtime.lastError.message); } }); sendResponse?.({ success: true }); return; } } catch (error) { console.debug("Tab open threw:", error?.message ?? error); } sendResponse?.({ success: false, error: "tabs_api_unavailable" }); return; } else if (request.message == "set_state") { // chrome.identity.getAuthToken({ interactive: true }, function (token) { let token = ""; fetch(getApiEndpoint(`/votes?videoId=${request.videoId}&likeCount=${request.likeCount || ""}`), { method: "GET", headers: { Accept: "application/json", }, }) .then((response) => response.json()) .then((response) => { sendResponse(response); }) .catch(); return true; } else if (request.message == "send_links") { toSend = toSend.concat(request.videoIds.filter((x) => !sentIds.has(x))); if (toSend.length >= 20) { fetch(getApiEndpoint("/votes"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(toSend), }); for (const toSendUrl of toSend) { sentIds.add(toSendUrl); } toSend = []; } } else if (request.message == "register") { voteClient .ensureRegistered() .then(({ userId }) => sendResponse?.({ success: true, userId })) .catch((error) => sendResponse?.({ success: false, error: error.message })); return true; } else if (request.message == "send_vote") { voteClient .submitVote(request.videoId, request.vote) .then(() => sendResponse?.({ success: true })) .catch((error) => sendResponse?.({ success: false, error: error.message })); return true; } else if (request.message === "patreon_oauth_login") { (async () => { try { const idApi = getIdentityApi(); if ( !idApi || typeof (idApi.getRedirectURL || (isChrome() && chrome.identity && chrome.identity.getRedirectURL)) !== "function" ) { sendResponse({ success: false, error: "identity API not available" }); return; } const redirectUri = isFirefox() && browser.identity.getRedirectURL ? browser.identity.getRedirectURL() : isChrome() && chrome.identity.getRedirectURL ? chrome.identity.getRedirectURL() : ""; const startRes = await fetch( getApiEndpoint(`/api/auth/oauth/login?redirectUri=${encodeURIComponent(redirectUri)}`), ); const startData = await startRes.json(); const responseUrl = await launchWebAuthFlow(startData.authUrl); const { code, state } = extractOAuthParams(responseUrl); if (!code) { sendResponse({ success: false, error: "No authorization code received" }); return; } const exchangeRes = await fetch(getApiEndpoint("/api/auth/oauth/exchange"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, state, expectedState: startData.state, redirectUri: startData.redirectUri || redirectUri, }), }); const authData = await exchangeRes.json(); if (authData && authData.success) { handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { sendResponse({ success: true, user: authData.user }); }); } else { sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); } } catch (e) { console.error("patreon_oauth_login error", e); sendResponse({ success: false, error: String((e && e.message) || e) }); } })(); return true; } else if (request.message === "github_oauth_login") { (async () => { try { const idApi = getIdentityApi(); if ( !idApi || typeof (idApi.getRedirectURL || (isChrome() && chrome.identity && chrome.identity.getRedirectURL)) !== "function" ) { sendResponse({ success: false, error: "identity API not available" }); return; } const redirectUri = isFirefox() && browser.identity.getRedirectURL ? browser.identity.getRedirectURL() : isChrome() && chrome.identity.getRedirectURL ? chrome.identity.getRedirectURL() : ""; const startRes = await fetch( getApiEndpoint(`/api/auth/github/login?redirectUri=${encodeURIComponent(redirectUri)}`), ); const startData = await startRes.json(); const responseUrl = await launchWebAuthFlow(startData.authUrl); const { code, state } = extractOAuthParams(responseUrl); if (!code) { sendResponse({ success: false, error: "No authorization code received" }); return; } const exchangeRes = await fetch(getApiEndpoint("/api/auth/github/exchange"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, state, expectedState: startData.state, redirectUri: startData.redirectUri || redirectUri, }), }); const authData = await exchangeRes.json(); if (authData && authData.success) { handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { sendResponse({ success: true, user: authData.user }); }); } else { sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); } } catch (e) { console.error("github_oauth_login error", e); sendResponse({ success: false, error: String((e && e.message) || e) }); } })(); return true; } }); function openChangelogTab(version) { try { const url = getChangelogUrl(); api.tabs.create({ url }, () => { if (api.runtime.lastError) { console.debug("Changelog tab could not open:", api.runtime.lastError.message); } persistChangelogVersion(version); }); } catch (error) { console.debug("Failed to open changelog tab", error); } } function scheduleChangelogVersion(version) { const storage = api?.storage?.local; if (!storage || typeof storage.set !== "function") { return false; } try { const valueToStore = version || true; storage.set({ [PENDING_CHANGELOG_STORAGE_KEY]: valueToStore }, () => { if (api.runtime.lastError) { console.debug("Failed to persist pending changelog version:", api.runtime.lastError.message); } }); return true; } catch (error) { console.debug("Storage set failed for pending changelog version", error); return false; } } function clearPendingChangelogVersion() { const storage = api?.storage?.local; if (!storage || typeof storage.remove !== "function") { return; } try { storage.remove(PENDING_CHANGELOG_STORAGE_KEY, () => { if (api.runtime.lastError) { console.debug("Failed to clear pending changelog version:", api.runtime.lastError.message); } }); } catch (error) { console.debug("Storage remove failed for pending changelog version", error); } } function showPendingChangelogIfNeeded() { const storage = api?.storage?.local; if (!storage || typeof storage.get !== "function") { return; } try { storage.get([PENDING_CHANGELOG_STORAGE_KEY, CHANGELOG_STORAGE_KEY], (result) => { if (api.runtime.lastError) { console.debug("Changelog storage read failed:", api.runtime.lastError.message); return; } const pendingValue = result?.[PENDING_CHANGELOG_STORAGE_KEY]; if (pendingValue === undefined || pendingValue === null || pendingValue === "") { return; } const lastShownValue = result?.[CHANGELOG_STORAGE_KEY]; if (lastShownValue !== undefined && lastShownValue !== null && lastShownValue !== "") { clearPendingChangelogVersion(); return; } openChangelogTab(typeof pendingValue === "string" ? pendingValue : null); }); } catch (error) { console.debug("Storage get failed for pending changelog version", error); } } function persistChangelogVersion(version) { const storage = api?.storage?.local; if (!storage || typeof storage.set !== "function") { clearPendingChangelogVersion(); return; } try { const valueToStore = version || true; storage.set({ [CHANGELOG_STORAGE_KEY]: valueToStore }, () => { if (api.runtime.lastError) { console.debug("Failed to persist changelog version:", api.runtime.lastError.message); return; } clearPendingChangelogVersion(); }); } catch (error) { console.debug("Storage set failed for changelog version", error); } } function maybeShowChangelog(details) { const reason = details?.reason; if (!reason) { return; } if (reason === "browser_update" || reason === "chrome_update") { return; } if (reason !== "install" && reason !== "update") { return; } const manifest = api.runtime.getManifest(); const currentVersion = manifest?.version; const storage = api?.storage?.local; const isInstall = reason === "install"; const showChangelog = () => { openChangelogTab(currentVersion || null); }; if (!storage || typeof storage.get !== "function") { showChangelog(); return; } try { storage.get([CHANGELOG_STORAGE_KEY, PENDING_CHANGELOG_STORAGE_KEY], (result) => { if (api.runtime.lastError) { console.debug("Changelog storage read failed:", api.runtime.lastError.message); showChangelog(); return; } const hasStoredValue = (value) => value !== undefined && value !== null && value !== ""; const lastShownValue = result?.[CHANGELOG_STORAGE_KEY]; const pendingValue = result?.[PENDING_CHANGELOG_STORAGE_KEY]; if (isInstall) { if (hasStoredValue(pendingValue)) { clearPendingChangelogVersion(); } if (!hasStoredValue(lastShownValue)) { showChangelog(); } return; } if (hasStoredValue(lastShownValue)) { return; } if (hasStoredValue(pendingValue)) { if (currentVersion && pendingValue !== currentVersion) { scheduleChangelogVersion(currentVersion); } return; } if (!scheduleChangelogVersion(currentVersion || null)) { showChangelog(); } }); } catch (error) { console.debug("Storage get failed for changelog version", error); showChangelog(); } } api.runtime.onInstalled.addListener((details) => { maybeShowChangelog(details); }); if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "function") { api.runtime.onStartup.addListener(() => { showPendingChangelogIfNeeded(); }); } // api.storage.sync.get(['lastShowChangelogVersion'], (details) => { // if (extConfig.showUpdatePopup === true && // details.lastShowChangelogVersion !== chrome.runtime.getManifest().version // ) { // // keep it inside get to avoid race condition // api.storage.sync.set({'lastShowChangelogVersion ': chrome.runtime.getManifest().version}); // // wait until async get runs & don't steal tab focus // api.tabs.create({url: api.runtime.getURL("/changelog/4/changelog_4.0.html"), active: false}); // } // }); const sentIds = new Set(); let toSend = []; function storageChangeHandler(changes, area) { if (changes.disableVoteSubmission !== undefined) { handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue); } if (changes.coloredThumbs !== undefined) { handleColoredThumbsChangeEvent(changes.coloredThumbs.newValue); } if (changes.coloredBar !== undefined) { handleColoredBarChangeEvent(changes.coloredBar.newValue); } if (changes.colorTheme !== undefined) { handleColorThemeChangeEvent(changes.colorTheme.newValue); } if (changes.numberDisplayFormat !== undefined) { handleNumberDisplayFormatChangeEvent(changes.numberDisplayFormat.newValue); } if (changes.numberDisplayReformatLikes !== undefined) { handleNumberDisplayReformatLikesChangeEvent(changes.numberDisplayReformatLikes.newValue); } if (changes.disableLogging !== undefined) { handleDisableLoggingChangeEvent(changes.disableLogging.newValue); } if (changes.showTooltipPercentage !== undefined) { handleShowTooltipPercentageChangeEvent(changes.showTooltipPercentage.newValue); } if (changes.numberDisplayReformatLikes !== undefined) { handleNumberDisplayReformatLikesChangeEvent(changes.numberDisplayReformatLikes.newValue); } if (changes.hidePremiumTeaser !== undefined) { handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); } } function handleDisableVoteSubmissionChangeEvent(value) { extConfig.disableVoteSubmission = value; if (value === true) { changeIcon(voteDisabledIconName); } else { changeIcon(defaultIconName); } } function handleDisableLoggingChangeEvent(value) { extConfig.disableLogging = value; } function handleNumberDisplayFormatChangeEvent(value) { extConfig.numberDisplayFormat = value; } function handleShowTooltipPercentageChangeEvent(value) { extConfig.showTooltipPercentage = value; } function handleTooltipPercentageModeChangeEvent(value) { if (!value) { value = "dash_like"; } extConfig.tooltipPercentageMode = value; } function changeIcon(iconName) { if (api.action !== undefined) api.action.setIcon({ path: "/icons/" + iconName }); else if (api.browserAction !== undefined) api.browserAction.setIcon({ path: "/icons/" + iconName }); else console.log("changing icon is not supported"); } function handleColoredThumbsChangeEvent(value) { extConfig.coloredThumbs = value; } function handleColoredBarChangeEvent(value) { extConfig.coloredBar = value; } function handleColorThemeChangeEvent(value) { if (!value) { value = "classic"; } extConfig.colorTheme = value; } function handleNumberDisplayReformatLikesChangeEvent(value) { extConfig.numberDisplayReformatLikes = value; } function handleHidePremiumTeaserChangeEvent(value) { extConfig.hidePremiumTeaser = value === true; } api.storage.onChanged.addListener(storageChangeHandler); function initExtConfig() { initializeDisableVoteSubmission(); initializeDisableLogging(); initializeColoredThumbs(); initializeColoredBar(); initializeColorTheme(); initializeNumberDisplayFormat(); initializeNumberDisplayReformatLikes(); initializeTooltipPercentage(); initializeTooltipPercentageMode(); initializeHidePremiumTeaser(); } function initializeDisableVoteSubmission() { api.storage.sync.get(["disableVoteSubmission"], (res) => { if (res.disableVoteSubmission === undefined) { api.storage.sync.set({ disableVoteSubmission: false }); } else { extConfig.disableVoteSubmission = res.disableVoteSubmission; if (res.disableVoteSubmission) changeIcon(voteDisabledIconName); } }); } function initializeDisableLogging() { api.storage.sync.get(["disableLogging"], (res) => { if (res.disableLogging === undefined) { api.storage.sync.set({ disableLogging: true }); } else { extConfig.disableLogging = res.disableLogging; } }); } function initializeColoredThumbs() { api.storage.sync.get(["coloredThumbs"], (res) => { if (res.coloredThumbs === undefined) { api.storage.sync.set({ coloredThumbs: false }); } else { extConfig.coloredThumbs = res.coloredThumbs; } }); } function initializeColoredBar() { api.storage.sync.get(["coloredBar"], (res) => { if (res.coloredBar === undefined) { api.storage.sync.set({ coloredBar: false }); } else { extConfig.coloredBar = res.coloredBar; } }); } function initializeColorTheme() { api.storage.sync.get(["colorTheme"], (res) => { if (res.colorTheme === undefined) { api.storage.sync.set({ colorTheme: false }); } else { extConfig.colorTheme = res.colorTheme; } }); } function initializeNumberDisplayFormat() { api.storage.sync.get(["numberDisplayFormat"], (res) => { if (res.numberDisplayFormat === undefined) { api.storage.sync.set({ numberDisplayFormat: "compactShort" }); } else { extConfig.numberDisplayFormat = res.numberDisplayFormat; } }); } function initializeTooltipPercentage() { api.storage.sync.get(["showTooltipPercentage"], (res) => { if (res.showTooltipPercentage === undefined) { api.storage.sync.set({ showTooltipPercentage: false }); } else { extConfig.showTooltipPercentage = res.showTooltipPercentage; } }); } function initializeTooltipPercentageMode() { api.storage.sync.get(["tooltipPercentageMode"], (res) => { if (res.tooltipPercentageMode === undefined) { api.storage.sync.set({ tooltipPercentageMode: "dash_like" }); } else { extConfig.tooltipPercentageMode = res.tooltipPercentageMode; } }); } function initializeNumberDisplayReformatLikes() { api.storage.sync.get(["numberDisplayReformatLikes"], (res) => { if (res.numberDisplayReformatLikes === undefined) { api.storage.sync.set({ numberDisplayReformatLikes: false }); } else { extConfig.numberDisplayReformatLikes = res.numberDisplayReformatLikes; } }); } function initializeHidePremiumTeaser() { api.storage.sync.get(["hidePremiumTeaser"], (res) => { if (res.hidePremiumTeaser === undefined) { api.storage.sync.set({ hidePremiumTeaser: false }); extConfig.hidePremiumTeaser = false; } else { extConfig.hidePremiumTeaser = res.hidePremiumTeaser === true; } }); } function isChrome() { return typeof chrome !== "undefined" && typeof chrome.runtime !== "undefined"; } function isFirefox() { return typeof browser !== "undefined" && typeof browser.runtime !== "undefined"; }