diff --git a/.gitignore b/.gitignore index 6a7fe32..3a57947 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,10 @@ apps/mobile/ios/ *.mobileprovision *.orig.* +# config signing — Ed25519 private key NEVER committed; public key is +.keys/*.key +!.keys/filmsnaps-ed25519.pub + # mobile build artifacts *.log build_commands.txt diff --git a/.keys/filmsnaps-ed25519.pub b/.keys/filmsnaps-ed25519.pub new file mode 100644 index 0000000..852685d --- /dev/null +++ b/.keys/filmsnaps-ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAuL54sRMa4NpYZnl4TiQgl4Dib93hrSrWxcvqQ78Sv+Y= +-----END PUBLIC KEY----- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adcc039..fc60f0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ a provider, and ship changes across web, desktop, and mobile. 3. [Development workflow](#development-workflow) 4. [Project structure](#project-structure) 5. [Adding a new provider](#adding-a-new-provider) -6. [Editing `blocklist.json`](#editing-blocklistjson) +6. [Editing `providers.json` + `filters.txt` (v5)](#editing-providersjson--filterstxt-v5) 7. [Testing](#testing) 8. [Code style](#code-style) 9. [Common pitfalls](#common-pitfalls) @@ -23,15 +23,17 @@ FilmSnaps is a pnpm + Turborepo monorepo. Read [docs/architecture.md](docs/architecture.md) for the full picture, and [docs/security.md](docs/security.md) before touching anything security-related. -| Directory | Package | What it is | -| --- | --- | --- | -| `apps/web` | `@filmsnaps/web` | Next.js web app (discovery + watch UI). | -| `apps/desktop` | `@filmsnaps/desktop` | Electron app wrapping the web app + hardened player. | -| `apps/mobile` | `@filmsnaps/mobile` | Expo / React Native app. | -| `apps/feedback` | `@filmsnaps/feedback` | Feedback portal (Cloudflare Workers + D1). | -| `packages/shared` | `@filmsnaps/shared` | Shared guards, provider registry, types, state. | -| `packages/adblock-config` | `@filmsnaps/adblock-config` | `blocklist.json` schema + validation. | -| `packages/filter-compiler` | `@filmsnaps/filter-compiler` | Adblock engine + mobile pattern artifacts. | +| Directory | Package | What it is | +| -------------------------- | ---------------------------- | -------------------------------------------------------- | +| `apps/web` | `@filmsnaps/web` | Next.js web app (discovery + watch UI). | +| `apps/desktop` | `@filmsnaps/desktop` | Electron app wrapping the web app + hardened player. | +| `apps/mobile` | `@filmsnaps/mobile` | Expo / React Native app. | +| `apps/feedback` | `@filmsnaps/feedback` | Feedback portal (Cloudflare Workers + D1). | +| `packages/shared` | `@filmsnaps/shared` | Shared guards, provider registry, types, state. | +| `packages/adblock-config` | `@filmsnaps/adblock-config` | v5 `providers.json` + `filters.txt` schema + validation. | +| `packages/filter-compiler` | `@filmsnaps/filter-compiler` | `@ghostery/adblocker` engine + mobile pattern export. | + +--- ## Setting up @@ -47,6 +49,8 @@ If you change a filter/blocklist config, regenerate the adblock artifacts: pnpm build:filters # recompiles compiled-engine.bin + android-adblock-patterns.json ``` +--- + ## Development workflow ### Web @@ -81,6 +85,8 @@ engine — see `apps/desktop/README.md`. cd apps/feedback && pnpm dev # http://localhost:3001 ``` +--- + ## Project structure ``` @@ -91,16 +97,17 @@ apps/ feedback/ Next.js + Workers + D1 packages/ shared/ shared logic (security bundles, providers, state) - adblock-config/ blocklist.json schema + validation - filter-compiler/ engine + pattern export -blocklist.json provider + blocking rules (single source of truth) + adblock-config/ providers.json + filters.txt v5 schema + validation + filter-compiler/ @ghostery/adblocker engine + mobile pattern export +providers.json v5 config (providers) — single source of truth, Ed25519-signed +providers.json.sig Ed25519 signature over providers.json +filters.txt v5 config (uBO/EasyList rules) +blocklist.json legacy v4 fallback (backward compat) ``` -## Adding a new provider +--- -Providers are **only** registered in the shared package — there is no separate -web/mobile provider list. See `apps/desktop/README.md` and -`apps/mobile/README.md` for per-platform notes. +## Adding a new provider ### Step 1 — Register in `@filmsnaps/shared` @@ -115,16 +122,74 @@ web/mobile provider list. See `apps/desktop/README.md` and embed: { movie: (id: string) => `/embed/movie/${id}`, tv: (id, season, episode) => `/embed/tv/${id}/${season}/${episode}`, + } +} +``` + +### Step 2 — Add its domains to `providers.json` (v5) + +**File:** `providers.json` (repo root, schema v5) + +```json +{ + "version": 5, + "providers": [ + { + "id": "myprovider", + "embedDomains": ["example.com", "www.example.com"], + "cdnDomains": ["cdn.example.com"], + "enabled": true, + "allowServerRedirects": false, + "blockHomePaths": ["/go-home"], + "apiIntercepts": [], + "cosmeticRules": [], + "adblockDisabled": false + } + ], + "providerProfiles": { + "example.com": { + "scripts": ["https://example.com/script.js"], + "iframes": ["https://cdn.example.com/frame.html"], + "images": ["https://example.com/image.png"] + } }, + "navigationGuard": { + "universalBlockPaths": ["/"] + }, + "rules": { + "videoDetection": { + "extensions": [".mp4", ".m3u8", ".ts"], + "pathPatterns": ["seg-", "init-", "chunk-"], + "enableSessionTrust": true, + "trustTTLMs": 900000 + }, + "alwaysBlock": { + "domains": [], + "pathPatterns": [] + } + } } ``` -### Step 2 — Add its domains to `blocklist.json` +### Step 3 — Add `filters.txt` entries (optional, for ad blocking) + +**File:** `filters.txt` (repo root) + +Standard uBO/EasyList syntax. Example rules: -Add a `providers[]` entry with `embedDomains` and `cdnDomains`, and any -`blockHomePaths` for its error-UI "Go Home" links. Run `pnpm build:filters`. +``` +@@||example.com^ # allowlist the embed domain +||google-analytics.com^$3p # block 3rd-party trackers +##.ad-banner # cosmetic rule +``` -### Step 3 — Test on each platform +### Step 4 — Regenerate compiler artifacts + +```bash +pnpm build:filters # rebuilds compiled-engine.bin + android-adblock-patterns.json +``` + +### Step 5 — Test on each platform - **Web** — iframe mounts the embed; check the video plays without 404s. - **Desktop** — full R0–R8 cascade + L5 preload. Verify with @@ -132,6 +197,14 @@ Add a `providers[]` entry with `embedDomains` and `cdnDomains`, and any - **Mobile** — native `PlayerWebView` + `shouldInterceptRequest`. Verify no ads, popups, or fullscreen issues. +### Step 6 — Sign the config (for OTA) + +Run the signing step to generate `providers.json.sig`: + +```bash +pnpm sign:providers # Ed25519-signs providers.json; .key in .keys/ (gitignored), .pub committed +``` + ### Providers needing custom handling If the provider doesn't work with the standard pipeline (Cloudflare challenge, @@ -140,12 +213,24 @@ the mobile `VideoWebView.tsx` / `PlayerWebViewOverlayView.kt` and the desktop preload, then test on all platforms. Do **not** weaken shared guards to make a provider work — prefer per-provider allowlist entries. -## Editing `blocklist.json` +--- -`blocklist.json` is the single source of truth (v4 schema). See -[docs/security.md](docs/security.md) → Configuration for the sections. After -editing, run `pnpm build:filters` so the compiled engine and mobile patterns -regenerate. +## Editing `providers.json` + `filters.txt` (v5) + +> The v5 config lives in `providers.json` (app logic) + `filters.txt` (uBO +> syntax), both Ed25519-signed (`providers.json.sig`). A legacy `blocklist.json` +> (v4) is kept for backward compatibility. + +**Workflow:** + +1. Edit `providers.json` (add/update provider entries, allowlists, nav-guard, + apiIntercepts, cosmetics, `allowServerRedirects`). +2. Edit `filters.txt` (uBO/EasyList rules — exact/suffix matching only, e.g. + `@@||cloudfront.net^`, `||doubleclick.net^$3p`, `##.ad-banner`). +3. Run `pnpm build:filters` — regenerates `compiled-engine.bin` (desktop) and + `android-adblock-patterns.json` (mobile). +4. Run `pnpm sign:providers` — Ed25519-signs `providers.json` → `providers.json.sig`. +5. Commit all four files. OTA clients will pull and verify the updated config. **Safety rules:** @@ -155,6 +240,11 @@ regenerate. the cascade on every provider. - `blockHomePaths` are per-provider deny-lists — append new home-page shapes as discovered. +- `allowServerRedirects: true` is only for redirect-mesh providers (vidsrc→viduki.net, + videasy→videasy.to). Enabling it on a non-redirect provider could let an ad + redirect through. + +--- ## Testing @@ -174,6 +264,8 @@ Current suites: If you change the R0–R8 cascade or the navigation guard, add/extend tests in these files. +--- + ## Code style - TypeScript, Prettier-formatted (`pnpm format`). @@ -185,10 +277,14 @@ these files. at runtime — reproduce shared logic there with a comment pointing at the canonical source (see `provider-config.ts`, `navigation-guard.ts`). +--- + ## Common pitfalls -- **Forgetting `pnpm build:filters`** after editing `blocklist.json` — the - desktop engine and mobile patterns go stale. +- **Forgetting `pnpm build:filters`** after editing `providers.json` or + `filters.txt` — the desktop engine and mobile patterns go stale. +- **Forgetting `pnpm sign:providers`** after editing `providers.json` — OTA + clients will reject the unsigned config and keep the last-known-good version. - **Adding a provider to only one platform.** Registration lives in `@filmsnaps/shared`; each app consumes the same registry. - **Weakening guards.** If a provider breaks, investigate the allowlist / diff --git a/README.md b/README.md index 20f0625..154c774 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,30 @@ Expo/React Native mobile app, and a feedback portal. ## Apps -| App | Package | Stack | Description | -| ----------------------------------- | --------------------- | ---------------------------------- | ---------------------------------------- | -| [Web](apps/web/README.md) | `@filmsnaps/web` | Next.js 16 (App Router) + Tailwind | Discovery UI, watch pages, API routes | -| [Desktop](apps/desktop/README.md) | `@filmsnaps/desktop` | Electron 43 + Next.js standalone | Web UI + native hardened player | -| [Mobile](apps/mobile/README.md) | `@filmsnaps/mobile` | Expo SDK 55 / React Native 0.83 | Phone app with downloads + native player | -| [Feedback](apps/feedback/README.md) | `@filmsnaps/feedback` | Next.js 16 + Cloudflare Workers/D1 | Public feedback portal | +| App | Package | Stack | Description | +| ----------------------------------- | --------------------- | ---------------------------------- | -------------------------------------------------------- | +| [Web](apps/web/README.md) | `@filmsnaps/web` | Next.js 16 (App Router) + Tailwind | Discovery UI, watch pages, API routes | +| [Desktop](apps/desktop/README.md) | `@filmsnaps/desktop` | Electron 43 + Next.js standalone | Web UI + native hardened player (WebContentsView hybrid) | +| [Mobile](apps/mobile/README.md) | `@filmsnaps/mobile` | Expo SDK 55 / React Native 0.83 | Phone app with downloads + native player | +| [Feedback](apps/feedback/README.md) | `@filmsnaps/feedback` | Next.js 16 + Cloudflare Workers/D1 | Public feedback portal | ## Packages -| Package | Description | -| ---------------------------- | --------------------------------------------------------------------- | -| `@filmsnaps/shared` | Shared guard scripts, provider registry, types, state, design tokens. | -| `@filmsnaps/adblock-config` | `blocklist.json` schema + validation. | -| `@filmsnaps/filter-compiler` | Adblocker engine + mobile pattern export artifacts. | +| Package | Description | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `@filmsnaps/shared` | Shared guard scripts, provider registry, types, state, design tokens. | +| `@filmsnaps/adblock-config` | v5 `providers.json` + `filters.txt` schema + validation + Ed25519 OTA config loader. | +| `@filmsnaps/filter-compiler` | Adblocker engine (@ghostery/adblocker WASM) + mobile pattern export. | --- ## Documentation - **[Security Architecture](docs/security.md)** — the full security stack: R0–R8 - rule cascade and L2–L8 desktop layers, mobile native protection, and the - `blocklist.json` configuration. + rule cascade and L2–L8 desktop layers, mobile native protection, WebContentsView hybrid, + and the `providers.json` + `filters.txt` v5 configuration. +- **[Security Expert Review](docs/security-expert-review.md)** — external expert review + and implementation status. - **[Architecture](docs/architecture.md)** — repository layout, data flow, builds, and CI. - **[Contributing](CONTRIBUTING.md)** — how to set up, develop, add a provider, @@ -79,16 +81,17 @@ build profiles). ## Common commands -| Command | Purpose | -| ------------------------ | ------------------------------------------------------------ | -| `pnpm build` | Build all apps/packages (Turborepo). | -| `pnpm lint` | Lint everything. | -| `pnpm test` | Run the Vitest suites (shared + desktop security). | -| `pnpm typecheck:desktop` | Typecheck the desktop app. | -| `pnpm format` | Prettier across the repo. | -| `pnpm build:filters` | Regenerate adblocker/filter artifacts from `blocklist.json`. | -| `pnpm cf:deploy` | Deploy the web app to Cloudflare Pages. | -| `pnpm dist:desktop` | Build the desktop installer. | +| Command | Purpose | +| ------------------------ | ---------------------------------------------------------------------------- | +| `pnpm build` | Build all apps/packages (Turborepo). | +| `pnpm lint` | Lint everything. | +| `pnpm test` | Run the Vitest suites (shared + desktop security). | +| `pnpm typecheck:desktop` | Typecheck the desktop app. | +| `pnpm format` | Prettier across the repo. | +| `pnpm build:filters` | Regenerate adblocker/filter artifacts from `providers.json` + `filters.txt`. | +| `pnpm sign:providers` | Sign `providers.json` with Ed25519 for OTA. | +| `pnpm cf:deploy` | Deploy the web app to Cloudflare Pages. | +| `pnpm dist:desktop` | Build the desktop installer. | --- @@ -102,6 +105,11 @@ build profiles). - **Multi-provider player** — provider registry in `@filmsnaps/shared`; each platform mounts embeds with native security layers (see [docs/security.md](docs/security.md)). +- **Native hardened desktop player** — WebContentsView hybrid (Electron 43), L8 `Page.addScriptToEvaluateOnNewDocument` HTML-bytes injection (replaces disabled CDP-Fetch that dropped renderer headers → Cloudflare 403), + `@ghostery/adblocker` (adblock-rs WASM), session trust with MIME-based 15-min TTL, `allowServerRedirects` for redirect-mesh providers. +- **Native hardened mobile player** — `PlayerWebView` native Expo module with `shouldInterceptRequest` filtering (Aho-Corasick unified trie), Ed25519-verified OTA config with ring-buffer rollback, 3×-failure watchdog, NavGuard server-redirect fix, session trust with 15-min TTL, and cosmetic rules from config. +- **Signed OTA config** — `providers.json` + `filters.txt` v5, Ed25519-signed, ring-buffer rollback (3 configs), + 3×-failure watchdog with local `heal-events.log` on both desktop and mobile. - **Mobile downloads** — SQLite-backed episode/movie downloads with a native downloader. - **Feedback portal** — account-free bug reports, feature requests, roadmap, diff --git a/SECURITY.md b/SECURITY.md index ef3efe5..8901eee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,15 +27,23 @@ across reloads, cross-site navigations, and process swaps. - **R0–R8 rule cascade** (`security/rule-cascade.ts`) — every provider request passes through it at the Chromium network layer. Always-block (R5/R6) wins - over any trust/allowlist; session trust (R0) is path-scoped and earned only by - serving video. -- **L2–L8 layers** — network filter (L2), CSP headers (L3), main-process nav/ - popup/redirect guard (L4), session-level preload (L5, the load-bearing in-page - protection), cosmetic filter (L6), CDP verification (L7, stethoscope only), - fail-closed frame sweep (L7b), and network HTML injection (L8, **disabled** — - see the doc). -- **Isolated provider partition** with a clean desktop-Chrome UA, no cache, - storage wiped on close. + over any trust/allowlist; session trust (R0) is path-scoped and earned only + by serving video. +- **L2–L8 layers** — network filter (L2), CSP headers (L3) + MIME-type trust, + main-process nav/popup/redirect guard (L4) + `allowServerRedirects`, session-level + preload (L5, the load-bearing in-page protection), cosmetic filter (L6), + CDP `Fetch` HTML injection (L8, replaces disabled `session.protocol.handle` that + dropped renderer headers → Cloudflare 403), fail-closed frame sweep (L7b), + and CDP `Page.addScriptToEvaluateOnNewDocument`. +- **WebContentsView hybrid** (Phase 3): main-owned singleton view, renderer + reserves black rect, all overlays driven by `overlayActive`. +- **Signed OTA config v5** — `providers.json` + `filters.txt`, Ed25519-signed, + ring-buffer rollback (3 configs), 3×-failure watchdog → local `heal-events.log`. +- **Structural warnings** — `enableWidevine`, MutationObserver bookkeeping, + pop-under detection at startup. +- **Visibility hardening** — `overlayActive` state in `PlayerProvider` → + `DesktopSecureWebview` `setVisible()` — server dropdown, CPU warning, error + overlays hide native view. ### Mobile (`apps/mobile`) @@ -46,15 +54,30 @@ across reloads, cross-site navigations, and process swaps. ### Web (`apps/web`) -- Standard security headers (`netlify.toml` / Next.js). +- Standard security headers (`X-Frame-Options`, `X-Content-Type-Options`, + `Referrer-Policy`). - The hardened player experience lives in desktop/mobile; the web app is primarily a discovery/UI layer. -## Configuration +### Configuration -`blocklist.json` (repo root) is the single source of truth for providers and -blocking rules. After editing it, run `pnpm build:filters` to regenerate the -compiled adblock engine and mobile pattern artifacts. +`providers.json` + `filters.txt` v5 (repo root) are the single source of truth +for providers and blocking rules, Ed25519-signed for OTA. + +- **`providers.json`**: schema v5, Ed25519-signed (`providers.json.sig`), per-provider + logic (`embedDomains`, `cdnDomains`, `enabled`, `allowServerRedirects`, + `blockHomePaths`, `apiIntercepts`, `cosmeticRules`, `adblockDisabled`). +- **`filters.txt`**: standard uBO/EasyList syntax, exact/suffix matching only + (`@@||domain^` — never substring), compiled into `compiled-engine.bin` (desktop R4) + and `android-adblock-patterns.json` (mobile R4b/R5b). +- Backward-compatible: `blocklist.json` still read when `providers.json` absent (v4 fallback). +- OTA: fetch on launch + every 2h, signature verified before apply, ring-buffer + rollback (3 configs), 3×-failure watchdog → local `heal-events.log`. + +After editing `providers.json` + `filters.txt`, run: + +- `pnpm build:filters` — regenerate compiled engine artifacts +- `pnpm sign:providers` — sign providers.json ## Reporting a vulnerability diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c4ad020..b946950 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,8 +2,8 @@ Electron app for Windows/macOS/Linux. Wraps the Next.js web app as a local standalone server and provides a **hardened native player**: provider embeds -load in a `` on an isolated session partition with the full R0–R8 -rule cascade and L2–L8 security layers. +load in a **`WebContentsView` (hybrid)** on an isolated session partition with +the full R0–R8 rule cascade and L2–L8 security layers. ## Stack @@ -12,29 +12,31 @@ rule cascade and L2–L8 security layers. `.next/standalone`) spawned as a local server on a free localhost port. - **electron-builder** for installers; **electron-updater** for auto-updates from GitHub Releases. -- **`@cliqz/adblocker`** FiltersEngine (compiled by `@filmsnaps/filter-compiler`) - for network-level ad blocking. +- **`@ghostery/adblocker`** FiltersEngine (adblock-rs WASM core, compiled by + `@filmsnaps/filter-compiler`) for network-level ad blocking. ## Layout ``` src/ - main.ts Main process: window, IPC, webview lockdown, L4/L7/L7b attach - preload.ts Main window preload (context bridge) + main.ts Main process: window, IPC, WebContentsView lifecycle, L4/L7/L7b/L8 attach + preload.ts Main window preload (context bridge + player:* IPC) preload/ provider-preload.ts Session-level provider preload (L5/L6) — PRIMARY in-page protection security/ rule-cascade.ts R0–R8 blocking decision-maker - session-trust.ts Path-scoped session trust (R0) + video detection - request-filter.ts webRequest filter, CSP headers (L3), provider session lifecycle - navigation-guard.ts L4 nav/popup/redirect guard + home-escape guard - provider-security.ts L7 CDP verification + L7b fail-closed frame sweep - html-injector.ts L8 network HTML injection — currently DISABLED + session-trust.ts Path-scoped session trust (R0) + MIME-based trust + 15-min TTL + request-filter.ts webRequest filter, CSP headers (L3), provider session lifecycle, onHeadersReceived MIME trust + navigation-guard.ts L4 nav/popup/redirect guard + home-escape + allowServerRedirects + provider-security.ts L7 CDP verification + L7b fail-closed frame sweep + Page.addScriptToEvaluateOnNewDocument + html-injector.ts L8 CDP-Fetch HTML injection (doc_start, preserves headers, fail-closed) cosmetic-filter.ts Engine-derived cosmetic CSS/scriptlets (L6) - filter-engine.ts @cliqz/adblocker singleton - provider-config.ts blocklist.json loader (CJS replica of @filmsnaps/adblock-config) + filter-engine.ts @ghostery/adblocker singleton + provider-config.ts providers.json v5 loader + OTA config (CJS replica) + ota-config.ts OTA fetch + ring-buffer rollback + 3×-failure watchdog + heal-events.log blocklist.ts Legacy flat blocklist fallback (R7) url-substring-filter.ts Mobile-parity substring trie (R4b) + structural-warnings.ts Startup structural checks (Widevine, MutationObserver, pop-under) scripts/ build-web.mjs Builds the web standalone bundle build-provider-preload.mjs Bakes the shared guard bundle into provider-preload.js @@ -73,7 +75,7 @@ Output goes to `apps/desktop/release/`: - **Linux:** `FilmSnaps-.AppImage` Production builds bundle: the web standalone build (`extraResources`), the -compiled filter engine, and `blocklist.json`. +compiled filter engine, `providers.json`, `filters.txt`, `providers.json.sig`, and Ed25519 public key. ## Security @@ -82,12 +84,16 @@ This app is where the strongest defenses live. Read the full walkthrough in - **R0–R8 rule cascade** (`security/rule-cascade.ts`) — every provider request passes through it at the Chromium network layer, before any page JS runs. -- **L5 session preload** — the load-bearing in-page protection, delivered at +- **L5 session preload** (`session.registerPreloadScript({ type: 'frame' })`) — the load-bearing in-page protection, delivered at document-start in every frame, surviving cross-site navigation. - **L4 navigation guard** — popups, cross-host navigation, redirects, and - home-page escapes blocked in the main process. -- **L8 is disabled** — `html-injector.ts` protocol interception is a V8 - diagnostic; re-arming is one line, but see the file for why it's off. + home-page escapes blocked in the main process. **`allowServerRedirects`** for redirect-mesh providers. +- **L8 CDP-Fetch HTML injection** — rewrites every HTML response at `document_start` via CDP `Fetch` domain, + preserves renderer headers (no Cloudflare 403), fail-closed 403 on injection failure. +- **Session trust (R0)** — MIME-type sniffing on `onHeadersReceived`, 15-min sliding TTL, path-scoped. +- **OTA config v5** — `providers.json` + `filters.txt` Ed25519-signed, ring-buffer rollback (3 configs), + 3×-failure watchdog, local `heal-events.log`. +- **Structural warnings** — `enableWidevine`, MutationObserver bookkeeping, pop-under detection at startup. ### Audit diagnostics @@ -95,6 +101,15 @@ Run the app with `FILMSNAPS_AUDIT=1` (allow-side request log) or `FILMSNAPS_AUDITNET=1` (CDP network header samples) to trace exactly what the security stack allowed/blocked. +## WebContentsView Hybrid (Phase 3) + +- Main owns a **single `WebContentsView`** (lazily created on first `player:open`, reused for app lifetime). +- Renderer reserves a black rect (`DesktopSecureWebview.tsx`); `ResizeObserver` → IPC `player:set-bounds`. +- Native view sits **above** the rect; React overlays (server dropdown, CPU warning, error) drive `player:set-visible=false` via `overlayActive`. +- Provider stays **main frame** → `will-navigate`/`will-redirect`/`did-fail-load` work unchanged. +- Fullscreen: `toggleFullscreen` → IPC `player:setFullscreen` → `mainWindow.setFullScreen()` + `providerViewFitToContent()`. +- Security stack (`navigation-guard`, `provider-security`, `provider-preload`) attaches to `view.webContents` — view-agnostic. + ## Auto-updates `electron-updater` checks GitHub Releases on launch, downloads updates in the diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 9f2ed97..a7dc5e0 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -32,6 +32,17 @@ extraResources: to: filter-engine/compiled-engine.bin - from: ../../packages/filter-compiler/build/android-adblock-patterns.json to: filter-engine/android-adblock-patterns.json + # v5 split config (OTA-signed). providers.json.sig + the public key let the + # app verify any OTA update before applying. filters.txt feeds the engine. + - from: ../../providers.json + to: providers.json + - from: ../../providers.json.sig + to: providers.json.sig + - from: ../../filters.txt + to: filters.txt + - from: ../../.keys/filmsnaps-ed25519.pub + to: filter-engine/filmsnaps-ed25519.pub + # Legacy v4 (mobile-facing; desktop reads v5 first, falls back to this). - from: ../../blocklist.json to: blocklist.json - from: ../web/.next/standalone diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e9ffc5c..d9d3d3a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,10 +17,10 @@ "clean": "rm -rf dist release" }, "dependencies": { - "@cliqz/adblocker": "^1.34.0", "@filmsnaps/adblock-config": "workspace:*", "@filmsnaps/filter-compiler": "workspace:*", "@filmsnaps/shared": "workspace:*", + "@ghostery/adblocker": "^2.18.2", "electron-updater": "^6.3.0" }, "devDependencies": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 91d89a6..b13cc55 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import { Menu, ipcMain, shell, + WebContentsView, webContents, } from "electron"; import { join } from "path"; @@ -47,6 +48,16 @@ import { verifyPreloadInFrames, } from "./security/provider-security"; import { registerCosmeticFilterIPC } from "./security/cosmetic-filter"; +import { + startOtaConfigLoop, + stopOtaConfigLoop, + recordProviderFailure, + recordProviderSuccess, +} from "./security/ota-config"; +import { + auditProviderSessionWarnings, + auditPreloadObserverBookkeeping, +} from "./security/structural-warnings"; // ── Constants ── @@ -74,6 +85,34 @@ let currentProviderSession: ReturnType | null = null; let currentProviderId: string | null = null; +// ── Provider WebContentsView (Phase 3 hybrid migration) ───────────── +// A single native WebContentsView owns the provider embed. Created lazily on +// the first player:open, reused for the app lifetime (mirrors the old +// singleton invariant — no React key, no remount race). The security +// stack (nav guard L4, CDP-Fetch L8, session preload L5/L6, verifyPreloadInFrames) +// attaches to view.webContents — the WebContents API is view-agnostic, so no +// security module changes. The React renderer reserves a black rect and drives +// bounds/visibility/fullscreen/load via the player:* IPC bridge (preload.ts). +let providerView: WebContentsView | null = null; +let providerViewAttached = false; +/** The URL the view is currently showing (for reload). */ +let providerViewUrl = ""; +/** Guard state for the persistent view — re-pointed per provider switch. */ +let providerViewGuard: { + updateConfig: (next: Parameters[1]) => void; +} | null = null; +/** True once the view's webContents had its one-time security attach. */ +let providerViewSecurityAttached = false; +/** Whether the renderer currently wants the view visible (overlay state). */ +let providerViewVisible = true; +/** The last bounds main applied (used to re-apply on fullscreen/resize). */ +let providerViewBounds: Electron.Rectangle = { + x: 0, + y: 0, + width: 0, + height: 0, +}; + // Pre-seed CDN domain set from blocklist.json, built at startup. // Used to pre-fill session trust so CDNs are R0-allowed from first request. const preSeededCdnDomains: Set = @@ -99,7 +138,8 @@ function createMainWindow(): void { nodeIntegration: false, sandbox: false, // The main app needs Node.js for IPC; provider content uses sandbox webSecurity: true, - webviewTag: true, // Enable tag for inline provider playback + // webviewTag intentionally NOT set — the provider embed now renders in a + // native WebContentsView (Phase 3 hybrid), not a tag. }, }); @@ -174,9 +214,49 @@ function createMainWindow(): void { createProviderSession(); console.log("[Main] Provider session pre-created with R0-R8 filters"); + // Structural warnings (Phase 2e) — surface likely security-drift without + // changing behavior. Gated to FILMSNAPS_AUDIT=1 so production stays quiet + // (the console.warn inside is only emitted when auditing). + if (process.env.FILMSNAPS_AUDIT === "1") { + // Widevine is NOT enabled on the provider session by design (the + // will-attach-webview lockdown sets no enableWidevine, and the partition + // is cache:false). Electron's Session has no readable widevineVersion + // flag, so this is audited as "disabled" today; if a future change enables + // Widevine in createProviderSession/webPreferences, add an explicit + // source-of-truth boolean there and thread it through this call. + auditProviderSessionWarnings({ + sessionWidevineEnabled: false, + providerId: getCurrentBlockingProviderId() ?? undefined, + }); + try { + const { readFileSync } = require("fs"); + const { join } = require("path"); + const preloadPath = join( + __dirname, + "..", + "preload", + "provider-preload.js", + ); + auditPreloadObserverBookkeeping(readFileSync(preloadPath, "utf8")); + } catch (e) { + console.warn( + "[Structural] Could not read preload for observer audit:", + e, + ); + } + } + + // OTA config loop — verify + apply the signed v5 config on launch and + // every 2h, with ring-buffer rollback + 3×-failure watchdog auto-heal. + startOtaConfigLoop(); + app.on("before-quit", () => stopOtaConfigLoop()); + // Register provider session IPC handlers (inline webview) registerProviderSessionIPC(); + // Register the player:* IPC handlers (WebContentsView hybrid — Phase 3) + registerPlayerViewIPC(); + // Register the engine-derived cosmetic filter IPC (Pillar B) — the preload's // DOM sweeper posts class/id/href tokens here and gets the engine's cosmetic // CSS + scriptlets back to apply to the live page. @@ -194,8 +274,25 @@ function createMainWindow(): void { // Save window state on changes + push maximize state to the renderer // so the custom title bar can swap its maximize/restore icon live. - mainWindow.on("resize", () => saveWindowState(mainWindow!)); + mainWindow.on("resize", () => { + saveWindowState(mainWindow!); + // DO NOT call providerViewFitToContent() here — it would fill the view to + // the whole window content bounds, overwriting the renderer-driven rect + // bounds and covering the server pill/dropdown. Renderer owns bounds + // outside fullscreen via player:set-bounds (ResizeObserver). + }); mainWindow.on("move", () => saveWindowState(mainWindow!)); + mainWindow.on("enter-full-screen", () => { + providerViewFitToContent(); + sendPlayerFullscreenState(); + }); + mainWindow.on("leave-full-screen", () => { + // Restore the last renderer-driven rect bounds on exiting fullscreen. + if (providerView && providerViewAttached) { + providerView.setBounds(providerViewBounds); + } + sendPlayerFullscreenState(); + }); mainWindow.on("maximize", () => { saveWindowState(mainWindow!); mainWindow?.webContents.send("window:maximized-changed", true); @@ -249,147 +346,23 @@ function createMainWindow(): void { } }); - // ── Webview security: partition lockdown + nav guard + CDP VERIFICATION ── - // The provider is the only guest this app mounts. We validate its - // partition, harden its webPreferences, and attach the main-process navigation - // guard (L4). The PRIMARY in-page protection (L5/L6 — protection script + - // cosmetic CSS at document-start in every frame) is delivered by the SESSION- - // LEVEL PRELOAD (session.setPreloads in request-filter.ts), which survives - // cross-site navigations that CDP cannot. CDP here is verification-only. - // NOTE: single live webview assumption — a pending-embed slot is sufficient today. - let pendingEmbedUrl: string | null = null; - - mainWindow.webContents.on( - "will-attach-webview", - (event, webPreferences, params) => { - // 1. Partition validation — ONLY the provider partition is allowed. - if ((params.partition ?? "") !== "persist:filmsnaps-provider") { - console.warn( - `[Main] Rejected webview with non-provider partition: ${params.partition}`, - ); - event.preventDefault(); - return; - } - - // 2. Lockdown webPreferences (main-process enforced, cannot be overridden - // by renderer-provided webpreferences). - // - // contextIsolation:false + nodeIntegrationInSubFrames:true are REQUIRED for - // the session-level provider preload (set via session.setPreloads) to run in - // the page's MAIN WORLD at document-start in EVERY frame (main + OOPIF), so - // its prototype overrides (canvas/WebGL spoofing, worker/sendBeacon blocking) - // take effect. This is safe because sandbox:true strips Node APIs from the - // preload scope — the page never gains require/process access. - webPreferences.nodeIntegration = false; - webPreferences.contextIsolation = false; // preload must share the main world - webPreferences.sandbox = true; // Node APIs still stripped - webPreferences.webSecurity = true; - webPreferences.nodeIntegrationInSubFrames = true; // preload in every OOPIF - webPreferences.nodeIntegrationInWorker = false; - webPreferences.allowRunningInsecureContent = false; - webPreferences.experimentalFeatures = false; - // NOTE: no webPreferences.allowPopups — popups are denied by the nav guard's - // setWindowOpenHandler (L4) + the renderer's new-window preventDefault. The - // webview element must NOT carry the allowpopups attribute (its mere presence - // enables popups even when false). - // Do NOT set webPreferences.preload here — the session-level preload - // already covers it; setting both can cause double-execution. - delete (webPreferences as any).preload; - delete (webPreferences as any).additionalArguments; - - // 3. Capture the embed URL for did-attach-webview (guest webContents is - // not available until attach completes). - pendingEmbedUrl = params.src ?? null; - }, - ); - - // 50ms debounce: React's hydration double-mount briefly creates two webview - // elements (one is destroyed) — wait for the dust to settle so L4/L7 attach to - // the surviving guest instead of one that dies 50ms later. Harmless delay: the - // session preload (L5/L6) runs at document-start regardless. - let attachTimer: NodeJS.Timeout | null = null; - mainWindow.webContents.on("did-attach-webview", (_event, guest) => { - if (attachTimer) clearTimeout(attachTimer); - attachTimer = setTimeout(() => { - if (guest.isDestroyed()) return; - const providerId = getCurrentBlockingProviderId(); - const embedUrl = pendingEmbedUrl || guest.getURL(); - const allowed = computeProviderAllowedDomains(providerId, embedUrl); - - // AUDIT — surface renderer-side logs to main-process stdout so a - // FILMSNAPS_AUDIT=1 run reveals what the protection bundle intercepted - // and, critically (expert V5), when/how a stream dies BEFORE it reaches - // onBeforeRequest / the ReqLog. The webview guest's console-message fires - // here in main with the page's console lines. - if (process.env.FILMSNAPS_AUDIT === "1") { - // `guest` here IS the guest WebContents (Electron's did-attach-webview - // hands us the guest's webContents directly). - // Electron 42: pass the console-message args via the Event object - // (the positional-args form is deprecated). - guest.on( - "console-message", - (_e: unknown, level: number, message: string) => { - if ( - message.includes("[PROTECTION]") || - message.includes("[STREAM-AUDIT]") - ) { - console.log(`[Webview console][lvl${level}] ${message}`); - } - }, - ); - } - - // L4 — main-process navigation/popup/redirect guard. Includes the - // path-level home-page escape guard (provider error-UI "Go Home" → - // provider.com/, which host-level checks can't catch). Config comes from - // blocklist.json (navigationGuard.universalBlockPaths + providers[].blockHomePaths). - const { - getProviderBlockHomePaths, - getUniversalBlockPaths, - } = require("./security/provider-config"); - const blockHomePaths = getProviderBlockHomePaths(providerId); - const universalBlockPaths = getUniversalBlockPaths(); - applyNavigationGuard(guest, { - providerUrl: embedUrl || "", - requestedEmbedUrl: embedUrl || "", - blockHomePaths, - universalBlockPaths, - additionalAllowedHosts: Array.from(allowed), - onBlocked: (type, url) => - console.warn(`[NavGuard] Blocked ${type}: ${url.slice(0, 120)}`), - // Escalate after the single auto-reload: tell the renderer to show the - // source-unavailable / error UI (never the provider's home page). - onEscaped: (count, url) => { - if (mainWindow?.isDestroyed()) return; - mainWindow?.webContents.send("provider:escape-blocked", { - url, - count, - }); - }, - }); - - // L7 — CDP verification layer: probes each live frame to confirm the - // session preload (L5/L6) is active. Does NOT inject — the preload does. - attachProviderSecurity(guest, { providerId, embedUrl }); - - // L7b — FAIL-CLOSED per-frame protection verification (no CDP): - // sweeps every committed frame for the preload guard sentinel, injects - // the protection bundle into about:blank/srcdoc/blob/data frames (the - // coverage holes both the session preload and L8 miss), and — if a - // committed frame is unprotected — stops THAT frame only (never the - // whole webview, which previously broke initial load). The user's - // contract is "security must apply every time, no matter what" — a - // brief error is preferable to a silent security failure. - verifyPreloadInFrames(guest, { - onFailClosed: (frameUrl) => { - if (guest.isDestroyed()) return; - console.warn( - `[Main] FAIL-CLOSED: protection absent in ${frameUrl.slice(0, 120)} — frame stopped`, - ); - }, - }); - }, 50); - }); + // ── Provider embed: WebContentsView (Phase 3 hybrid) ────────────────────── + // The provider embed no longer renders in a tag (webviewTag is not + // set on this window). Instead a single native WebContentsView is created + // lazily by ensureProviderView() on the first player:open IPC and reused for + // the app lifetime. All of the security layers that used to attach in the + // old will-attach-webview / did-attach-webview handlers now attach directly + // to view.webContents in ensureProviderView(): + // - L4 nav guard (applyNavigationGuard) — re-pointed per provider switch + // - L7 CDP verification + L8 CDP-Fetch injection (attachProviderSecurity) + // - L7b fail-closed per-frame sweep (verifyPreloadInFrames) + // - OTA watchdog (did-fail-load → recordProviderFailure) + recordProviderSuccess + // - console-message AUDIT forwarding (Electron 42 Event form, not positional) + // The session-level preload (L5/L6) + R0-R8 webRequest filters (L2) + CSP (L3) + // are partition-keyed and apply automatically because the view uses the same + // 'persist:filmsnaps-provider' partition as createProviderSession(). + // NOTE: no 50ms debounce needed — the view's WebContents exists synchronously + // at construction, so there is no React hydration double-mount race. // Remove native menu bar — app uses its own header navigation Menu.setApplicationMenu(null); @@ -405,10 +378,443 @@ function createMainWindow(): void { } mainWindow.on("closed", () => { + // Release the provider view (and its webContents) with the window. + if (providerView && !providerView.webContents.isDestroyed()) { + try { + providerView.webContents.close(); + } catch { + /* already gone */ + } + } + providerView = null; + providerViewAttached = false; + providerViewGuard = null; + providerViewSecurityAttached = false; mainWindow = null; }); } +// ── Provider WebContentsView (Phase 3 hybrid) ─────────────────────── + +/** + * Forward a provider-view state update to the renderer (player:state). + * The renderer's DesktopSecureWebview maps these to its loading/error UI. + */ +function sendPlayerState(partial: Partial): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + const state: PlayerViewStateMain = { + loading: _playerState.loading, + loaded: _playerState.loaded, + error: _playerState.error, + provisionalError: _playerState.provisionalError, + ...partial, + }; + Object.assign(_playerState, partial); + mainWindow.webContents.send("player:state", state); +} + +/** Main-process mirror of the renderer's PlayerViewState (preload.ts). */ +interface PlayerViewStateMain { + loading: boolean; + loaded: boolean; + error: string | null; + provisionalError: string | null; + /** Window fullscreen state (hybrid fullscreen is window-level). */ + isFullscreen?: boolean; + audit?: string; +} + +const _playerState: PlayerViewStateMain = { + loading: false, + loaded: false, + error: null, + provisionalError: null, + isFullscreen: false, +}; + +/** Reset the renderer-facing player state (on close / new provider). */ +function resetPlayerState(): void { + _playerState.loading = false; + _playerState.loaded = false; + _playerState.error = null; + _playerState.provisionalError = null; +} + +/** Push the window's current fullscreen state to the renderer. */ +function sendPlayerFullscreenState(): void { + const win = mainWindow; + if (!win || win.isDestroyed()) return; + sendPlayerState({ isFullscreen: win.isFullScreen() }); +} + +/** + * Ensure the provider view exists (created lazily on the first player:open). + * Mirrors the old singleton invariant — ONE WebContents owned by + * main, reused for the app lifetime. The security stack attaches ONCE here: + * nav guard (L4), CDP verification + L8 Fetch injection + fail-closed sweep + * (attachProviderSecurity + verifyPreloadInFrames) — all on view.webContents. + */ +function ensureProviderView(): WebContentsView | null { + const win = mainWindow; + if (!win || win.isDestroyed()) return null; + + // Reuse existing view if it exists (even if detached) — avoids re-attach + // cost and leaked WebContents on hide/show cycles. + if (providerView && !providerView.webContents.isDestroyed()) { + if (!providerViewAttached) { + win.contentView.addChildView(providerView); + providerView.setBounds(providerViewBounds); + providerView.setVisible(providerViewVisible); + providerViewAttached = true; + } + return providerView; + } + + if (providerView && providerViewAttached) return providerView; + + // Lazy-create on first use. webPreferences: + // - partition: the SAME persistent provider partition the session filters + // + registered frame preload live on (request-filter.ts createProviderSession). + // - NO preload here — the session registerPreloadScript(type:'frame') covers + // it; setting one would double-execute (see the will-attach-webview note). + // - contextIsolation:false + nodeIntegrationInSubFrames:true REQUIRED for + // the session preload to run in the main world of every frame (same + // reasoning as the old webview lockdown). sandbox:true strips Node. + const view = new WebContentsView({ + webPreferences: { + partition: "persist:filmsnaps-provider", + sandbox: true, + contextIsolation: false, + nodeIntegration: false, + nodeIntegrationInSubFrames: true, + webSecurity: true, + }, + }); + + providerView = view; + win.contentView.addChildView(view); + view.setVisible(providerViewVisible); + view.setBounds(providerViewBounds); + providerViewAttached = true; + + const wc = view.webContents; + const providerId = getCurrentBlockingProviderId(); + + // ── Forward load/error/audit state to the renderer (player:state) ── + wc.on("did-start-loading", () => { + sendPlayerState({ loading: true }); + }); + wc.on("did-stop-loading", () => { + sendPlayerState({ loading: false }); + }); + wc.on("did-finish-load", () => { + sendPlayerState({ loaded: true, loading: false, error: null }); + if (providerId) recordProviderSuccess(providerId); + }); + wc.on("did-fail-load", (_e, code, desc, _url, isMainFrame) => { + if (!isMainFrame) return; + // ERR_ABORTED (-3) = superseded navigation / stop() — not a real failure. + if (code === -3) return; + if (desc && recordProviderFailure(providerId ?? "", desc)) { + // OTA watchdog reverted config — reload so the healed config applies. + console.warn( + `[Main] OTA watchdog reverted config after ${desc} — reloading embed`, + ); + void wc.loadURL(providerViewUrl); + return; + } + sendPlayerState({ error: desc || "Failed to load" }); + }); + wc.on("did-fail-provisional-load", (_e, code, desc, url, isMainFrame) => { + if (!isMainFrame) return; + if (code === -3) return; // superseded — transient + // A provisional failure on the initial server hop (redirect-mesh) is often + // transient — the embed may redirect to the real player host. The renderer + // shows an error only if no load completes shortly after. + console.warn( + `[Main] Provider provisional load failed ${code} ${desc} ${url.slice(0, 100)}`, + ); + sendPlayerState({ provisionalError: desc || "Failed to load" }); + }); + wc.on("console-message", (event) => { + // Electron 42: the new Event form carries message/level (string). + const { message, level } = event; + if ( + message.includes("[PROTECTION]") || + message.includes("[STREAM-AUDIT]") + ) { + console.log(`[ProviderView console][${level}] ${message}`); + sendPlayerState({ audit: message }); + } + }); + + // ── L4 nav guard — installed ONCE, re-pointed per provider switch ── + const { + getProviderBlockHomePaths, + getUniversalBlockPaths, + getAllowServerRedirects, + } = require("./security/provider-config"); + const allowed = computeProviderAllowedDomains(providerId, providerViewUrl); + const guard = applyNavigationGuard(wc, { + providerUrl: providerViewUrl, + requestedEmbedUrl: providerViewUrl, + blockHomePaths: getProviderBlockHomePaths(providerId ?? ""), + universalBlockPaths: getUniversalBlockPaths(), + allowServerRedirects: getAllowServerRedirects(providerId ?? ""), + additionalAllowedHosts: Array.from(allowed), + onBlocked: (type, url) => + console.warn(`[NavGuard] Blocked ${type}: ${url.slice(0, 120)}`), + onEscaped: (count, url) => { + if (mainWindow?.isDestroyed()) return; + mainWindow?.webContents.send("provider:escape-blocked", { url, count }); + }, + }); + providerViewGuard = { updateConfig: guard.updateConfig }; + + // ── CDP verification + L8 Fetch injection + fail-closed frame sweep ── + attachProviderSecurity(wc, { providerId, embedUrl: providerViewUrl }); + verifyPreloadInFrames(wc, { + onFailClosed: (frameUrl) => { + if (wc.isDestroyed()) return; + console.warn( + `[Main] FAIL-CLOSED: protection absent in ${frameUrl.slice(0, 120)} — frame stopped`, + ); + }, + }); + + wc.once("destroyed", () => { + providerViewAttached = false; + providerViewGuard = null; + providerViewSecurityAttached = false; + }); + + providerViewSecurityAttached = true; + console.log( + `[Main] Provider WebContentsView created (wc ${wc.id}), security attached`, + ); + return view; +} + +/** Load a provider embed URL into the persistent view (lazy-creates it). */ +function openProviderView(embedUrl: string): void { + const win = mainWindow; + if (!win || win.isDestroyed()) return; + + // Clear session storage between provider switches to prevent residual state + // (cookies, auth tokens, ad-tracking state) from the previous provider from + // persisting into the new provider's session. This prevents: auth failures, + // cross-provider tracking, and memory leaks from accumulated DOM tokens. + void clearProviderStorage(); + + // Set the URL FIRST so ensureProviderView() (which reads providerViewUrl for + // the initial nav-guard config) sees the real embed URL even on first create. + providerViewUrl = embedUrl; + + const view = ensureProviderView(); + if (!view) return; + + // Re-point the nav guard for this provider (URL/hosts/redirect policy). + const providerId = getCurrentBlockingProviderId(); + const { + getProviderBlockHomePaths, + getUniversalBlockPaths, + getAllowServerRedirects, + } = require("./security/provider-config"); + const allowed = computeProviderAllowedDomains(providerId, embedUrl); + providerViewGuard?.updateConfig({ + providerUrl: embedUrl, + requestedEmbedUrl: embedUrl, + blockHomePaths: getProviderBlockHomePaths(providerId ?? ""), + universalBlockPaths: getUniversalBlockPaths(), + allowServerRedirects: getAllowServerRedirects(providerId ?? ""), + additionalAllowedHosts: Array.from(allowed), + onBlocked: (type, url) => + console.warn(`[NavGuard] Blocked ${type}: ${url.slice(0, 120)}`), + onEscaped: (count, url) => { + if (mainWindow?.isDestroyed()) return; + mainWindow?.webContents.send("provider:escape-blocked", { url, count }); + }, + }); + + resetPlayerState(); + sendPlayerState({ loading: true }); + // Do NOT force the view visible here — the renderer drives visibility via + // player:set-visible (overlay-aware). The native view draws over ALL DOM, so + // force-showing it would cover a React overlay (loading/error/CPU warning/ + // server dropdown) that must win. The view keeps its current visibility; + // DesktopSecureWebview shows it when no overlay is active. + view.webContents.loadURL(embedUrl).catch((err) => { + console.warn(`[Main] player:open loadURL failed:`, err); + sendPlayerState({ error: String(err?.message ?? err) }); + }); +} + +/** Hide + detach the view (reusable; webContents survives for reuse). */ +function closeProviderView(): void { + if (!providerView || !mainWindow) return; + // Exit fullscreen if active — a hidden view has no business keeping the + // window in fullscreen mode (e.g., user hits Escape to close overlay). + if (mainWindow.isFullScreen()) { + mainWindow.setFullScreen(false); + } + try { + if (providerViewAttached) + mainWindow.contentView.removeChildView(providerView); + } catch { + /* view not attached */ + } + providerViewAttached = false; + providerViewVisible = false; + resetPlayerState(); +} + +/** Position the view over the renderer's black rect (integers required). */ +function setProviderBounds(rect: Electron.Rectangle): void { + const win = mainWindow; + if (win && win.isFullScreen()) return; // main owns bounds during fullscreen + providerViewBounds = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + providerView?.setBounds(providerViewBounds); +} + +/** Clear session storage (cookies, localStorage, IndexedDB, cache) between provider switches. + * This prevents residual state from the previous provider (cookies, auth tokens, + * residual ad-tracking state) from persisting into the new provider's session, + * which can cause: auth failures, cross-provider tracking, and memory leaks from + * accumulated DOM tokens. Called at the start of openProviderView() before the + * new URL loads. */ +async function clearProviderStorage(): Promise { + if (!providerView || !providerView.webContents) return; + try { + await providerView.webContents.session.clearStorageData(); + await providerView.webContents.session.clearCache(); + console.log( + "[Main] Session storage cleared between provider switches (provider: " + + getCurrentBlockingProviderId() + + ")", + ); + } catch (err) { + // Best-effort — if the view isn't attached yet, skip. + console.warn("[Main] Failed to clear session storage:", err); + } +} + +/** + * Show/hide the view. The renderer hides it whenever a React overlay (loading, + * error, CPU warning, server dropdown) must render above the rect — the native + * view draws over the entire DOM, so hiding is the only way an overlay wins the + * z-order. When fully hidden we also removeChildView so it never captures input. + */ +function setProviderVisible(visible: boolean): void { + providerViewVisible = visible; + if (!providerView || !mainWindow) return; + if (visible) { + if (!providerViewAttached) { + mainWindow.contentView.addChildView(providerView); + providerViewAttached = true; + // Re-apply the last-known bounds: setBounds on a DETACHED view is a no-op + // (the renderer keeps calling player:set-bounds while hidden via the + // ResizeObserver), so the bounds must be re-applied once the view is back + // in the contentView or it would appear at stale/zero size. + providerView.setBounds(providerViewBounds); + } + providerView.setVisible(true); + } else { + providerView.setVisible(false); + if (providerViewAttached) { + try { + mainWindow.contentView.removeChildView(providerView); + } catch { + /* view not attached */ + } + providerViewAttached = false; + } + } +} + +/** + * Fullscreen the whole window (the view fills the content area). Electron has + * no view-only fullscreen; we fullscreen the frameless window and re-apply + * bounds on enter/leave-full-screen (see registerProviderViewWindowHandlers). + */ +function setProviderFullscreen(fullscreen: boolean): void { + const win = mainWindow; + if (!win || win.isDestroyed()) return; + if (win.isFullScreen() !== fullscreen) { + win.setFullScreen(fullscreen); + // enter-full-screen / leave-full-screen events push the state to the + // renderer (they also re-apply the view bounds). macOS fullscreen is + // async — the event is the authoritative signal. + } else { + sendPlayerFullscreenState(); + } +} + +/** + * Fill the view to the whole window — used ONLY on fullscreen transitions. In + * normal (windowed) mode the RENDERER owns the bounds: it measures its player + * rect via ResizeObserver and pushes player:set-bounds continuously, including + * on window resize. Filling the content bounds here outside fullscreen would + * overwrite the rect bounds and expand the native view over the entire page + * (covering the server pill/dropdown and every other control) — the bug that + * hid the server selector beneath the webview. + */ +function providerViewFitToContent(): void { + const win = mainWindow; + if (!providerView || !win || win.isDestroyed()) return; + if (!win.isFullScreen()) return; // renderer owns bounds outside fullscreen + const cb = win.getContentBounds(); + setProviderBounds({ + x: cb.x, + y: cb.y, + width: cb.width, + height: cb.height, + }); +} + +/** Register the player:* IPC handlers (WebContentsView hybrid). */ +function registerPlayerViewIPC(): void { + ipcMain.handle("player:open", (_e, embedUrl: string) => { + openProviderView(String(embedUrl ?? "")); + return { success: true }; + }); + ipcMain.handle("player:close", () => { + closeProviderView(); + return { success: true }; + }); + let boundsDebounceTimeout: NodeJS.Timeout | null = null; + + ipcMain.handle("player:set-bounds", (_e, rect: Electron.Rectangle) => { + if (boundsDebounceTimeout) clearTimeout(boundsDebounceTimeout); + boundsDebounceTimeout = setTimeout(() => { + setProviderBounds(rect ?? { x: 0, y: 0, width: 0, height: 0 }); + boundsDebounceTimeout = null; + }, 16); // ~60fps throttling — prevents IPC thrash on window drag/resize + return { success: true }; + }); + ipcMain.handle("player:set-visible", (_e, visible: boolean) => { + setProviderVisible(!!visible); + return { success: true }; + }); + ipcMain.handle("player:fullscreen", (_e, fullscreen: boolean) => { + setProviderFullscreen(!!fullscreen); + return { success: true }; + }); + ipcMain.handle("player:reload", () => { + if (providerView && !providerView.webContents.isDestroyed()) { + providerView.webContents.reload(); + } + return { success: true }; + }); + ipcMain.handle("player:get-webcontents-id", () => { + return providerView?.webContents.id ?? -1; + }); +} + // ── Provider Session IPC (inline webview) ────────────────────────── /** @@ -633,6 +1039,37 @@ async function startNextServer(): Promise { // ── App Lifecycle ── app.whenReady().then(() => { + // ── Pre-warm @ghostery/adblocker (adblock-rs WASM) ────────────────────── + // The compiled-engine.bin (~7MB) deserializes asynchronously. Doing this + // during appReady (instead of on first provider click) means the engine is + // warm in memory before the user can trigger a provider load, eliminating + // the ~50-200ms cold-start freeze that would otherwise block UI responsiveness. + // The engine singleton is stored globally so all R4 handlers share one warm + // instance instead of deserializing separately. + try { + const { deserialize } = require("@ghostery/adblocker"); + const { readFileSync } = require("fs"); + const { join } = require("path"); + const enginePath = join(__dirname, "..", "build", "compiled-engine.bin"); + const engineBuffer = readFileSync(enginePath); + (async () => { + const engine = await deserialize(engineBuffer); + // Store on globalThis so the renderer and main R4 handlers share it. + // The engine is idempotent — deserialize is safe to call once. + globalThis["filmsnapsFiltersEngine"] = engine; + console.log( + "[Main] @ghostery/adblocker WASM engine pre-warmed (deserialized, " + + (engine ? "ready" : "null") + + ")", + ); + })(); + } catch (err) { + console.warn( + "[Main] @ghostery/adblocker pre-warm failed (continuing without it):", + err, + ); + } + // Kick off the filter-engine load BEFORE creating the main window so the // 7MB engine deserializes in parallel with window setup instead of blocking // the event loop for ~50-200ms. R4's onBeforeRequest awaits this same diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 199d87b..d690c41 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -96,6 +96,41 @@ export interface ElectronAPI { onEscapeBlocked: ( callback: (event: { url: string; count: number }) => void, ) => () => void; + + /** + * Player namespace (WebContentsView hybrid). The provider embed renders in a + * native WebContentsView owned by main; these bridge methods let the React + * renderer drive it (open/close/bounds/visibility/fullscreen/reload/state). + */ + player: { + open: (embedUrl: string) => Promise; + close: () => Promise; + setBounds: (rect: { + x: number; + y: number; + width: number; + height: number; + }) => Promise; + setVisible: (visible: boolean) => Promise; + setFullscreen: (fullscreen: boolean) => Promise; + reload: () => Promise; + getWebContentsId: () => Promise; + onState: (callback: (state: PlayerViewState) => void) => () => void; + }; +} + +/** + * Provider native-view state pushed to the renderer (main → player:state). + * Mirrors the type in apps/web/types/electron.d.ts — keep in sync. + */ +interface PlayerViewState { + loading: boolean; + loaded: boolean; + error: string | null; + provisionalError: string | null; + /** Window fullscreen state (hybrid fullscreen is window-level). */ + isFullscreen?: boolean; + audit?: string; } // Read version from package.json at build time @@ -175,4 +210,29 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.removeListener("provider:escape-blocked", listener); }; }, + + player: { + open: (embedUrl: string) => ipcRenderer.invoke("player:open", embedUrl), + close: () => ipcRenderer.invoke("player:close"), + setBounds: (rect: { + x: number; + y: number; + width: number; + height: number; + }) => ipcRenderer.invoke("player:set-bounds", rect), + setVisible: (visible: boolean) => + ipcRenderer.invoke("player:set-visible", visible), + setFullscreen: (fullscreen: boolean) => + ipcRenderer.invoke("player:fullscreen", fullscreen), + reload: () => ipcRenderer.invoke("player:reload"), + getWebContentsId: () => ipcRenderer.invoke("player:get-webcontents-id"), + onState: (callback: (state: PlayerViewState) => void) => { + const listener = (_event: unknown, state: PlayerViewState) => + callback(state); + ipcRenderer.on("player:state", listener); + return () => { + ipcRenderer.removeListener("player:state", listener); + }; + }, + }, }); diff --git a/apps/desktop/src/preload/provider-preload.ts b/apps/desktop/src/preload/provider-preload.ts index 0795692..a399f42 100644 --- a/apps/desktop/src/preload/provider-preload.ts +++ b/apps/desktop/src/preload/provider-preload.ts @@ -425,62 +425,23 @@ } // ═══════════════════════════════════════════════════════════════ - // 2. COSMETIC CSS — injected as soon as / exists, via - // MutationObserver. Re-injects if the page removes our "); + const bare = ""; + const out = injectCosmetics(bare, payload); + expect(out).toContain(""); + expect(out.indexOf("")).toBeLessThan(out.indexOf("")); + }); + + it("returns the input unchanged when payload is empty", () => { + const html = ""; + expect(injectCosmetics(html, { styles: "", scripts: [] })).toBe(html); + }); +}); + +// ── CDP-Fetch handler (mocked debugger) ───────────────────────────────────── + +describe("armFetchHtmlInjection (CDP Fetch domain)", () => { + beforeEach(() => { + fsState.read = () => "BUNDLE"; + }); + + it("returns undefined (L8 unarmed) when the protection source is unavailable", async () => { + fsState.read = () => "__THROW__"; + const { armFetchHtmlInjection } = await loadInjector(); + expect(armFetchHtmlInjection(mockDebugger())).toBeUndefined(); + }); + + it("continues non-Document resources untouched (fast path)", async () => { + const { armFetchHtmlInjection } = await loadInjector(); + const dbg = mockDebugger(); + const handler = armFetchHtmlInjection(dbg)!; + expect(handler).toBeDefined(); + await handler({ + requestId: "r1", + resourceType: "Script", + request: { url: "https://cdn.example.com/app.js" }, + }); + expect(dbg.calls).toContainEqual([ + "Fetch.continueRequest", + { requestId: "r1" }, + ]); + expect(dbg.calls.some((c) => c[0] === "Fetch.getResponseBody")).toBe(false); + }); + + it("rewrites a paused Document and fulfills with original headers preserved", async () => { + const { armFetchHtmlInjection } = await loadInjector(); + const dbg = mockDebugger(); + dbg.responses["Fetch.getResponseBody"] = { + body: Buffer.from( + "providerhi", + "utf-8", + ).toString("base64"), + base64Encoded: true, + }; + const handler = armFetchHtmlInjection(dbg)!; + await handler({ + requestId: "doc1", + resourceType: "Document", + request: { url: "https://provider.com/embed/123" }, + responseStatusCode: 200, + responseStatusText: "OK", + responseHeaders: [ + { name: "content-type", value: "text/html; charset=utf-8" }, + { name: "content-encoding", value: "gzip" }, + { name: "set-cookie", value: "session=abc" }, + { name: "Content-Security-Policy", value: "script-src 'self'" }, + ], + }); + + const fulfill = dbg.calls.find((c) => c[0] === "Fetch.fulfillRequest")?.[1]; + expect(fulfill).toBeDefined(); + expect(fulfill.requestId).toBe("doc1"); + expect(fulfill.responseCode).toBe(200); + + const decoded = Buffer.from(fulfill.body, "base64").toString("utf-8"); + expect(decoded).toContain("`; - - // After or . - const headMatch = html.match(/]/i); - if (headMatch?.index != null) { - const close = html.indexOf(">", headMatch.index); - if (close !== -1) { - return html.slice(0, close + 1) + tag + html.slice(close + 1); - } - } - - // No — after (or ). - const htmlMatch = html.match(/]/i); - if (htmlMatch?.index != null) { - const close = html.indexOf(">", htmlMatch.index); - if (close !== -1) { - return html.slice(0, close + 1) + tag + html.slice(close + 1); - } - } - - // Bare fragment — prepend. - return tag + html; -} +// ── HTML-bytes-level cosmetic injection ────────────────────────────────────────── /** * Inject engine-derived cosmetic CSS + scriptlets before (falling back @@ -114,7 +98,7 @@ function injectProtection(html: string, script: string): string { * exactly like mobile's HTML-level injection). DOM-triggered rules are handled * separately by the in-page DOM sweeper → IPC → per-frame injection. */ -function injectCosmetics( +export function injectCosmetics( html: string, payload: { styles: string; scripts: string[] }, ): string { @@ -147,164 +131,3 @@ function injectCosmetics( } return html + frag; } - -// ── Registration ──────────────────────────────────────────────────────────── - -const armedSessions = new WeakSet(); - -/** - * Arm network-layer HTML protection injection for a provider session. - * - * MUST be registered before the session's first request (i.e. at startup, - * inside createProviderSession) — Electron requires protocol.handle to be - * registered before any request to the scheme. Idempotent per session. - */ -export function registerHtmlInjection(session: Session): void { - if (armedSessions.has(session)) return; - armedSessions.add(session); - - const source = getProtectionSource(); - if (!source) { - // SAFETY VALVE: fail-closed-on-all-HTML with an empty source would block - // every provider page (the injection itself can never succeed). Instead we - // do NOT arm L8 at all — the provider-preload (L5) and the per-frame sweep - // (L7b, provider-security.ts) remain the fail-closed gate. - console.error( - "[HtmlInjector] No protection source — network HTML injection NOT armed. " + - "Provider preload + frame-sweep remain the fail-closed gate.", - ); - return; - } - - // Fail-CLOSED for HTML documents, per expert consultation: if the protection - // source loaded and a text/html response reaches us, we MUST NOT serve it - // unprotectable — that is exactly the coverage hole that let ads render. - // Non-HTML/media streams remain untouched (never buffered). - const handler = async (request: Request): Promise => { - // Forward through the SAME session's network stack. bypassCustomProtocolHandlers - // prevents infinite recursion; the session's webRequest handlers (R0-R8 - // onBeforeRequest + onHeadersReceived CSP) still fire. - // - // NOTE: we intentionally do NOT use net.fetch() here. net.fetch issues - // requests against the DEFAULT session and has no `session` option in - // Electron 42 (verified in electron.d.ts) — switching would silently route - // provider traffic outside this partition, losing the R0-R8 filter and the - // provider session entirely. session.fetch keeps everything on the - // provider partition. (Expert 2's net.fetch recommendation was based on - // SSE/HTTP-2 concerns that do not apply to text/html document rewrites.) - let response: Response; - try { - response = await session.fetch(request, { - bypassCustomProtocolHandlers: true, - }); - } catch { - // Blocked by R0-R8 onBeforeRequest or aborted — reflect the block. - return blockedResponse(); - } - - // Only rewrite HTML documents. Everything else (media segments, scripts, - // images, XHR/JSON, blobs) streams through untouched — no buffering. - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("text/html")) return response; - - try { - const html = await response.text(); - let injected = injectProtection(html, source); - - // ── Engine-derived cosmetic CSS + scriptlets at the HTML-bytes level ── - // (V5 Gap A — the primary parity fix). Hostname comes from the RESPONSE - // URL (always known, never about:blank), matching mobile's native - // getCosmeticSelectors(host) → " + return if (css.isEmpty()) null else css } - } catch (_: Exception) {} - return "" + null + } catch (_: Exception) { null } + } + + /** Same match logic as [cosmeticCssForHost] but returns the individual rule list. */ + private fun cosmeticRulesForHost(host: String): List? { + return try { + val providers = BlocklistConfigLoader.config.providers + for (p in providers) { + if (!p.enabled) continue + if (p.cosmeticRules.isEmpty()) continue + val matched = p.embedDomains.any { host == it.lowercase() || host.endsWith(".$it".lowercase()) } + if (!matched) continue + return p.cosmeticRules.toList() + } + null + } catch (_: Exception) { null } + } + + /** + * Build a static cosmetic " + } + + /** + * Expert Q6/Q7: Reliable cosmetic injection via addDocumentStartJavaScript, + * INDEPENDENT of the fragile HTML-fetch branch (line ~1681). The desktop-parity + * cosmeticRules are applied declaratively at document_start so they survive the + * provider's React re-renders — fixing screenscape (source 3) where the HTML + * branch's second HttpURLConnection fetch silently fails (nxsha's succeeds) and + * the