From c31f60cc2464bee7844d859c6815c9e2342debdb Mon Sep 17 00:00:00 2001 From: Anarios Date: Sun, 18 Oct 2026 18:13:46 +0200 Subject: [PATCH] Fix Firefox consent and release interface regressions Keep private windows counts-only and separate account consent from core access. Correct stale account UI, Premium themes and ranges, display preferences, and RTL popup and release layouts. --- AMO_RELEASE_NOTES.md | 4 +- .../e2e/fixtures/navigation-page.html | 14 +- Extensions/combined/consent.js | 105 ++++--- Extensions/combined/consent.spec.js | 55 +++- Extensions/combined/content-style.css | 97 ++++--- Extensions/combined/manifest-firefox.json | 2 +- Extensions/combined/popup-context.js | 8 + Extensions/combined/popup-context.spec.js | 35 +++ Extensions/combined/popup-locale.spec.js | 48 ++++ Extensions/combined/popup-private.spec.js | 112 ++++++++ Extensions/combined/popup.css | 22 ++ Extensions/combined/popup.html | 1 + Extensions/combined/popup.js | 218 ++++++++------ Extensions/combined/popup.spec.js | 40 ++- .../combined/ryd.background-changelog.spec.js | 71 ++++- Extensions/combined/ryd.background.js | 59 +++- Extensions/combined/ryd.background.spec.js | 122 ++++++-- Extensions/combined/ryd.content-script.js | 53 +++- .../combined/src/account-data-consent.js | 53 ++++ .../combined/src/account-data-consent.spec.js | 222 ++++++++++++++ Extensions/combined/src/amo-policy.spec.js | 2 +- Extensions/combined/src/bar.js | 3 +- Extensions/combined/src/buttons.js | 3 + Extensions/combined/src/buttons.spec.js | 44 +++ Extensions/combined/src/changelog/index.js | 2 + .../combined/src/changelog/index.spec.js | 54 ++++ Extensions/combined/src/consent-prompt.js | 9 +- .../combined/src/consent-prompt.spec.js | 43 ++- .../src/content-script-private.spec.js | 199 +++++++++++++ Extensions/combined/src/core-data-consent.js | 34 +++ .../combined/src/core-data-consent.spec.js | 69 +++++ .../src/data-collection-permissions.js | 4 + .../src/data-collection-permissions.spec.js | 15 + .../combined/src/discussion-background.js | 52 +++- .../src/discussion-background.spec.js | 270 +++++++++++++++++- .../src/discussion/client-content.spec.js | 31 ++ Extensions/combined/src/discussion/client.js | 22 ++ .../combined/src/discussion/client.spec.js | 87 +++++- Extensions/combined/src/discussion/index.js | 2 + .../combined/src/discussion/index.spec.js | 25 ++ Extensions/combined/src/discussion/panel.js | 14 +- .../combined/src/discussion/panel.spec.js | 95 ++++++ Extensions/combined/src/discussion/popup.js | 2 + .../combined/src/discussion/popup.spec.js | 23 ++ Extensions/combined/src/events.js | 26 +- Extensions/combined/src/events.spec.js | 82 +++++- Extensions/combined/src/logging.js | 14 + Extensions/combined/src/logging.spec.js | 34 +++ Extensions/combined/src/patreon.js | 2 + Extensions/combined/src/patreon.spec.js | 20 ++ .../src/premiumAnalytics/activity/index.js | 28 +- .../premiumAnalytics/activity/index.spec.js | 31 +- .../premiumAnalytics/activity/layout.spec.js | 1 + .../src/premiumAnalytics/index.spec.js | 50 ++++ .../src/premiumAnalytics/lifecycle.js | 22 +- .../combined/src/premiumAnalytics/logging.js | 10 +- .../src/premiumAnalytics/logging.spec.js | 12 + .../src/premiumAnalytics/map/index.js | 14 + .../src/premiumAnalytics/map/index.spec.js | 20 ++ .../combined/src/premiumAnalytics/panel.js | 2 +- .../combined/src/premiumAnalytics/requests.js | 4 +- .../combined/src/premiumAnalytics/theme.js | 67 ++--- .../src/premiumAnalytics/theme.spec.js | 72 ++++- Extensions/combined/src/private-browsing.js | 28 ++ .../combined/src/private-browsing.spec.js | 44 +++ .../src/reaction-presentation.spec.js | 177 +++++++++++- Extensions/combined/src/state.js | 81 +++++- Extensions/combined/src/state.spec.js | 221 ++++++++++++++ Extensions/combined/src/utils.js | 21 +- Extensions/combined/src/utils.spec.js | 39 ++- Extensions/combined/src/vote-data-request.js | 2 + .../combined/src/vote-data-request.spec.js | 19 ++ .../extension-owned-page-locale.e2e.js | 165 +++++++++++ .../extension-premium-analytics-theme.e2e.js | 181 ++++++++++++ Extensions/e2e/hermetic-artifact-smoke.js | 2 +- .../e2e/hermetic-artifact-smoke.spec.js | 17 +- README_AMO_SOURCE.md | 4 +- 77 files changed, 3563 insertions(+), 394 deletions(-) create mode 100644 Extensions/combined/popup-context.js create mode 100644 Extensions/combined/popup-context.spec.js create mode 100644 Extensions/combined/popup-locale.spec.js create mode 100644 Extensions/combined/popup-private.spec.js create mode 100644 Extensions/combined/src/account-data-consent.js create mode 100644 Extensions/combined/src/account-data-consent.spec.js create mode 100644 Extensions/combined/src/changelog/index.spec.js create mode 100644 Extensions/combined/src/content-script-private.spec.js create mode 100644 Extensions/combined/src/discussion/client-content.spec.js create mode 100644 Extensions/combined/src/logging.js create mode 100644 Extensions/combined/src/logging.spec.js create mode 100644 Extensions/combined/src/private-browsing.js create mode 100644 Extensions/combined/src/private-browsing.spec.js create mode 100644 Extensions/e2e/extension/extension-owned-page-locale.e2e.js create mode 100644 Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js diff --git a/AMO_RELEASE_NOTES.md b/AMO_RELEASE_NOTES.md index 8e40c31..3ddc77c 100644 --- a/AMO_RELEASE_NOTES.md +++ b/AMO_RELEASE_NOTES.md @@ -1,7 +1,9 @@ -# Return YouTube Dislike 4.0.6 +# Return YouTube Dislike 4.1.0 What's new since 3.0.0.18: +- **RYD comments:** read and react to discussions under videos and Shorts. Commenter and Premium members can post and reply, with notifications for replies and reactions. +- **Firefox consent and translations:** updated permission controls and new translations. - **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync. - **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit. - **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps. diff --git a/Extensions/UserScript/e2e/fixtures/navigation-page.html b/Extensions/UserScript/e2e/fixtures/navigation-page.html index 8d9bef7..97011fd 100644 --- a/Extensions/UserScript/e2e/fixtures/navigation-page.html +++ b/Extensions/UserScript/e2e/fixtures/navigation-page.html @@ -755,7 +755,7 @@ const button = control?.querySelector("button"); if (control && button) { control.setAttribute("data-fixture-native-hydration-pending", role); - delayedNativeButtons.push({ button, control }); + delayedNativeButtons.push({ button, control, parent: button.parentElement }); button.remove(); } } @@ -772,9 +772,15 @@ } if (delayedNativeButtons.length > 0) { setTimeout(() => { - for (const { button, control } of delayedNativeButtons) { - if (replacement.isConnected && control.isConnected && !control.querySelector("button")) { - control.appendChild(button); + for (const { button, control, parent } of delayedNativeButtons) { + if ( + replacement.isConnected && + control.isConnected && + parent.isConnected && + control.contains(parent) && + !control.querySelector("button") + ) { + parent.appendChild(button); control.removeAttribute("data-fixture-native-hydration-pending"); } } diff --git a/Extensions/combined/consent.js b/Extensions/combined/consent.js index 0021c64..fb5b864 100644 --- a/Extensions/combined/consent.js +++ b/Extensions/combined/consent.js @@ -1,11 +1,24 @@ import { consentApi, hasCoreDataPermission, requestCoreDataPermission } from "./src/core-data-consent"; +import { isPrivateBrowsingContext } from "./src/private-browsing"; const continueButton = document.getElementById("consent-continue"); const declineButton = document.getElementById("consent-decline"); const status = document.getElementById("consent-status"); -const canRequestPermissions = typeof consentApi()?.permissions?.request === "function"; +const privateBrowsing = isPrivateBrowsingContext(); +const privateSetupInstruction = "Enable RYD in a normal Firefox window to load counts here."; +const canRequestPermissions = !privateBrowsing && typeof consentApi()?.permissions?.request === "function"; -if (!canRequestPermissions) { +if (privateBrowsing) { + const main = document.querySelector("main"); + const message = document.createElement("p"); + message.textContent = + "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here."; + const instruction = document.getElementById("consent-next-step"); + instruction.textContent = privateSetupInstruction; + const actions = document.getElementById("consent-actions"); + actions.replaceChildren(declineButton); + main.replaceChildren(main.querySelector("img"), main.querySelector("h1"), message, instruction, actions, status); +} else if (!canRequestPermissions) { document.getElementById("consent-next-step").textContent = "Continue opens RYD's permission page. Firefox will only ask after you select Allow in Firefox there. " + "If you decline, RYD will not load counts or submit votes. YouTube's own buttons will still work."; @@ -27,50 +40,56 @@ function notifyParent(type) { } async function dismiss() { - try { - await consentApi().storage.local.set({ rydConsentDismissed: true }); - } catch (_) {} - status.textContent = "RYD is paused. You can enable it here whenever you're ready."; + if (!privateBrowsing) { + try { + await consentApi().storage.local.set({ rydConsentDismissed: true }); + } catch (_) {} + } + status.textContent = privateBrowsing + ? privateSetupInstruction + : "RYD is paused. You can enable it here whenever you're ready."; notifyParent("ryd-consent-dismissed"); } -continueButton.addEventListener("click", () => { - if (!canRequestPermissions) { - continueButton.disabled = true; - void consentApi() - .runtime.sendMessage({ message: "ryd_open_consent" }) - .then( - (response) => { - continueButton.disabled = false; - status.textContent = response?.success - ? "Finish enabling RYD in the permission page." - : "Open the RYD extension menu to review permissions."; - }, - () => { - continueButton.disabled = false; - status.textContent = "Open the RYD extension menu to review permissions."; - }, - ); - return; - } - notifyParent("ryd-consent-requested"); - const request = requestCoreDataPermission(); - continueButton.disabled = true; - declineButton.disabled = true; - status.textContent = "Choose whether to allow the permissions in Firefox."; - void request.then(async (granted) => { - if (granted && (await hasCoreDataPermission())) { - try { - await consentApi().storage.local.remove("rydConsentDismissed"); - } catch (_) {} - document.getElementById("consent-actions").hidden = true; - status.textContent = "RYD is enabled. You can return to YouTube."; - notifyParent("ryd-consent-granted"); - } else { - await dismiss(); - continueButton.disabled = false; - declineButton.disabled = false; +if (!privateBrowsing) { + continueButton.addEventListener("click", () => { + if (!canRequestPermissions) { + continueButton.disabled = true; + void consentApi() + .runtime.sendMessage({ message: "ryd_open_consent" }) + .then( + (response) => { + continueButton.disabled = false; + status.textContent = response?.success + ? "Finish enabling RYD in the permission page." + : "Open the RYD extension menu to review permissions."; + }, + () => { + continueButton.disabled = false; + status.textContent = "Open the RYD extension menu to review permissions."; + }, + ); + return; } + notifyParent("ryd-consent-requested"); + const request = requestCoreDataPermission(); + continueButton.disabled = true; + declineButton.disabled = true; + status.textContent = "Choose whether to allow the permissions in Firefox."; + void request.then(async (granted) => { + if (granted && (await hasCoreDataPermission())) { + try { + await consentApi().storage.local.remove("rydConsentDismissed"); + } catch (_) {} + document.getElementById("consent-actions").hidden = true; + status.textContent = "RYD is enabled. You can return to YouTube."; + notifyParent("ryd-consent-granted"); + } else { + await dismiss(); + continueButton.disabled = false; + declineButton.disabled = false; + } + }); }); -}); +} declineButton.addEventListener("click", dismiss); diff --git a/Extensions/combined/consent.spec.js b/Extensions/combined/consent.spec.js index 39ca892..ed56c4a 100644 --- a/Extensions/combined/consent.spec.js +++ b/Extensions/combined/consent.spec.js @@ -6,21 +6,32 @@ jest.mock("./src/core-data-consent", () => ({ requestCoreDataPermission: jest.fn(), consentApi: jest.fn(), })); +jest.mock("./src/private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() })); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("Firefox consent explainer", () => { let permission; let local; - beforeEach(() => { + let sendMessage; + function openExplainer({ privateBrowsing = false, canRequest = true } = {}) { jest.resetModules(); document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8"); permission = require("./src/core-data-consent"); - local = { set: jest.fn(async () => {}), remove: jest.fn(async () => {}) }; - permission.consentApi.mockReturnValue({ storage: { local }, permissions: { request: jest.fn() } }); + require("./src/private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing); + local = { get: jest.fn(), set: jest.fn(async () => {}), remove: jest.fn(async () => {}) }; + sendMessage = jest.fn(async () => ({ success: true })); + permission.consentApi.mockReturnValue({ + storage: { local }, + runtime: { sendMessage }, + ...(canRequest ? { permissions: { request: jest.fn() } } : {}), + }); permission.hasCoreDataPermission.mockResolvedValue(false); permission.requestCoreDataPermission.mockResolvedValue(false); + const continueButton = document.getElementById("consent-continue"); require("./consent"); - }); + return continueButton; + } + beforeEach(() => openExplainer()); test("opening the explainer and selecting Not now request no permissions", async () => { expect(document.querySelector("main").textContent).toMatch(/random ID/); @@ -50,16 +61,40 @@ describe("Firefox consent explainer", () => { }); test("a page-embedded explainer opens the extension consent page without requesting permissions", async () => { - jest.resetModules(); - document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8"); - permission = require("./src/core-data-consent"); - const sendMessage = jest.fn(async () => ({ success: true })); - permission.consentApi.mockReturnValue({ storage: { local }, runtime: { sendMessage } }); - require("./consent"); + openExplainer({ canRequest: false }); document.getElementById("consent-continue").click(); await flush(); expect(sendMessage).toHaveBeenCalledWith({ message: "ryd_open_consent" }); expect(permission.requestCoreDataPermission).not.toHaveBeenCalled(); expect(document.getElementById("consent-next-step").textContent).toMatch(/permission page/); }); + + test.each([true, false])( + "private setup stays local without requesting permissions or opening a normal page when request API availability is %s", + async (canRequest) => { + const continueButton = openExplainer({ privateBrowsing: true, canRequest }); + expect(document.getElementById("consent-next-step").textContent).toBe( + "Enable RYD in a normal Firefox window to load counts here.", + ); + expect(document.querySelector("main").textContent).toContain( + "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.", + ); + expect(document.querySelector("main").textContent).not.toMatch(/random ID|Allow in Firefox|Continue/); + expect(document.getElementById("consent-continue")).toBeNull(); + continueButton.click(); + document.getElementById("consent-decline").click(); + await flush(); + + expect(permission.consentApi).not.toHaveBeenCalled(); + expect(permission.hasCoreDataPermission).not.toHaveBeenCalled(); + expect(permission.requestCoreDataPermission).not.toHaveBeenCalled(); + expect(local.get).not.toHaveBeenCalled(); + expect(local.set).not.toHaveBeenCalled(); + expect(local.remove).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(document.getElementById("consent-status").textContent).toBe( + "Enable RYD in a normal Firefox window to load counts here.", + ); + }, + ); }); diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css index fdb57ec..8db4b48 100644 --- a/Extensions/combined/content-style.css +++ b/Extensions/combined/content-style.css @@ -903,13 +903,33 @@ ytd-menu-renderer.ytd-watch-metadata { color: var(--yt-spec-call-to-action, #3ea6ff); } +.ryd-premium-analytics, +.ryd-premium-tier-notice { + --ryd-analytics-bg: var(--yt-spec-base-background, #fff); + --ryd-analytics-fg: var(--yt-spec-text-primary, #0f0f0f); + --ryd-analytics-muted: var(--yt-spec-text-secondary, #606060); + --ryd-analytics-border: rgba(0, 0, 0, 0.08); + --ryd-analytics-surface: rgba(15, 23, 42, 0.04); + --ryd-analytics-row: rgba(15, 23, 42, 0.03); +} + +html[dark]:not([dark="false"]):not([light]) .ryd-premium-analytics, +html[dark]:not([dark="false"]):not([light]) .ryd-premium-tier-notice { + --ryd-analytics-bg: var(--yt-spec-base-background, #202020); + --ryd-analytics-fg: var(--yt-spec-text-primary, #fff); + --ryd-analytics-muted: var(--yt-spec-text-secondary, #bbb); + --ryd-analytics-border: rgba(255, 255, 255, 0.08); + --ryd-analytics-surface: rgba(255, 255, 255, 0.02); + --ryd-analytics-row: rgba(255, 255, 255, 0.03); +} + .ryd-premium-analytics { - background: var(--yt-spec-base-background, #202020); - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--ryd-analytics-bg); + border: 1px solid var(--ryd-analytics-border); border-radius: 16px; padding: 16px; margin-bottom: 16px; - color: var(--yt-spec-text-primary, #ffffff); + color: var(--ryd-analytics-fg); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22); font-family: Roboto, Arial, sans-serif; position: relative; @@ -996,12 +1016,12 @@ html[dark] .ryd-premium-teaser { } .ryd-premium-tier-notice { - background: var(--yt-spec-base-background, #202020); - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--ryd-analytics-bg); + border: 1px solid var(--ryd-analytics-border); border-radius: 16px; padding: 20px; margin-bottom: 16px; - color: var(--yt-spec-text-primary, #ffffff); + color: var(--ryd-analytics-fg); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); font-family: Roboto, Arial, sans-serif; display: flex; @@ -1026,7 +1046,7 @@ html[dark] .ryd-premium-teaser { .ryd-premium-tier-notice__message { margin: 0; font-size: 13px; - color: var(--yt-spec-text-secondary, #bcccdc); + color: var(--ryd-analytics-muted); line-height: 1.5; } @@ -1282,6 +1302,7 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible { .ryd-analytics__controls { display: flex; + flex-wrap: wrap; gap: 12px; align-items: center; flex: 1 1 auto; @@ -1329,30 +1350,30 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible { color: #0f172a; } -html[dark] .ryd-analytics__ranges, -html[dark] .ryd-analytics__modes, -html[dark] .ryd-analytics__window-toggle { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle { background: rgba(255, 255, 255, 0.05); border: none; } -html[dark] .ryd-analytics__ranges button, -html[dark] .ryd-analytics__modes button, -html[dark] .ryd-analytics__window-toggle button { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button { color: var(--yt-spec-text-secondary, #bbbbbb); } -html[dark] .ryd-analytics__ranges button.is-active, -html[dark] .ryd-analytics__modes button.is-active, -html[dark] .ryd-analytics__window-toggle button.is-active { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button.is-active, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button.is-active, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button.is-active { background: rgba(255, 255, 255, 0.15); color: var(--yt-spec-text-primary, #ffffff); box-shadow: none; } -html[dark] .ryd-analytics__ranges button:hover, -html[dark] .ryd-analytics__modes button:hover, -html[dark] .ryd-analytics__window-toggle button:hover { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button:hover, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button:hover, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button:hover { background: rgba(255, 255, 255, 0.1); color: var(--yt-spec-text-primary, #ffffff); } @@ -1375,11 +1396,11 @@ html[dark] .ryd-analytics__window-toggle button:hover { color: #4b5563; } -html[dark] .ryd-analytics__window-label { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-label { color: var(--yt-spec-text-primary, #ffffff); } -html[dark] .ryd-analytics__window-hint { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-hint { color: var(--yt-spec-text-secondary, #a3a3a3); } @@ -1390,7 +1411,7 @@ html[dark] .ryd-analytics__window-hint { } .ryd-analytics__section { - background: rgba(255, 255, 255, 0.02); + background: var(--ryd-analytics-surface); border-radius: 16px; padding: 12px; display: flex; @@ -1439,14 +1460,14 @@ html[dark] .ryd-analytics__window-hint { box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); } -html[dark] .ryd-analytics__section-expand { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand { border: none; background: rgba(255, 255, 255, 0.08); color: var(--yt-spec-text-secondary, #cbd5f5); } -html[dark] .ryd-analytics__section-expand.is-active, -html[dark] .ryd-analytics__section-expand:hover { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand.is-active, +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand:hover { background: rgba(99, 102, 241, 0.25); color: #f8fafc; box-shadow: none; @@ -1471,7 +1492,7 @@ html[dark] .ryd-analytics__section-expand:hover { .ryd-analytics__bucket-label { font-size: 12px; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__chart { @@ -1494,7 +1515,7 @@ html[dark] .ryd-analytics__section-expand:hover { .ryd-analytics__map-label { font-size: 12px; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__map-reset { @@ -1517,12 +1538,12 @@ html[dark] .ryd-analytics__section-expand:hover { box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35); } -html[dark] .ryd-analytics__map-reset { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset { background: rgba(255, 255, 255, 0.08); color: var(--yt-spec-text-secondary, #bbbbbb); } -html[dark] .ryd-analytics__map-reset:hover { +html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset:hover { background: rgba(255, 255, 255, 0.18); color: var(--yt-spec-text-primary, #ffffff); } @@ -1544,7 +1565,7 @@ html[dark] .ryd-analytics__map-reset:hover { margin: 0 0 6px; font-size: 13px; font-weight: 600; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__list-items { @@ -1561,8 +1582,8 @@ html[dark] .ryd-analytics__map-reset:hover { justify-content: space-between; align-items: center; font-size: 12px; - color: var(--yt-spec-text-primary, #ffffff); - background: rgba(255, 255, 255, 0.03); + color: var(--ryd-analytics-fg); + background: var(--ryd-analytics-row); border-radius: 6px; padding: 6px 8px; } @@ -1580,7 +1601,7 @@ html[dark] .ryd-analytics__map-reset:hover { } .ryd-analytics__placeholder { - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); font-style: italic; background: transparent !important; justify-content: flex-start !important; @@ -1588,7 +1609,7 @@ html[dark] .ryd-analytics__map-reset:hover { .ryd-analytics__footer { font-size: 12px; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__totals { @@ -1617,21 +1638,21 @@ html[dark] .ryd-analytics__map-reset:hover { letter-spacing: 0.08em; font-weight: 600; font-size: 10px; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__totals-value { font-weight: 600; font-variant-numeric: tabular-nums; - color: var(--yt-spec-text-primary, #f1f1f1); + color: var(--ryd-analytics-fg); } .ryd-analytics__period-label { font-size: 11px; - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); } .ryd-analytics__summary-meta { - color: var(--yt-spec-text-secondary, #bbbbbb); + color: var(--ryd-analytics-muted); line-height: 1.4; } diff --git a/Extensions/combined/manifest-firefox.json b/Extensions/combined/manifest-firefox.json index 492b248..19081a0 100644 --- a/Extensions/combined/manifest-firefox.json +++ b/Extensions/combined/manifest-firefox.json @@ -4,7 +4,7 @@ "default_locale": "en", "version": "__RYD_VERSION__", "manifest_version": 2, - "incognito": "not_allowed", + "incognito": "spanning", "background": { "scripts": ["ryd.background.js"] }, diff --git a/Extensions/combined/popup-context.js b/Extensions/combined/popup-context.js new file mode 100644 index 0000000..90cb04b --- /dev/null +++ b/Extensions/combined/popup-context.js @@ -0,0 +1,8 @@ +(() => { + if (location.protocol !== "moz-extension:") return; + // Prepare a distinct document URL before the first extension API access. + // Firefox uses that initial URL when identifying popup message senders. + const url = new URL(location.href); + url.searchParams.set("rydContext", crypto.randomUUID()); + history.replaceState(null, "", url.href); +})(); diff --git a/Extensions/combined/popup-context.spec.js b/Extensions/combined/popup-context.spec.js new file mode 100644 index 0000000..78a1b36 --- /dev/null +++ b/Extensions/combined/popup-context.spec.js @@ -0,0 +1,35 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +test("Firefox popup establishes its document identity before touching extension APIs", () => { + const source = fs.readFileSync(path.join(__dirname, "popup-context.js"), "utf8"); + const context = { + location: { protocol: "moz-extension:", href: "moz-extension://fixture/popup.html?existing=1#details" }, + URL, + crypto: { randomUUID: () => "synthetic-context" }, + history: { replaceState: jest.fn() }, + }; + for (const api of ["browser", "chrome"]) { + Object.defineProperty(context, api, { + get() { + throw new Error("Extension context initialized too soon"); + }, + }); + } + vm.runInNewContext(source, context); + expect(context.history.replaceState).toHaveBeenCalledWith( + null, + "", + "moz-extension://fixture/popup.html?existing=1&rydContext=synthetic-context#details", + ); + const html = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); + expect(html.match(/]*src="([^"]+)"/)[1]).toBe("popup-context.js"); +}); + +test.each(["chrome-extension:", "safari-web-extension:"])("%s popup URLs remain unchanged", (protocol) => { + const source = fs.readFileSync(path.join(__dirname, "popup-context.js"), "utf8"); + const history = { replaceState: jest.fn() }; + vm.runInNewContext(source, { location: { protocol }, history }); + expect(history.replaceState).not.toHaveBeenCalled(); +}); diff --git a/Extensions/combined/popup-locale.spec.js b/Extensions/combined/popup-locale.spec.js new file mode 100644 index 0000000..e4ad7d9 --- /dev/null +++ b/Extensions/combined/popup-locale.spec.js @@ -0,0 +1,48 @@ +/** @jest-environment jsdom */ +const fs = require("node:fs"); +const path = require("node:path"); + +jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: () => new Promise(() => {}) })); +jest.mock("./src/core-data-consent", () => ({ onCoreDataPermissionChanged: jest.fn() })); + +describe("popup locale direction", () => { + beforeEach(() => { + jest.resetModules(); + document.documentElement.removeAttribute("dir"); + document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); + const messages = require("./_locales/en/messages.json"); + global.chrome = { + i18n: { getMessage: jest.fn((key) => messages[key]?.message || "") }, + runtime: { getManifest: () => ({ version: "1.0.0" }) }, + storage: { + sync: { get: jest.fn(), set: jest.fn() }, + onChanged: { addListener: jest.fn() }, + }, + }; + }); + + afterEach(() => { + delete global.chrome; + document.documentElement.removeAttribute("dir"); + }); + + test.each(["rtl", "ltr"])("uses %s locale direction while localizing the popup", (direction) => { + const getMessage = chrome.i18n.getMessage.getMockImplementation(); + chrome.i18n.getMessage.mockImplementation((key) => (key === "@@bidi_dir" ? direction : getMessage(key))); + + require("./popup"); + + expect(document.documentElement.dir).toBe(direction); + expect(document.querySelector("h1").textContent).toBe("Return YouTube Dislike"); + expect(document.documentElement.innerHTML).not.toContain("__MSG_"); + }); + + test.each(["", "auto"])("preserves existing direction when locale metadata is %j", (direction) => { + document.documentElement.dir = "rtl"; + chrome.i18n.getMessage.mockImplementation((key) => (key === "@@bidi_dir" ? direction : "")); + + require("./popup"); + + expect(document.documentElement.dir).toBe("rtl"); + }); +}); diff --git a/Extensions/combined/popup-private.spec.js b/Extensions/combined/popup-private.spec.js new file mode 100644 index 0000000..dc817e0 --- /dev/null +++ b/Extensions/combined/popup-private.spec.js @@ -0,0 +1,112 @@ +/** @jest-environment jsdom */ +const fs = require("fs"); +const path = require("path"); + +jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: jest.fn(() => Promise.resolve()) })); +jest.mock("./src/core-data-consent", () => ({ + fetchWithCoreConsent: jest.fn(), + onCoreDataPermissionChanged: jest.fn(), +})); +jest.mock("./src/discussion/popup", () => ({ initDiscussionPopup: jest.fn() })); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const privateMessage = + "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here."; + +describe("private popup", () => { + function openPopup({ privateBrowsing, firefox = true }) { + jest.resetModules(); + const messages = require("./_locales/en/messages.json"); + document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); + global.chrome = { + extension: { inIncognitoContext: privateBrowsing }, + i18n: { getMessage: (key) => messages[key]?.message || "" }, + runtime: { + getManifest: () => (firefox ? require("./manifest-firefox.json") : { version: "1.0.0" }), + sendMessage: jest.fn(), + }, + permissions: { + getAll: jest.fn(), + request: jest.fn(), + onRemoved: { addListener: jest.fn() }, + }, + tabs: { create: jest.fn() }, + storage: { + sync: { + get: jest.fn((keys, callback) => + callback({ numberDisplayFormat: "compactShort", hideClutterButtons: false }), + ), + set: jest.fn(), + remove: jest.fn(), + }, + local: { get: jest.fn(), set: jest.fn(), remove: jest.fn() }, + onChanged: { addListener: jest.fn() }, + }, + }; + global.browser = global.chrome; + global.fetch = jest.fn(); + require("./popup"); + } + + afterEach(() => { + delete global.chrome; + delete global.browser; + delete global.fetch; + }); + + test("shows only branding and the approved count-only message without initializing shared state", async () => { + openPopup({ privateBrowsing: true }); + await flush(); + + expect(document.getElementById("ryd-private-counts-message").textContent).toBe(privateMessage); + expect(document.querySelector("h1").textContent).toBe("Return YouTube Dislike"); + expect(document.querySelectorAll("img")).toHaveLength(1); + expect(document.querySelector("img").getAttribute("src")).toBe("icons/logo.svg"); + expect( + document.querySelector("button, input, select, a, iframe, #patreon-section, #ryd-discussion-popup"), + ).toBeNull(); + expect(chrome.storage.sync.get).not.toHaveBeenCalled(); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(chrome.storage.sync.remove).not.toHaveBeenCalled(); + expect(chrome.storage.local.get).not.toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(chrome.storage.local.remove).not.toHaveBeenCalled(); + expect(chrome.storage.onChanged.addListener).not.toHaveBeenCalled(); + expect(chrome.permissions.getAll).not.toHaveBeenCalled(); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + expect(chrome.permissions.onRemoved.addListener).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(require("./src/consent-prompt").waitForCoreDataConsent).not.toHaveBeenCalled(); + expect(require("./src/core-data-consent").onCoreDataPermissionChanged).not.toHaveBeenCalled(); + expect(require("./src/discussion/popup").initDiscussionPopup).not.toHaveBeenCalled(); + }); + + test.each([ + ["a normal Firefox window", false, true], + ["a Chrome incognito window", true, false], + ])( + "preserves consent, account, discussion, links and settings initialization in %s", + async (_, privateBrowsing, firefox) => { + openPopup({ privateBrowsing, firefox }); + await flush(); + + expect(document.getElementById("ryd-private-counts-message")).toBeNull(); + expect(require("./src/consent-prompt").waitForCoreDataConsent).toHaveBeenCalledWith({ alwaysExplain: true }); + expect(require("./src/discussion/popup").initDiscussionPopup).toHaveBeenCalledTimes(1); + expect(chrome.storage.sync.get).toHaveBeenCalledWith( + ["patreonUser", "patreonSessionToken"], + expect.any(Function), + ); + expect(chrome.storage.onChanged.addListener).toHaveBeenCalledTimes(1); + + document.getElementById("link_website").click(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + document.getElementById("disable_vote_submission").click(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith({ disableVoteSubmission: true }); + document.getElementById("advancedToggle").click(); + expect(document.getElementById("advancedSettings").style.pointerEvents).toBe("auto"); + }, + ); +}); diff --git a/Extensions/combined/popup.css b/Extensions/combined/popup.css index 3d72a96..b3c17f4 100644 --- a/Extensions/combined/popup.css +++ b/Extensions/combined/popup.css @@ -617,3 +617,25 @@ input:checked + .slider::before { flex-direction: column; align-items: center; } + +[dir="rtl"] .switch::before, +[dir="rtl"] .label-with-hover-tip::before { + left: auto; + right: 0; +} + +[dir="rtl"] .switchLabel { + margin-left: 0; + margin-right: 0.5rem; + transform: translateX(-35px); +} + +[dir="rtl"] #advancedToggle { + right: auto; + left: 26px; +} + +[dir="rtl"] .setting-help-popover { + right: auto; + left: 0; +} diff --git a/Extensions/combined/popup.html b/Extensions/combined/popup.html index a350271..e570200 100644 --- a/Extensions/combined/popup.html +++ b/Extensions/combined/popup.html @@ -3,6 +3,7 @@ + __MSG_extensionName__ diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js index ac62e7f..77cfd26 100644 --- a/Extensions/combined/popup.js +++ b/Extensions/combined/popup.js @@ -2,7 +2,9 @@ import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js" import { getGitHubLoginErrorMessage } from "./src/github-login-error"; import { initDiscussionPopup } from "./src/discussion/popup"; import { waitForCoreDataConsent } from "./src/consent-prompt"; -import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./src/core-data-consent"; +import { onCoreDataPermissionChanged } from "./src/core-data-consent"; +import { fetchWithAccountConsent } from "./src/account-data-consent"; +import { isPrivateBrowsingContext } from "./src/private-browsing"; import { DEFAULT_CUSTOM_COLORS, getThemeColor, normalizeCustomColors } from "../common/color-themes"; import { hasAuthenticationDataPermission, @@ -38,6 +40,8 @@ let customColorsReady = false; /* Change language */ function localizeHtmlPage() { + const direction = chrome.i18n.getMessage("@@bidi_dir"); + if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction; //Localize by replacing __MSG_***__ meta tags var objects = document.getElementsByTagName("html"); for (var j = 0; j < objects.length; j++) { @@ -55,20 +59,35 @@ function localizeHtmlPage() { } localizeHtmlPage(); -const coreConsentReady = waitForCoreDataConsent({ alwaysExplain: true }); -coreConsentReady.then(() => initDiscussionPopup()); -onCoreDataPermissionChanged((granted) => { - if (!granted) void waitForCoreDataConsent({ alwaysExplain: true }); -}); +const privateBrowsing = isPrivateBrowsingContext(); +const coreConsentReady = privateBrowsing ? null : waitForCoreDataConsent({ alwaysExplain: true }); +if (privateBrowsing) { + const home = document.createElement("center"); + const message = document.createElement("p"); + message.id = "ryd-private-counts-message"; + message.textContent = + "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here."; + home.append( + document.querySelector("body > center > img").cloneNode(true), + document.querySelector("body > center > h1").cloneNode(true), + message, + ); + document.body.replaceChildren(home); +} else { + coreConsentReady.then(() => initDiscussionPopup()); + onCoreDataPermissionChanged((granted) => { + if (!granted) void waitForCoreDataConsent({ alwaysExplain: true }); + }); -/* Links */ -createLink(config.links.website, "link_website"); -createLink(config.links.github, "link_github"); -createLink(config.links.discord, "link_discord"); -createLink(config.links.faq, "link_faq"); -createLink(config.links.donate, "link_donate"); -createLink(config.links.help, "link_help"); -createLink(config.links.changelog, "link_changelog"); + /* Links */ + createLink(config.links.website, "link_website"); + createLink(config.links.github, "link_github"); + createLink(config.links.discord, "link_discord"); + createLink(config.links.faq, "link_faq"); + createLink(config.links.donate, "link_donate"); + createLink(config.links.help, "link_help"); + createLink(config.links.changelog, "link_changelog"); +} function createLink(url, id) { document.getElementById(id).addEventListener("click", () => { @@ -76,73 +95,75 @@ function createLink(url, id) { }); } -document.getElementById("disable_vote_submission").addEventListener("click", (ev) => { - chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked }); -}); +if (!privateBrowsing) { + document.getElementById("disable_vote_submission").addEventListener("click", (ev) => { + chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked }); + }); -document.getElementById("disable_logging").addEventListener("click", (ev) => { - chrome.storage.sync.set({ disableLogging: ev.target.checked }); -}); + document.getElementById("disable_logging").addEventListener("click", (ev) => { + chrome.storage.sync.set({ disableLogging: ev.target.checked }); + }); -document.getElementById("colored_thumbs").addEventListener("click", (ev) => { - chrome.storage.sync.set({ coloredThumbs: ev.target.checked }); -}); + document.getElementById("colored_thumbs").addEventListener("click", (ev) => { + chrome.storage.sync.set({ coloredThumbs: ev.target.checked }); + }); -document.getElementById("colored_bar").addEventListener("click", (ev) => { - chrome.storage.sync.set({ coloredBar: ev.target.checked }); -}); + document.getElementById("colored_bar").addEventListener("click", (ev) => { + chrome.storage.sync.set({ coloredBar: ev.target.checked }); + }); -document.getElementById("color_theme").addEventListener("change", (ev) => { - colorThemeRevision++; - handleColorThemeChangeEvent(ev.target.value); - chrome.storage.sync.set({ colorTheme: ev.target.value }); -}); + document.getElementById("color_theme").addEventListener("change", (ev) => { + colorThemeRevision++; + handleColorThemeChangeEvent(ev.target.value); + chrome.storage.sync.set({ colorTheme: ev.target.value }); + }); -for (const mode of ["light", "dark"]) { - for (const vote of ["like", "dislike"]) { - const input = document.getElementById(`custom_color_${mode}_${vote}`); - input.addEventListener("input", () => { - if (!customColorsReady) return; - customColorsRevision++; - const colors = normalizeCustomColors(config.customColors); - colors[mode][vote] = input.value; - handleCustomColorsChangeEvent(colors); - }); - input.addEventListener("change", () => { - if (!customColorsReady) return; - const colors = normalizeCustomColors(config.customColors); - colors[mode][vote] = input.value; - customColorsRevision++; - handleCustomColorsChangeEvent(colors); - chrome.storage.sync.set({ customColors: colors }); - }); + for (const mode of ["light", "dark"]) { + for (const vote of ["like", "dislike"]) { + const input = document.getElementById(`custom_color_${mode}_${vote}`); + input.addEventListener("input", () => { + if (!customColorsReady) return; + customColorsRevision++; + const colors = normalizeCustomColors(config.customColors); + colors[mode][vote] = input.value; + handleCustomColorsChangeEvent(colors); + }); + input.addEventListener("change", () => { + if (!customColorsReady) return; + const colors = normalizeCustomColors(config.customColors); + colors[mode][vote] = input.value; + customColorsRevision++; + handleCustomColorsChangeEvent(colors); + chrome.storage.sync.set({ customColors: colors }); + }); + } } + + document.getElementById("number_format").addEventListener("change", (ev) => { + chrome.storage.sync.set({ numberDisplayFormat: ev.target.value }); + }); + + document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => { + chrome.storage.sync.set({ showTooltipPercentage: ev.target.checked }); + }); + + document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => { + chrome.storage.sync.set({ tooltipPercentageMode: ev.target.value }); + }); + + document.getElementById("number_reformat_likes").addEventListener("click", (ev) => { + chrome.storage.sync.set({ numberDisplayReformatLikes: ev.target.checked }); + }); + + document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => { + chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked }); + }); + + document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => { + chrome.storage.sync.set({ hideClutterButtons: ev.target.checked }); + }); } -document.getElementById("number_format").addEventListener("change", (ev) => { - chrome.storage.sync.set({ numberDisplayFormat: ev.target.value }); -}); - -document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => { - chrome.storage.sync.set({ showTooltipPercentage: ev.target.checked }); -}); - -document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => { - chrome.storage.sync.set({ tooltipPercentageMode: ev.target.value }); -}); - -document.getElementById("number_reformat_likes").addEventListener("click", (ev) => { - chrome.storage.sync.set({ numberDisplayReformatLikes: ev.target.checked }); -}); - -document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => { - chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked }); -}); - -document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => { - chrome.storage.sync.set({ hideClutterButtons: ev.target.checked }); -}); - function initPatreonAuth() { const loggedOutView = document.getElementById("patreon-logged-out"); const loggedInView = document.getElementById("patreon-logged-in"); @@ -252,7 +273,7 @@ function initPatreonAuth() { return { status: "consent_required" }; } - const response = await fetchWithCoreConsent(getApiEndpoint("/api/auth/verify"), { + const response = await fetchWithAccountConsent(getApiEndpoint("/api/auth/verify"), { method: "POST", headers: { "Content-Type": "application/json", @@ -479,6 +500,13 @@ function initPatreonAuth() { showLoggedOutView(); }); + onCoreDataPermissionChanged((granted) => { + if (granted) return; + authenticationGeneration++; + chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]); + showLoggedOutView(); + }); + logoutBtn.addEventListener("click", () => { authenticationGeneration++; showLoggedOutView(); @@ -487,28 +515,30 @@ function initPatreonAuth() { }); } -coreConsentReady.then(() => initPatreonAuth()); +if (!privateBrowsing) { + coreConsentReady.then(() => initPatreonAuth()); -/* Advanced Toggle */ -const advancedToggle = document.getElementById("advancedToggle"); -advancedToggle.addEventListener("click", () => { - const adv = document.getElementById("advancedSettings"); - if (config.advanced) { - adv.style.transform = "scale(1.1)"; - adv.style.pointerEvents = "none"; - adv.style.opacity = "0"; - advancedToggle.innerHTML = config.showAdvancedMessage; - } else { - adv.style.transform = "scale(1)"; - adv.style.pointerEvents = "auto"; - adv.style.opacity = "1"; - advancedToggle.innerHTML = config.hideAdvancedMessage; - } - config.advanced = !config.advanced; -}); + /* Advanced Toggle */ + const advancedToggle = document.getElementById("advancedToggle"); + advancedToggle.addEventListener("click", () => { + const adv = document.getElementById("advancedSettings"); + if (config.advanced) { + adv.style.transform = "scale(1.1)"; + adv.style.pointerEvents = "none"; + adv.style.opacity = "0"; + advancedToggle.innerHTML = config.showAdvancedMessage; + } else { + adv.style.transform = "scale(1)"; + adv.style.pointerEvents = "auto"; + adv.style.opacity = "1"; + advancedToggle.innerHTML = config.hideAdvancedMessage; + } + config.advanced = !config.advanced; + }); -chrome.storage.onChanged.addListener(storageChangeHandler); -initConfig(); + chrome.storage.onChanged.addListener(storageChangeHandler); + initConfig(); +} function initConfig() { initializeDisableVoteSubmission(); diff --git a/Extensions/combined/popup.spec.js b/Extensions/combined/popup.spec.js index 15c8ea2..6aac2f0 100644 --- a/Extensions/combined/popup.spec.js +++ b/Extensions/combined/popup.spec.js @@ -13,10 +13,18 @@ const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avata describe("popup account consent lifecycle", () => { let readCachedSession; let removeConsent; + let removeCoreConsent; let consent; beforeEach(async () => { jest.resetModules(); + const permissionRemovedListeners = new Set(); + const coreConsentListeners = new Set(); + require("./src/core-data-consent").onCoreDataPermissionChanged.mockImplementation((listener) => { + coreConsentListeners.add(listener); + return () => coreConsentListeners.delete(listener); + }); + removeCoreConsent = () => [...coreConsentListeners].forEach((listener) => listener(false)); consent = true; document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); global.chrome = { @@ -28,8 +36,13 @@ describe("popup account consent lifecycle", () => { request: jest.fn(() => Promise.resolve(consent)), onRemoved: { addListener: (listener) => { - removeConsent = () => listener({ data_collection: ["authenticationInfo"] }); + permissionRemovedListeners.add(listener); + removeConsent = () => + [...permissionRemovedListeners].forEach((listener) => + listener({ data_collection: ["authenticationInfo"] }), + ); }, + removeListener: (listener) => permissionRemovedListeners.delete(listener), }, }, identity: { getRedirectURL: jest.fn() }, @@ -135,6 +148,31 @@ describe("popup account consent lifecycle", () => { expectLoggedOut(); }); + test("does not restore an account when core consent is removed during the final account-permission query", async () => { + let resolvePermission; + fetch.mockResolvedValue({ + json: async () => { + chrome.permissions.getAll.mockImplementationOnce(() => new Promise((resolve) => (resolvePermission = resolve))); + return { valid: true, membershipTier: "premium" }; + }, + }); + readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" }); + await flushPromises(); + expect(typeof resolvePermission).toBe("function"); + + removeCoreConsent(); + resolvePermission({ data_collection: ["authenticationInfo"] }); + await flushPromises(); + + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(chrome.storage.sync.remove).toHaveBeenCalledWith([ + "patreonAuthenticated", + "patreonUser", + "patreonSessionToken", + ]); + expectLoggedOut(); + }); + test.each(["revocation", "logout"])( "does not restore an account or avatar from verification after %s", async (event) => { diff --git a/Extensions/combined/ryd.background-changelog.spec.js b/Extensions/combined/ryd.background-changelog.spec.js index 6f24088..c55a188 100644 --- a/Extensions/combined/ryd.background-changelog.spec.js +++ b/Extensions/combined/ryd.background-changelog.spec.js @@ -1,4 +1,9 @@ jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() })); +jest.mock("./src/core-data-consent", () => ({ + hasCoreDataPermission: jest.fn(), + onCoreDataPermissionChanged: jest.fn(), + fetchWithCoreConsent: jest.fn(), +})); jest.mock("./src/data-collection-permissions", () => ({ hasAuthenticationDataPermission: jest.fn(), onAuthenticationDataPermissionRemoved: jest.fn(), @@ -14,9 +19,11 @@ describe("background changelog lifecycle", () => { let startupListener; let stored; - beforeEach(() => { + beforeEach(async () => { jest.resetModules(); stored = {}; + require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(false); + require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false); require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) }); global.__RYD_LIVE_TEST_BUILD__ = false; global.chrome = { @@ -54,6 +61,8 @@ describe("background changelog lifecycle", () => { global.browser = global.chrome; jest.spyOn(console, "debug").mockImplementation(() => {}); require("./ryd.background"); + await flushCallbacks(); + require("./src/core-data-consent").hasCoreDataPermission.mockClear(); }); afterEach(() => { @@ -69,8 +78,68 @@ describe("background changelog lifecycle", () => { expect(chrome.tabs.create).toHaveBeenCalledTimes(1); expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function)); expect(stored).toEqual({ lastShownChangelogVersion: VERSION }); + expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled(); }); + test.each([false, true])( + "opens Firefox setup on a fresh install with missing consent (temporary=%s)", + async (temporary) => { + require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); + installedListener({ reason: "install", temporary }); + await flushCallbacks(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(2); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function)); + expect(chrome.tabs.create).toHaveBeenCalledWith( + { url: "moz-extension://test/consent.html" }, + expect.any(Function), + ); + expect(require("./src/core-data-consent").hasCoreDataPermission).toHaveBeenCalledWith({ queryBackground: false }); + expect(stored).toEqual({ lastShownChangelogVersion: VERSION }); + }, + ); + + test("skips Firefox setup when core consent is already granted", async () => { + require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); + require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true); + installedListener({ reason: "install" }); + await flushCallbacks(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function)); + }); + + test("a delayed changelog storage read does not take focus from Firefox setup", async () => { + require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); + let readChangelogStorage; + chrome.storage.local.get.mockImplementationOnce((keys, callback) => { + readChangelogStorage = callback; + }); + installedListener({ reason: "install" }); + await flushCallbacks(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + expect(chrome.tabs.create).toHaveBeenLastCalledWith( + { url: "moz-extension://test/consent.html" }, + expect.any(Function), + ); + readChangelogStorage({}); + await flushCallbacks(); + expect(chrome.tabs.create).toHaveBeenLastCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function)); + expect(stored).toEqual({ lastShownChangelogVersion: VERSION }); + }); + + test.each(["update", "browser_update", "chrome_update"])( + "does not open Firefox permission setup on %s or subsequent startup", + async (reason) => { + require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); + stored.lastShownChangelogVersion = VERSION; + installedListener({ reason, previousVersion: "3.0.0.18" }); + await flushCallbacks(); + startupListener(); + await flushCallbacks(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled(); + }, + ); + test.each(["install", "update"])("preserves already-seen suppression on %s", async (reason) => { stored.lastShownChangelogVersion = "4.0.4"; installedListener({ reason, temporary: true, previousVersion: "4.0.4" }); diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js index d9c64c3..5ad4341 100644 --- a/Extensions/combined/ryd.background.js +++ b/Extensions/combined/ryd.background.js @@ -3,6 +3,8 @@ import { createVoteClient } from "../common/vote-client"; import { createBrowserCredentialStore } from "./src/vote-client-adapter"; import { loginWithGitHub } from "./src/github-auth"; import { createDiscussionBackground } from "./src/discussion-background"; +import { fetchWithAccountConsent } from "./src/account-data-consent"; +import { isFirefoxExtension, isNormalMessageSender } from "./src/private-browsing"; import { coreConsentError, fetchWithCoreConsent, @@ -58,6 +60,7 @@ const discussionBackground = createDiscussionBackground({ api, voteClient, fetchImpl: fetchWithCoreConsent, + accountFetchImpl: fetchWithAccountConsent, hasConsent: async () => (await hasCoreDataPermission({ queryBackground: false })) && (!usesFirefoxDataCollectionConsent() || (await hasAuthenticationDataPermission({ queryBackground: false }))), @@ -71,6 +74,7 @@ function broadcastPatreonStatus(authenticated, user, sessionToken, generation = if (generation !== authenticationGeneration) return; tabs .filter((tab) => { + if (isFirefoxExtension(api) && tab.incognito !== false) return false; try { const hostname = new URL(tab.url).hostname; return hostname === "youtube.com" || hostname.endsWith(".youtube.com"); @@ -245,6 +249,18 @@ function extractOAuthParams(responseUrl) { } api.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request?.message === "ryd_has_core_consent" || isNormalMessageSender(api, sender)) { + return handleMessage(request, sender, sendResponse); + } + sendResponse({ + success: false, + ok: false, + granted: false, + error: { code: "private_browsing", message: "Only count display is available in private windows." }, + }); +}); + +function handleMessage(request, sender, sendResponse) { if (request?.message === "ryd_open_consent") { if (sender.id !== api.runtime.id || sender.url !== api.runtime.getURL("consent.html")) { sendResponse({ success: false }); @@ -256,13 +272,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { if (consentPageTabId !== null) { try { const existing = await tabs.get(consentPageTabId); - if (existing.url === api.runtime.getURL("consent.html")) { + if (existing.url === api.runtime.getURL("consent.html") && existing.incognito === false) { await tabs.update(consentPageTabId, { active: true }); return; } } catch (_) {} } - const tab = await tabs.create({ url: api.runtime.getURL("consent.html") }); + const tab = await tabs.create({ + url: api.runtime.getURL("consent.html"), + ...(Number.isInteger(sender.tab?.windowId) ? { windowId: sender.tab.windowId } : {}), + }); consentPageTabId = tab.id; })().finally(() => { consentPageOpening = null; @@ -381,7 +400,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { ? chrome.identity.getRedirectURL() : ""; - const startRes = await fetchWithCoreConsent( + const startRes = await fetchWithAccountConsent( getApiEndpoint(`/api/auth/oauth/login?redirectUri=${encodeURIComponent(redirectUri)}`), ); const startData = await startRes.json(); @@ -396,7 +415,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { await requireCurrentAuthenticationConsent(generation); - const exchangeRes = await fetchWithCoreConsent(getApiEndpoint("/api/auth/oauth/exchange"), { + const exchangeRes = await fetchWithAccountConsent(getApiEndpoint("/api/auth/oauth/exchange"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -448,7 +467,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { : ""; const authData = await loginWithGitHub({ - fetchImpl: fetchWithCoreConsent, + fetchImpl: fetchWithAccountConsent, redirectUri, launchWebAuthFlow, requireConsent: () => requireCurrentAuthenticationConsent(generation), @@ -471,12 +490,12 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { })(); return true; } -}); +} -function openChangelogTab(version) { +function openChangelogTab(version, { active } = {}) { try { const url = getChangelogUrl(); - api.tabs.create({ url }, () => { + api.tabs.create({ url, ...(active === false ? { active: false } : {}) }, () => { if (api.runtime.lastError) { console.debug("Changelog tab could not open:", api.runtime.lastError.message); return; @@ -574,7 +593,7 @@ function persistChangelogVersion(version) { } } -function maybeShowChangelog(details) { +function maybeShowChangelog(details, { active } = {}) { const reason = details?.reason; if (!reason) { return; @@ -595,7 +614,7 @@ function maybeShowChangelog(details) { const showImmediately = reason === "install" || details.temporary === true; const showChangelog = () => { - openChangelogTab(currentVersion || null); + openChangelogTab(currentVersion || null, { active }); }; if (!storage || typeof storage.get !== "function") { @@ -646,8 +665,26 @@ function maybeShowChangelog(details) { } } +async function handleInstallation(details) { + const needsConsent = + details?.reason === "install" && + usesFirefoxDataCollectionConsent() && + !(await hasCoreDataPermission({ queryBackground: false })); + maybeShowChangelog(details, { active: needsConsent ? false : undefined }); + if (!needsConsent) return; + + try { + api.tabs.create({ url: api.runtime.getURL("consent.html") }, (tab) => { + if (api.runtime.lastError) return; + consentPageTabId = tab?.id ?? null; + }); + } catch (error) { + console.debug("Failed to open permission setup", error); + } +} + api.runtime.onInstalled.addListener((details) => { - maybeShowChangelog(details); + void handleInstallation(details); }); if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "function") { diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js index babb446..53042ce 100644 --- a/Extensions/combined/ryd.background.spec.js +++ b/Extensions/combined/ryd.background.spec.js @@ -6,6 +6,7 @@ jest.mock("./src/core-data-consent", () => ({ onCoreDataPermissionChanged: jest.fn(), })); +const normalSender = { id: "extension-id", tab: { id: 1, incognito: false, windowId: 1 } }; const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); const startData = () => { const loginUrl = fetch.mock.calls.filter(([url]) => url.includes("/github/login?")).at(-1)?.[0]; @@ -35,8 +36,10 @@ describe("background account consent lifecycle", () => { beforeEach(() => { jest.resetModules(); + const permissionRemovedListeners = new Set(); consent = true; require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true); + require("./src/core-data-consent").onCoreDataPermissionChanged.mockReturnValue(() => {}); originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto"); Object.defineProperty(globalThis, "crypto", { configurable: true, @@ -66,8 +69,13 @@ describe("background account consent lifecycle", () => { contains: jest.fn().mockResolvedValue(true), onRemoved: { addListener: (listener) => { - removeConsent = () => listener({ data_collection: ["authenticationInfo"] }); + permissionRemovedListeners.add(listener); + removeConsent = () => + [...permissionRemovedListeners].forEach((listener) => + listener({ data_collection: ["authenticationInfo"] }), + ); }, + removeListener: (listener) => permissionRemovedListeners.delete(listener), }, }, identity: { @@ -83,10 +91,12 @@ describe("background account consent lifecycle", () => { onChanged: { addListener: jest.fn() }, }, tabs: { - get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html" })), + get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html", incognito: false })), create: jest.fn(async () => ({ id: 9 })), update: jest.fn(async () => ({ id: 9 })), - query: jest.fn((query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }])), + query: jest.fn((query, callback) => + callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456", incognito: false }]), + ), sendMessage: jest.fn(), }, }; @@ -147,7 +157,7 @@ describe("background account consent lifecycle", () => { "data:text/plain,youtube.com", "blob:https://www.youtube.com/test", ]; - const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url })); + const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url, incognito: false })); const otherTabs = otherUrls.map((url, index) => ({ id: youtubeTabs.length + index, url })); chrome.tabs.query.mockImplementation((query, callback) => callback([...otherTabs, ...youtubeTabs])); const user = { fullName: "Test" }; @@ -159,7 +169,7 @@ describe("background account consent lifecycle", () => { const statusMessages = () => chrome.tabs.sendMessage.mock.calls.filter(([, payload]) => payload.message === "patreon_status_changed"); - messageListener({ message }, {}, response); + messageListener({ message }, normalSender, response); await flushPromises(); expect(response).toHaveBeenCalledWith({ success: true, user }); @@ -172,7 +182,7 @@ describe("background account consent lifecycle", () => { ); chrome.tabs.sendMessage.mockClear(); - messageListener({ message: "patreon_logout" }, {}, jest.fn()); + messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); await flushPromises(); expect(statusMessages()).toEqual( @@ -196,16 +206,16 @@ describe("background account consent lifecycle", () => { ok: true, json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }), }); - messageListener({ message: "patreon_oauth_login" }, {}, jest.fn()); + messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn()); await flushPromises(); expect(completeQuery).toEqual(expect.any(Function)); if (event === "revocation") revoke(); - else messageListener({ message: "patreon_logout" }, {}, jest.fn()); + else messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); await flushPromises(); const callsBeforeCompletion = chrome.tabs.sendMessage.mock.calls.slice(); - completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test" }]); + completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false }]); expect(chrome.tabs.sendMessage.mock.calls).toEqual(callsBeforeCompletion); expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( @@ -225,9 +235,71 @@ describe("background account consent lifecycle", () => { expect(chrome.storage.sync.set).not.toHaveBeenCalled(); }); + test.each([ + "send_vote", + "register", + "patreon_oauth_login", + "github_oauth_login", + "patreon_logout", + "ryd_discussion", + "set_state", + "send_links", + "ryd_open_tab", + "ryd_open_consent", + "ryd_has_authentication_consent", + ])("private %s is rejected before shared work", async (message) => { + const response = jest.fn(); + messageListener( + { + message, + videoId: "abcdefghijk", + vote: -1, + videoIds: Array(20).fill("abcdefghijk"), + url: "https://example.org/", + }, + { ...normalSender, tab: { id: 2, incognito: true }, url: "moz-extension://fixture/consent.html" }, + response, + ); + await flushPromises(); + expect(response).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + granted: false, + error: expect.objectContaining({ code: "private_browsing" }), + }), + ); + const client = require("../common/vote-client").createVoteClient.mock.results[0].value; + expect(client.ensureRegistered).not.toHaveBeenCalled(); + expect(client.getRegisteredUser).not.toHaveBeenCalled(); + expect(client.submitVote).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(chrome.storage.sync.remove).not.toHaveBeenCalled(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled(); + }); + + test("private tabs can read consent but receive no account broadcasts", async () => { + chrome.tabs.query.mockImplementation((query, callback) => + callback([ + { id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false }, + { id: 2, url: "https://www.youtube.com/watch?v=test", incognito: true }, + ]), + ); + const response = jest.fn(); + messageListener({ message: "ryd_has_core_consent" }, { id: "extension-id", tab: { incognito: true } }, response); + messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); + await flushPromises(); + expect(response).toHaveBeenCalledWith({ granted: true }); + const recipients = chrome.tabs.sendMessage.mock.calls + .filter(([, payload]) => payload.message === "patreon_status_changed") + .map(([id]) => id); + expect(recipients).toEqual([1]); + }); + test.each([-1, 0, 1])("submits vote %s through the client that registers on demand", async (vote) => { const response = jest.fn(); - messageListener({ message: "send_vote", videoId: "video123456", vote }, {}, response); + messageListener({ message: "send_vote", videoId: "video123456", vote }, normalSender, response); await flushPromises(); const voteClient = require("../common/vote-client").createVoteClient.mock.results[0].value; expect(voteClient.submitVote).toHaveBeenCalledWith("video123456", vote); @@ -239,7 +311,7 @@ describe("background account consent lifecycle", () => { async (message) => { require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false); const response = jest.fn(); - messageListener({ message, videoId: "video123456", vote: -1 }, {}, response); + messageListener({ message, videoId: "video123456", vote: -1 }, normalSender, response); await flushPromises(); const client = require("../common/vote-client").createVoteClient.mock.results[0].value; expect(client.ensureRegistered).not.toHaveBeenCalled(); @@ -251,7 +323,7 @@ describe("background account consent lifecycle", () => { test("a standalone registration request does not create an ID", async () => { const response = jest.fn(); - messageListener({ message: "register" }, {}, response); + messageListener({ message: "register" }, normalSender, response); await flushPromises(); expect(response).toHaveBeenCalledWith({ success: false, error: "vote_required" }); expect( @@ -261,7 +333,7 @@ describe("background account consent lifecycle", () => { test("only the explainer can open the consent page and repeated clicks reuse the tab", async () => { const response = jest.fn(); - const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" }; + const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" }; messageListener({ message: "ryd_open_consent" }, { ...sender, url: "https://www.youtube.com/" }, response); expect(response).toHaveBeenCalledWith({ success: false }); expect(chrome.tabs.create).not.toHaveBeenCalled(); @@ -275,7 +347,7 @@ describe("background account consent lifecycle", () => { }); test("reopens the explainer if its previous tab has navigated away", async () => { - const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" }; + const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" }; messageListener({ message: "ryd_open_consent" }, sender, jest.fn()); await flushPromises(); chrome.tabs.get.mockResolvedValue({ id: 9, url: "https://example.org/" }); @@ -290,7 +362,7 @@ describe("background account consent lifecycle", () => { async (message) => { consent = false; const response = jest.fn(); - messageListener({ message }, {}, response); + messageListener({ message }, normalSender, response); await flushPromises(); expect(fetch).not.toHaveBeenCalled(); expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled(); @@ -303,7 +375,7 @@ describe("background account consent lifecycle", () => { async (message) => { chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], origins: [] }); const response = jest.fn(); - messageListener({ message }, {}, response); + messageListener({ message }, normalSender, response); await flushPromises(); expect(fetch).not.toHaveBeenCalled(); expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled(); @@ -316,7 +388,7 @@ describe("background account consent lifecycle", () => { async (message) => { const start = deferred(); fetch.mockReturnValueOnce(start.promise); - messageListener({ message }, {}, jest.fn()); + messageListener({ message }, normalSender, jest.fn()); await flushPromises(); revoke(); start.resolve({ ok: true, json: async () => startData() }); @@ -332,9 +404,9 @@ describe("background account consent lifecycle", () => { const oauth = deferred(); fetch.mockResolvedValue({ ok: true, json: async () => startData() }); chrome.identity.launchWebAuthFlow.mockReturnValue(oauth.promise); - messageListener({ message }, {}, jest.fn()); + messageListener({ message }, normalSender, jest.fn()); await flushPromises(); - messageListener({ message: "patreon_logout" }, {}, jest.fn()); + messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); oauth.resolve("https://extension.example/callback?code=test&state=test"); await flushPromises(); expect(fetch).toHaveBeenCalledTimes(1); @@ -348,7 +420,7 @@ describe("background account consent lifecycle", () => { const exchange = deferred(); fetch.mockResolvedValueOnce({ ok: true, json: async () => startData() }).mockReturnValueOnce(exchange.promise); const response = jest.fn(); - messageListener({ message }, {}, response); + messageListener({ message }, normalSender, response); await flushPromises(); expect(fetch).toHaveBeenCalledTimes(2); revoke(); @@ -382,10 +454,10 @@ describe("background account consent lifecycle", () => { json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }), }); const response = jest.fn(); - messageListener({ message: "patreon_oauth_login" }, {}, response); + messageListener({ message: "patreon_oauth_login" }, normalSender, response); await flushPromises(); if (event === "revocation") revoke(); - else messageListener({ message: "patreon_logout" }, {}, jest.fn()); + else messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); await flushPromises(); expect(chrome.storage.sync.remove).not.toHaveBeenCalled(); saved(); @@ -436,11 +508,11 @@ describe("background account consent lifecycle", () => { ok: true, json: async () => ({ success: true, user: { fullName: "New" }, sessionToken: "new-token" }), }); - messageListener({ message: "patreon_oauth_login" }, {}, jest.fn()); + messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn()); await flushPromises(); - messageListener({ message: "patreon_logout" }, {}, jest.fn()); + messageListener({ message: "patreon_logout" }, normalSender, jest.fn()); const newResponse = jest.fn(); - messageListener({ message: "github_oauth_login" }, {}, newResponse); + messageListener({ message: "github_oauth_login" }, normalSender, newResponse); await flushPromises(); completeOldSave(); await flushPromises(); diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js index a0bb474..3481a80 100644 --- a/Extensions/combined/ryd.content-script.js +++ b/Extensions/combined/ryd.content-script.js @@ -21,6 +21,7 @@ import { restoreCurrentState, setInitialState, storedData, + NEUTRAL_STATE, } from "./src/state"; import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils"; import { @@ -42,6 +43,7 @@ import { initDiscussion } from "./src/discussion"; import { waitForCoreDataConsent } from "./src/consent-prompt"; import { onCoreDataPermissionChanged } from "./src/core-data-consent"; import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request"; +import { isPrivateBrowsingContext } from "./src/private-browsing"; import { actionBarHasHydratedData, captureShortsNativeControlInventory, @@ -70,10 +72,34 @@ let pendingShortsMutationSurface = null; let shortsRouteEpoch = 0; let observedRouteKey = null; let coreConsentGranted = false; +let privatePageActive = true; +const privateBrowsingContext = isPrivateBrowsingContext(); const pendingNavigation = createPendingNavigationTracker(); const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated"; const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request"; +function clearPrivateVoteState() { + clearVoteDataRequestCache(); + Object.assign(storedData, { likes: 0, dislikes: 0, previousState: NEUTRAL_STATE, videoId: null }); +} + +if (privateBrowsingContext) { + window.addEventListener("pagehide", () => { + privatePageActive = false; + clearPrivateVoteState(); + clearRenderedVoteState(); + initializedVideoId = null; + isSetInitialStateDone = false; + if (jsInitChecktimer !== null) { + clearInterval(jsInitChecktimer); + jsInitChecktimer = null; + } + }); + window.addEventListener("pageshow", () => { + privatePageActive = true; + }); +} + function injectPageWorldHelpers() { return new Promise((resolve) => { const script = document.createElement("script"); @@ -107,6 +133,7 @@ onCoreDataPermissionChanged((granted) => { coreConsentGranted = granted; if (!granted) { clearVoteDataRequestCache(); + if (privateBrowsingContext) clearPrivateVoteState(); storedData.videoId = null; initializedVideoId = null; isSetInitialStateDone = false; @@ -119,13 +146,17 @@ onCoreDataPermissionChanged((granted) => { const pageWorldHelpersReady = injectPageWorldHelpers(); // Listen before asynchronous initialization so settings changed in another tab // are applied while the page is still loading. -getBrowser().storage.onChanged.addListener(storageChangeHandler); -isStorageListenerRegistered = true; +if (!privateBrowsingContext) { + getBrowser().storage.onChanged.addListener(storageChangeHandler); + isStorageListenerRegistered = true; +} await initExtConfig(); observeColorTheme(); await pageWorldHelpersReady; -initPatreonFeatures(); -initDiscussion(); +if (!privateBrowsingContext) { + initPatreonFeatures(); + initDiscussion(); +} function ensureShortsNavigationObserver() { if (!isShorts()) { @@ -308,6 +339,7 @@ function getStableShortsControls(videoId) { } async function checkForInitialization() { + if (privateBrowsingContext && !privatePageActive) return; if (!coreConsentGranted) return; if (initializationCheckRunning) return; initializationCheckRunning = true; @@ -341,7 +373,7 @@ async function checkForInitialization() { } else { rendered = await setInitialState(); } - if (rendered === false || !coreConsentGranted) return; + if (rendered === false || !coreConsentGranted || (privateBrowsingContext && !privatePageActive)) return; const currentControls = getButtonControls(); if ( videoId !== getVideoId(window.location.href) || @@ -369,13 +401,17 @@ async function checkForInitialization() { clearInterval(jsInitChecktimer); jsInitChecktimer = null; } - if (!isStorageListenerRegistered) { + if (!privateBrowsingContext && !isStorageListenerRegistered) { getBrowser().storage.onChanged.addListener(storageChangeHandler); isStorageListenerRegistered = true; } } } catch (exception) { - console.warn("Initialization failed; retrying when the current controls are ready.", exception); + if (privateBrowsingContext) { + console.warn("Initialization failed; retrying when the current controls are ready."); + } else { + console.warn("Initialization failed; retrying when the current controls are ready.", exception); + } } finally { initializationCheckRunning = false; } @@ -405,6 +441,7 @@ const initializationCycle = createInitializationCycleRunner(async () => { }); function triggerInitializationCycle() { + if (privateBrowsingContext && !privatePageActive) return Promise.resolve(); if (!coreConsentGranted) return Promise.resolve(); return initializationCycle.request(); } @@ -415,6 +452,7 @@ async function setEventListeners() { document.addEventListener("yt-navigate-start", function () { clearVoteDataRequestCache(); + if (privateBrowsingContext) clearPrivateVoteState(); shortsRouteEpoch += 1; pendingShortsMutationSurface = null; pendingNavigation.begin(() => { @@ -523,6 +561,7 @@ function watchControlsNeedInitialization() { } setInterval(() => { + if (privateBrowsingContext && !privatePageActive) return; if (!coreConsentGranted) return; syncShortsRouteEpoch(); if (isShorts()) ensureShortsNavigationObserver(); diff --git a/Extensions/combined/src/account-data-consent.js b/Extensions/combined/src/account-data-consent.js new file mode 100644 index 0000000..b64e4eb --- /dev/null +++ b/Extensions/combined/src/account-data-consent.js @@ -0,0 +1,53 @@ +import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./core-data-consent"; +import { + hasAuthenticationDataPermission, + onAuthenticationDataPermissionRemoved, + usesFirefoxDataCollectionConsent, +} from "./data-collection-permissions"; + +function accountConsentError() { + return new Error("Authentication data consent required."); +} + +function fetchWithAccountConsent(input, options = {}, fetchImpl = globalThis.fetch) { + if (!usesFirefoxDataCollectionConsent()) return fetchWithCoreConsent(input, options, fetchImpl); + + return (async () => { + const controller = new AbortController(); + const abort = () => controller.abort(); + const unsubscribe = onAuthenticationDataPermissionRemoved(abort); + const unsubscribeCore = onCoreDataPermissionChanged((granted) => { + if (!granted) abort(); + }); + const api = typeof browser !== "undefined" ? browser : globalThis.chrome; + const messages = typeof api?.permissions?.onRemoved?.addListener === "function" ? null : api?.runtime?.onMessage; + const onMessage = (message) => { + if (message?.message === "patreon_status_changed" && message.authenticated === false) abort(); + }; + messages?.addListener?.(onMessage); + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + + try { + const response = await fetchWithCoreConsent( + input, + { ...options, signal: controller.signal }, + async (requestInput, requestOptions) => { + // Check after the core-consent query, immediately before sending account data. + const granted = await hasAuthenticationDataPermission(); + if (!granted || controller.signal.aborted || requestOptions.signal.aborted) throw accountConsentError(); + return fetchImpl(requestInput, requestOptions); + }, + ); + if (!(await hasAuthenticationDataPermission()) || controller.signal.aborted) throw accountConsentError(); + return response; + } finally { + unsubscribe(); + unsubscribeCore(); + messages?.removeListener?.(onMessage); + options.signal?.removeEventListener("abort", abort); + } + })(); +} + +export { fetchWithAccountConsent }; diff --git a/Extensions/combined/src/account-data-consent.spec.js b/Extensions/combined/src/account-data-consent.spec.js new file mode 100644 index 0000000..8dfa805 --- /dev/null +++ b/Extensions/combined/src/account-data-consent.spec.js @@ -0,0 +1,222 @@ +import { fetchWithAccountConsent } from "./account-data-consent"; +import { CORE_DATA_PERMISSIONS } from "./core-data-consent"; + +const URL = "https://api.example/account"; +const OPTIONS = { headers: { Authorization: "Bearer synthetic-token" } }; +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const deferred = () => { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +}; + +function event() { + const listeners = new Set(); + return { + addListener: (listener) => listeners.add(listener), + removeListener: (listener) => listeners.delete(listener), + emit: (value) => [...listeners].forEach((listener) => listener(value)), + size: () => listeners.size, + }; +} + +describe("account request consent boundary", () => { + let granted; + let fetchImpl; + + beforeEach(() => { + granted = [...CORE_DATA_PERMISSIONS, "authenticationInfo"]; + global.browser = { + runtime: { + getManifest: () => ({ browser_specific_settings: { gecko: { data_collection_permissions: {} } } }), + onMessage: event(), + sendMessage: jest.fn(), + }, + permissions: { + getAll: jest.fn(async () => ({ data_collection: [...granted] })), + request: jest.fn(), + onAdded: event(), + onRemoved: event(), + }, + }; + fetchImpl = jest.fn(async () => new Response('{"valid":true}')); + }); + + afterEach(() => { + expect(browser.runtime.onMessage.size()).toBe(0); + if (browser.permissions) { + expect(browser.permissions.onAdded.size()).toBe(0); + expect(browser.permissions.onRemoved.size()).toBe(0); + } + delete global.browser; + }); + + function revokeAuthentication() { + granted = [...CORE_DATA_PERMISSIONS]; + browser.permissions.onRemoved.emit({ data_collection: ["authenticationInfo"] }); + } + + test("granted account requests return their response without requesting permissions", async () => { + const response = await fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + expect(await response.json()).toEqual({ valid: true }); + expect(fetchImpl).toHaveBeenCalledWith(URL, expect.objectContaining(OPTIONS)); + expect(browser.permissions.request).not.toHaveBeenCalled(); + }); + + test.each(["missing", "unavailable"])("%s authentication consent blocks account traffic", async (state) => { + granted = [...CORE_DATA_PERMISSIONS]; + if (state === "unavailable") { + browser.permissions.getAll + .mockResolvedValueOnce({ data_collection: [...CORE_DATA_PERMISSIONS] }) + .mockRejectedValueOnce(new Error("Unavailable")); + } + await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("rechecks authentication after a delayed core query even without a removal event", async () => { + const coreCheck = deferred(); + browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + granted = [...CORE_DATA_PERMISSIONS]; + coreCheck.resolve({ data_collection: [...CORE_DATA_PERMISSIONS, "authenticationInfo"] }); + await expect(pending).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test.each([false, true])("revocation during a core query blocks traffic after regrant=%s", async (regrant) => { + const coreCheck = deferred(); + browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + revokeAuthentication(); + if (regrant) granted.push("authenticationInfo"); + coreCheck.resolve({ data_collection: [...granted] }); + await expect(pending).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test.each(["authenticationInfo", CORE_DATA_PERMISSIONS[0]])( + "revoking %s during the final account query blocks a stale granting result", + async (permission) => { + const accountCheck = deferred(); + browser.permissions.getAll + .mockResolvedValueOnce({ data_collection: [...granted] }) + .mockReturnValueOnce(accountCheck.promise); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + await flush(); + browser.permissions.onRemoved.emit({ data_collection: [permission] }); + accountCheck.resolve({ data_collection: [...granted] }); + await expect(pending).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }, + ); + + test("revocation aborts in-flight work and discards a body that finishes after regrant", async () => { + const body = deferred(); + let signal; + fetchImpl.mockImplementationOnce(async (_, options) => { + signal = options.signal; + return { arrayBuffer: () => body.promise }; + }); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + await flush(); + revokeAuthentication(); + granted.push("authenticationInfo"); + expect(signal.aborted).toBe(true); + body.resolve(new ArrayBuffer(0)); + await expect(pending).rejects.toThrow("consent"); + }); + + test("a final permission check discards a completed response if removal events are unavailable", async () => { + fetchImpl.mockImplementationOnce(async () => { + granted = [...CORE_DATA_PERMISSIONS]; + return new Response('{"valid":true}'); + }); + await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("content requests observe account revocation while their background permission query is pending", async () => { + delete browser.permissions; + const accountCheck = deferred(); + browser.runtime.sendMessage.mockImplementation(({ message }) => + message === "ryd_has_core_consent" ? Promise.resolve({ granted: true }) : accountCheck.promise, + ); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + await flush(); + browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false }); + accountCheck.resolve({ granted: true }); + await expect(pending).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("content revocation aborts a pending body and rejects its result", async () => { + delete browser.permissions; + browser.runtime.sendMessage.mockResolvedValue({ granted: true }); + const body = deferred(); + let signal; + fetchImpl.mockImplementationOnce(async (_, options) => { + signal = options.signal; + return { arrayBuffer: () => body.promise }; + }); + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + await flush(); + browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false }); + expect(signal.aborted).toBe(true); + body.resolve(new ArrayBuffer(0)); + await expect(pending).rejects.toThrow("consent"); + }); + + test.each(["native", "content"])( + "core revocation during the final %s account query discards a buffered response", + async (context) => { + const accountCheck = deferred(); + if (context === "native") { + browser.permissions.getAll + .mockResolvedValueOnce({ data_collection: [...granted] }) + .mockResolvedValueOnce({ data_collection: [...granted] }) + .mockResolvedValueOnce({ data_collection: [...granted] }) + .mockReturnValueOnce(accountCheck.promise); + } else { + delete browser.permissions; + let accountQueries = 0; + browser.runtime.sendMessage.mockImplementation(({ message }) => { + if (message === "ryd_has_core_consent" || ++accountQueries === 1) return Promise.resolve({ granted: true }); + return accountCheck.promise; + }); + } + const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + await flush(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + if (context === "native") { + browser.permissions.onRemoved.emit({ data_collection: [CORE_DATA_PERMISSIONS[0]] }); + accountCheck.resolve({ data_collection: [...granted] }); + } else { + browser.runtime.onMessage.emit({ message: "ryd_core_consent_changed", granted: false }); + accountCheck.resolve({ granted: true }); + } + await expect(pending).rejects.toThrow("consent"); + }, + ); + + test("caller cancellation prevents a pending account request from starting", async () => { + const accountCheck = deferred(); + browser.permissions.getAll + .mockResolvedValueOnce({ data_collection: [...granted] }) + .mockReturnValueOnce(accountCheck.promise); + const controller = new AbortController(); + const pending = fetchWithAccountConsent(URL, { ...OPTIONS, signal: controller.signal }, fetchImpl); + await flush(); + controller.abort(); + accountCheck.resolve({ data_collection: [...granted] }); + await expect(pending).rejects.toThrow("consent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("other browsers keep their ordinary request behavior", async () => { + browser.runtime.getManifest = () => ({}); + await fetchWithAccountConsent(URL, OPTIONS, fetchImpl); + expect(fetchImpl).toHaveBeenCalledWith(URL, OPTIONS); + expect(browser.permissions.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/Extensions/combined/src/amo-policy.spec.js b/Extensions/combined/src/amo-policy.spec.js index d10cc13..5d2c6ea 100644 --- a/Extensions/combined/src/amo-policy.spec.js +++ b/Extensions/combined/src/amo-policy.spec.js @@ -12,7 +12,7 @@ describe("Firefox AMO policy declarations", () => { const manifest = JSON.parse(readExtensionFile("manifest-firefox.json")); const gecko = manifest.browser_specific_settings.gecko; - expect(manifest.incognito).toBe("not_allowed"); + expect(manifest.incognito).toBe("spanning"); expect(gecko.id).toBe("{762f9885-5a13-4abd-9c77-433dcd38b8fd}"); expect(gecko.strict_min_version).toBe("140.0"); expect(gecko.data_collection_permissions).toEqual({ diff --git a/Extensions/combined/src/bar.js b/Extensions/combined/src/bar.js index 7f2e5b5..0bb1545 100644 --- a/Extensions/combined/src/bar.js +++ b/Extensions/combined/src/bar.js @@ -1,3 +1,4 @@ +import { logger } from "./logging"; import { getButtons, getDislikeButton, getLikeButton } from "./buttons"; import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts, isVideoLiked } from "./state"; import { getColorFromTheme, getElementWidth, getVideoId, isInViewport, querySelector } from "./utils"; @@ -192,7 +193,7 @@ function createRateBar(likes, dislikes) { } } } else { - console.log("removing bar"); + logger.log("removing bar"); if (rateBar) { (rateBar.closest(".ryd-tooltip") ?? rateBar).remove(); } diff --git a/Extensions/combined/src/buttons.js b/Extensions/combined/src/buttons.js index ccb8efc..f8b0c8f 100644 --- a/Extensions/combined/src/buttons.js +++ b/Extensions/combined/src/buttons.js @@ -1,5 +1,6 @@ import { isMobile, isShorts, extConfig } from "./state"; import { getVideoId, querySelector, querySelectorAll } from "./utils"; +import { isPrivateBrowsingContext } from "./private-browsing"; import { actionBarHasHydratedData, getShortsIdentityLinkVideoIds, @@ -310,6 +311,7 @@ function isSyntheticShortsDislike(dislikeButton) { function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeButton()) { if (!isSyntheticShortsDislike(dislikeButton)) return false; + if (isPrivateBrowsingContext()) pressed = false; dislikeButton.classList.toggle("style-default-active", pressed); dislikeButton.classList.toggle("style-text", !pressed); @@ -319,6 +321,7 @@ function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeBut function setSyntheticShortsDislikeEnabled(enabled, dislikeButton = getDislikeButton()) { if (!isSyntheticShortsDislike(dislikeButton)) return false; + if (isPrivateBrowsingContext()) enabled = false; const button = dislikeButton.querySelector("button"); if (!button) return false; diff --git a/Extensions/combined/src/buttons.spec.js b/Extensions/combined/src/buttons.spec.js index 7676af2..fb14abe 100644 --- a/Extensions/combined/src/buttons.spec.js +++ b/Extensions/combined/src/buttons.spec.js @@ -1,5 +1,11 @@ /** @jest-environment jsdom */ +jest.mock("./private-browsing", () => ({ + ...jest.requireActual("./private-browsing"), + isPrivateBrowsingContext: jest.fn(), +})); + +const { isPrivateBrowsingContext } = require("./private-browsing"); const { extConfig, refreshReactionPresentation } = require("./state"); const { ensureSyntheticShortsDislikeButton, @@ -94,6 +100,7 @@ function setNativeTextBoxes() { describe("desktop watch button ownership", () => { beforeEach(() => { + isPrivateBrowsingContext.mockReturnValue(false); history.replaceState({}, "", "/watch?v=BBBBBBBBBBB"); extConfig.selectors.buttons.regular.desktopMenu = ["ytd-menu-renderer.ytd-watch-metadata > div"]; extConfig.selectors.buttons.regular.desktopNoMenu = ["#top-level-buttons-computed"]; @@ -686,6 +693,43 @@ describe("desktop watch button ownership", () => { expect(dislike.classList.contains("style-default-active")).toBe(true); }); + test("keeps a private synthetic Shorts count disabled and unpressed through direct setters", () => { + isPrivateBrowsingContext.mockReturnValue(true); + history.replaceState({}, "", "/shorts/BBBBBBBBBBB"); + document.body.innerHTML = ` + + + + + + `; + const actionBar = document.querySelector("reel-action-bar-view-model"); + setBox(actionBar); + const dislike = ensureSyntheticShortsDislikeButton(actionBar, { + currentVideoId: "BBBBBBBBBBB", + isHydrated: true, + isStable: true, + }); + const nativeLike = actionBar.querySelector("like-button-view-model button"); + const nativeClick = jest.fn(); + nativeLike.addEventListener("click", nativeClick); + const syntheticClick = jest.fn(); + dislike.querySelector("button").addEventListener("click", syntheticClick); + + setSyntheticShortsDislikeEnabled(true, dislike); + setSyntheticShortsDislikePressed(true, dislike); + dislike.querySelector("button").click(); + + expect(dislike.querySelector("button").disabled).toBe(true); + expect(dislike.querySelector("button").getAttribute("aria-disabled")).toBe("true"); + expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("false"); + expect(dislike.classList.contains("style-default-active")).toBe(false); + expect(getButtonControls(actionBar).ready).toBe(true); + expect(syntheticClick).not.toHaveBeenCalled(); + expect(nativeClick).not.toHaveBeenCalled(); + expect(nativeLike.getAttribute("aria-pressed")).toBe("true"); + }); + test("does not mutate an offscreen URL-matching Shorts placeholder before activation", () => { history.replaceState({}, "", "/shorts/BBBBBBBBBBB"); document.body.innerHTML = ` diff --git a/Extensions/combined/src/changelog/index.js b/Extensions/combined/src/changelog/index.js index 17da7a5..e6c7909 100644 --- a/Extensions/combined/src/changelog/index.js +++ b/Extensions/combined/src/changelog/index.js @@ -26,6 +26,8 @@ function applyLocaleMetadata() { if (browserLocale) { document.documentElement.lang = browserLocale; } + const direction = chrome?.i18n?.getMessage?.("@@bidi_dir"); + if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction; } catch (error) { console.debug("Unable to resolve UI locale", error); } diff --git a/Extensions/combined/src/changelog/index.spec.js b/Extensions/combined/src/changelog/index.spec.js new file mode 100644 index 0000000..a500120 --- /dev/null +++ b/Extensions/combined/src/changelog/index.spec.js @@ -0,0 +1,54 @@ +/** @jest-environment jsdom */ +const fs = require("node:fs"); +const path = require("node:path"); + +jest.mock("../utils", () => ({ + getBrowser: () => "Chrome", + localize: (key) => require("../../_locales/en/messages.json")[key]?.message || "", +})); + +const { initChangelogPage } = require("./index"); + +describe("release page locale metadata", () => { + beforeEach(() => { + jest.spyOn(document, "readyState", "get").mockReturnValue("complete"); + document.documentElement.removeAttribute("lang"); + document.documentElement.removeAttribute("dir"); + document.documentElement.innerHTML = fs.readFileSync( + path.join(__dirname, "../../changelog/4/changelog_4.1.html"), + "utf8", + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete global.chrome; + document.documentElement.removeAttribute("lang"); + document.documentElement.removeAttribute("dir"); + }); + + test.each([ + ["fa", "rtl"], + ["en", "ltr"], + ])("uses %s language and %s direction while localizing the release page", (locale, direction) => { + global.chrome = { i18n: { getMessage: (key) => ({ "@@ui_locale": locale, "@@bidi_dir": direction })[key] } }; + + initChangelogPage(); + + expect(document.documentElement.lang).toBe(locale); + expect(document.documentElement.dir).toBe(direction); + expect(document.querySelector("h1").textContent).not.toContain("__MSG_"); + expect(document.documentElement.innerHTML).not.toContain("__MSG_"); + }); + + test.each(["", "auto"])("preserves document metadata when locale metadata is %j", (metadata) => { + document.documentElement.lang = "en"; + document.documentElement.dir = "ltr"; + global.chrome = { i18n: { getMessage: (key) => (key === "@@bidi_dir" ? metadata : "") } }; + + initChangelogPage(); + + expect(document.documentElement.lang).toBe("en"); + expect(document.documentElement.dir).toBe("ltr"); + }); +}); diff --git a/Extensions/combined/src/consent-prompt.js b/Extensions/combined/src/consent-prompt.js index 7ed5398..7275a55 100644 --- a/Extensions/combined/src/consent-prompt.js +++ b/Extensions/combined/src/consent-prompt.js @@ -1,5 +1,6 @@ import { consentApi, hasCoreDataPermission, onCoreDataPermissionChanged } from "./core-data-consent"; import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions"; +import { isPrivateBrowsingContext } from "./private-browsing"; let pendingConsent = null; @@ -10,9 +11,11 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) { if (await hasCoreDataPermission()) return; const api = consentApi(); let dismissed = false; - try { - dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true; - } catch (_) {} + if (!isPrivateBrowsingContext()) { + try { + dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true; + } catch (_) {} + } await new Promise((resolve) => { const host = document.createElement("div"); host.id = "ryd-consent-prompt"; diff --git a/Extensions/combined/src/consent-prompt.spec.js b/Extensions/combined/src/consent-prompt.spec.js index 4162366..566f28e 100644 --- a/Extensions/combined/src/consent-prompt.spec.js +++ b/Extensions/combined/src/consent-prompt.spec.js @@ -5,6 +5,7 @@ jest.mock("./core-data-consent", () => ({ onCoreDataPermissionChanged: jest.fn(), })); jest.mock("./data-collection-permissions", () => ({ usesFirefoxDataCollectionConsent: jest.fn() })); +jest.mock("./private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() })); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("consent overlay lifecycle", () => { @@ -16,7 +17,8 @@ describe("consent overlay lifecycle", () => { jest.resetModules(); core = require("./core-data-consent"); require("./data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); - local = { get: jest.fn(async () => ({})) }; + require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(false); + local = { get: jest.fn(async () => ({})), set: jest.fn(), remove: jest.fn() }; core.consentApi.mockReturnValue({ storage: { local }, runtime: { getURL: () => "moz-extension://fixture/consent.html" }, @@ -96,4 +98,43 @@ describe("consent overlay lifecycle", () => { changed(true); await pending; }); + + test("private setup skips stored dismissal state and dismisses only the current overlay", async () => { + require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(true); + local.get.mockResolvedValue({ rydConsentDismissed: true }); + const pending = waitForConsent(); + let granted = false; + void pending.then(() => (granted = true)); + const { frame, host } = await mounted(); + expect(host.hasAttribute("collapsed")).toBe(false); + expect(local.get).not.toHaveBeenCalled(); + message(frame, "ryd-consent-dismissed"); + await flush(); + expect(host.hasAttribute("collapsed")).toBe(true); + expect(granted).toBe(false); + host.shadowRoot.querySelector("button").click(); + expect(host.hasAttribute("collapsed")).toBe(false); + expect(local.set).not.toHaveBeenCalled(); + expect(local.remove).not.toHaveBeenCalled(); + core.hasCoreDataPermission.mockResolvedValue(true); + changed(true); + await pending; + expect(host.isConnected).toBe(false); + }); + + test.each([false, true])( + "an existing grant skips setup and stored dismissal state (private=%s)", + async (privateBrowsing) => { + require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing); + local.get.mockResolvedValue({ rydConsentDismissed: true }); + core.hasCoreDataPermission.mockResolvedValue(true); + await waitForConsent(); + expect(document.querySelector("#ryd-consent-prompt")).toBeNull(); + expect(core.consentApi).not.toHaveBeenCalled(); + expect(core.onCoreDataPermissionChanged).not.toHaveBeenCalled(); + expect(local.get).not.toHaveBeenCalled(); + expect(local.set).not.toHaveBeenCalled(); + expect(local.remove).not.toHaveBeenCalled(); + }, + ); }); diff --git a/Extensions/combined/src/content-script-private.spec.js b/Extensions/combined/src/content-script-private.spec.js new file mode 100644 index 0000000..37d139c --- /dev/null +++ b/Extensions/combined/src/content-script-private.spec.js @@ -0,0 +1,199 @@ +/** @jest-environment jsdom */ + +import { readFileSync } from "fs"; +import { join } from "path"; +import { transformSync } from "@babel/core"; +import * as initializationCycle from "./initialization-cycle"; + +const source = readFileSync(join(__dirname, "../ryd.content-script.js"), "utf8"); +const executable = transformSync(source, { + babelrc: false, + configFile: false, + plugins: [ + ({ types }) => ({ + visitor: { + ImportDeclaration(path) { + const bindings = path.node.specifiers.map(({ imported, local }) => + types.objectProperty(imported, local, false, imported.name === local.name), + ); + path.replaceWith( + types.variableDeclaration("const", [ + types.variableDeclarator( + types.objectPattern(bindings), + types.memberExpression(types.identifier("dependencies"), path.node.source, true), + ), + ]), + ); + }, + }, + }), + ], +}).code; +const execute = new Function( + "dependencies", + "window", + "document", + "setInterval", + "clearInterval", + "setTimeout", + "clearTimeout", + "__RYD_LIVE_TEST_BUILD__", + `return (async () => { ${executable} })();`, +); + +function harness(privateContext) { + const page = document.implementation.createHTMLDocument(); + const pageWindow = new EventTarget(); + pageWindow.location = new URL("https://www.youtube.com/watch?v=abcdefghijk"); + const buttons = page.createElement("div"); + const likeButton = page.createElement("div"); + const dislikeButton = page.createElement("div"); + const nativeLikeButton = page.createElement("button"); + const nativeDislikeButton = page.createElement("button"); + likeButton.append(nativeLikeButton); + dislikeButton.append(nativeDislikeButton); + buttons.append(likeButton, dislikeButton); + page.body.append(buttons); + const controls = { + buttons, + dislikeButton, + dislikeTextContainer: page.createElement("span"), + likeButton, + nativeDislikeButton, + nativeLikeButton, + ready: true, + }; + const storedData = { likes: 0, dislikes: 0, videoId: null, previousState: "NEUTRAL_STATE" }; + const storage = { onChanged: { addListener: jest.fn() } }; + const state = { + NEUTRAL_STATE: "NEUTRAL_STATE", + storedData, + clearRenderedVoteState: jest.fn(), + hasLoadedStateForVideo: (videoId) => storedData.videoId === videoId, + initExtConfig: jest.fn(async () => undefined), + isLikesDisabled: () => false, + isShorts: () => false, + restoreCurrentState: jest.fn(() => true), + setInitialState: jest.fn(async () => { + Object.assign(storedData, { likes: 100, dislikes: 10, videoId: "abcdefghijk" }); + return true; + }), + }; + let permissionChanged; + const buttonFunctions = { + getButtonControls: () => controls, + getDislikeTextContainer: () => controls.dislikeTextContainer, + hasRenderedBox: () => true, + markButtonsForVideo: jest.fn(), + setSyntheticShortsDislikeEnabled: jest.fn(), + }; + const dependencies = { + "./src/buttons": buttonFunctions, + "./src/state": state, + "./src/utils": { + getBrowser: () => ({ storage, runtime: { getURL: (path) => path } }), + getVideoId: () => "abcdefghijk", + isVideoLoaded: () => true, + }, + "./src/events": { + addLikeDislikeEventListener: jest.fn(), + createSmartimationObserver: jest.fn(), + storageChangeHandler: jest.fn(), + observeColorTheme: jest.fn(), + }, + "./src/bar": { hasUsableRateBar: () => true }, + "./src/initialization-cycle": initializationCycle, + "./src/patreon": { initPatreonFeatures: jest.fn() }, + "./src/discussion": { initDiscussion: jest.fn() }, + "./src/consent-prompt": { waitForCoreDataConsent: jest.fn(async () => true) }, + "./src/core-data-consent": { + onCoreDataPermissionChanged: (listener) => { + permissionChanged = listener; + }, + }, + "./src/vote-data-request": { + cancelObsoleteVoteDataRequests: jest.fn(), + clearVoteDataRequestCache: jest.fn(), + }, + "./src/private-browsing": { isPrivateBrowsingContext: () => privateContext }, + "../common/shorts-control-readiness": {}, + }; + const append = page.head.appendChild.bind(page.head); + page.head.appendChild = (script) => { + append(script); + script.onload.call(script); + return script; + }; + return { + buttonFunctions, + dependencies, + page, + pageWindow, + permissionChanged: (granted) => permissionChanged(granted), + run: () => execute(dependencies, pageWindow, page, setInterval, clearInterval, setTimeout, clearTimeout, false), + state, + storage, + }; +} + +const flush = async () => { + for (let index = 0; index < 20; index++) await Promise.resolve(); +}; + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +test.each([false, true])( + "initializes count display with context-specific account and storage setup (private=%s)", + async (privateContext) => { + const context = harness(privateContext); + await context.run(); + expect(context.state.initExtConfig).toHaveBeenCalledTimes(1); + expect(context.state.setInitialState).toHaveBeenCalledTimes(1); + expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1); + expect(context.storage.onChanged.addListener).toHaveBeenCalledTimes(privateContext ? 0 : 1); + expect(context.dependencies["./src/patreon"].initPatreonFeatures).toHaveBeenCalledTimes(privateContext ? 0 : 1); + expect(context.dependencies["./src/discussion"].initDiscussion).toHaveBeenCalledTimes(privateContext ? 0 : 1); + }, +); + +test.each(["pagehide", "yt-navigate-start", "consent-loss"])( + "clears private document count state on %s", + async (eventName) => { + const context = harness(true); + await context.run(); + const requests = context.dependencies["./src/vote-data-request"]; + requests.clearVoteDataRequestCache.mockClear(); + if (eventName === "consent-loss") context.permissionChanged(false); + else (eventName === "pagehide" ? context.pageWindow : context.page).dispatchEvent(new Event(eventName)); + expect(requests.clearVoteDataRequestCache).toHaveBeenCalled(); + expect(context.state.storedData).toEqual({ likes: 0, dislikes: 0, previousState: "NEUTRAL_STATE", videoId: null }); + expect(context.state.clearRenderedVoteState).toHaveBeenCalled(); + }, +); + +test("private page exit suppresses late initialization and requests a fresh state on return", async () => { + const context = harness(true); + let resolveInitialState; + context.state.setInitialState.mockImplementationOnce(() => new Promise((resolve) => (resolveInitialState = resolve))); + const initialized = context.run(); + await flush(); + expect(context.state.setInitialState).toHaveBeenCalledTimes(1); + context.pageWindow.dispatchEvent(new Event("pagehide")); + resolveInitialState(true); + await initialized; + jest.advanceTimersByTime(1000); + await flush(); + expect(context.buttonFunctions.markButtonsForVideo).not.toHaveBeenCalled(); + expect(context.buttonFunctions.setSyntheticShortsDislikeEnabled).not.toHaveBeenCalled(); + expect(context.state.setInitialState).toHaveBeenCalledTimes(1); + + context.pageWindow.dispatchEvent(new Event("pageshow")); + jest.advanceTimersByTime(500); + await flush(); + expect(context.state.setInitialState).toHaveBeenCalledTimes(2); + expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1); +}); diff --git a/Extensions/combined/src/core-data-consent.js b/Extensions/combined/src/core-data-consent.js index 23d0241..d76a353 100644 --- a/Extensions/combined/src/core-data-consent.js +++ b/Extensions/combined/src/core-data-consent.js @@ -1,4 +1,6 @@ import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions"; +import { getApiUrl } from "./config"; +import { isPrivateBrowsingContext } from "./private-browsing"; const CORE_DATA_PERMISSIONS = Object.freeze([ "personallyIdentifyingInfo", @@ -32,6 +34,7 @@ async function hasCoreDataPermission({ queryBackground = true } = {}) { } function requestCoreDataPermission() { + if (isPrivateBrowsingContext()) return Promise.resolve(false); if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true); try { // Request directly in the explainer's click handler to preserve Firefox's user gesture. @@ -72,6 +75,37 @@ function onCoreDataPermissionChanged(listener) { function fetchWithCoreConsent(input, options = {}, fetchImpl = globalThis.fetch) { if (!usesFirefoxDataCollectionConsent()) return fetchImpl(input, options); return (async () => { + if (isPrivateBrowsingContext()) { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + const method = options.method ?? input?.method ?? "GET"; + const allowedKeys = url.pathname === "/votes" ? ["videoId", "likeCount"] : []; + let allowedQuery = true; + // Firefox content-script wrappers do not expose URLSearchParams iterators. + url.searchParams.forEach((value, key) => { + if (!allowedKeys.includes(key)) allowedQuery = false; + }); + if ( + url.origin !== new URL(getApiUrl()).origin || + !["/votes", "/configs/selectors"].includes(url.pathname) || + method.toUpperCase() !== "GET" || + url.username || + url.password || + !allowedQuery || + options.body != null || + input?.body != null + ) + throw new Error("Only count display is available in private windows."); + input = url.href; + options = { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "omit", + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + signal: options.signal, + }; + } const controller = new AbortController(); const abort = () => controller.abort(); const unsubscribe = onCoreDataPermissionChanged((granted) => { diff --git a/Extensions/combined/src/core-data-consent.spec.js b/Extensions/combined/src/core-data-consent.spec.js index 54be343..361d720 100644 --- a/Extensions/combined/src/core-data-consent.spec.js +++ b/Extensions/combined/src/core-data-consent.spec.js @@ -135,6 +135,75 @@ describe("optional Firefox core data consent", () => { expect(browser.runtime.onMessage.size()).toBe(0); }); + test("private counts strip credentials and headers and disable caching and redirects", async () => { + granted = [...CORE_DATA_PERMISSIONS]; + browser.extension = { inIncognitoContext: true }; + const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}')); + for (const path of ["/votes?videoId=abcdefghijk&likeCount=100", "/configs/selectors"]) { + const result = await fetchWithCoreConsent( + `https://returnyoutubedislikeapi.com${path}`, + { credentials: "include", cache: "force-cache", headers: { Authorization: "synthetic-account-token" } }, + fetchImpl, + ); + expect(await result.json()).toEqual({ dislikes: 42 }); + expect(fetchImpl.mock.calls.at(-1)[1]).toEqual( + expect.objectContaining({ + method: "GET", + credentials: "omit", + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + }), + ); + } + expect(await requestCoreDataPermission()).toBe(false); + expect(browser.permissions.request).not.toHaveBeenCalled(); + }); + + test("private count checks work when Firefox blocks URLSearchParams iterators", async () => { + granted = [...CORE_DATA_PERMISSIONS]; + browser.extension = { inIncognitoContext: true }; + const keys = jest.spyOn(URLSearchParams.prototype, "keys").mockReturnValue({ + [Symbol.iterator]() { + throw new TypeError("Iterator access denied"); + }, + }); + try { + const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}')); + const response = await fetchWithCoreConsent( + "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=100", + {}, + fetchImpl, + ); + expect(await response.json()).toEqual({ dislikes: 42 }); + await expect( + fetchWithCoreConsent( + "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&sessionToken=synthetic", + {}, + fetchImpl, + ), + ).rejects.toThrow("count display"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + } finally { + keys.mockRestore(); + } + }); + + test.each([ + ["https://returnyoutubedislikeapi.com/send", {}], + ["https://returnyoutubedislikeapi.com/votes", { method: "POST" }], + ["https://returnyoutubedislikeapi.com/votes?sessionToken=synthetic", {}], + ["https://returnyoutubedislikeapi.com/configs/selectors?videoId=abcdefghijk", {}], + ["https://api.example/votes", {}], + ])("private requests reject non-display operations: %s", async (url, options) => { + granted = [...CORE_DATA_PERMISSIONS]; + browser.extension = { inIncognitoContext: true }; + const fetchImpl = jest.fn(); + await expect(fetchWithCoreConsent(url, options, fetchImpl)).rejects.toThrow("count display"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + test("other browsers keep their normal fetch and do not request Firefox permissions", async () => { browser.runtime.getManifest = () => ({ manifest_version: 3 }); const response = {}; diff --git a/Extensions/combined/src/data-collection-permissions.js b/Extensions/combined/src/data-collection-permissions.js index ca217ec..cec6d62 100644 --- a/Extensions/combined/src/data-collection-permissions.js +++ b/Extensions/combined/src/data-collection-permissions.js @@ -1,3 +1,5 @@ +import { isPrivateBrowsingContext } from "./private-browsing"; + const AUTHENTICATION_DATA_PERMISSION = "authenticationInfo"; const ACCOUNT_DATA_PERMISSIONS = Object.freeze([AUTHENTICATION_DATA_PERMISSION]); const AUTHENTICATION_DATA_DESCRIPTOR = Object.freeze({ @@ -51,6 +53,7 @@ function queryBackgroundForAuthenticationDataPermission() { } function hasAuthenticationDataPermission({ queryBackground = true } = {}) { + if (isPrivateBrowsingContext()) return Promise.resolve(false); if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true); const permissions = getPermissionsApi(); if (!permissions) { @@ -71,6 +74,7 @@ function hasAuthenticationDataPermission({ queryBackground = true } = {}) { } function requestAuthenticationDataPermission() { + if (isPrivateBrowsingContext()) return Promise.resolve(false); if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true); // Keep this direct request as the first operation in the login click stack. diff --git a/Extensions/combined/src/data-collection-permissions.spec.js b/Extensions/combined/src/data-collection-permissions.spec.js index ddb791a..46158f1 100644 --- a/Extensions/combined/src/data-collection-permissions.spec.js +++ b/Extensions/combined/src/data-collection-permissions.spec.js @@ -56,6 +56,21 @@ describe("Firefox data-collection permissions", () => { expect(request).not.toHaveBeenCalled(); }); + it("private windows cannot query or request account consent", async () => { + global.browser = { + extension: { inIncognitoContext: true }, + runtime: { getManifest: () => firefoxManifest, sendMessage: jest.fn() }, + permissions: { request: jest.fn(), getAll: jest.fn() }, + }; + await expect(hasAuthenticationDataPermission()).resolves.toBe(false); + await expect(requestAuthenticationDataPermission()).resolves.toBe(false); + expect(browser.permissions.getAll).not.toHaveBeenCalled(); + expect(browser.permissions.request).not.toHaveBeenCalled(); + delete browser.permissions; + await expect(hasAuthenticationDataPermission()).resolves.toBe(false); + expect(browser.runtime.sendMessage).not.toHaveBeenCalled(); + }); + it.each([{}, { data_collection: [] }, { data_collection: ["financialAndPaymentInfo"] }])( "does not accept a successful request without a recorded authentication grant: %j", async (grantedPermissions) => { diff --git a/Extensions/combined/src/discussion-background.js b/Extensions/combined/src/discussion-background.js index 12bfac6..20c054a 100644 --- a/Extensions/combined/src/discussion-background.js +++ b/Extensions/combined/src/discussion-background.js @@ -1,6 +1,7 @@ import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr"; import { createVoteClient, solvePuzzle } from "../../common/vote-client"; import { discussionConfig } from "./discussion-config"; +import { isFirefoxExtension, isNormalMessageSender } from "./private-browsing"; const ANONYMOUS_SESSION = Object.freeze({ authenticated: false, @@ -205,6 +206,7 @@ export function createDiscussionBackground({ api, voteClient, fetchImpl = (...args) => fetch(...args), + accountFetchImpl = fetchImpl, hasConsent = async () => true, settings = discussionConfig, connectionFactory = createConnection, @@ -303,7 +305,7 @@ export function createDiscussionBackground({ scope?.controllers.add(controller); const timeout = setTimeout(() => controller.abort(), 20000); try { - const response = await fetchImpl(`${origin}/api/discussion${path}`, { + const response = await (auth ? accountFetchImpl : fetchImpl)(`${origin}/api/discussion${path}`, { method, credentials: "omit", cache: "no-store", @@ -359,22 +361,58 @@ export function createDiscussionBackground({ type === "toast" ? { active: true, lastFocusedWindow: true } : {}, ); if (expected !== generation || stopped) return; - const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url)); + const firefox = isFirefoxExtension(api); + const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url) && (!firefox || tab.incognito === false)); await Promise.allSettled( (type === "toast" ? targets.slice(0, 1) : targets).map((tab) => callBrowser(api, api.tabs, "sendMessage", tab.id, message), ), ); if (type !== "toast" && expected === generation) { - await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {}); + if (firefox) { + try { + const extensionUrl = api.runtime.getURL(""); + for (const view of api.extension?.getViews?.() || []) { + try { + if ( + view.browser?.extension?.inIncognitoContext === false && + view.location?.href?.startsWith(extensionUrl) + ) + view.dispatchEvent(new view.CustomEvent("ryd-discussion-event", { detail: structuredClone(message) })); + } catch (_) {} + } + } catch (_) {} + } else { + await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {}); + } } } + function badgeText() { + return inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : ""; + } + + async function updateTabBadge(tab, expected = generation) { + if (expected !== generation || stopped || tab?.incognito !== false || !Number.isInteger(tab.id)) return; + const action = api.action || api.browserAction; + await callBrowser(api, action, "setBadgeText", { tabId: tab.id, text: badgeText() }).catch(() => {}); + if (expected === generation && !stopped) + await callBrowser(api, action, "setBadgeBackgroundColor", { tabId: tab.id, color: "#cc0000" }).catch(() => {}); + } + async function updateBadge(expected = generation) { if (expected !== generation || stopped) return; const action = api.action || api.browserAction; + if (isFirefoxExtension(api)) { + await callBrowser(api, action, "setBadgeText", { text: "" }).catch(() => {}); + if (expected !== generation || stopped) return; + const tabs = await callBrowser(api, api.tabs, "query", {}); + if (expected !== generation || stopped) return; + await Promise.allSettled((tabs || []).map((tab) => updateTabBadge(tab, expected))); + return; + } await callBrowser(api, action, "setBadgeText", { - text: inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "", + text: badgeText(), }).catch(() => {}); if (expected === generation) await callBrowser(api, action, "setBadgeBackgroundColor", { color: "#cc0000" }).catch(() => {}); @@ -706,6 +744,7 @@ export function createDiscussionBackground({ async function handleMessage(requestMessage, sender) { try { + if (!isNormalMessageSender(api, sender)) throw new DiscussionError("private_browsing"); const { operation, payload, scope: scopeValue } = validateRequest(api, requestMessage, sender); const scope = scopeFor(scopeValue, sender); if (operation === "cancelScope") { @@ -745,6 +784,11 @@ export function createDiscussionBackground({ } function initialize() { + if (isFirefoxExtension(api)) { + api.tabs?.onCreated?.addListener((tab) => updateTabBadge(tab).catch(() => {})); + api.tabs?.onUpdated?.addListener((tabId, change, tab) => updateTabBadge(tab).catch(() => {})); + updateBadge().catch(() => {}); + } api.tabs?.onRemoved?.addListener((tabId) => { for (const [key, scope] of navigationScopes) if (scope.tabId === tabId) { diff --git a/Extensions/combined/src/discussion-background.spec.js b/Extensions/combined/src/discussion-background.spec.js index 9bb1999..df5dd21 100644 --- a/Extensions/combined/src/discussion-background.spec.js +++ b/Extensions/combined/src/discussion-background.spec.js @@ -32,6 +32,17 @@ const deferred = () => { return { promise, resolve }; }; +function extensionView(incognito, url = "moz-extension://extension-id/popup.html") { + return { + browser: { extension: { inIncognitoContext: incognito } }, + location: { href: url }, + CustomEvent: jest.fn(function (type, options) { + return { type, detail: options.detail }; + }), + dispatchEvent: jest.fn(), + }; +} + function storageArea(data = {}) { return { data, @@ -47,16 +58,27 @@ function storageArea(data = {}) { }; } -function harness({ authenticated = false, consent = true, fetchOverride, testMode = false, solveOverride } = {}) { +function harness({ + authenticated = false, + consent = true, + fetchOverride, + accountFetchImpl, + testMode = false, + solveOverride, + firefox = false, + views = [], +} = {}) { const sync = storageArea(authenticated ? { patreonAuthenticated: true, patreonSessionToken: "account-token" } : {}); const local = storageArea(); let alarmListener; const api = { runtime: { id: "extension-id", - getURL: (path) => `chrome-extension://extension-id/${path}`, + getURL: (path) => `${firefox ? "moz" : "chrome"}-extension://extension-id/${path}`, + getManifest: () => (firefox ? { browser_specific_settings: { gecko: {} } } : {}), sendMessage: jest.fn((message, callback) => callback?.()), }, + extension: { getViews: jest.fn(() => views) }, storage: { sync, local }, action: { setBadgeText: jest.fn((value, callback) => callback?.()), @@ -65,12 +87,14 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod tabs: { query: jest.fn((query, callback) => callback([ - { id: 1, url: sender.url }, - { id: 2, url: sender.url }, - { id: 3, url: "https://example.com/" }, + { id: 1, url: sender.url, incognito: false }, + { id: 2, url: sender.url, incognito: false }, + { id: 3, url: "https://example.com/", incognito: false }, ]), ), sendMessage: jest.fn((id, message, callback) => callback?.()), + onCreated: { addListener: jest.fn() }, + onUpdated: { addListener: jest.fn() }, }, alarms: { create: jest.fn(), @@ -168,6 +192,7 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod api, voteClient, fetchImpl, + accountFetchImpl, hasConsent: async () => consent, connectionFactory, solve: solveOverride || jest.fn(async () => ({ solution: "AAAAAA==" })), @@ -206,7 +231,242 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod }; } +describe("Firefox private discussion isolation", () => { + const normalSender = { ...sender, tab: { ...sender.tab, incognito: false } }; + + test.each(["session", "capabilities", "comments", "react", "notifications", "cancelScope"])( + "rejects private %s before validation, account reads, or shared work", + async (operation) => { + const h = harness({ authenticated: true, firefox: true }); + const source = { ...normalSender, tab: { ...normalSender.tab, incognito: true } }; + expect(await h.send(operation, { invalid: true }, source)).toMatchObject({ + ok: false, + error: { code: "private_browsing" }, + }); + expect(h.sync.get).not.toHaveBeenCalled(); + expect(h.local.get).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.voteClient.getRegisteredUser).not.toHaveBeenCalled(); + expect(h.voteClient.ensureRegistered).not.toHaveBeenCalled(); + expect(h.api.tabs.sendMessage).not.toHaveBeenCalled(); + expect(h.api.action.setBadgeText).not.toHaveBeenCalled(); + }, + ); + + test("rejects a tab whose normal browsing context cannot be verified", async () => { + const h = harness({ authenticated: true, firefox: true }); + expect(await h.send("session", {}, sender)).toMatchObject({ error: { code: "private_browsing" } }); + expect(h.sync.get).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + test.each([true, undefined])( + "rejects a private or unverified extension view (%s) before account reads", + async (incognito) => { + const url = "moz-extension://extension-id/popup.html?rydContext=private-test-context"; + const h = harness({ authenticated: true, firefox: true, views: [extensionView(incognito, url)] }); + expect(await h.send("session", {}, { id: sender.id, url })).toMatchObject({ + ok: false, + error: { code: "private_browsing" }, + }); + expect(h.sync.get).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }, + ); + + test("preserves account requests from a verified normal Firefox extension view", async () => { + const url = "moz-extension://extension-id/popup.html?rydContext=normal-test-context"; + const h = harness({ authenticated: true, firefox: true, views: [extensionView(false, url)] }); + expect((await h.send("session", {}, { id: sender.id, url })).data.authenticated).toBe(true); + h.client.stop(); + }); + + test("delivers shared account events only to normal YouTube tabs and verified extension views", async () => { + const normal = extensionView(false); + const privateView = extensionView(true); + const unknown = extensionView(undefined); + const foreign = extensionView(false, "https://www.youtube.com/watch?v=video123456"); + const unavailable = extensionView(false); + unavailable.dispatchEvent.mockImplementation(() => { + throw new Error("View closed"); + }); + const h = harness({ + authenticated: true, + firefox: true, + views: [privateView, unknown, foreign, unavailable, normal], + }); + h.api.tabs.query.mockImplementation((query, done) => + done([ + { id: 2, url: sender.url, incognito: true }, + { id: 1, url: sender.url, incognito: false }, + { id: 3, url: sender.url }, + ]), + ); + expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true); + await flush(); + expect(h.api.tabs.sendMessage.mock.calls.length).toBeGreaterThan(0); + expect(h.api.tabs.sendMessage.mock.calls.every(([tabId]) => tabId === 1)).toBe(true); + expect( + normal.dispatchEvent.mock.calls.some( + ([event]) => event.detail.type === "session" && event.detail.data.authenticated, + ), + ).toBe(true); + expect( + normal.dispatchEvent.mock.calls.some( + ([event]) => event.detail.type === "inbox" && event.detail.data.unreadCount === 1, + ), + ).toBe(true); + expect(normal.CustomEvent).toHaveBeenCalledWith( + "ryd-discussion-event", + expect.objectContaining({ detail: expect.any(Object) }), + ); + for (const view of [privateView, unknown, foreign]) { + expect(view.CustomEvent).not.toHaveBeenCalled(); + expect(view.dispatchEvent).not.toHaveBeenCalled(); + } + expect(h.api.runtime.sendMessage).not.toHaveBeenCalled(); + h.client.stop(); + }); + + test("isolates each Firefox view's session and inbox mutations from the worker and other views", async () => { + const first = extensionView(false); + const second = extensionView(false); + first.dispatchEvent.mockImplementation(({ detail }) => { + if (detail.type === "session" && detail.data.profile) detail.data.profile.nickname = "Changed in one view"; + if (detail.type === "inbox" && detail.data.items.length) { + detail.data.items[0].read = true; + detail.data.items[0].actorNickname = "Changed in one view"; + } + }); + const h = harness({ authenticated: true, firefox: true, views: [first, second] }); + const response = await h.send("session", {}, normalSender); + await flush(); + expect(response.data.profile.nickname).toBe("Tester"); + expect((await h.send("session", {}, normalSender)).data.profile.nickname).toBe("Tester"); + const sessionEvent = second.dispatchEvent.mock.calls.find( + ([event]) => event.detail.type === "session" && event.detail.data.authenticated, + )[0]; + expect(sessionEvent.detail.data.profile.nickname).toBe("Tester"); + const inboxEvent = second.dispatchEvent.mock.calls.find( + ([event]) => event.detail.type === "inbox" && event.detail.data.items.length, + )[0]; + expect(inboxEvent.detail.data.items[0]).toMatchObject({ read: false, actorNickname: null }); + expect(h.local.data.discussionState.items[0]).toMatchObject({ read: false, actorNickname: null }); + h.client.stop(); + }); + + test("never falls back to global runtime account events when extension views are unavailable", async () => { + const h = harness({ authenticated: true, firefox: true }); + h.api.extension.getViews.mockImplementation(() => { + throw new Error("Unavailable"); + }); + expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true); + await flush(); + expect(h.api.runtime.sendMessage).not.toHaveBeenCalled(); + expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "inbox")).toBe(true); + h.client.stop(); + }); + + test("does not send account toasts to an active private YouTube tab", async () => { + const h = harness({ authenticated: true, firefox: true }); + h.api.tabs.query.mockImplementation((query, done) => + done( + query.active ? [{ id: 2, url: sender.url, incognito: true }] : [{ id: 1, url: sender.url, incognito: false }], + ), + ); + await h.send("session", {}, normalSender); + await flush(); + h.setInbox({ + items: [{ ...h.notification, revision: "2", likeCount: 2 }], + unreadCount: 1, + revision: "2", + nextCursor: null, + }); + h.sockets[0].handlers.NotificationsChanged({ revision: "2" }); + await flush(); + await flush(); + expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "toast")).toBe(false); + expect(h.api.tabs.sendMessage.mock.calls.some(([tabId]) => tabId === 2)).toBe(false); + h.client.stop(); + }); + + test("keeps the default badge empty and applies account counts only to normal tabs, including new tabs", async () => { + const h = harness({ authenticated: true, firefox: true }); + h.api.tabs.query.mockImplementation((query, done) => + done([ + { id: 1, url: sender.url, incognito: false }, + { id: 2, url: sender.url, incognito: true }, + { id: 3, url: "https://example.com/", incognito: false }, + { id: 4, url: sender.url }, + ]), + ); + h.client.initialize(); + await flush(); + await flush(); + const badgeValues = h.api.action.setBadgeText.mock.calls.map(([value]) => value); + expect(badgeValues).toContainEqual({ text: "" }); + expect(badgeValues).toContainEqual({ tabId: 1, text: "1" }); + expect(badgeValues).toContainEqual({ tabId: 3, text: "1" }); + expect( + badgeValues.every((value) => (value.tabId === undefined ? value.text === "" : [1, 3].includes(value.tabId))), + ).toBe(true); + expect(h.api.action.setBadgeBackgroundColor.mock.calls.every(([value]) => [1, 3].includes(value.tabId))).toBe(true); + + h.api.action.setBadgeText.mockClear(); + h.api.action.setBadgeBackgroundColor.mockClear(); + const created = h.api.tabs.onCreated.addListener.mock.calls[0][0]; + const updated = h.api.tabs.onUpdated.addListener.mock.calls[0][0]; + await created({ id: 5, incognito: true }); + await updated(5, {}, { id: 5, incognito: true }); + await created({ id: 6 }); + expect(h.api.action.setBadgeText).not.toHaveBeenCalled(); + expect(h.api.action.setBadgeBackgroundColor).not.toHaveBeenCalled(); + await created({ id: 7, incognito: false }); + await updated(8, {}, { id: 8, incognito: false }); + expect(h.api.action.setBadgeText.mock.calls.map(([value]) => value)).toEqual([ + { tabId: 7, text: "1" }, + { tabId: 8, text: "1" }, + ]); + h.client.clearSession(); + await flush(); + expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: "" }, expect.any(Function)); + h.client.stop(); + }); + + test("preserves non-Firefox incognito discussion requests and global account updates", async () => { + const h = harness({ authenticated: true }); + const response = await h.send("session", {}, { ...sender, tab: { ...sender.tab, incognito: true } }); + expect(response.data.authenticated).toBe(true); + await flush(); + expect( + h.api.runtime.sendMessage.mock.calls.some(([event]) => event.type === "session" && event.data.authenticated), + ).toBe(true); + expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ text: "1" }, expect.any(Function)); + expect(h.api.extension.getViews).not.toHaveBeenCalled(); + h.client.stop(); + }); +}); + describe("discussion worker boundary", () => { + test("authenticated discussion traffic uses the account guard while anonymous reads keep their ordinary path", async () => { + const accountFetchImpl = jest.fn(async () => { + throw new Error("Authentication data consent required."); + }); + const authenticated = harness({ authenticated: true, accountFetchImpl }); + expect((await authenticated.send("session")).ok).toBe(false); + expect(accountFetchImpl).toHaveBeenCalledWith( + `${ORIGIN}/api/discussion/me`, + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer account-token" }) }), + ); + expect(authenticated.fetchImpl).not.toHaveBeenCalled(); + + accountFetchImpl.mockClear(); + const anonymous = harness({ accountFetchImpl }); + expect((await anonymous.send("comments", { videoId: VIDEO_ID, sort: "new" })).ok).toBe(true); + expect(anonymous.fetchImpl).toHaveBeenCalled(); + expect(accountFetchImpl).not.toHaveBeenCalled(); + }); + test("public comments do not create a voter identity before the first reaction", async () => { const h = harness(); h.voteClient.getRegisteredUser.mockResolvedValue(null); diff --git a/Extensions/combined/src/discussion/client-content.spec.js b/Extensions/combined/src/discussion/client-content.spec.js new file mode 100644 index 0000000..3377bbf --- /dev/null +++ b/Extensions/combined/src/discussion/client-content.spec.js @@ -0,0 +1,31 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"} + */ +import { subscribeDiscussionEvents } from "./client"; + +afterEach(() => { + delete global.browser; +}); + +test("Firefox content clients ignore page-level account events and retain tab messages", () => { + const onMessage = { addListener: jest.fn(), removeListener: jest.fn() }; + global.browser = { + extension: { inIncognitoContext: false }, + runtime: { + getManifest: () => ({ browser_specific_settings: { gecko: {} } }), + getURL: (path) => `moz-extension://extension-id/${path}`, + onMessage, + }, + }; + const callback = jest.fn(); + const unsubscribe = subscribeDiscussionEvents(callback); + const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: true } }; + window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: message })); + expect(callback).not.toHaveBeenCalled(); + const receive = onMessage.addListener.mock.calls[0][0]; + receive(message); + expect(callback).toHaveBeenCalledWith(message); + unsubscribe(); + expect(onMessage.removeListener).toHaveBeenCalledWith(receive); +}); diff --git a/Extensions/combined/src/discussion/client.js b/Extensions/combined/src/discussion/client.js index 428e576..094a9fe 100644 --- a/Extensions/combined/src/discussion/client.js +++ b/Extensions/combined/src/discussion/client.js @@ -1,9 +1,15 @@ +import { isFirefoxExtension, isPrivateBrowsingContext } from "../private-browsing"; + function runtimeApi() { return typeof chrome !== "undefined" ? chrome.runtime : globalThis.browser?.runtime; } export function requestDiscussion(operation, payload = {}, scope) { return new Promise((resolve, reject) => { + if (isPrivateBrowsingContext()) { + reject(Object.assign(new Error("Discussion service unavailable"), { code: "private_browsing" })); + return; + } const runtime = runtimeApi(); if (!runtime?.sendMessage) { reject(Object.assign(new Error("Discussion service unavailable"), { code: "unavailable" })); @@ -79,6 +85,22 @@ export function createDiscussionRequestScope(videoId, request = requestDiscussio } export function subscribeDiscussionEvents(listener) { + if (isPrivateBrowsingContext()) return () => {}; + const api = typeof browser !== "undefined" ? browser : globalThis.chrome; + const extensionUrl = api?.runtime?.getURL?.(""); + if ( + isFirefoxExtension(api) && + typeof window !== "undefined" && + window.location.protocol === "moz-extension:" && + extensionUrl && + window.location.href.startsWith(extensionUrl) + ) { + const receive = (event) => { + if (event.detail?.message === "ryd_discussion_event") listener(event.detail); + }; + window.addEventListener("ryd-discussion-event", receive); + return () => window.removeEventListener("ryd-discussion-event", receive); + } const event = runtimeApi()?.onMessage; const receive = (message) => { if (message?.message === "ryd_discussion_event") listener(message); diff --git a/Extensions/combined/src/discussion/client.spec.js b/Extensions/combined/src/discussion/client.spec.js index a76e93c..c65184a 100644 --- a/Extensions/combined/src/discussion/client.spec.js +++ b/Extensions/combined/src/discussion/client.spec.js @@ -1,8 +1,93 @@ -/** @jest-environment jsdom */ +/** + * @jest-environment jsdom + * @jest-environment-options {"url":"moz-extension://extension-id/popup.html"} + */ import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client"; +function firefoxApi(incognito = false) { + return { + extension: { inIncognitoContext: incognito }, + runtime: { + getManifest: () => ({ browser_specific_settings: { gecko: {} } }), + getURL: (path) => `moz-extension://extension-id/${path}`, + sendMessage: jest.fn((message, done) => done({ ok: true, data: { authenticated: true } })), + onMessage: { addListener: jest.fn(), removeListener: jest.fn() }, + }, + }; +} + afterEach(() => { delete global.chrome; + delete global.browser; +}); + +test("private Firefox requests fail before reaching the shared runtime", async () => { + global.browser = firefoxApi(true); + await expect(requestDiscussion("session")).rejects.toMatchObject({ code: "private_browsing" }); + await expect(requestDiscussion("comments", { videoId: "abcdefghijk" })).rejects.toMatchObject({ + code: "private_browsing", + }); + expect(global.browser.runtime.sendMessage).not.toHaveBeenCalled(); +}); + +test("private Firefox clients do not subscribe to either account event channel", () => { + global.browser = firefoxApi(true); + const addEventListener = jest.spyOn(window, "addEventListener"); + const callback = jest.fn(); + const unsubscribe = subscribeDiscussionEvents(callback); + window.dispatchEvent( + new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event", type: "inbox" } }), + ); + expect(callback).not.toHaveBeenCalled(); + expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled(); + expect(addEventListener).not.toHaveBeenCalled(); + unsubscribe(); + addEventListener.mockRestore(); +}); + +test("normal Firefox requests retain the runtime request channel", async () => { + global.browser = firefoxApi(); + await expect(requestDiscussion("session")).resolves.toEqual({ authenticated: true }); + expect(global.browser.runtime.sendMessage).toHaveBeenCalledTimes(1); +}); + +test("normal Firefox extension pages receive account events from their own view and unsubscribe", () => { + global.browser = firefoxApi(); + const callback = jest.fn(); + const unsubscribe = subscribeDiscussionEvents(callback); + window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "unrelated" } })); + expect(callback).not.toHaveBeenCalled(); + const data = { message: "ryd_discussion_event", type: "inbox", data: { unreadCount: 3 } }; + window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data })); + expect(callback).toHaveBeenCalledWith(data); + expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled(); + unsubscribe(); + window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data })); + expect(callback).toHaveBeenCalledTimes(1); +}); + +test("does not accept view events on a different extension's page", () => { + global.browser = firefoxApi(); + global.browser.runtime.getURL = (path) => `moz-extension://different-extension/${path}`; + const callback = jest.fn(); + const unsubscribe = subscribeDiscussionEvents(callback); + window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event" } })); + expect(callback).not.toHaveBeenCalled(); + const receive = global.browser.runtime.onMessage.addListener.mock.calls[0][0]; + const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: false } }; + receive(message); + expect(callback).toHaveBeenCalledWith(message); + unsubscribe(); + expect(global.browser.runtime.onMessage.removeListener).toHaveBeenCalledWith(receive); +}); + +test("preserves non-Firefox private request behavior", async () => { + global.chrome = { + extension: { inIncognitoContext: true }, + runtime: { getManifest: () => ({}), sendMessage: jest.fn((message, done) => done({ ok: true, data: {} })) }, + }; + await expect(requestDiscussion("session")).resolves.toEqual({}); + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledTimes(1); }); test("uses a typed worker envelope and returns only the response data", async () => { diff --git a/Extensions/combined/src/discussion/index.js b/Extensions/combined/src/discussion/index.js index 0827488..a0d5728 100644 --- a/Extensions/combined/src/discussion/index.js +++ b/Extensions/combined/src/discussion/index.js @@ -4,10 +4,12 @@ import { discussionLanguage, discussionText as t } from "./localize"; import { createDiscussionPanel } from "./panel"; import { showDiscussionToast } from "./inbox"; import { button, element, discussionLinkTarget, isDiscussionCommentId } from "./dom"; +import { isPrivateBrowsingContext } from "../private-browsing"; let installed = null; export function initDiscussion({ request = requestDiscussion } = {}) { + if (isPrivateBrowsingContext()) return null; if (installed) return installed; let panel = null; let panelScope = null; diff --git a/Extensions/combined/src/discussion/index.spec.js b/Extensions/combined/src/discussion/index.spec.js index 8a0dcdd..be88265 100644 --- a/Extensions/combined/src/discussion/index.spec.js +++ b/Extensions/combined/src/discussion/index.spec.js @@ -3,7 +3,13 @@ * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"} */ +jest.mock("../private-browsing", () => ({ + ...jest.requireActual("../private-browsing"), + isPrivateBrowsingContext: jest.fn(), +})); + import { initDiscussion } from "./index"; +import { isPrivateBrowsingContext } from "../private-browsing"; import { NATIVE_STATE_REQUEST, NATIVE_STATE_ATTRIBUTE } from "./detector"; import { threadUrl } from "./dom"; import { discussionText } from "./localize"; @@ -20,6 +26,7 @@ let listener; let eventListener; beforeEach(() => { + isPrivateBrowsingContext.mockReturnValue(false); jest.useFakeTimers(); document.body.innerHTML = ``; window.history.replaceState({}, "", `/watch?v=${VIDEO}`); @@ -42,6 +49,24 @@ beforeEach(() => { ); document.addEventListener(NATIVE_STATE_REQUEST, listener, true); }); + +test("private discussion initialization creates no requests, bridge, UI or subscriptions", () => { + isPrivateBrowsingContext.mockReturnValue(true); + const privateRequest = jest.fn(); + const append = jest.spyOn(document.head, "append"); + const timer = jest.spyOn(global, "setInterval"); + try { + expect(initDiscussion({ request: privateRequest })).toBeNull(); + expect(privateRequest).not.toHaveBeenCalled(); + expect(append).not.toHaveBeenCalled(); + expect(timer).not.toHaveBeenCalled(); + expect(document.querySelector(".ryd-discussion-panel")).toBeNull(); + expect(eventListener).toBeUndefined(); + } finally { + append.mockRestore(); + timer.mockRestore(); + } +}); afterEach(() => { feature?.destroy(); document.removeEventListener(NATIVE_STATE_REQUEST, listener, true); diff --git a/Extensions/combined/src/discussion/panel.js b/Extensions/combined/src/discussion/panel.js index 0f6b9c2..354a82a 100644 --- a/Extensions/combined/src/discussion/panel.js +++ b/Extensions/combined/src/discussion/panel.js @@ -75,6 +75,7 @@ export function createDiscussionPanel({ let activeTab = "discussion"; const drafts = new Map(); const postingIds = new Map(); + let draftGeneration = 0; const rootReplyHandlers = new Map(); let moderationSequence = 0; let reportCursor = null; @@ -237,6 +238,8 @@ export function createDiscussionPanel({ if (submit.disabled) return; const text = input.value.trim(); if (!text || text.length > maxLength) return; + const accountSnapshot = draftGeneration; + const stillCurrent = () => current() && accountSnapshot === draftGeneration; submit.disabled = true; input.disabled = true; message.textContent = t("saving"); @@ -247,7 +250,7 @@ export function createDiscussionPanel({ ? { id: comment.id, text } : { videoId, text, requestId, ...(parentId ? { parentId, replyToId } : {}) }; const result = await request(comment ? "editComment" : "createComment", payload); - if (!current()) return; + if (!stillCurrent()) return; drafts.delete(key); postingIds.delete(key); if (onSaved) onSaved(result); @@ -258,9 +261,9 @@ export function createDiscussionPanel({ await load(); } } catch (error) { - if (current()) message.textContent = discussionError(error); + if (stillCurrent()) message.textContent = discussionError(error); } finally { - if (current()) { + if (stillCurrent()) { input.disabled = false; submit.disabled = !input.value.trim(); } @@ -676,6 +679,11 @@ export function createDiscussionPanel({ setSession(next) { const previousIdentity = session.authenticated ? session.profile?.id || "authenticated" : "anonymous"; const nextIdentity = next?.authenticated ? next.profile?.id || "authenticated" : "anonymous"; + if (previousIdentity !== nextIdentity) { + draftGeneration++; + drafts.clear(); + postingIds.clear(); + } accountGeneration++; moderationSequence++; session = next || SIGNED_OUT; diff --git a/Extensions/combined/src/discussion/panel.spec.js b/Extensions/combined/src/discussion/panel.spec.js index 05ce6a0..ad4f410 100644 --- a/Extensions/combined/src/discussion/panel.spec.js +++ b/Extensions/combined/src/discussion/panel.spec.js @@ -3,6 +3,101 @@ import { createDiscussionPanel } from "./panel"; import { discussionText } from "./localize"; +const otherSession = { + authenticated: true, + canPost: true, + isModerator: false, + profile: { id: "55555555-5555-4555-8555-555555555555", nickname: "Other writer" }, +}; + +function identityForm(root, kind) { + if (kind === "root") return root.querySelector(".ryd-discussion-composer form"); + root.querySelector(`[data-action='${kind === "edit" ? "edit" : "reply"}']`).click(); + return root.querySelector(".ryd-discussion-inline-editor form"); +} + +function typeIdentityDraft(form, text) { + const input = form.querySelector("textarea"); + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +test.each(["root", "reply", "edit"])("identity change discards the previous account's %s draft", async (kind) => { + const root = await mount({ session }); + typeIdentityDraft(identityForm(root, kind), "Private draft for first account"); + panel.setSession(otherSession); + await flush(); + expect(root.querySelector(".ryd-discussion-composer textarea").value).toBe(""); + expect(root.querySelector("[data-action='edit']")).toBeNull(); + panel.setSession(session); + await flush(); + expect(identityForm(root, kind).querySelector("textarea").value).toBe(kind === "edit" ? "First comment" : ""); +}); + +test("logout prevents a root draft returning after signing in again", async () => { + const root = await mount({ session }); + typeIdentityDraft(identityForm(root, "root"), "First account draft"); + panel.setSession({ authenticated: false, canPost: false, profile: null }); + await flush(); + panel.setSession(session); + await flush(); + expect(identityForm(root, "root").querySelector("textarea").value).toBe(""); +}); + +test("same-account refresh preserves a failed submission's retry ID", async () => { + Object.defineProperty(globalThis.crypto, "randomUUID", { + configurable: true, + value: jest + .fn() + .mockReturnValueOnce("44444444-4444-4444-8444-444444444444") + .mockReturnValue("66666666-6666-4666-8666-666666666666"), + }); + const root = await mount({ session }); + request.mockRejectedValueOnce({ code: "unavailable" }); + submit(identityForm(root, "root"), "Retry my own draft"); + await flush(); + panel.setSession({ ...session }); + await flush(); + request.mockResolvedValueOnce(comment({ text: "Retry my own draft" })); + identityForm(root, "root").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flush(); + const creates = request.mock.calls.filter(([operation]) => operation === "createComment"); + expect(creates).toHaveLength(2); + expect(creates[1][1].requestId).toBe(creates[0][1].requestId); +}); + +test.each(["root", "reply", "edit"])("same-account refresh preserves its %s draft", async (kind) => { + const root = await mount({ session }); + typeIdentityDraft(identityForm(root, kind), "Keep my own draft"); + panel.setSession({ ...session, profile: { ...session.profile } }); + await flush(); + expect(identityForm(root, kind).querySelector("textarea").value).toBe("Keep my own draft"); +}); + +test.each(["root", "reply", "edit"])("old %s completion cannot clear a draft after A to B to A", async (kind) => { + const root = await mount({ session }); + let finish; + const operation = kind === "edit" ? "editComment" : "createComment"; + request.mockImplementation((name) => { + if (name === operation) + return new Promise((resolve) => { + finish = resolve; + }); + return Promise.resolve({ items: [comment()], nextCursor: null }); + }); + submit(identityForm(root, kind), "Old pending submission"); + panel.setSession(otherSession); + await flush(); + panel.setSession(session); + await flush(); + typeIdentityDraft(identityForm(root, kind), "New draft after returning"); + finish(comment({ ...(kind === "reply" ? { id: REPLY, parentId: ROOT } : {}), text: "Old pending submission" })); + await flush(); + panel.setSession({ ...session }); + await flush(); + expect(identityForm(root, kind).querySelector("textarea").value).toBe("New draft after returning"); +}); + const VIDEO = "abcdefghijk"; const ROOT = "11111111-1111-1111-1111-111111111111"; const REPLY = "22222222-2222-2222-2222-222222222222"; diff --git a/Extensions/combined/src/discussion/popup.js b/Extensions/combined/src/discussion/popup.js index e9ea8de..916b435 100644 --- a/Extensions/combined/src/discussion/popup.js +++ b/Extensions/combined/src/discussion/popup.js @@ -2,8 +2,10 @@ import { requestDiscussion, subscribeDiscussionEvents } from "./client"; import { createDiscussionInbox } from "./inbox"; import { discussionText as t } from "./localize"; import { button, element } from "./dom"; +import { isPrivateBrowsingContext } from "../private-browsing"; export function initDiscussionPopup() { + if (isPrivateBrowsingContext()) return; const root = document.getElementById("ryd-discussion-popup"); if (!root) return; if (!document.documentElement.lang && chrome.i18n?.getUILanguage) diff --git a/Extensions/combined/src/discussion/popup.spec.js b/Extensions/combined/src/discussion/popup.spec.js index 32c8c50..2d27007 100644 --- a/Extensions/combined/src/discussion/popup.spec.js +++ b/Extensions/combined/src/discussion/popup.spec.js @@ -111,6 +111,29 @@ test("a shared inbox update preserves the current screen and its navigation", as expect(document.activeElement).toBe(toggle); }); +test("private Firefox popup initialization does not request or subscribe to account data", async () => { + await flush(); + window.dispatchEvent(new Event("unload")); + document.body.innerHTML = new DOMParser().parseFromString(popupHtml, "text/html").body.innerHTML; + requestDiscussion.mockClear(); + subscribeDiscussionEvents.mockClear(); + global.browser = { + runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) }, + extension: { inIncognitoContext: true }, + }; + try { + initDiscussionPopup(); + const privateRoot = document.getElementById("ryd-discussion-popup"); + document.getElementById("ryd-discussion-inbox-toggle").click(); + expect(privateRoot.hidden).toBe(true); + expect(privateRoot.childElementCount).toBe(0); + expect(requestDiscussion).not.toHaveBeenCalled(); + expect(subscribeDiscussionEvents).not.toHaveBeenCalled(); + } finally { + delete global.browser; + } +}); + test("Escape on the home screen leaves the popup unchanged", () => { const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true }); document.dispatchEvent(event); diff --git a/Extensions/combined/src/events.js b/Extensions/combined/src/events.js index 9933b5d..e1269d3 100644 --- a/Extensions/combined/src/events.js +++ b/Extensions/combined/src/events.js @@ -1,3 +1,4 @@ +import { logger } from "./logging"; import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils"; import { checkForSignInButton, @@ -14,6 +15,7 @@ import { storedData, refreshReactionPresentation, persistSyntheticShortsDislikeState, + updateDisableLogging, } from "./state"; import { publishHideClutterButtons } from "./clutter-button-setting"; import { createRateBar } from "./bar"; @@ -28,8 +30,10 @@ import { } from "../../common/vote-transition"; import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation"; import { normalizeCustomColors, observeYouTubeTheme } from "../../common/color-themes"; +import { isPrivateBrowsingContext } from "./private-browsing"; function sendVote(vote) { + if (isPrivateBrowsingContext()) return; if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) { const result = getBrowser().runtime.sendMessage({ message: "send_vote", @@ -69,6 +73,7 @@ function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, synth } function handleVoteAction(action) { + if (isPrivateBrowsingContext()) return; if (checkForSignInButton() === false) { const videoId = getVideoId(window.location.href); if (!videoId || storedData.videoId !== videoId) return; @@ -150,6 +155,10 @@ function ensureDelegatedReactionListener() { function addLikeDislikeEventListener(likeButton = getLikeButton(), dislikeButton = getDislikeButton()) { if (!likeButton || !dislikeButton) return; + if (isPrivateBrowsingContext()) { + observeReactionPresentation(likeButton, dislikeButton); + return; + } ensureDelegatedReactionListener(); observeReactionPresentation(likeButton, dislikeButton); if (!boundDislikeButtons.has(dislikeButton)) { @@ -176,7 +185,7 @@ function createSmartimationObserver(buttons = getButtons()) { const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, buttons); if (smartimationContainer && smartimationObserver.container != smartimationContainer) { - console.log("Initializing smartimation mutation observer"); + logger.log("Initializing smartimation mutation observer"); smartimationObserver.disconnect(); smartimationObserver.observe(smartimationContainer); smartimationObserver.container = smartimationContainer; @@ -184,6 +193,10 @@ function createSmartimationObserver(buttons = getButtons()) { } function storageChangeHandler(changes, area) { + if (isPrivateBrowsingContext()) return; + if (changes.disableLogging !== undefined && area === "sync") { + updateDisableLogging(changes.disableLogging.newValue); + } if (changes.disableVoteSubmission !== undefined) { handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue); } @@ -203,6 +216,16 @@ function storageChangeHandler(changes, area) { if (changes.numberDisplayFormat !== undefined) { handleNumberDisplayFormatChangeEvent(changes.numberDisplayFormat.newValue); } + if (area === "sync" && (changes.showTooltipPercentage !== undefined || changes.tooltipPercentageMode !== undefined)) { + if (changes.showTooltipPercentage !== undefined) { + extConfig.showTooltipPercentage = changes.showTooltipPercentage.newValue ?? false; + } + if (changes.tooltipPercentageMode !== undefined) { + extConfig.tooltipPercentageMode = changes.tooltipPercentageMode.newValue || "dash_like"; + } + const videoId = getVideoId(window.location.href); + if (videoId && storedData.videoId === videoId) createRateBar(storedData.likes, storedData.dislikes); + } if (changes.numberDisplayReformatLikes !== undefined) { handleNumberDisplayReformatLikesChangeEvent(changes.numberDisplayReformatLikes.newValue); } @@ -249,6 +272,7 @@ function observeColorTheme() { function handleNumberDisplayFormatChangeEvent(value) { extConfig.numberDisplayFormat = value; refreshReactionPresentation(); + updateDOMDislikes(); } function handleNumberDisplayReformatLikesChangeEvent(value) { diff --git a/Extensions/combined/src/events.spec.js b/Extensions/combined/src/events.spec.js index 70cd0f3..4548989 100644 --- a/Extensions/combined/src/events.spec.js +++ b/Extensions/combined/src/events.spec.js @@ -5,6 +5,11 @@ const mockSendMessage = jest.fn(); +jest.mock("./private-browsing", () => ({ + ...jest.requireActual("./private-browsing"), + isPrivateBrowsingContext: jest.fn(), +})); + jest.mock("./utils", () => ({ createObserver: () => ({ disconnect: jest.fn(), observe: jest.fn() }), getBrowser: jest.fn(), @@ -19,7 +24,7 @@ jest.mock("./buttons", () => ({ getDislikeButton: jest.fn(), getLikeButton: jest.fn(), getNativeButton: (control) => control?.querySelector("button[aria-pressed]"), - isSyntheticShortsDislike: () => false, + isSyntheticShortsDislike: jest.fn(() => false), setSyntheticShortsDislikePressed: jest.fn(), })); @@ -32,6 +37,7 @@ jest.mock("./state", () => ({ getLikeCountFromButton: jest.fn(), persistSyntheticShortsDislikeState: jest.fn(async () => undefined), setDislikes: jest.fn(), + updateDisableLogging: jest.fn(), setLikes: jest.fn(), refreshReactionPresentation: jest.fn(), storedData: { dislikes: 100, likes: 300, previousState: "neutral", videoId: "abcdefghijk" }, @@ -42,12 +48,23 @@ jest.mock("./bar", () => ({ })); import { createRateBar } from "./bar"; -import { getButtons, getDislikeButton, getLikeButton } from "./buttons"; -import { extConfig, setDislikes, storedData } from "./state"; -import { addLikeDislikeEventListener, storageChangeHandler, updateDOMDislikes } from "./events"; +import { getButtons, getDislikeButton, getLikeButton, isSyntheticShortsDislike } from "./buttons"; +import { extConfig, persistSyntheticShortsDislikeState, setDislikes, storedData, updateDisableLogging } from "./state"; +import { + addLikeDislikeEventListener, + dislikeClicked, + likeClicked, + sendVote, + storageChangeHandler, + updateDOMDislikes, +} from "./events"; +import { isPrivateBrowsingContext } from "./private-browsing"; import { getBrowser } from "./utils"; beforeEach(() => { + isPrivateBrowsingContext.mockReturnValue(false); + isSyntheticShortsDislike.mockReturnValue(false); + extConfig.disableVoteSubmission = false; createRateBar.mockClear(); setDislikes.mockClear(); mockSendMessage.mockReset(); @@ -62,6 +79,47 @@ beforeEach(() => { history.replaceState({}, "", "/watch?v=abcdefghijk"); }); +test.each([false, true])("private direct reactions do not alter counts or submit (disabled=%s)", (disabled) => { + isPrivateBrowsingContext.mockReturnValue(true); + extConfig.disableVoteSubmission = disabled; + storedData.previousState = "LIKED_STATE"; + const before = { ...storedData }; + const likeButton = document.createElement("div"); + const nativeLike = document.createElement("button"); + const nativeLikeClick = jest.fn(); + nativeLike.addEventListener("click", nativeLikeClick); + likeButton.append(nativeLike); + getLikeButton.mockReturnValue(likeButton); + isSyntheticShortsDislike.mockReturnValue(true); + + sendVote(-1); + dislikeClicked(); + likeClicked(); + + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(nativeLikeClick).not.toHaveBeenCalled(); + expect(persistSyntheticShortsDislikeState).not.toHaveBeenCalled(); + expect(storedData).toEqual(before); + expect(setDislikes).not.toHaveBeenCalled(); +}); + +test("private native reactions keep their YouTube click behavior without local reaction changes", () => { + isPrivateBrowsingContext.mockReturnValue(true); + const controls = createReactionControls("private"); + document.body.replaceChildren(controls.buttons); + getButtons.mockReturnValue(controls.buttons); + getLikeButton.mockReturnValue(controls.likeButton); + getDislikeButton.mockReturnValue(controls.dislikeButton); + const nativeAction = jest.fn(); + controls.nativeDislikeButton.addEventListener("click", nativeAction); + const before = { ...storedData }; + addLikeDislikeEventListener(controls.likeButton, controls.dislikeButton); + controls.nativeDislikeButton.click(); + expect(nativeAction).toHaveBeenCalledTimes(1); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(storedData).toEqual(before); +}); + test("renders cached counts only while they belong to the current video", () => { expect(updateDOMDislikes()).toBe(true); expect(setDislikes).toHaveBeenCalledWith("100"); @@ -84,6 +142,22 @@ test("does not render cached counts when the current URL has no video identity", expect(createRateBar).not.toHaveBeenCalled(); }); +test.each([true, false, undefined])("applies live sync logging preference changes (%s)", (newValue) => { + storageChangeHandler({ disableLogging: { oldValue: !newValue, newValue } }, "sync"); + expect(updateDisableLogging).toHaveBeenCalledWith(newValue); +}); + +test.each(["local", "session", "managed", undefined])("ignores logging preference events outside sync (%s)", (area) => { + storageChangeHandler({ disableLogging: { newValue: false } }, area); + expect(updateDisableLogging).not.toHaveBeenCalled(); +}); + +test("private tabs ignore normal logging preference changes", () => { + isPrivateBrowsingContext.mockReturnValue(true); + storageChangeHandler({ disableLogging: { newValue: false } }, "sync"); + expect(updateDisableLogging).not.toHaveBeenCalled(); +}); + test("publishes hide-clutter setting changes to the page-world menu fixer", () => { storageChangeHandler({ hideClutterButtons: { newValue: true } }, "sync"); diff --git a/Extensions/combined/src/logging.js b/Extensions/combined/src/logging.js new file mode 100644 index 0000000..35fe3f5 --- /dev/null +++ b/Extensions/combined/src/logging.js @@ -0,0 +1,14 @@ +let loggingDisabled = false; + +export function setLoggingDisabled(value) { + loggingDisabled = value; +} + +export const logger = { + log(...args) { + if (!loggingDisabled) console.log(...args); + }, + debug(...args) { + if (!loggingDisabled) console.debug(...args); + }, +}; diff --git a/Extensions/combined/src/logging.spec.js b/Extensions/combined/src/logging.spec.js new file mode 100644 index 0000000..309d94b --- /dev/null +++ b/Extensions/combined/src/logging.spec.js @@ -0,0 +1,34 @@ +import { logger, setLoggingDisabled } from "./logging"; + +afterEach(() => { + setLoggingDisabled(false); + jest.restoreAllMocks(); +}); + +test("suppresses only extension log/debug calls without changing the console", () => { + const methods = Object.fromEntries( + ["log", "debug", "info", "warn", "error"].map((name) => [ + name, + jest.spyOn(console, name).mockImplementation(() => {}), + ]), + ); + const payload = { count: 42 }; + + for (const disabled of [true, false, true, false]) { + Object.values(methods).forEach((method) => method.mockClear()); + setLoggingDisabled(disabled); + logger.log("extension log", payload); + logger.debug("extension debug", payload); + for (const [name, method] of Object.entries(methods)) { + expect(console[name]).toBe(method); + console[name]("unrelated message"); + const expectedCalls = !disabled && ["log", "debug"].includes(name) ? 2 : 1; + expect(method).toHaveBeenCalledTimes(expectedCalls); + expect(method).toHaveBeenLastCalledWith("unrelated message"); + if (expectedCalls === 2) { + expect(method).toHaveBeenNthCalledWith(1, `extension ${name}`, payload); + expect(method.mock.contexts[0]).toBe(console); + } + } + } +}); diff --git a/Extensions/combined/src/patreon.js b/Extensions/combined/src/patreon.js index 027f439..ce52a37 100644 --- a/Extensions/combined/src/patreon.js +++ b/Extensions/combined/src/patreon.js @@ -1,5 +1,6 @@ import { initPremiumAnalytics, teardownPremiumAnalytics, updatePremiumSession } from "./premiumAnalytics"; import { initPremiumTeaser, setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./premiumAnalytics/teaser"; +import { isPrivateBrowsingContext } from "./private-browsing"; import { hasAuthenticationDataPermission, onAuthenticationDataPermissionRemoved, @@ -13,6 +14,7 @@ let patreonState = { }; function initPatreonFeatures() { + if (isPrivateBrowsingContext()) return; initPremiumTeaser(); let sessionGeneration = 0; const initialGeneration = sessionGeneration; diff --git a/Extensions/combined/src/patreon.spec.js b/Extensions/combined/src/patreon.spec.js index e19a35d..59c3aff 100644 --- a/Extensions/combined/src/patreon.spec.js +++ b/Extensions/combined/src/patreon.spec.js @@ -20,6 +20,12 @@ jest.mock("./data-collection-permissions", () => ({ usesFirefoxDataCollectionConsent: jest.fn(), })); +jest.mock("./private-browsing", () => ({ + ...jest.requireActual("./private-browsing"), + isPrivateBrowsingContext: jest.fn(), +})); +import { isPrivateBrowsingContext } from "./private-browsing"; + import { initPatreonFeatures, patreonState } from "./patreon"; const analyticsMocks = jest.requireMock("./premiumAnalytics"); @@ -32,6 +38,7 @@ describe("Patreon authentication consent", () => { let permissionRemovedListener; beforeEach(() => { + isPrivateBrowsingContext.mockReturnValue(false); jest.clearAllMocks(); document.body.innerHTML = '
'; patreonState.authenticated = false; @@ -65,6 +72,19 @@ describe("Patreon authentication consent", () => { delete global.chrome; }); + it("skips all private account reads, teasers and session listeners even when initialized directly", async () => { + isPrivateBrowsingContext.mockReturnValue(true); + consentMocks.hasAuthenticationDataPermission.mockResolvedValue(true); + initPatreonFeatures(); + await flushPromises(); + expect(chrome.storage.sync.get).not.toHaveBeenCalled(); + expect(chrome.runtime.onMessage.addListener).not.toHaveBeenCalled(); + expect(consentMocks.hasAuthenticationDataPermission).not.toHaveBeenCalled(); + expect(consentMocks.onAuthenticationDataPermissionRemoved).not.toHaveBeenCalled(); + expect(jest.requireMock("./premiumAnalytics/teaser").initPremiumTeaser).not.toHaveBeenCalled(); + expect(analyticsMocks.initPremiumAnalytics).not.toHaveBeenCalled(); + }); + it("does not read a cached token without authentication consent", async () => { initPatreonFeatures(); await flushPromises(); diff --git a/Extensions/combined/src/premiumAnalytics/activity/index.js b/Extensions/combined/src/premiumAnalytics/activity/index.js index 15d7cd1..e34d5ca 100644 --- a/Extensions/combined/src/premiumAnalytics/activity/index.js +++ b/Extensions/combined/src/premiumAnalytics/activity/index.js @@ -5,7 +5,7 @@ import { setActivityBucketLabel } from "../panel"; import { clampRangeToBounds, combineBounds, computeChartBounds, updateGlobalBounds } from "./time"; import { toEpoch, sanitizeCount } from "../utils"; import { localize } from "../../utils"; -import { getTextColor, getMutedTextColor, getBorderColor } from "../theme"; +import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor } from "../theme"; import { logRangeSelection, logTimeBounds } from "../logging"; let activityTranslator = (key, substitutions) => localize(key, substitutions); @@ -273,6 +273,28 @@ export function resizeActivityChart() { analyticsState.activityChart?.resize(); } +export function refreshActivityTheme() { + analyticsState.activityChart?.setOption({ + legend: { textStyle: { color: getTextColor() } }, + xAxis: { + axisLabel: { color: getMutedTextColor() }, + axisLine: { lineStyle: { color: getBorderColor() } }, + }, + yAxis: { + axisLabel: { color: getMutedTextColor() }, + splitLine: { lineStyle: { color: getBorderColor(0.35) } }, + }, + dataZoom: [ + { + backgroundColor: getSurfaceColor(0.05, 0.05), + borderColor: getBorderColor(0.1), + textStyle: { color: getMutedTextColor() }, + handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) }, + }, + ], + }); +} + export function disposeActivityChart() { if (analyticsState.activityChart) { analyticsState.activityChart.off?.("dataZoom", handleDataZoom); @@ -297,8 +319,8 @@ function createDataZoom(bounds, selection) { xAxisIndex: 0, height: 18, bottom: 20, - backgroundColor: "rgba(255,255,255,0.05)", - borderColor: "rgba(255,255,255,0.1)", + backgroundColor: getSurfaceColor(0.05, 0.05), + borderColor: getBorderColor(0.1), textStyle: { color: getMutedTextColor() }, handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) }, realtime: true, diff --git a/Extensions/combined/src/premiumAnalytics/activity/index.spec.js b/Extensions/combined/src/premiumAnalytics/activity/index.spec.js index 85072b4..edb269f 100644 --- a/Extensions/combined/src/premiumAnalytics/activity/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/activity/index.spec.js @@ -30,6 +30,7 @@ jest.mock("../theme", () => ({ getTextColor: jest.fn(() => "#111111"), getMutedTextColor: jest.fn(() => "#222222"), getBorderColor: jest.fn(() => "#333333"), + getSurfaceColor: jest.fn(() => "#444444"), })); jest.mock("../logging", () => ({ @@ -46,6 +47,7 @@ import { clearActivityChart, resetChartZoom, resizeActivityChart, + refreshActivityTheme, disposeActivityChart, registerZoomSelectionListener, setActivityTranslator, @@ -134,6 +136,34 @@ describe("premiumAnalytics.activity", () => { jest.restoreAllMocks(); }); + it("refreshes only chart colors while keeping the selected range and series", () => { + const theme = jest.requireMock("../theme"); + theme.getTextColor.mockReturnValue("#111111"); + theme.getMutedTextColor.mockReturnValue("#222222"); + theme.getBorderColor.mockReturnValue("#333333"); + theme.getSurfaceColor.mockReturnValue("#444444"); + const chart = ensureActivityChart(); + analyticsState.selectionRange = { from: 10, to: 20 }; + const points = [{ timestampUtc: "2026-01-01T00:00:00Z", likes: 4, dislikes: 1 }]; + analyticsState.latestSeriesPoints = points; + refreshActivityTheme(); + expect(chart.setOption).toHaveBeenCalledWith( + expect.objectContaining({ + legend: { textStyle: { color: "#111111" } }, + xAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }), + yAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }), + dataZoom: [expect.objectContaining({ backgroundColor: "#444444" })], + }), + ); + const update = chart.setOption.mock.calls.at(-1)[0]; + expect(update).not.toHaveProperty("series"); + expect(update.dataZoom[0]).not.toHaveProperty("startValue"); + expect(update.dataZoom[0]).not.toHaveProperty("endValue"); + expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 }); + expect(analyticsState.latestSeriesPoints).toBe(points); + expect(chart.dispatchAction).not.toHaveBeenCalled(); + }); + it("initializes and caches the chart", () => { const chart = ensureActivityChart(); @@ -162,7 +192,6 @@ describe("premiumAnalytics.activity", () => { expect(logging.logRangeSelection).toHaveBeenCalled(); expect(analyticsState.latestSeriesPoints).toHaveLength(2); expect(analyticsState.selectionRange.from).toBeLessThanOrEqual(analyticsState.selectionRange.to); - }); it("updates the bucket label when rendering", () => { diff --git a/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js b/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js index 8e54844..5f444fd 100644 --- a/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js +++ b/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js @@ -10,6 +10,7 @@ jest.mock("../theme", () => ({ getTextColor: () => "#ffffff", getMutedTextColor: () => "#b3b3b3", getBorderColor: () => "#444444", + getSurfaceColor: () => "rgba(255,255,255,0.06)", })); jest.mock("../logging", () => ({ logRangeSelection: jest.fn(), logTimeBounds: jest.fn() })); diff --git a/Extensions/combined/src/premiumAnalytics/index.spec.js b/Extensions/combined/src/premiumAnalytics/index.spec.js index 77c469a..f5579f3 100644 --- a/Extensions/combined/src/premiumAnalytics/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/index.spec.js @@ -9,6 +9,7 @@ jest.mock("../config", () => ({ jest.mock("../data-collection-permissions", () => ({ hasAuthenticationDataPermission: jest.fn(), + onAuthenticationDataPermissionRemoved: () => () => {}, usesFirefoxDataCollectionConsent: jest.fn(), })); @@ -33,6 +34,7 @@ jest.mock("./activity", () => ({ clearActivityChart: jest.fn(), resetChartZoom: jest.fn(), resizeActivityChart: jest.fn(), + refreshActivityTheme: jest.fn(), disposeActivityChart: jest.fn(), registerZoomSelectionListener: jest.fn(), })); @@ -40,6 +42,7 @@ jest.mock("./activity", () => ({ jest.mock("./map", () => ({ ensureMapChart: jest.fn(), renderMap: jest.fn(), + refreshMapTheme: jest.fn(), clearMapChart: jest.fn(), resizeMapChart: jest.fn(), disposeMapChart: jest.fn(), @@ -210,10 +213,36 @@ describe("premiumAnalytics", () => { }); afterEach(() => { + teardownPremiumAnalytics(); + document.documentElement.removeAttribute("dark"); delete global.fetch; delete global.chrome; }); + it("repaints existing charts after a host theme toggle without fetching or changing the selected range", async () => { + document.documentElement.removeAttribute("dark"); + initPremiumAnalytics(); + await flushPromises(); + analyticsState.selectionRange = { from: 10, to: 20 }; + analyticsState.mapView = "us"; + fetch.mockClear(); + activityMocks.refreshActivityTheme.mockClear(); + mapMocks.refreshMapTheme.mockClear(); + mockRenderMap.mockClear(); + document.documentElement.setAttribute("dark", ""); + await flushPromises(); + expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1); + expect(mapMocks.refreshMapTheme).toHaveBeenCalledTimes(1); + expect(mockRenderMap).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 }); + expect(analyticsState.mapView).toBe("us"); + teardownPremiumAnalytics(); + document.documentElement.removeAttribute("dark"); + await flushPromises(); + expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1); + }); + it("initializes only once and wires listeners", () => { initPremiumAnalytics(); expect(mockConfigurePanelCallbacks).toHaveBeenCalled(); @@ -461,6 +490,27 @@ describe("premiumAnalytics", () => { expect(firstUrl).toBe(secondUrl); }); + it("restores a preset when a custom selection rounds to the same number of days", async () => { + mockGetApiEndpoint.mockImplementation((path) => `https://api.example${path}`); + initPremiumAnalytics(); + const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)[0]; + analyticsState.currentVideoId = "abcdefghijk"; + analyticsState.currentRange = 30; + analyticsState.usingCustomRange = true; + analyticsState.customSelection = { from: 10, to: 20 }; + analyticsState.selectionRange = { from: 10, to: 20 }; + fetch.mockClear(); + + callbacks.onRangePreset(30); + + expect(analyticsState.usingCustomRange).toBe(false); + expect(analyticsState.customSelection).toBeNull(); + expect(analyticsState.selectionRange).toEqual({ from: null, to: null }); + await flushPromises(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(new URL(fetch.mock.calls[0][0]).searchParams.has("selectedRangeStartUtc")).toBe(false); + }); + it("defers chart zoom resets until preset data arrives", () => { initPremiumAnalytics(); const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)?.[0]; diff --git a/Extensions/combined/src/premiumAnalytics/lifecycle.js b/Extensions/combined/src/premiumAnalytics/lifecycle.js index a508a69..78e205f 100644 --- a/Extensions/combined/src/premiumAnalytics/lifecycle.js +++ b/Extensions/combined/src/premiumAnalytics/lifecycle.js @@ -7,8 +7,13 @@ import { updateModeButtons, applyChartExpansionState, } from "./panel"; -import { resizeActivityChart, disposeActivityChart, registerZoomSelectionListener } from "./activity"; -import { renderMap, resizeMapChart, disposeMapChart } from "./map"; +import { + resizeActivityChart, + disposeActivityChart, + registerZoomSelectionListener, + refreshActivityTheme, +} from "./activity"; +import { renderMap, resizeMapChart, disposeMapChart, refreshMapTheme } from "./map"; import { debounce } from "./utils"; import { getVideoId } from "../utils"; @@ -16,12 +21,18 @@ import { MS_PER_DAY, EXPANDABLE_SECTIONS } from "./constants"; import { requestAnalytics, scheduleSelectionFetch, normalizeSelection } from "./requests"; import { setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./teaser"; import { showTierNotice, hideTierNotice } from "./tierNotice"; +import { observeTheme } from "./theme"; let resizeListener = null; +let themeObserver = null; function initPremiumAnalytics() { if (analyticsState.initialized) return; analyticsState.initialized = true; + themeObserver = observeTheme(() => { + refreshActivityTheme(); + refreshMapTheme(); + }); if (!analyticsState.zoomListenerRegistered) { registerZoomSelectionListener(handleChartSelection); @@ -65,7 +76,7 @@ function handleNavigation() { function handleRangePreset(rangeDays) { const state = analyticsState; - const samePreset = rangeDays === state.currentRange; + const samePreset = !state.usingCustomRange && rangeDays === state.currentRange; if (samePreset) { return; } @@ -135,8 +146,7 @@ function handleChartSelection(range) { } const sameAsCurrent = - analyticsState.selectionRange.from === normalized.from && - analyticsState.selectionRange.to === normalized.to; + analyticsState.selectionRange.from === normalized.from && analyticsState.selectionRange.to === normalized.to; analyticsState.selectionRange = normalized; analyticsState.customSelection = { ...normalized }; @@ -169,6 +179,8 @@ function teardownPanel() { } function teardownPremiumAnalytics() { + themeObserver?.disconnect(); + themeObserver = null; teardownPanel(); if (resizeListener) { window.removeEventListener("resize", resizeListener); diff --git a/Extensions/combined/src/premiumAnalytics/logging.js b/Extensions/combined/src/premiumAnalytics/logging.js index 330fc62..c63bec7 100644 --- a/Extensions/combined/src/premiumAnalytics/logging.js +++ b/Extensions/combined/src/premiumAnalytics/logging.js @@ -1,8 +1,10 @@ +import { logger } from "../logging"; + const LOG_PREFIX = '[PremiumAnalytics]'; export function logTimeBounds(label, bounds) { if (!bounds) return; - console.log( + logger.log( `${LOG_PREFIX} ${label} bounds:`, bounds.min != null ? new Date(bounds.min).toISOString() : 'null', '->', @@ -12,7 +14,7 @@ export function logTimeBounds(label, bounds) { export function logRangeSelection(label, range) { if (!range) return; - console.log( + logger.log( `${LOG_PREFIX} ${label} selection:`, range.from != null ? new Date(range.from).toISOString() : 'null', '->', @@ -21,12 +23,12 @@ export function logRangeSelection(label, range) { } export function logFetchRequest(videoId, params) { - console.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString()); + logger.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString()); } export function logZoomPreview(label, bounds) { if (!bounds) return; - console.log( + logger.log( `${LOG_PREFIX} ${label} preview:`, new Date(bounds.from).toISOString(), '->', diff --git a/Extensions/combined/src/premiumAnalytics/logging.spec.js b/Extensions/combined/src/premiumAnalytics/logging.spec.js index 1e2cf2d..c0581ed 100644 --- a/Extensions/combined/src/premiumAnalytics/logging.spec.js +++ b/Extensions/combined/src/premiumAnalytics/logging.spec.js @@ -2,6 +2,7 @@ * @jest-environment jsdom */ +import { setLoggingDisabled } from "../logging"; import { logTimeBounds, logRangeSelection, @@ -17,9 +18,20 @@ describe("premiumAnalytics.logging", () => { }); afterEach(() => { + setLoggingDisabled(false); consoleSpy.mockRestore(); }); + it("suppresses analytics messages without replacing console.log", () => { + setLoggingDisabled(true); + logTimeBounds("chart", { min: 0, max: 1 }); + logRangeSelection("selection", { from: 0, to: 1 }); + logFetchRequest("video-id", new URLSearchParams({ a: "1" })); + logZoomPreview("preview", { from: 3, to: 4 }); + expect(consoleSpy).not.toHaveBeenCalled(); + expect(console.log).toBe(consoleSpy); + }); + it("logs time bounds when values are provided", () => { logTimeBounds("chart", { min: 0, max: 1 }); diff --git a/Extensions/combined/src/premiumAnalytics/map/index.js b/Extensions/combined/src/premiumAnalytics/map/index.js index f706526..1871373 100644 --- a/Extensions/combined/src/premiumAnalytics/map/index.js +++ b/Extensions/combined/src/premiumAnalytics/map/index.js @@ -238,6 +238,20 @@ export function renderMap(countries) { updateMapResetButtonVisibility(); } +export function refreshMapTheme() { + analyticsState.mapChart?.setOption({ + visualMap: { textStyle: { color: getMutedTextColor() } }, + geo: { + itemStyle: { + borderColor: getBorderColor(0.4), + borderWidth: 0.6, + areaColor: getSurfaceColor(0.06, 0.02), + }, + emphasis: { itemStyle: { areaColor: getHoverFillColor(0.18, 0.18) } }, + }, + }); +} + function resolveMapContext(state) { const focusCode = typeof state.mapFocusCountry === "string" ? normalizeIsoCode(state.mapFocusCountry) : ""; const subdivisions = Array.isArray(state.latestSubdivisions) ? state.latestSubdivisions : []; diff --git a/Extensions/combined/src/premiumAnalytics/map/index.spec.js b/Extensions/combined/src/premiumAnalytics/map/index.spec.js index 2543de1..f5e7bdb 100644 --- a/Extensions/combined/src/premiumAnalytics/map/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/map/index.spec.js @@ -101,6 +101,7 @@ import { analyticsState } from "../state"; import { ensureMapChart, renderMap, + refreshMapTheme, disposeMapChart, clearMapChart, resizeMapChart, @@ -110,6 +111,25 @@ import { const enMessages = require("../../../_locales/en/messages.json"); +test("theme refresh merges only colors without replacing map data, center or zoom", () => { + const chart = { setOption: jest.fn() }; + analyticsState.mapChart = chart; + try { + refreshMapTheme(); + expect(chart.setOption).toHaveBeenCalledTimes(1); + expect(chart.setOption.mock.calls[0]).toHaveLength(1); + const update = chart.setOption.mock.calls[0][0]; + expect(Object.keys(update).sort()).toEqual(["geo", "visualMap"]); + expect(Object.keys(update.geo).sort()).toEqual(["emphasis", "itemStyle"]); + expect(update.geo).not.toHaveProperty("center"); + expect(update.geo).not.toHaveProperty("zoom"); + expect(update.geo).not.toHaveProperty("map"); + expect(update).not.toHaveProperty("series"); + } finally { + analyticsState.mapChart = null; + } +}); + function getMessage(key, substitutions) { const entry = enMessages[key]; if (!entry) return key; diff --git a/Extensions/combined/src/premiumAnalytics/panel.js b/Extensions/combined/src/premiumAnalytics/panel.js index 9d35eb1..d79d9d0 100644 --- a/Extensions/combined/src/premiumAnalytics/panel.js +++ b/Extensions/combined/src/premiumAnalytics/panel.js @@ -418,7 +418,7 @@ function positionExpandedSection(section, button) { applyAnchorPosition(section, anchorRect, { maxWidthFallback: "min(960px, calc(100vw - 48px))" }); section.style.maxHeight = "calc(100vh - 120px)"; section.style.zIndex = "2147483647"; - section.style.background = "var(--yt-spec-base-background, #202020)"; + section.style.background = "var(--ryd-analytics-bg)"; section.style.boxShadow = "0 30px 60px rgba(0, 0, 0, 0.45)"; section.style.padding = "16px"; section.style.overflowY = "auto"; diff --git a/Extensions/combined/src/premiumAnalytics/requests.js b/Extensions/combined/src/premiumAnalytics/requests.js index fa80fca..8afe533 100644 --- a/Extensions/combined/src/premiumAnalytics/requests.js +++ b/Extensions/combined/src/premiumAnalytics/requests.js @@ -1,5 +1,5 @@ import { getApiEndpoint } from "../config"; -import { fetchWithCoreConsent } from "../core-data-consent"; +import { fetchWithAccountConsent } from "../account-data-consent"; import { analyticsState, COUNTRY_LIMIT } from "./state"; import { ensurePanel, updateRangeButtons, updateRangeAnchorButtons, setFooterMessage, setLoadingState } from "./panel"; import { debounce, safeJson } from "./utils"; @@ -104,7 +104,7 @@ function requestAnalyticsWithConsent({ selection } = {}) { analyticsState.sessionToken === sessionToken && analyticsState.sessionActive; - fetchWithCoreConsent(url, { + fetchWithAccountConsent(url, { headers: { Authorization: `Bearer ${sessionToken}`, "Content-Type": "application/json", diff --git a/Extensions/combined/src/premiumAnalytics/theme.js b/Extensions/combined/src/premiumAnalytics/theme.js index 95d9787..593572c 100644 --- a/Extensions/combined/src/premiumAnalytics/theme.js +++ b/Extensions/combined/src/premiumAnalytics/theme.js @@ -1,9 +1,15 @@ export function getTextColor() { - return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() || "#ffffff"; + return ( + getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() || + (isDarkTheme() ? "#ffffff" : "#0f0f0f") + ); } export function getMutedTextColor() { - return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() || "#b3b3b3"; + return ( + getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() || + (isDarkTheme() ? "#b3b3b3" : "#606060") + ); } export function getBorderColor(alpha = 0.15) { @@ -25,48 +31,21 @@ export function getHoverFillColor(lightAlpha = 0.18, darkAlpha = 0.18) { export function isDarkTheme() { const root = document.documentElement; - if (root.hasAttribute("dark") && !root.hasAttribute("light")) { - return true; - } - if (root.getAttribute("dark") === "false") { - return false; - } - const colorValue = getComputedStyle(root).getPropertyValue("--yt-spec-text-primary").trim(); - const color = parseColor(colorValue); - if (color) { - const luminance = (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255; - return luminance > 0.5; - } - return true; + return root.hasAttribute("dark") && root.getAttribute("dark") !== "false" && !root.hasAttribute("light"); } -function parseColor(value) { - if (!value) { - return null; - } - const trimmed = value.trim(); - if (trimmed.startsWith('#')) { - const hex = trimmed.slice(1); - if (hex.length === 3) { - const r = parseInt(hex[0] + hex[0], 16); - const g = parseInt(hex[1] + hex[1], 16); - const b = parseInt(hex[2] + hex[2], 16); - return { r, g, b }; - } - if (hex.length === 6) { - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return { r, g, b }; - } - return null; - } - const rgbMatch = trimmed.match(/rgba?\(([^)]+)\)/i); - if (rgbMatch) { - const parts = rgbMatch[1].split(',').map((part) => parseFloat(part.trim())); - if (parts.length >= 3) { - return { r: parts[0], g: parts[1], b: parts[2] }; - } - } - return null; +export function observeTheme(onChange) { + const colors = () => `${isDarkTheme()}:${getTextColor()}:${getMutedTextColor()}`; + let previous = colors(); + const observer = new MutationObserver(() => { + const next = colors(); + if (next === previous) return; + previous = next; + onChange(); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["dark", "light", "style", "class"], + }); + return observer; } diff --git a/Extensions/combined/src/premiumAnalytics/theme.spec.js b/Extensions/combined/src/premiumAnalytics/theme.spec.js index 3904e5b..ca6bd73 100644 --- a/Extensions/combined/src/premiumAnalytics/theme.spec.js +++ b/Extensions/combined/src/premiumAnalytics/theme.spec.js @@ -2,12 +2,22 @@ * @jest-environment jsdom */ -import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor, getHoverFillColor, isDarkTheme } from "./theme"; +import { + getTextColor, + getMutedTextColor, + getBorderColor, + getSurfaceColor, + getHoverFillColor, + isDarkTheme, + observeTheme, +} from "./theme"; describe("premiumAnalytics.theme", () => { const originalGetComputedStyle = window.getComputedStyle; beforeEach(() => { + document.documentElement.setAttribute("dark", ""); + document.documentElement.removeAttribute("light"); window.getComputedStyle = jest.fn().mockImplementation(() => ({ getPropertyValue: (name) => { if (name === "--yt-spec-text-primary") return " #fefefe "; @@ -19,6 +29,9 @@ describe("premiumAnalytics.theme", () => { afterEach(() => { window.getComputedStyle = originalGetComputedStyle; + document.documentElement.removeAttribute("dark"); + document.documentElement.removeAttribute("light"); + document.documentElement.removeAttribute("style"); }); it("returns trimmed primary text color", () => { @@ -34,8 +47,47 @@ describe("premiumAnalytics.theme", () => { expect(getBorderColor()).toBe("rgba(255,255,255,0.15)"); }); + it.each([null, "false"])("uses light fallbacks without an enabled dark marker (%s)", (dark) => { + if (dark === null) document.documentElement.removeAttribute("dark"); + else document.documentElement.setAttribute("dark", dark); + window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" }); + expect(isDarkTheme()).toBe(false); + expect(getTextColor()).toBe("#0f0f0f"); + expect(getMutedTextColor()).toBe("#606060"); + expect(getBorderColor()).toBe("rgba(15,23,42,0.15)"); + }); + + it("uses dark fallbacks only while the host dark marker is enabled", () => { + window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" }); + expect(isDarkTheme()).toBe(true); + expect(getTextColor()).toBe("#ffffff"); + expect(getMutedTextColor()).toBe("#b3b3b3"); + document.documentElement.setAttribute("light", ""); + expect(isDarkTheme()).toBe(false); + expect(getTextColor()).toBe("#0f0f0f"); + }); + + it("notifies for effective theme colors and stops observing after disconnect", async () => { + window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" }); + const changed = jest.fn(); + const observer = observeTheme(changed); + document.documentElement.style.setProperty("--unrelated", "1"); + await Promise.resolve(); + expect(changed).not.toHaveBeenCalled(); + document.documentElement.removeAttribute("dark"); + await Promise.resolve(); + expect(changed).toHaveBeenCalledTimes(1); + document.documentElement.setAttribute("dark", ""); + await Promise.resolve(); + expect(changed).toHaveBeenCalledTimes(2); + observer.disconnect(); + document.documentElement.removeAttribute("dark"); + await Promise.resolve(); + expect(changed).toHaveBeenCalledTimes(2); + }); + it("derives surface colors based on theme", () => { - document.documentElement.setAttribute('dark', ''); + document.documentElement.setAttribute("dark", ""); window.getComputedStyle.mockImplementation(() => ({ getPropertyValue: (name) => { if (name === "--yt-spec-text-primary") return "#fefefe"; @@ -44,11 +96,11 @@ describe("premiumAnalytics.theme", () => { }, })); expect(isDarkTheme()).toBe(true); - expect(getSurfaceColor()).toBe('rgba(255,255,255,0.02)'); - expect(getHoverFillColor()).toBe('rgba(255,255,255,0.18)'); + expect(getSurfaceColor()).toBe("rgba(255,255,255,0.02)"); + expect(getHoverFillColor()).toBe("rgba(255,255,255,0.18)"); - document.documentElement.removeAttribute('dark'); - document.documentElement.setAttribute('light', ''); + document.documentElement.removeAttribute("dark"); + document.documentElement.setAttribute("light", ""); window.getComputedStyle.mockImplementation(() => ({ getPropertyValue: (name) => { if (name === "--yt-spec-text-primary") return "#111111"; @@ -57,9 +109,9 @@ describe("premiumAnalytics.theme", () => { }, })); expect(isDarkTheme()).toBe(false); - expect(getBorderColor(0.2)).toBe('rgba(15,23,42,0.2)'); - expect(getSurfaceColor()).toBe('rgba(15,23,42,0.06)'); - expect(getHoverFillColor()).toBe('rgba(15,23,42,0.18)'); - document.documentElement.removeAttribute('light'); + expect(getBorderColor(0.2)).toBe("rgba(15,23,42,0.2)"); + expect(getSurfaceColor()).toBe("rgba(15,23,42,0.06)"); + expect(getHoverFillColor()).toBe("rgba(15,23,42,0.18)"); + document.documentElement.removeAttribute("light"); }); }); diff --git a/Extensions/combined/src/private-browsing.js b/Extensions/combined/src/private-browsing.js new file mode 100644 index 0000000..e10bd52 --- /dev/null +++ b/Extensions/combined/src/private-browsing.js @@ -0,0 +1,28 @@ +function extensionApi() { + return typeof browser !== "undefined" ? browser : globalThis.chrome; +} + +function isFirefoxExtension(api = extensionApi()) { + return Boolean(api?.runtime?.getManifest?.()?.browser_specific_settings?.gecko); +} + +function isPrivateBrowsingContext() { + const api = extensionApi(); + return isFirefoxExtension(api) && api?.extension?.inIncognitoContext === true; +} + +function isNormalMessageSender(api, sender) { + if (!isFirefoxExtension(api)) return true; + if (sender?.id !== api.runtime.id) return false; + if (sender.tab) return sender.tab.incognito === false; + try { + const url = new URL(sender.url); + if (!url.searchParams.get("rydContext")) return false; + const views = api.extension.getViews().filter((view) => view.location.href === sender.url); + return views.length === 1 && views[0].browser?.extension?.inIncognitoContext === false; + } catch (_) { + return false; + } +} + +export { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender }; diff --git a/Extensions/combined/src/private-browsing.spec.js b/Extensions/combined/src/private-browsing.spec.js new file mode 100644 index 0000000..02e3ef4 --- /dev/null +++ b/Extensions/combined/src/private-browsing.spec.js @@ -0,0 +1,44 @@ +import { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender } from "./private-browsing"; + +afterEach(() => { + delete global.browser; + delete global.chrome; +}); + +test("background routing uses browser tab privacy metadata", () => { + const api = { runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) } }; + expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: false } })).toBe(true); + expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: true } })).toBe(false); + expect(isNormalMessageSender(api, { id: "fixture", tab: {} })).toBe(false); + expect(isNormalMessageSender(api, { id: "foreign", tab: { incognito: false } })).toBe(false); +}); + +test("Firefox popup routing requires a distinct live normal view", () => { + const url = "moz-extension://fixture/popup.html?rydContext=synthetic-view"; + const view = { location: { href: url }, browser: { extension: { inIncognitoContext: false } } }; + const api = { + runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) }, + extension: { getViews: jest.fn(() => [view]) }, + }; + const sender = { id: "fixture", url }; + expect(isNormalMessageSender(api, sender)).toBe(true); + expect(isNormalMessageSender(api, { ...sender, url: "moz-extension://fixture/popup.html" })).toBe(false); + view.browser.extension.inIncognitoContext = true; + expect(isNormalMessageSender(api, sender)).toBe(false); + api.extension.getViews.mockReturnValue([]); + expect(isNormalMessageSender(api, sender)).toBe(false); +}); + +test("private display mode is limited to Firefox private contexts", () => { + global.browser = { + runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) }, + extension: { inIncognitoContext: true }, + }; + expect(isFirefoxExtension()).toBe(true); + expect(isPrivateBrowsingContext()).toBe(true); + browser.extension.inIncognitoContext = false; + expect(isPrivateBrowsingContext()).toBe(false); + browser.extension.inIncognitoContext = true; + browser.runtime.getManifest = () => ({ manifest_version: 3 }); + expect(isPrivateBrowsingContext()).toBe(false); +}); diff --git a/Extensions/combined/src/reaction-presentation.spec.js b/Extensions/combined/src/reaction-presentation.spec.js index ff3f072..5665f01 100644 --- a/Extensions/combined/src/reaction-presentation.spec.js +++ b/Extensions/combined/src/reaction-presentation.spec.js @@ -13,7 +13,7 @@ import { setSyntheticShortsDislikeEnabled, __RewireAPI__ as buttonsRewire, } from "./buttons"; -import { addLikeDislikeEventListener, storageChangeHandler } from "./events"; +import { addLikeDislikeEventListener, storageChangeHandler, __RewireAPI__ as eventsRewire } from "./events"; import { extConfig, isVideoDisliked, @@ -91,6 +91,8 @@ beforeEach(() => { coloredThumbs: false, colorTheme: "classic", disableVoteSubmission: true, + showTooltipPercentage: false, + tooltipPercentageMode: "dash_like", }); }); @@ -448,3 +450,176 @@ test("synthetic Shorts Dislike clears the native Like without activating a prepe expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("true"); expect(storedData).toMatchObject({ previousState: DISLIKED_STATE, likes: 729, dislikes: 13 }); }); + +describe("live tooltip preferences", () => { + function mountTooltip({ enabled = false, mode = "dash_like", nested = false } = {}) { + mount({ likes: 260 }); + storedData.dislikes = 4; + extConfig.showTooltipPercentage = enabled; + extConfig.tooltipPercentageMode = mode; + createRateBar.mockImplementation(renderRateBar); + renderRateBar(storedData.likes, storedData.dislikes); + const bar = document.querySelector("#ryd-bar-container"); + bar.getBoundingClientRect = () => ({ top: 0, left: 0, right: 200, bottom: 2, width: 200, height: 2 }); + const tooltip = document.querySelector("#ryd-dislike-tooltip"); + if (nested) { + const content = document.createElement("div"); + content.id = "tooltip"; + content.innerHTML = tooltip.innerHTML; + tooltip.replaceChildren(content); + } + createRateBar.mockClear(); + return tooltip; + } + + const tooltipText = (tooltip) => tooltip.textContent.replace(/\s+/g, " ").trim(); + + test.each([ + ["dash_like", "260 / 4 - 98.5%"], + ["dash_dislike", "260 / 4 - 1.5%"], + ["both", "98.5% / 1.5%"], + ["only_like", "98.5%"], + ["only_dislike", "1.5%"], + ])("updates an existing nested tooltip when sync mode changes to %s", (mode, expected) => { + const tooltip = mountTooltip({ enabled: true, mode: mode === "dash_like" ? "both" : "dash_like", nested: true }); + const beforeCounts = { ...storedData }; + + storageChangeHandler({ tooltipPercentageMode: { newValue: mode } }, "sync"); + + expect(tooltipText(tooltip)).toBe(expected); + expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip); + expect(storedData).toEqual(beforeCounts); + }); + + test.each([true, false])("updates the existing tooltip when percentages become %s", (enabled) => { + const tooltip = mountTooltip({ enabled: !enabled, mode: "only_dislike" }); + + storageChangeHandler({ showTooltipPercentage: { newValue: enabled } }, "sync"); + + expect(tooltipText(tooltip)).toBe(enabled ? "1.5%" : "260 / 4"); + expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip); + }); + + test("applies a combined preference event before repainting once", () => { + const tooltip = mountTooltip(); + + storageChangeHandler( + { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } }, + "sync", + ); + + expect(tooltipText(tooltip)).toBe("1.5%"); + expect(createRateBar).toHaveBeenCalledTimes(1); + }); + + test("removing tooltip settings restores their existing defaults", () => { + const tooltip = mountTooltip({ enabled: true, mode: "only_dislike" }); + + storageChangeHandler( + { showTooltipPercentage: { oldValue: true }, tooltipPercentageMode: { oldValue: "only_dislike" } }, + "sync", + ); + expect(tooltipText(tooltip)).toBe("260 / 4"); + storageChangeHandler({ showTooltipPercentage: { newValue: true } }, "sync"); + expect(tooltipText(tooltip)).toBe("260 / 4 - 98.5%"); + }); + + test.each(["local", "session", "managed", undefined])("ignores tooltip preferences outside sync (%s)", (area) => { + const tooltip = mountTooltip(); + + storageChangeHandler( + { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } }, + area, + ); + + expect(tooltipText(tooltip)).toBe("260 / 4"); + expect(extConfig.showTooltipPercentage).toBe(false); + expect(extConfig.tooltipPercentageMode).toBe("dash_like"); + expect(createRateBar).not.toHaveBeenCalled(); + }); + + test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])( + "retains preferences without repainting outgoing cached counts on %s", + (url) => { + const tooltip = mountTooltip(); + history.replaceState({}, "", url); + + storageChangeHandler( + { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } }, + "sync", + ); + + expect(extConfig.showTooltipPercentage).toBe(true); + expect(extConfig.tooltipPercentageMode).toBe("only_dislike"); + expect(tooltipText(tooltip)).toBe("260 / 4"); + expect(createRateBar).not.toHaveBeenCalled(); + }, + ); + + test("private tabs retain their existing isolation from normal tooltip preferences", () => { + const tooltip = mountTooltip(); + const restore = eventsRewire.__set__("isPrivateBrowsingContext", () => true); + try { + storageChangeHandler( + { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } }, + "sync", + ); + expect(extConfig.showTooltipPercentage).toBe(false); + expect(extConfig.tooltipPercentageMode).toBe("dash_like"); + expect(tooltipText(tooltip)).toBe("260 / 4"); + expect(createRateBar).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe("live dislike number format", () => { + test.each([ + ["compactShort", false, "1.3K", "11299"], + ["compactLong", false, "1.3 thousand", "11299"], + ["standard", false, "1,335", "11299"], + ["compactShort", true, "1.3K", "11K"], + ["compactLong", true, "1.3 thousand", "11 thousand"], + ["standard", true, "1,335", "11,299"], + ])("formats cached dislikes as %s with native Like reformatting=%s", (mode, reformatLikes, dislikes, likes) => { + document.documentElement.lang = "en"; + const controls = mount({ likes: 11299 }); + const nativeLikeText = getLikeTextContainer().firstChild; + storedData.dislikes = 1335; + extConfig.numberDisplayReformatLikes = reformatLikes; + extConfig.numberDisplayFormat = mode === "standard" ? "compactShort" : "standard"; + controls.dislikeTextContainer.innerText = mode === "standard" ? "1.3K" : "1,335"; + const readVotes = jest.fn(); + const restore = stateRewire.__set__("requestVoteData", readVotes); + try { + storageChangeHandler({ numberDisplayFormat: { newValue: mode } }, "sync"); + + expect(controls.dislikeTextContainer.innerText).toBe(dislikes); + expect(getLikeTextContainer().firstChild).toBe(nativeLikeText); + expect(nativeLikeText.data).toBe(likes); + expect(storedData.likes).toBe(11299); + expect(storedData.dislikes).toBe(1335); + expect(readVotes).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])( + "does not format outgoing cached dislikes on %s", + (url) => { + const controls = mount({ likes: 11299 }); + storedData.dislikes = 1335; + controls.dislikeTextContainer.innerText = "1,335"; + history.replaceState({}, "", url); + createRateBar.mockClear(); + + storageChangeHandler({ numberDisplayFormat: { newValue: "compactLong" } }, "sync"); + + expect(extConfig.numberDisplayFormat).toBe("compactLong"); + expect(controls.dislikeTextContainer.innerText).toBe("1,335"); + expect(createRateBar).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/Extensions/combined/src/state.js b/Extensions/combined/src/state.js index 4068532..1aeb643 100644 --- a/Extensions/combined/src/state.js +++ b/Extensions/combined/src/state.js @@ -1,3 +1,4 @@ +import { logger } from "./logging"; import { getButtonControls, getLikeButton, @@ -32,6 +33,7 @@ import { createBrowserSyntheticDislikeStore } from "./vote-client-adapter"; import { requestVoteData } from "./vote-data-request"; import { fetchWithCoreConsent } from "./core-data-consent"; import { normalizeCustomColors } from "../../common/color-themes"; +import { isPrivateBrowsingContext } from "./private-browsing"; const DEFAULT_SELECTORS = { dislikeTextContainer: [ @@ -183,6 +185,7 @@ function isRoundedDesign() { let syntheticDislikeStore = null; function getSyntheticDislikeStore() { + if (isPrivateBrowsingContext()) return null; if (syntheticDislikeStore) return syntheticDislikeStore; const browserApi = getBrowser(); if (!browserApi?.storage?.local) return null; @@ -199,7 +202,7 @@ async function readSyntheticShortsDislikeState(videoId) { try { return await store.isDisliked(videoId); } catch (error) { - console.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error); + logger.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error); return false; } } @@ -333,7 +336,7 @@ function refreshReactionPresentation() { } function setDislikes(dislikesCount) { - console.log(`SET dislikes ${dislikesCount}`); + if (!isPrivateBrowsingContext()) logger.log(`SET dislikes ${dislikesCount}`); const controls = getButtonControls(); const _container = getDislikeTextContainer(controls); @@ -344,7 +347,7 @@ function setDislikes(dislikesCount) { if (!isLikesDisabled(controls)) { _dislikeText = dislikesCount; } else { - console.log("likes count disabled by creator"); + logger.log("likes count disabled by creator"); _dislikeText = localize("TextLikesDisabled"); } @@ -425,7 +428,7 @@ async function setState(storedData) { } const videoId = getVideoId(window.location.href); const dislikeButton = getDislikeButton(); - if (isSyntheticShortsDislike(dislikeButton)) { + if (isSyntheticShortsDislike(dislikeButton) && !isPrivateBrowsingContext()) { const storedDisliked = await readSyntheticShortsDislikeState(videoId); if (getVideoId(window.location.href) !== videoId) return; const liked = isVideoLiked(); @@ -435,7 +438,7 @@ async function setState(storedData) { } else { storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE; } - console.log("Video is loaded. Adding buttons..."); + logger.log("Video is loaded. Adding buttons..."); const likeCount = getLikeCountFromButton() || null; let response; @@ -446,8 +449,10 @@ async function setState(storedData) { displayError(error, videoId); return; } - console.log("response from api:"); - console.log(JSON.stringify(response)); + if (!isPrivateBrowsingContext()) { + logger.log("response from api:"); + logger.log(JSON.stringify(response)); + } if (getVideoId(window.location.href) !== videoId) { return; } @@ -520,6 +525,11 @@ function clearRenderedVoteState(controls = getButtonControls()) { } async function initExtConfig() { + if (isPrivateBrowsingContext()) { + await initializePrivateDisplayPreferences(); + await initializeSelectors(); + return; + } initializeDisableVoteSubmission(); initializeDisableLogging(); initializeColoredThumbs(); @@ -534,8 +544,39 @@ async function initExtConfig() { await initializeSelectors(); } +function initializePrivateDisplayPreferences() { + const preferences = [ + "coloredThumbs", + "coloredBar", + "colorTheme", + "customColors", + "numberDisplayFormat", + "showTooltipPercentage", + "tooltipPercentageMode", + "numberDisplayReformatLikes", + HIDE_CLUTTER_BUTTONS_STORAGE_KEY, + ]; + extConfig.disableLogging = true; + initializeLogging(); + extConfig.hidePremiumTeaser = true; + return new Promise((resolve) => { + getBrowser().storage.sync.get(preferences, (values = {}) => { + for (const key of preferences) { + if (values[key] !== undefined) extConfig[key] = values[key]; + } + extConfig.colorTheme = values.colorTheme || "classic"; + extConfig.customColors = normalizeCustomColors(values.customColors); + extConfig.hideClutterButtons = publishHideClutterButtons(values[HIDE_CLUTTER_BUTTONS_STORAGE_KEY]); + resolve(); + }); + }); +} + async function initializeSelectors() { const controller = new AbortController(); + const privateContext = isPrivateBrowsingContext(); + const abortOnPageHide = () => controller.abort(); + if (privateContext) window.addEventListener("pagehide", abortOnPageHide, { once: true }); const timeout = setTimeout(() => controller.abort(), SELECTOR_REQUEST_TIMEOUT_MS); try { const response = await fetchWithCoreConsent(getApiEndpoint("/configs/selectors"), { @@ -544,20 +585,23 @@ async function initializeSelectors() { Accept: "application/json", }, signal: controller.signal, + ...(privateContext ? { credentials: "omit", cache: "no-store" } : {}), }); if (!response.ok) { throw new Error(`Selector request failed with HTTP ${response.status}`); } const result = await response.json(); + if (privateContext && controller.signal.aborted) return false; extConfig.selectors = mergeConfig(DEFAULT_SELECTORS, result); - console.log(result); + if (!isPrivateBrowsingContext()) logger.log(result); return true; } catch (error) { extConfig.selectors = cloneConfig(DEFAULT_SELECTORS); - console.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error); + logger.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error); return false; } finally { clearTimeout(timeout); + if (privateContext) window.removeEventListener("pagehide", abortOnPageHide); } } @@ -571,16 +615,22 @@ function initializeDisableVoteSubmission() { }); } +let loggingPreferenceRevision = 0; + +function updateDisableLogging(value) { + loggingPreferenceRevision += 1; + extConfig.disableLogging = value ?? true; + initializeLogging(); +} + function initializeDisableLogging() { + const revision = loggingPreferenceRevision; getBrowser().storage.sync.get(["disableLogging"], (res) => { + if (revision !== loggingPreferenceRevision) return; if (res.disableLogging === undefined) { getBrowser().storage.sync.set({ disableLogging: true }); - extConfig.disableLogging = true; - } else { - extConfig.disableLogging = res.disableLogging; } - // Initialize console methods based on logging config - initializeLogging(); + updateDisableLogging(res.disableLogging); }); } @@ -688,7 +738,7 @@ function initializeHideClutterButtons() { const storedValue = res[HIDE_CLUTTER_BUTTONS_STORAGE_KEY]; const normalized = normalizeHideClutterButtons(storedValue); extConfig.hideClutterButtons = publishHideClutterButtons(normalized); - if (storedValue === undefined) { + if (storedValue === undefined && !isPrivateBrowsingContext()) { storage.set({ [HIDE_CLUTTER_BUTTONS_STORAGE_KEY]: false }); } resolve(normalized); @@ -723,4 +773,5 @@ export { clearRenderedVoteState, persistSyntheticShortsDislikeState, initializeHideClutterButtons, + updateDisableLogging, }; diff --git a/Extensions/combined/src/state.spec.js b/Extensions/combined/src/state.spec.js index fdd5333..a18aec5 100644 --- a/Extensions/combined/src/state.spec.js +++ b/Extensions/combined/src/state.spec.js @@ -82,12 +82,15 @@ import { clearRenderedVoteState, extConfig, getLikeCountFromButton, + initExtConfig, initializeHideClutterButtons, initializeSelectors, + updateDisableLogging, restoreCurrentState, setDislikes, setState, storedData, + persistSyntheticShortsDislikeState, __RewireAPI__ as stateRewire, } from "./state"; @@ -127,6 +130,8 @@ afterEach(() => { stateRewire.__ResetDependency__("nativeLikeCounts"); stateRewire.__ResetDependency__("isMobile"); stateRewire.__ResetDependency__("syntheticDislikeStore"); + stateRewire.__ResetDependency__("isPrivateBrowsingContext"); + stateRewire.__ResetDependency__("initializeLogging"); }); afterAll(() => { @@ -135,6 +140,49 @@ afterAll(() => { delete globalThis.__mockControlsUnavailable; }); +test.each([true, false, undefined])("initializes the logging preference (%s)", (value) => { + const initializeLogging = jest.fn(); + stateRewire.__set__("initializeLogging", initializeLogging); + const set = jest.fn(); + globalThis.__mockBrowser = { + storage: { sync: { get: (_keys, callback) => callback({ disableLogging: value }), set } }, + }; + + stateRewire.__get__("initializeDisableLogging")(); + + expect(extConfig.disableLogging).toBe(value ?? true); + expect(initializeLogging).toHaveBeenCalledTimes(1); + if (value === undefined) expect(set).toHaveBeenCalledWith({ disableLogging: true }); + else expect(set).not.toHaveBeenCalled(); +}); + +test.each([true, false, undefined])("a late logging preference read cannot override a live update (%s)", (value) => { + const initializeLogging = jest.fn(); + stateRewire.__set__("initializeLogging", initializeLogging); + let finishRead; + const set = jest.fn(); + globalThis.__mockBrowser = { + storage: { sync: { get: (_keys, callback) => (finishRead = callback), set } }, + }; + stateRewire.__get__("initializeDisableLogging")(); + + updateDisableLogging(false); + finishRead({ disableLogging: value }); + + expect(extConfig.disableLogging).toBe(false); + expect(initializeLogging).toHaveBeenCalledTimes(1); + expect(set).not.toHaveBeenCalled(); +}); + +test("removing the logging preference restores the default suppression", () => { + const initializeLogging = jest.fn(); + stateRewire.__set__("initializeLogging", initializeLogging); + updateDisableLogging(false); + updateDisableLogging(undefined); + expect(extConfig.disableLogging).toBe(true); + expect(initializeLogging).toHaveBeenCalledTimes(2); +}); + test("hide-clutter initialization persists and publishes the default-off value", async () => { const set = jest.fn(); globalThis.__mockBrowser = { @@ -535,3 +583,176 @@ test("falls back to bundled selectors when the remote selector request never set jest.useRealTimers(); } }); + +describe("private count display", () => { + beforeEach(() => { + stateRewire.__set__("isPrivateBrowsingContext", () => true); + }); + + test.each([false, true])("reads only display preferences without default writes (saved=%s)", async (saved) => { + const initializeLogging = jest.fn(); + stateRewire.__set__("initializeLogging", initializeLogging); + const preferences = saved + ? { + coloredThumbs: true, + coloredBar: true, + colorTheme: "custom", + customColors: { light: { like: "#123456", dislike: "#654321" } }, + numberDisplayFormat: "standard", + showTooltipPercentage: true, + tooltipPercentageMode: "percentage", + numberDisplayReformatLikes: true, + hideClutterButtons: true, + disableLogging: false, + patreonSessionToken: "synthetic-normal-session", + } + : {}; + Object.assign(extConfig, { + coloredThumbs: false, + coloredBar: false, + colorTheme: "classic", + numberDisplayFormat: "compactShort", + showTooltipPercentage: false, + tooltipPercentageMode: "dash_like", + numberDisplayReformatLikes: false, + hideClutterButtons: false, + }); + const storage = { + sync: { + get: jest.fn((_keys, callback) => callback(preferences)), + set: jest.fn(), + }, + local: { get: jest.fn(), set: jest.fn(), remove: jest.fn() }, + onChanged: { addListener: jest.fn(), removeListener: jest.fn() }, + }; + globalThis.__mockBrowser = { storage }; + fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + const consoleLog = console.log; + const consoleDebug = console.debug; + + await initExtConfig(); + + expect(storage.sync.get).toHaveBeenCalledTimes(1); + expect(storage.sync.get).toHaveBeenCalledWith( + [ + "coloredThumbs", + "coloredBar", + "colorTheme", + "customColors", + "numberDisplayFormat", + "showTooltipPercentage", + "tooltipPercentageMode", + "numberDisplayReformatLikes", + "hideClutterButtons", + ], + expect.any(Function), + ); + expect(storage.sync.set).not.toHaveBeenCalled(); + expect(storage.local.get).not.toHaveBeenCalled(); + expect(storage.local.set).not.toHaveBeenCalled(); + expect(storage.local.remove).not.toHaveBeenCalled(); + expect(storage.onChanged.addListener).not.toHaveBeenCalled(); + expect(initializeLogging).toHaveBeenCalledTimes(1); + expect(console.log).toBe(consoleLog); + expect(console.debug).toBe(consoleDebug); + expect(extConfig.coloredBar).toBe(saved); + expect(extConfig.numberDisplayFormat).toBe(saved ? "standard" : "compactShort"); + expect(extConfig.customColors.light.like).toBe(saved ? "#123456" : "#007600"); + expect(extConfig.disableLogging).toBe(true); + expect(extConfig).not.toHaveProperty("patreonSessionToken"); + expect(fetch).toHaveBeenCalledWith( + "https://example.test/configs/selectors", + expect.objectContaining({ method: "GET", credentials: "omit", cache: "no-store" }), + ); + }); + + test("private initialization suppresses its own count diagnostics without replacing console methods", async () => { + const { logger, setLoggingDisabled } = jest.requireActual("./logging"); + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + const debug = jest.spyOn(console, "debug").mockImplementation(() => {}); + const ownLog = jest.spyOn(logger, "log"); + const initializeLogging = jest.fn(() => setLoggingDisabled(extConfig.disableLogging)); + stateRewire.__set__("initializeLogging", initializeLogging); + const storage = { + sync: { + get: jest.fn((_keys, callback) => callback({ disableLogging: false })), + set: jest.fn(), + }, + }; + globalThis.__mockBrowser = { storage }; + fetch + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) }); + extConfig.disableLogging = false; + setLoggingDisabled(false); + + try { + await initExtConfig(); + await setState(storedData); + logger.debug("Private count diagnostic"); + + expect(initializeLogging).toHaveBeenCalledTimes(1); + expect(extConfig.disableLogging).toBe(true); + expect(ownLog).toHaveBeenCalledWith("Video is loaded. Adding buttons..."); + expect(log).not.toHaveBeenCalled(); + expect(debug).not.toHaveBeenCalled(); + expect(console.log).toBe(log); + expect(console.debug).toBe(debug); + expect(storage.sync.get.mock.calls[0][0]).not.toContain("disableLogging"); + expect(storage.sync.set).not.toHaveBeenCalled(); + } finally { + setLoggingDisabled(false); + ownLog.mockRestore(); + log.mockRestore(); + debug.mockRestore(); + } + }); + + test("never restores or writes synthetic Shorts history, including direct persistence calls", async () => { + const store = { isDisliked: jest.fn(async () => true), setDisliked: jest.fn(async () => undefined) }; + stateRewire.__set__("syntheticDislikeStore", store); + stateRewire.__set__("isMobile", () => false); + getDislikeButton().synthetic = true; + fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) }); + + await setState(storedData); + await persistSyntheticShortsDislikeState("abcdefghijk", true); + await persistSyntheticShortsDislikeState("abcdefghijk", false); + + expect(store.isDisliked).not.toHaveBeenCalled(); + expect(store.setDisliked).not.toHaveBeenCalled(); + expect(storedData).toMatchObject({ dislikes: 65, previousState: "NEUTRAL_STATE", videoId: "abcdefghijk" }); + expect(setSyntheticShortsDislikePressed).toHaveBeenLastCalledWith(false, getDislikeButton()); + }); + + test("omits private response and count payloads from diagnostics", async () => { + const payload = { dislikes: 654321, likes: 100, videoId: "private-test-video", marker: "private-count-payload" }; + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + fetch.mockResolvedValueOnce({ ok: true, json: async () => payload }); + try { + await setState(storedData); + expect(JSON.stringify(log.mock.calls)).not.toMatch(/654321|private-test-video|private-count-payload/); + } finally { + log.mockRestore(); + } + }); + + test("does not save a missing clutter preference when initialized directly", async () => { + const storage = { get: jest.fn((_keys, callback) => callback({})), set: jest.fn() }; + globalThis.__mockBrowser = { storage: { sync: storage } }; + await initializeHideClutterButtons(); + expect(storage.set).not.toHaveBeenCalled(); + }); + + test("discards a late private selector response after page exit", async () => { + const pending = deferred(); + fetch.mockReturnValueOnce(pending.promise); + const initialization = initializeSelectors(); + const signal = fetch.mock.calls[0][1].signal; + window.dispatchEvent(new Event("pagehide")); + expect(signal.aborted).toBe(true); + pending.resolve({ ok: true, json: async () => ({ privateSelectorMarker: true }) }); + await expect(initialization).resolves.toBe(false); + expect(extConfig.selectors).not.toHaveProperty("privateSelectorMarker"); + }); +}); diff --git a/Extensions/combined/src/utils.js b/Extensions/combined/src/utils.js index 5636e49..8b82062 100644 --- a/Extensions/combined/src/utils.js +++ b/Extensions/combined/src/utils.js @@ -1,3 +1,4 @@ +import { logger, setLoggingDisabled } from "./logging"; import { extConfig, isShorts } from "./state"; import { getThemeColor, isYouTubeDarkTheme } from "../../common/color-themes"; @@ -33,7 +34,7 @@ function getNumberFormatter(optionSelect) { ?.getAttribute("href"), )?.searchParams?.get("locale"); } catch { - console.log("Cannot find browser locale. Use en as default for number formatting."); + logger.log("Cannot find browser locale. Use en as default for number formatting."); userLocales = "en"; } } @@ -91,7 +92,7 @@ function getBrowser() { } else if (typeof browser !== "undefined" && typeof browser.runtime !== "undefined") { return browser; } else { - console.log("browser is not supported"); + logger.log("browser is not supported"); return false; } } @@ -242,22 +243,8 @@ function isVideoLoaded() { return querySelector(videoLoadedSelectors.map((selector) => selector.replace("{videoId}", videoId))) !== undefined; } -const originalConsole = { - log: console.log.bind(console), - debug: console.debug.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), -}; - function initializeLogging() { - if (extConfig.disableLogging) { - console.log = () => {}; - console.debug = () => {}; - } else { - console.log = originalConsole.log; - console.debug = originalConsole.debug; - } + setLoggingDisabled(extConfig.disableLogging); } function getColorFromTheme(voteIsLike) { diff --git a/Extensions/combined/src/utils.spec.js b/Extensions/combined/src/utils.spec.js index 53d64bc..379b86d 100644 --- a/Extensions/combined/src/utils.spec.js +++ b/Extensions/combined/src/utils.spec.js @@ -45,6 +45,7 @@ jest.mock("./state", () => { }); import { extConfig, isShorts } from "./state"; +import { logger, setLoggingDisabled } from "./logging"; import { numberFormat, getNumberFormatter, @@ -90,6 +91,7 @@ function resetGlobals() { jest.restoreAllMocks(); jest.clearAllMocks(); extConfig.disableLogging = false; + setLoggingDisabled(false); extConfig.colorTheme = "classic"; extConfig.numberDisplayFormat = "compactShort"; isShorts.mockReturnValue(false); @@ -448,28 +450,21 @@ describe("utils", () => { }); describe("initializeLogging", () => { - it("disables console output when configured", () => { - extConfig.disableLogging = true; - const originalLog = console.log; - const originalDebug = console.debug; - - initializeLogging(); - - expect(console.log).not.toBe(originalLog); - expect(console.debug).not.toBe(originalDebug); - }); - - it("restores console output when logging is enabled", () => { - const stubLog = jest.fn(); - const stubDebug = jest.fn(); - console.log = stubLog; - console.debug = stubDebug; - - extConfig.disableLogging = false; - initializeLogging(); - - expect(console.log).not.toBe(stubLog); - expect(console.debug).not.toBe(stubDebug); + it("applies the preference to extension logging without replacing console methods", () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + const debug = jest.spyOn(console, "debug").mockImplementation(() => {}); + for (const disabled of [true, false, true]) { + log.mockClear(); + debug.mockClear(); + extConfig.disableLogging = disabled; + initializeLogging(); + logger.log("extension log"); + logger.debug("extension debug"); + expect(console.log).toBe(log); + expect(console.debug).toBe(debug); + expect(log).toHaveBeenCalledTimes(disabled ? 0 : 1); + expect(debug).toHaveBeenCalledTimes(disabled ? 0 : 1); + } }); }); diff --git a/Extensions/combined/src/vote-data-request.js b/Extensions/combined/src/vote-data-request.js index 33be055..7e1f79e 100644 --- a/Extensions/combined/src/vote-data-request.js +++ b/Extensions/combined/src/vote-data-request.js @@ -1,5 +1,6 @@ import { getApiEndpoint } from "./config"; import { fetchWithCoreConsent } from "./core-data-consent"; +import { isPrivateBrowsingContext } from "./private-browsing"; const WITHOUT_LIKE_COUNT = "without-like-count"; const EXTENSION_ID_PATTERN = /^[a-p]{32}$/; @@ -100,6 +101,7 @@ function requestVoteData(videoId, { fetchImpl = globalThis.fetch, likeCount = nu Accept: createVoteDataAcceptHeader(), }, signal, + ...(isPrivateBrowsingContext() ? { credentials: "omit", cache: "no-store" } : {}), }, fetchImpl, ); diff --git a/Extensions/combined/src/vote-data-request.spec.js b/Extensions/combined/src/vote-data-request.spec.js index 5ecb23c..8aa1138 100644 --- a/Extensions/combined/src/vote-data-request.spec.js +++ b/Extensions/combined/src/vote-data-request.spec.js @@ -2,6 +2,12 @@ jest.mock("./config", () => ({ getApiEndpoint: (pathname) => `https://api.test${pathname}`, })); +jest.mock("./private-browsing", () => ({ + ...jest.requireActual("./private-browsing"), + isPrivateBrowsingContext: jest.fn(), +})); +import { isPrivateBrowsingContext } from "./private-browsing"; + import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache, @@ -24,10 +30,23 @@ function response(payload = { dislikes: 12, likes: 34 }) { } beforeEach(() => { + isPrivateBrowsingContext.mockReturnValue(false); clearVoteDataRequestCache(); global.fetch = jest.fn().mockResolvedValue(response()); }); +test("private count requests omit credentials and HTTP cache use without account headers", async () => { + isPrivateBrowsingContext.mockReturnValue(true); + await requestVoteData(VIDEO_A); + expect(fetch).toHaveBeenCalledWith(`https://api.test/votes?videoId=${VIDEO_A}`, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "omit", + cache: "no-store", + signal: expect.any(AbortSignal), + }); +}); + afterAll(() => { delete global.fetch; }); diff --git a/Extensions/e2e/extension/extension-owned-page-locale.e2e.js b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js new file mode 100644 index 0000000..039e9b7 --- /dev/null +++ b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js @@ -0,0 +1,165 @@ +const path = require("node:path"); +const { chromium, test, expect } = require("@playwright/test"); +const { VIDEO_A } = require("../../UserScript/e2e/harness"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); + +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(__dirname, "../../combined/dist/chrome"), +); + +async function assertSettingsLayout(page, direction, testInfo) { + await page.locator("#advancedToggle").click(); + await expect(page.locator("#advancedSettings")).toHaveCSS("opacity", "1"); + const layout = await page.evaluate(() => { + const rect = (element) => { + const { left, right, top, bottom } = element.getBoundingClientRect(); + return { left, right, top, bottom }; + }; + const panel = document.getElementById("advancedSettings"); + const panelLeft = panel.getBoundingClientRect().left + panel.clientLeft; + return { + clipLeft: panelLeft, + clipRight: panelLeft + panel.clientWidth, + toggle: rect(document.getElementById("advancedToggle")), + switches: [...panel.querySelectorAll(".switchLabel")].map((label) => ({ + label: rect(label), + slider: rect(label.closest(".switch").querySelector(".slider")), + })), + }; + }); + expect(layout.switches.length).toBeGreaterThan(0); + for (const { label, slider } of layout.switches) { + if (direction === "rtl") expect(label.right).toBeLessThanOrEqual(slider.left + 0.5); + else expect(label.left).toBeGreaterThanOrEqual(slider.right - 0.5); + expect(label.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5); + expect(label.right).toBeLessThanOrEqual(layout.clipRight + 0.5); + const toggleOverlapsSlider = + layout.toggle.left < slider.right && + layout.toggle.right > slider.left && + layout.toggle.top < slider.bottom && + layout.toggle.bottom > slider.top; + expect(toggleOverlapsSlider).toBe(false); + } + await page.screenshot({ path: testInfo.outputPath("settings-375.png"), fullPage: true }); + + const hoverTips = page.locator(".switch[data-hover], .label-with-hover-tip[data-hover]"); + expect(await hoverTips.count()).toBeGreaterThan(0); + for (const host of await hoverTips.all()) { + await page.mouse.move(0, 0); + await expect + .poll(() => + hoverTips.evaluateAll((elements) => + elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"), + ), + ) + .toBe(true); + await host.hover(); + await expect + .poll(() => host.evaluate((element) => getComputedStyle(element, "::before").visibility)) + .toBe("visible"); + const bounds = await host.evaluate((element) => { + const host = element.getBoundingClientRect(); + const tip = getComputedStyle(element, "::before"); + const width = + parseFloat(tip.width) + + (tip.boxSizing === "border-box" + ? 0 + : parseFloat(tip.paddingLeft) + + parseFloat(tip.paddingRight) + + parseFloat(tip.borderLeftWidth) + + parseFloat(tip.borderRightWidth)); + const left = tip.left === "auto" ? host.right - parseFloat(tip.right) - width : host.left + parseFloat(tip.left); + return { left, right: left + width }; + }); + expect(bounds.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5); + expect(bounds.right).toBeLessThanOrEqual(layout.clipRight + 0.5); + } + + await page.mouse.move(0, 0); + await expect + .poll(() => + hoverTips.evaluateAll((elements) => + elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"), + ), + ) + .toBe(true); + await page.locator("#hide_clutter_buttons_help").hover(); + const help = page.locator("#hide_clutter_buttons_tooltip"); + await expect(help).toBeVisible(); + const helpBounds = await help.boundingBox(); + expect(helpBounds.x).toBeGreaterThanOrEqual(layout.clipLeft - 0.5); + expect(helpBounds.x + helpBounds.width).toBeLessThanOrEqual(layout.clipRight + 0.5); + await page.screenshot({ path: testInfo.outputPath("settings-help-375.png"), fullPage: true }); +} + +for (const { locale, language, direction } of [ + { locale: "fa-IR", language: "fa", direction: "rtl" }, + { locale: "en-US", language: "en", direction: "ltr" }, +]) { + test(`built popup and release page follow ${direction} locale metadata for ${locale}`, async ({}, testInfo) => { + const apiServer = await startHermeticApiServer(); + const browserType = { + launchPersistentContext: (profile, options) => + chromium.launchPersistentContext(profile, { ...options, args: [...options.args, `--lang=${language}`] }), + }; + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + browserType, + contextOptions: { locale }, + }); + try { + await adapter.start(); + await adapter.context.route("https://fonts.googleapis.com/**", (route) => + route.fulfill({ body: "", contentType: "text/css", status: 200 }), + ); + await adapter.context.route("https://fonts.gstatic.com/**", (route) => + route.fulfill({ body: "", contentType: "font/woff2", status: 200 }), + ); + await adapter.context.route("https://raw.githubusercontent.com/**", (route) => + route.fulfill({ body: JSON.stringify({ version: "4.0.5" }), contentType: "application/json", status: 200 }), + ); + expect(await adapter.worker.evaluate(() => chrome.i18n.getMessage("@@bidi_dir"))).toBe(direction); + await adapter.openWatch(VIDEO_A); + await adapter.waitForWatchResult(VIDEO_A); + const hostDirection = await adapter.page.evaluate(() => ({ + attribute: document.documentElement.dir, + computed: getComputedStyle(document.documentElement).direction, + })); + const page = await adapter.context.newPage(); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") pageErrors.push(message.text()); + }); + for (const [name, url, widths] of [ + ["popup", "popup.html", [375]], + ["release", "changelog/4/changelog_4.1.html", [375, 1280]], + ]) { + for (const width of widths) { + await page.setViewportSize({ width, height: 1000 }); + await page.goto(`chrome-extension://${adapter.extensionId}/${url}`, { waitUntil: "load" }); + await expect(page.locator("html")).toHaveAttribute("dir", direction); + await expect(page.locator("html")).toHaveCSS("direction", direction); + expect(await page.evaluate(() => document.documentElement.innerHTML.includes("__MSG_"))).toBe(false); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`${name}-${width}.png`), fullPage: true }); + if (name === "popup") await assertSettingsLayout(page, direction, testInfo); + } + } + expect( + await adapter.page.evaluate(() => ({ + attribute: document.documentElement.dir, + computed: getComputedStyle(document.documentElement).direction, + })), + ).toEqual(hostDirection); + await adapter.assertNoPageSignals(testInfo.title); + expect(pageErrors).toEqual([]); + expect(adapter.backend.blockedRequests).toEqual([]); + expect(apiServer.unexpectedRequests).toEqual([]); + } finally { + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } + }); +} diff --git a/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js b/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js new file mode 100644 index 0000000..9a367d8 --- /dev/null +++ b/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js @@ -0,0 +1,181 @@ +const { test, expect } = require("@playwright/test"); +const { VIDEO_A } = require("../../UserScript/e2e/harness"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); + +async function applyTheme(page, dark, nativeTokens) { + await page.evaluate( + ({ dark, nativeTokens }) => { + const root = document.documentElement; + root.toggleAttribute("dark", dark); + for (const [name, value] of Object.entries({ + "--yt-spec-base-background": dark ? "#151923" : "#fff8f0", + "--yt-spec-text-primary": dark ? "#e4eaff" : "#302010", + "--yt-spec-text-secondary": dark ? "#abbbd2" : "#705030", + })) { + root.style.setProperty(name, nativeTokens ? value : "initial"); + } + document.body.style.backgroundColor = dark ? "#0f0f0f" : "#fff"; + }, + { dark, nativeTokens }, + ); +} + +for (const nativeTokens of [false, true]) { + test(`premium analytics follows the host theme with native color tokens ${nativeTokens}`, async () => { + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + contextOptions: { colorScheme: "dark" }, + }); + let analyticsRequests = 0; + const analyticsQueries = []; + try { + await adapter.start(); + await adapter.context.addInitScript(() => { + if (location.hostname !== "www.youtube.com") return; + document.addEventListener("DOMContentLoaded", () => { + const secondary = document.createElement("aside"); + secondary.id = "secondary"; + secondary.style.cssText = "width: 520px; margin: 24px;"; + const inner = document.createElement("div"); + inner.id = "secondary-inner"; + secondary.append(inner); + document.body.append(secondary); + }); + }); + await adapter.context.route( + (url) => url.pathname.startsWith("/api/patreon/analytics/video/"), + async (route) => { + analyticsRequests++; + const query = new URL(route.request().url()).searchParams; + analyticsQueries.push(Object.fromEntries(query)); + const selectedStart = query.get("selectedRangeStartUtc") || "2026-01-01T00:00:00Z"; + const selectedEnd = + query.get("selectedRangeEndUtc") || + new Date(Date.parse(selectedStart) + Number(query.get("rangeDays") || 30) * 86400000).toISOString(); + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ + timeSeries: { + bucket: "day", + totalRangeStartUtc: "2026-01-01T00:00:00Z", + totalRangeEndUtc: "2026-04-01T00:00:00Z", + selectedRangeStartUtc: selectedStart, + selectedRangeEndUtc: selectedEnd, + points: [ + { timestampUtc: selectedStart, likes: 10, dislikes: 2 }, + { timestampUtc: selectedEnd, likes: 20, dislikes: 3 }, + ], + }, + geo: { countries: [], subdivisions: [], topLikes: [], topDislikes: [] }, + summary: { totalLikes: 30, totalDislikes: 5, uniqueIps: 20, countriesRepresented: 0 }, + }), + }); + }, + ); + await adapter.context.route( + (url) => url.pathname === "/api/discussion/me", + (route) => route.fulfill({ status: 401, contentType: "application/json", body: '{"error":"invalid_session"}' }), + ); + await adapter.worker.evaluate(async () => { + await chrome.storage.sync.set({ + patreonAuthenticated: true, + patreonSessionToken: "synthetic-theme-session", + patreonUser: { fullName: "Synthetic member", hasActiveMembership: true, membershipTier: "premium" }, + }); + }); + await adapter.openWatch(VIDEO_A); + const panel = adapter.page.locator(".ryd-premium-analytics"); + await expect(panel).toBeVisible(); + await expect(panel).toHaveAttribute("aria-busy", "false"); + await expect(panel.locator("canvas").first()).toBeVisible(); + const controlLayout = () => + panel.evaluate((element) => ({ + panel: element.getBoundingClientRect().toJSON(), + buttons: [...element.querySelectorAll(".ryd-range, .ryd-range-anchor")].map((button) => + button.getBoundingClientRect().toJSON(), + ), + })); + const normalLayout = await controlLayout(); + await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "332px")); + const narrowLayout = await controlLayout(); + expect(narrowLayout.panel.width).toBe(332); + expect(narrowLayout.buttons).toHaveLength(6); + for (const button of narrowLayout.buttons) { + expect(button.left).toBeGreaterThanOrEqual(narrowLayout.panel.left); + expect(button.right).toBeLessThanOrEqual(narrowLayout.panel.right); + } + await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "520px")); + const restoredLayout = await controlLayout(); + restoredLayout.buttons.forEach((button, index) => { + expect(button.left).toBeCloseTo(normalLayout.buttons[index].left, 1); + expect(button.top).toBeCloseTo(normalLayout.buttons[index].top, 1); + expect(button.width).toBeCloseTo(normalLayout.buttons[index].width, 1); + expect(button.height).toBeCloseTo(normalLayout.buttons[index].height, 1); + }); + for (const dark of [false, true, false]) { + const requestsBefore = analyticsRequests; + await applyTheme(adapter.page, dark, nativeTokens); + const background = nativeTokens + ? dark + ? "rgb(21, 25, 35)" + : "rgb(255, 248, 240)" + : dark + ? "rgb(32, 32, 32)" + : "rgb(255, 255, 255)"; + const foreground = nativeTokens + ? dark + ? "rgb(228, 234, 255)" + : "rgb(48, 32, 16)" + : dark + ? "rgb(255, 255, 255)" + : "rgb(15, 15, 15)"; + await expect(panel).toHaveCSS("background-color", background); + await expect(panel).toHaveCSS("color", foreground); + await expect(panel.locator(".ryd-analytics__totals-value").first()).toHaveCSS("color", foreground); + await expect(panel.locator(".ryd-range").first()).toHaveCSS( + "color", + dark ? (nativeTokens ? "rgb(171, 187, 210)" : "rgb(187, 187, 187)") : "rgb(31, 41, 55)", + ); + await adapter.page.waitForTimeout(500); + expect(analyticsRequests).toBe(requestsBefore); + for (const chart of ["activity", "lists", "map"]) { + const expand = panel.locator(`.ryd-analytics__section-expand[data-chart="${chart}"]`); + await expand.click(); + await expect(panel.locator(`.ryd-analytics__section[data-chart="${chart}"]`)).toHaveCSS( + "background-color", + background, + ); + await expand.click(); + } + } + await adapter.page.locator('.ryd-range[data-range="7"]').click(); + await expect(adapter.page.locator('.ryd-range[data-range="7"]')).toHaveClass(/is-active/); + await expect.poll(() => analyticsRequests).toBeGreaterThan(1); + await adapter.page.locator('.ryd-range[data-range="30"]').click(); + await expect(panel).toHaveAttribute("aria-busy", "false"); + const chart = await panel.locator("#ryd-analytics-activity").boundingBox(); + const plotWidth = chart.width - 60; + const sliderX = chart.x + 40 + plotWidth / 6; + const sliderY = chart.y + chart.height - 29; + await adapter.page.mouse.move(sliderX, sliderY); + await adapter.page.mouse.down(); + await adapter.page.mouse.move(sliderX + plotWidth / 6, sliderY, { steps: 10 }); + await adapter.page.mouse.up(); + await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("Custom selection"); + await expect.poll(() => analyticsQueries.at(-1)).toHaveProperty("selectedRangeStartUtc"); + await adapter.page.locator('.ryd-range[data-range="30"]').click(); + await expect(panel.locator('.ryd-range[data-range="30"]')).toHaveClass(/is-active/); + await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("First 30 days"); + await expect.poll(() => analyticsQueries.at(-1)).toMatchObject({ rangeDays: "30", rangeAnchor: "first" }); + expect(analyticsQueries.at(-1)).not.toHaveProperty("selectedRangeStartUtc"); + expect(adapter.backend.blockedRequests).toEqual([]); + expect(apiServer.unexpectedRequests).toEqual([]); + } finally { + await adapter.close(); + await apiServer.close(); + } + }); +} diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js index 168cf39..7349777 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.js +++ b/Extensions/e2e/hermetic-artifact-smoke.js @@ -466,7 +466,7 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) { "ryd.background.js still contains the production API origin after transformation.", ); const changelogListener = - /api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n maybeShowChangelog\(details\);\r?\n\}\);/g; + /api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n (?:maybeShowChangelog\(details\);|void handleInstallation\(details\);)\r?\n\}\);/g; if ((transformedBackground.match(changelogListener) ?? []).length !== 1) { removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-"); throw new Error("ryd.background.js must have exactly one recognized first-install changelog listener to suppress."); diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js index 8217def..cfa578f 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.spec.js +++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js @@ -49,7 +49,7 @@ test("the extension adapter honors the selected artifact even when a scenario us } }); -function createExtensionFixture(lineEnding = "\n") { +function createExtensionFixture(lineEnding = "\n", installedHandler = "maybeShowChangelog(details);") { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-")); temporaryDirectories.push(directory); fs.writeFileSync( @@ -66,7 +66,7 @@ function createExtensionFixture(lineEnding = "\n") { ); fs.writeFileSync( path.join(directory, "ryd.background.js"), - `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`.replaceAll( + `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n ${installedHandler}\n});`.replaceAll( "\n", lineEnding, ), @@ -381,10 +381,15 @@ test("the routed fake backend records a valid preflight and blocks an unknown on ]); }); -test.each(["\n", "\r\n"])( - "redirects MV3 background traffic with %j line endings and preserves routed traffic", - (lineEnding) => { - const sourceDirectory = createExtensionFixture(lineEnding); +test.each([ + ["\n", "maybeShowChangelog(details);"], + ["\r\n", "maybeShowChangelog(details);"], + ["\n", "void handleInstallation(details);"], + ["\r\n", "void handleInstallation(details);"], +])( + "redirects MV3 background traffic with %j line endings and %s while preserving routed traffic", + (lineEnding, installedHandler) => { + const sourceDirectory = createExtensionFixture(lineEnding, installedHandler); const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127"); temporaryDirectories.push(prepared.temporaryRoot); diff --git a/README_AMO_SOURCE.md b/README_AMO_SOURCE.md index ce148d1..ee748c3 100644 --- a/README_AMO_SOURCE.md +++ b/README_AMO_SOURCE.md @@ -4,7 +4,7 @@ This source package is provided for Mozilla Add-ons review. The extension is bui ## Build environment -Build the 4.0.6 release with this exact toolchain: +Build the 4.1.0 release with this exact toolchain: - Windows 11 (PowerShell); the build script also supports Linux and macOS - Node.js 22.17.0 @@ -49,7 +49,7 @@ The script performs all required technical build steps: 1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use. 2. Installs the locked dependencies with `npm ci`. 3. Builds the extension with the release-specific `npm run build-for-amo` hook. -4. Verifies the generated Firefox manifest is version 4.0.6, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version. +4. Verifies the generated Firefox manifest is version 4.1.0, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version. 5. Verifies `menu-fixer.js` and the other required Firefox files were generated. 6. Verifies `ryd.content-script.js` is below 5 MB. 7. Verifies every generated JavaScript file has no source-map reference.