Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 100 additions & 3 deletions extension/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ src/
strictMode.ts <- domain/lock/StrictModeChallenge.kt + RuleWeakening.kt
emergencyPass.ts <- domain/emergency/EmergencyPass.kt
stats.ts <- ui/screens/stats/StatsCalculator.kt
channels.ts (channel-list decision matrix; pure, no DOM)
budgets.ts, messages.ts, ruleResolver.ts
background/ The service worker: dnr, tracker, tempAllow, alarmsHub, badge,
messagesRouter, storage.
content/ YouTube: selectors.ts (ALL selectors), youtube.ts, youtube.css
content/ YouTube: selectors.ts (ALL selectors), youtube.ts, youtube.css,
channelDetection.ts (3-tier channel identification), channelFilter.ts
(feed filtering + watch gate + colour flip), unhook.ts (hide toggles)
entrypoints/ WXT entrypoints: background, blocked/, popup/, dashboard/, onboarding/,
youtube.content.ts
ui/ Shared React primitives + design tokens + typed rpc wrapper
Expand Down Expand Up @@ -86,6 +89,80 @@ unit-testable, exactly as the Android domain layer is.
Shorts with 3-layer nav detection (`yt-navigate-finish` + `popstate` + debounced
observer).

### Channel lists, gray-screen and the hide toggles (v1.1)

