/** * Privacy-redirect destinations. When enabled, Warden rewrites the * URL just before launch so a tracked-host (Twitter, X, YouTube, …) * is opened through a privacy-respecting front-end (Nitter, Invidious, * Piped, …). Path / query / hash are preserved — only the host is * swapped. * * The matching host list, per destination, includes every variant * Warden will hand off (e.g. `twitter.com`, `x.com`, plus subdomains * via the suffix check). Add a new destination by appending here + * giving it a stable `id` (used as a settings-key fragment). */ export type ProxyInstance = { /** Bare hostname of the proxy instance — no scheme, no trailing slash. */ host: string; /** Optional human label. Falls back to the hostname when omitted. */ label?: string; }; export type ProxyDestination = { /** Stable id used in settings prefs — never renamed. */ id: string; /** Display label for the picker. */ label: string; /** Hostname suffixes that route to this proxy. Subdomain-aware. */ matches: string[]; /** Default instance list. Order = fallback priority when randomize is off. */ instances: ProxyInstance[]; /** Optional in-place URL rewrite applied BEFORE the host swap. * Use when paths differ between the original site and the proxy * (e.g. youtu.be uses short paths, Invidious wants /watch?v=…). */ rewritePath?: (u: URL) => void; }; export const PROXY_DESTINATIONS: ProxyDestination[] = [ { id: 'twitter', label: 'Twitter / X', matches: ['twitter.com', 'x.com'], instances: [ { host: 'nitter.net' }, { host: 'nitter.catsarch.com' }, { host: 'nitter.tiekoetter.com' }, { host: 'nitter.privacyredirect.com' }, { host: 'xcancel.com' }, { host: 'nitter.kareem.one', label: 'kareem.one' }, { host: 'nuku.trabun.org', label: 'nuku' }, { host: 'twiiit.com', label: 'twiiit' }, { host: 'twitterviewer.net', label: 'twitterviewer' }, ], }, { id: 'youtube', label: 'YouTube', matches: ['youtube.com', 'youtu.be'], instances: [ { host: 'inv.nadeko.net' }, { host: 'invidious.tiekoetter.com' }, { host: 'invidious.nerdvpn.de', label: 'nerdvpn' }, ], rewritePath: (u) => { const host = u.hostname.toLowerCase(); if (host === 'youtu.be' || host.endsWith('.youtu.be')) { const id = u.pathname.replace(/^\/+/, '').split('/')[0]; if (id && !u.searchParams.has('v')) { u.pathname = '/watch'; u.searchParams.set('v', id); } } }, }, { id: 'reddit', label: 'Reddit', matches: ['reddit.com'], instances: [ { host: 'redlib.catsarch.com' }, { host: 'redlib.tiekoetter.com' }, { host: 'redlib.nadeko.net', label: 'nadeko' }, { host: 'redlib.privacyredirect.com', label: 'privacyredirect' }, { host: 'redlib.privadency.com', label: 'privadency' }, { host: 'safereddit.com', label: 'safereddit (SFW)' }, { host: 'red.artemislena.eu', label: 'artemislena' }, { host: 'redlib.r4fo.com', label: 'r4fo' }, { host: 'redlib.cow.rip', label: 'cow.rip' }, ], }, { id: 'bluesky', label: 'Bluesky', matches: ['bsky.app'], instances: [ { host: 'fxbsky.app', label: 'fxbsky (embed)' }, { host: 'skylib.coffee', label: 'skylib' }, { host: 'skylib.catsarch.com', label: 'skylib (catsarch)' }, ], }, { id: 'instagram', label: 'Instagram', matches: ['instagram.com'], instances: [ { host: 'toinstagram.com', label: 'toinstagram' }, { host: 'adamlikes.men', label: 'adamlikes' }, { host: 'instagram7.com', label: 'instagram7' }, { host: 'kittygr.am', label: 'kittygram' }, { host: 'kg.meowing.de', label: 'kittygram (meowing)' }, { host: 'kittygram.kareem.one', label: 'kittygram (kareem)' }, ], }, { id: 'tiktok', label: 'TikTok', matches: ['tiktok.com'], instances: [ { host: 'tnktok.com', label: 'tnktok' }, { host: 'tfxktok.com', label: 'tfxktok' }, { host: 'tiktokez.com', label: 'tiktokez' }, { host: 'kktiktok.com', label: 'kktiktok' }, { host: 'vxtiktok.com', label: 'vxtiktok (legacy)' }, { host: 'tiktxk.com', label: 'tiktxk (legacy)' }, ], }, { id: 'pinterest', label: 'Pinterest', matches: ['pinterest.com'], instances: [ { host: 'pinterest.bunk.im', label: 'bunk.im' }, ], }, { id: 'threads', label: 'Threads', matches: ['threads.net', 'threads.com'], instances: [ { host: 'shoelace.mint.lgbt', label: 'shoelace' }, ], }, ]; // ──────────────────────────────────────────────────────────────────────── // Config shape — persisted as a JSON blob via the native string-pref shim. // ──────────────────────────────────────────────────────────────────────── export type ProxyConfig = { /** Master toggle. When false, no rewriting happens regardless of dest state. */ enabled: boolean; /** Per-destination state, keyed by destination id. */ dests: Record; /** User-defined destinations alongside the built-in ones. Each is * self-contained — its own label, match hosts, and instance list. */ customDests: CustomDest[]; }; /** * Fully user-defined proxy destination. Lives in ProxyConfig.customDests * (NOT in `dests`) because its label, matches, and instance list are * all user-supplied — there's no built-in defaults table to reference. */ export type CustomDest = { id: string; // 'custom_' label: string; matches: string[]; // hostnames to intercept (subdomain-aware) instances: string[]; // proxy hosts (always user-defined) randomize: boolean; disabled: string[]; // instance hosts the user has toggled off }; export type ProxyDestConfig = { /** When true, every launch picks a fresh random enabled instance. */ randomize: boolean; /** Instance hosts the user has disabled (works for built-in or custom). */ disabled: string[]; /** User-added custom instance hosts. Merged into the candidate list * alongside the built-in defaults. */ custom: string[]; }; export const PROXY_PREF_KEY = 'proxy_config'; export const DEFAULT_PROXY_CONFIG: ProxyConfig = { // On by default — Warden's whole premise is "your default browser // isn't the right place for every link"; routing social/video URLs // through privacy front-ends matches that intent. Per-destination // rotation defaults remain off (user opts in per host). enabled: true, dests: Object.fromEntries( PROXY_DESTINATIONS.map((d) => [d.id, { randomize: false, disabled: [], custom: [] }]), ), customDests: [], }; /** * Parse a stored JSON blob into a ProxyConfig, defaulting any missing * keys. Used at app start to hydrate the in-memory config from * SharedPreferences without crashing on schema drift. */ export function loadProxyConfig(raw: string): ProxyConfig { try { if (!raw) return DEFAULT_PROXY_CONFIG; const parsed = JSON.parse(raw); const merged: ProxyConfig = { enabled: parsed?.enabled === true, dests: { ...DEFAULT_PROXY_CONFIG.dests }, customDests: [], }; for (const dest of PROXY_DESTINATIONS) { const entry = parsed?.dests?.[dest.id]; merged.dests[dest.id] = { randomize: entry?.randomize === true, disabled: Array.isArray(entry?.disabled) ? entry.disabled.filter((x: unknown) => typeof x === 'string') : [], custom: Array.isArray(entry?.custom) ? entry.custom.filter((x: unknown) => typeof x === 'string') : [], }; } if (Array.isArray(parsed?.customDests)) { merged.customDests = parsed.customDests .filter((c: any) => c && typeof c.id === 'string' && typeof c.label === 'string') .map((c: any) => ({ id: c.id, label: c.label, matches: Array.isArray(c.matches) ? c.matches.filter((x: unknown) => typeof x === 'string') : [], instances: Array.isArray(c.instances) ? c.instances.filter((x: unknown) => typeof x === 'string') : [], randomize: c.randomize === true, disabled: Array.isArray(c.disabled) ? c.disabled.filter((x: unknown) => typeof x === 'string') : [], })); } return merged; } catch { return DEFAULT_PROXY_CONFIG; } } // ──────────────────────────────────────────────────────────────────────── // URL rewriting // ──────────────────────────────────────────────────────────────────────── /** * Adapt a CustomDest into the ProxyDestination shape so the rest of * the code can iterate one homogenous list. The mapping is a thin * view — both sides share `id` so subsequent config lookups still * point at the original CustomDest entry. */ function customAsDestination(c: CustomDest): ProxyDestination { return { id: c.id, label: c.label, matches: c.matches, instances: c.instances.map((host) => ({ host })), }; } /** Built-in destinations + the user's custom destinations, in display order. */ export function effectiveDestinations(cfg: ProxyConfig): ProxyDestination[] { return [...PROXY_DESTINATIONS, ...cfg.customDests.map(customAsDestination)]; } /** * The effective ProxyDestConfig for a destination id, whether it's a * built-in (state lives in `cfg.dests`) or a custom one (state lives * inline on the CustomDest itself). Returned shape matches * ProxyDestConfig so callers don't need to branch. */ function configFor(destId: string, cfg: ProxyConfig): ProxyDestConfig { const c = cfg.customDests.find((x) => x.id === destId); if (c) { return { randomize: c.randomize, disabled: c.disabled, custom: [] }; } return cfg.dests[destId] ?? { randomize: false, disabled: [], custom: [] }; } function destForHost(host: string, cfg: ProxyConfig): ProxyDestination | null { const lower = host.toLowerCase(); for (const d of effectiveDestinations(cfg)) { if (d.matches.some((m) => lower === m || lower.endsWith('.' + m))) return d; } return null; } /** * Resolve the proxy destination (if any) for a given URL. Public * wrapper so callers can decide whether to surface UI like a "cycle * instance" button without re-parsing the URL themselves. */ export function findDestinationForUrl(input: string, cfg: ProxyConfig): ProxyDestination | null { try { return destForHost(new URL(input).hostname, cfg); } catch { return null; } } /** Number of enabled instances for a destination under a given config. */ export function enabledInstanceCount( dest: ProxyDestination, cfg: ProxyConfig, ): number { const destCfg = configFor(dest.id, cfg); const disabled = new Set(destCfg.disabled); return allInstancesFor(dest, cfg).filter((i) => !disabled.has(i.host)).length; } /** * Pick the proxy instance Warden should rewrite to for a given * destination, honouring per-destination randomize + per-instance * enable state. Returns null when the user has disabled every * instance for this destination. */ /** * Combine built-in + user-added custom instances into one candidate * list, in the order they'd appear in the UI (built-ins first, then * customs in insertion order). Order matters because the non-random * pick always returns the first enabled candidate. */ export function allInstancesFor( dest: ProxyDestination, cfg: ProxyConfig, ): ProxyInstance[] { // Custom destinations carry their instances inline (already on // dest.instances), so the per-built-in `custom` extension is a no-op // for them. For built-ins we splice in the user-added customs from // the config. const destCfg = cfg.dests[dest.id]; const customs: ProxyInstance[] = (destCfg?.custom ?? []).map((host) => ({ host })); return [...dest.instances, ...customs]; } /** * Pick a proxy instance from the enabled candidate list using an * explicit cycle index. Index wraps modulo enabled-count, so the * caller can simply increment a counter to walk forward through the * rotation without worrying about bounds. * * The "randomize" preference now only seeds the initial cycle index * (handled by the caller) — the per-pick logic itself is deterministic * for a given (cfg, index) pair so the status-row preview and the * actual launch can never disagree. */ export function pickInstance( dest: ProxyDestination, cfg: ProxyConfig, cycleIndex: number = 0, ): ProxyInstance | null { const destCfg = configFor(dest.id, cfg); const disabled = new Set(destCfg.disabled); const enabled = allInstancesFor(dest, cfg).filter((i) => !disabled.has(i.host)); if (enabled.length === 0) return null; const n = enabled.length; // Positive modulo so negative indices (shouldn't happen, but defensive) still wrap. const idx = ((cycleIndex % n) + n) % n; return enabled[idx]; } /** * Normalise + validate a user-entered proxy hostname. Strips any * scheme / path / query / hash the user may have pasted and verifies * the remainder is a plausible host (lowercase alnum + hyphens with * at least one dot). Returns null on invalid input. */ /** * Generate a stable id for a freshly-added custom destination. * `custom_` is short, unique enough, and obviously * a custom id when grepping a config blob later. */ export function newCustomDestId(): string { return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } export function normalizeProxyHost(input: string): string | null { let s = input.trim().toLowerCase(); if (!s) return null; s = s.replace(/^https?:\/\//, ''); s = s.split(/[/?#]/)[0]; if (!/^[a-z0-9][a-z0-9.-]*\.[a-z0-9-]+$/.test(s)) return null; return s; } /** * Rewrite the URL's host if it matches one of our proxy destinations * and the master toggle is on. Returns the rewritten URL (or the * original if no rewrite applies). `cycleIndex` advances forward * through the enabled candidate list — used by the status-row cycle * button so the user can step through proxies deterministically. */ export function rewriteThroughProxy( input: string, cfg: ProxyConfig, cycleIndex: number = 0, ): { url: string; via: string | null } { if (!cfg.enabled) return { url: input, via: null }; try { const u = new URL(input); const dest = destForHost(u.hostname, cfg); if (!dest) return { url: input, via: null }; const inst = pickInstance(dest, cfg, cycleIndex); if (!inst) return { url: input, via: null }; // Apply per-destination path normalisation BEFORE the host swap // (e.g. youtu.be/{id} → /watch?v={id} for Invidious). dest.rewritePath?.(u); u.hostname = inst.host; // Most proxies (Nitter, Invidious, Piped) speak HTTPS; force it. u.protocol = 'https:'; return { url: u.toString(), via: inst.host }; } catch { return { url: input, via: null }; } }