- **Channel identification is three-tier** (ext-03 §3): `ytInitialPlayerResponse.videoDetails`
first, then an `ytInitialData` brace-counting scan, then a DOM selector chain. YouTube
ships different shapes on different surfaces, so one path is not enough.
- **THE INLINE TIERS GO STALE ON SPA NAVIGATION.** YouTube does not rewrite
`ytInitialPlayerResponse` / `ytInitialData` on a watch -> watch client-side hop, so they
keep describing whatever video was last FULL-loaded. The fast tier is therefore the
*stalest* one during normal browsing. Both inline tiers must prove they describe the
current video (their declared videoId === the URL's `v`); a tier that declares a different
id, or none at all, is skipped in favour of the DOM byline, which YouTube really does
re-render. Live QA caught this bypassing the whitelist, blacklist AND gray-screen at once.
- **THE BYLINE ITSELF LAGS TOO, hence the settle window.** Fixing the permanent bypass left
a transient one: `yt-navigate-finish` fires BEFORE YouTube re-renders the owner byline, so
for ~1.5-5s even the DOM tier reports the previous video's channel. The forward direction
(allowed -> blocked) is a couple of ungated seconds; the REVERSE (blocked -> allowed) was
far worse, a channel the user explicitly allowed was accused of being "off your list" for
3-5s. Punishing someone for watching what they said they wanted is the most damaging thing
this extension can do. `core/channelFreshness.ts` resolves it with a cheap discriminator:
staleness only MATTERS while the byline still names the channel from before the hop, so
detection is CONFIRMED the moment it differs (or the inline data is authoritative, or a
6s backstop elapses) and SETTLING until then. While settling we withhold the interstitial
and stay gray, both fail-safe directions.
- **A debounce can be STARVED.** The corrective re-check was losing to YouTube's
post-navigation mutation storm, which kept resetting the 250ms debounce, that starvation
is what stretched the window to 5s. Anything that MUST happen after an event needs its own
timer that page activity cannot reset (`SETTLE_RECHECK_MS`), not just a debounced observer.
- **The decision is pure and separate from the DOM.** `core/channels.ts` answers "what does
this channel mean" with no DOM at all; `content/channelFilter.ts` only applies the answer.
That split is what let the full mode x listed x unknown matrix be tested exhaustively.
- **The unknown-channel case FAILS OPEN, deliberately and observably.** If detection fails
(YouTube moved its DOM, the page hasn't hydrated), a hard whitelist that failed *shut*
would block ALL of YouTube the moment a selector rots. So an unidentified channel is
allowed, but with a distinct `reason: 'unknown-channel'`, so "we checked and it's fine" is
always distinguishable from "we couldn't tell". Gray-screen takes the OPPOSITE bias: an
unidentified channel never earns colour, because staying gray is harmless.
- **Feed cards are matched per-card.** The channel chain is run SCOPED to each card; an
unscoped query returns the first channel on the page for every card.
- **A selector match that yields nothing is a MISS, not an answer.** `channelFromDom`
iterates every rung and every match until one produces an identifier, skipping hrefless
placeholder anchors, taking `elements[0]` of the first matching rung let a hidden empty
anchor on search results mask the real `/@handle` link and leak the whitelist.
- **Some selector chains are ladders, some are lists.** A chain is normally alternatives
for ONE element (first match wins), but a few surfaces are genuinely several elements
shown together, the end-screen grid and the creator end-cards coexist, so those carry
`matchAll: true`. Getting this wrong hides one and leaves the other on screen.
- **The fail-open is observable.** When some cards resolve and others do not, the content
script logs a one-shot warning naming the count, so YouTube DOM churn shows up in
devtools rather than silently degrading channel filtering into a no-op.

#### Gray-screen: the mechanism and its flash behaviour

The requirement is contradictory on its face, the CSS must be *gateable* (off when the
feature is off) AND applied *before first paint* (or the page flashes in full colour, which
is exactly the hit the feature exists to remove). Neither obvious option delivers both:

| Approach | Gateable? | Flash-free? |
|---|---|---|
| Static `content_scripts.css` in the manifest | No, always on | Yes |
| CSS injected from JS after a storage read | Yes | No, paints in colour first |
| **Dynamic `chrome.scripting.registerContentScripts` with `css`** | **Yes** | **Yes** |

We use the third. The service worker registers `public/grayscale.css` while the feature is on
and unregisters it when off; Chrome injects registered CSS "before any DOM is constructed or
displayed" (verified in the `chrome.scripting` reference). Registration is re-derived from
settings on every worker wake, because `persistAcrossSessions` defaults to true and a stale
registration would otherwise outlive the setting that asked for it.

**The one flash that remains, and why it is the right one:** going gray -> COLOUR flickers
once, after the channel check resolves, on an *allowed* channel. That direction is
unavoidable (we cannot know the channel before the page exists) and is the correct trade: a
brief gray frame on a video you're allowed to watch is a far better failure than a colour
frame on every video you are trying not to be pulled into.

## Non-negotiables

- **Zero network requests.** No telemetry, no remote config, no CDN. All selectors are
Expand Down Expand Up @@ -133,6 +210,20 @@ xvfb), scoped with `paths: ['extension/**']`. The Android workflow carries the m
- **Return `true` from the `onMessage` listener** to keep the channel open for an async
response, and always send *something* — otherwise callers hang forever.
- **Gate settings changes in the WORKER, not the UI.** A gate a page can skip is not a gate.
- **`chrome.scripting.registerContentScripts` accepts `css` and injects it before first
paint.** That is the only way to have CSS that is BOTH runtime-gateable and flash-free;
static manifest CSS cannot be turned off and JS-injected CSS is always too late.
- **A hard whitelist must fail OPEN.** Whatever identifies the thing being filtered can
break; if 'unidentified' means 'blocked', one rotted selector blocks the entire site.
Fail open, but carry a distinct reason so the fail-open is observable rather than silent.
- **e2e can drive the YouTube content script with no network**: map `*.youtube.com` onto
the local fixture server too. It MUST be served over HTTPS, youtube.com is in Chrome's
HSTS preload list, so `http://` is force-upgraded before the resolver rule applies and a
plain-HTTP server answers ERR_SSL_PROTOCOL_ERROR. The fixture generates a throwaway
self-signed cert per run (never committed) and Chrome runs with --ignore-certificate-errors.
- **Each hiding feature owns its own CSS class.** Shorts hiding, the Unhook toggles and the
channel filter use three different classes: with one shared class, turning any of them
off would un-hide the others' elements.
- **ENGINE INVARIANT: if any rule applies, the verdict is a BLOCK.** ALLOW means "no rule
applies here" and nothing else. DNR has already redirected by the time the engine runs, so
an ALLOW while a rule still applies is not a harmless no-op — it bounces the user back to
Expand Down Expand Up @@ -180,5 +271,11 @@ xvfb), scoped with `paths: ['extension/**']`. The Android workflow carries the m
rather than pretending; honesty is the differentiator (ext-02).
- YouTube fixtures in `tests/content/fixtures/` are hand-authored from the ext-03 taxonomy,
not live DOM captures. Refresh them from real YouTube DOM when possible.
- v1.1 scope (channel whitelist/blacklist, gray-screen mode, Instagram/TikTok/X surfaces) is
specified in the PRD but not built.
- Instagram / TikTok / X reel surfaces are specified in the PRD's v1.1 section but NOT built
, this phase covered the YouTube half only.
- **Disabling autoplay is best-effort.** There is no API for it, so we click the player's
own switch when it reads `aria-checked="true"`. YouTube re-renders the player and can
restore its own state, so it is re-applied on every SPA navigation and is not a guarantee.
The dashboard says so plainly rather than implying certainty.
- Channel detection depends on YouTube's `ytInitialPlayerResponse` / `ytInitialData` shapes.
Fixture tests cover the documented shapes, but they are hand-authored, not live captures.
2 changes: 1 addition & 1 deletion extension/e2e/blocking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test.describe('site blocking', () => {
await page.goto(siteUrl('blocked.test'));

// The DNR redirect must carry the original URL through to the block page.
await expect(page).toHaveURL(/blocked\.html\?target=http:\/\/blocked\.test\//);
await expect(page).toHaveURL(/blocked\.html\?target=https:\/\/blocked\.test\//);
await expect(page.getByRole('button', { name: /go back/i })).toBeVisible();
});

Expand Down
101 changes: 95 additions & 6 deletions extension/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
* exactly as it would see youtube.com.
*/

import { createServer, type Server } from 'node:http';
import { execFileSync } from 'node:child_process';
import type { Server } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import type { AddressInfo } from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -25,6 +29,7 @@ import {
type BrowserContext,
type Worker,
} from '@playwright/test';
import { DEFAULT_SETTINGS, SCHEMA_VERSION } from '../src/core/settingsSchema';
import type { NudgeSettings, SiteRule } from '../src/core/settingsSchema';

const here = path.dirname(fileURLToPath(import.meta.url));
Expand All @@ -33,10 +38,89 @@ export const EXTENSION_PATH = path.resolve(here, '../.output/chrome-mv3');
/** The storage key the extension persists settings under (see background/storage.ts). */
const SETTINGS_KEY = 'nudge:settings';

/**
* A YouTube-shaped page, served for the mapped youtube.com host.
*
* `?channel=UCxxxx&name=Foo` embeds a real `ytInitialPlayerResponse` so the extension's
* own detection runs against the shape it expects, this is what lets the channel features
* be tested end to end (real content script, real registered CSS, real service worker) with
* no network access at all.
*/
function youtubePage(url: URL): string {
const channelId = url.searchParams.get('channel') ?? '';
const name = url.searchParams.get('name') ?? 'Test Channel';
const videoId = url.searchParams.get('v') ?? '';
// `staleVideo` reproduces the SPA case: inline JSON pinned to a DIFFERENT video than the
// URL names, which is what YouTube actually serves after a client-side navigation.
const inlineVideoId = url.searchParams.get('staleVideo') ?? videoId;

const playerResponse =
channelId === ''
? ''
: `<script>var ytInitialPlayerResponse = ${JSON.stringify({
videoDetails: { videoId: inlineVideoId, channelId, author: name, title: 'A video' },
})};</script>`;

// A real watch page also carries a channel byline in the DOM, which YouTube re-renders on
// every navigation, that is the tier the staleness guard falls through to.
const domChannelId = url.searchParams.get('domChannel') ?? channelId;
const byline =
domChannelId === ''
? ''
: `<ytd-channel-name id="channel-name"><a class="yt-formatted-string" ` +
`href="/channel/${domChannelId}" aria-label="Go to channel ${name}">${name}</a>` +
`</ytd-channel-name>`;

return (
`<!doctype html><html><head><title>${name} - YouTube</title></head><body>` +
`<h1 id="host">www.youtube.com</h1><p id="path">${url.pathname}${url.search}</p>` +
playerResponse +
`<ytd-watch-flexy><div id="primary"><video id="player"></video>${byline}</div>` +
`<div id="secondary"><div id="related">recommendations</div></div></ytd-watch-flexy>` +
`<div id="comments"><div id="contents">comments</div></div>` +
`</body></html>`
);
}

/**
* A throwaway self-signed cert for the test server.
*
* Needed because youtube.com is in Chrome's HSTS PRELOAD list: `http://www.youtube.com/` is
* force-upgraded to HTTPS before it ever reaches our resolver rule, so a plain-HTTP fixture
* server answers with ERR_SSL_PROTOCOL_ERROR. Generated per-run into a temp dir rather than
* committed - a private key in a public repo is a bad habit even when it is worthless - and
* Chrome is launched with --ignore-certificate-errors so the cert never has to be trusted.
*/
function generateSelfSignedCert(): { key: Buffer; cert: Buffer } {
const dir = mkdtempSync(`${tmpdir()}/nudge-e2e-cert-`);
execFileSync(
'openssl',
[
'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', `${dir}/key.pem`,
'-out', `${dir}/cert.pem`,
'-days', '1',
'-subj', '/CN=localhost',
'-addext',
'subjectAltName=DNS:localhost,DNS:*.youtube.com,DNS:youtube.com,DNS:*.test,IP:127.0.0.1',
],
{ stdio: 'ignore' },
);
return { key: readFileSync(`${dir}/key.pem`), cert: readFileSync(`${dir}/cert.pem`) };
}

function startTestServer(): Promise<Server> {
const server = createServer((req, res) => {
const { key, cert } = generateSelfSignedCert();
const server = createHttpsServer({ key, cert }, (req, res) => {
const host = (req.headers.host ?? 'unknown').split(':')[0]!;
const url = new URL(req.url ?? '/', `http://${host}`);
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });

if (host.endsWith('youtube.com')) {
res.end(youtubePage(url));
return;
}

res.end(
`<!doctype html><html><head><title>${host}</title></head>` +
`<body><h1 id="host">${host}</h1><p id="path">${req.url}</p></body></html>`,
Expand Down Expand Up @@ -85,7 +169,9 @@ export const test = base.extend<ExtensionFixtures>({
`--load-extension=${EXTENSION_PATH}`,
// Any *.test hostname resolves to the local server, so page URLs stay clean
// (`http://blocked.test/`) and domain matching is realistic.
`--host-resolver-rules=MAP *.test 127.0.0.1:${port}`,
// youtube.com is mapped too, so the YouTube content script (which only matches
// *.youtube.com) runs for real against a YouTube-shaped page, still zero network.
`--host-resolver-rules=MAP *.test 127.0.0.1:${port}, MAP *.youtube.com 127.0.0.1:${port}`,
// Keep each browser's footprint small. The suite launches one persistent context
// per test, and on a developer machine that is competing with a real browser for
// memory; a bloated test browser gets OOM-killed and surfaces as the confusing
Expand All @@ -96,6 +182,8 @@ export const test = base.extend<ExtensionFixtures>({
'--disable-features=Translate,MediaRouter,OptimizationHints',
'--no-first-run',
'--no-default-browser-check',
// The fixture server presents a throwaway self-signed cert (see above).
'--ignore-certificate-errors',
],
});

Expand All @@ -119,7 +207,7 @@ export const test = base.extend<ExtensionFixtures>({

siteUrl: async ({ context }, use) => {
void context;
await use((host: string, pathname = '/') => `http://${host}${pathname}`);
await use((host: string, pathname = '/') => `https://${host}${pathname}`);
},

setSettings: async ({ serviceWorker }, use) => {
Expand Down Expand Up @@ -252,14 +340,15 @@ export async function sendFromExtensionPage<T>(
/** A minimal valid settings object for tests to spread over. */
export function baseSettings(overrides: Partial<NudgeSettings> = {}): Partial<NudgeSettings> {
return {
schemaVersion: 1,
schemaVersion: SCHEMA_VERSION,
globalEnabled: true,
onboardingComplete: true,
rules: [],
messages: { delayTitles: [], delaySubtitles: [], hardBlockMessages: [] },
strictMode: { enabled: false, challengeLength: 24 },
emergencyPass: { enabled: true },
youtube: { shortsMode: 'INHERIT', hideShortsShelf: false, shortsDelaySeconds: 15 },
// Spread the REAL defaults so adding a settings field never breaks the whole suite.
youtube: { ...DEFAULT_SETTINGS.youtube },
tempAllowMinutes: 10,
...overrides,
};
Expand Down
2 changes: 1 addition & 1 deletion extension/e2e/redirectLoop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test.describe('Hard Block + Daily Time Limit (redirect-loop regression)', () =>
// Settled: the block page stayed put rather than bouncing back to the site.
expect(navigations).toBe(afterLoad);
await expect(page).toHaveURL(/blocked\.html\?target=/);
expect(page.url()).not.toMatch(/^http:\/\/blocked\.test/);
expect(page.url()).not.toMatch(/^https?:\/\/blocked\.test/);
});

test('under budget it is a plain Hard Block, not a "limit reached" block', async ({
Expand Down
Loading
Loading