/** * A Flow is a saved launch recipe — browser + mode + optional intent * extras — that opens the current URL in one tap. Stored as a single * JSON array under SharedPrefs key 'flows_v1'. */ import type { Browser, PrivacyMode } from './browsers'; import { getStringPref, setStringPref } from '@/modules/default-browser'; export type Profile = 'raw' | 'privacy' | 'work'; export const PROFILES: readonly Profile[] = ['raw', 'privacy', 'work'] as const; export const PROFILE_LABEL: Record = { raw: 'Raw', privacy: 'Privacy', work: 'Work', }; export const PREF_FLOWS = 'flows_v1'; /** * Intent extra key for the calling-app referrer. Warden always * strips the referrer (Uri.EMPTY) at launch by default. A Flow can * override that by adding an EXTRA_REFERRER extra with a non-empty * value — most commonly the literal placeholder `{source}`, which * the native module resolves to the original referrer Android * delivered with the incoming VIEW intent. Any other value is * parsed as a URI and passed through verbatim. */ export const REFERRER_EXTRA_KEY = 'android.intent.extra.REFERRER'; export const REFERRER_SOURCE_PLACEHOLDER = '{source}'; export type FlowExtra = { key: string; type: 'bool' | 'string' | 'int'; value: string; }; export type Flow = { id: string; title: string; subtitle: string; browserPkg: string; mode: PrivacyMode; extras: FlowExtra[]; /** * Which privacy-posture profile this Flow belongs to. * - raw: normal tab only, no incognito/mini, referrer stripped * - privacy: incognito/ephemeral/max, referrer stripped (default) * - work: normal tab only, referrer preserved (for {source}) */ profile: Profile; /** * When a URL arrives via share / VIEW intent, the *single* Flow marked * autoFire=true fires immediately and Warden exits its task. Zero or * multiple matches → fall back to showing the Flow list (URL prefilled). * Mutual exclusion is enforced softly at save time, not by the type. */ autoFire: boolean; }; /** * Curated extras a user is likely to want, grouped by which browser * family understands them. The editor surfaces these as quick-picks * before falling back to a freeform key/type/value triple. */ export type ExtraSuggestion = { key: string; type: 'bool' | 'string' | 'int'; defaultValue: string; label: string; hint?: string; family: 'chromium' | 'firefox' | 'cct' | 'any'; /** Enumerated valid values for the extra (e.g. color-scheme ints). When * set, the editor renders a tap-to-cycle pill instead of a free-text * input. The `value` is the string written to the FlowExtra. */ values?: { label: string; value: string }[]; }; export const EXTRA_SUGGESTIONS: ExtraSuggestion[] = [ { key: 'androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING', type: 'bool', defaultValue: 'true', label: 'Ephemeral Custom Tab', hint: 'Chrome 137+ honors this — no history/cookies/cache persisted.', family: 'cct', }, { key: 'androidx.browser.customtabs.extra.COLOR_SCHEME', type: 'int', defaultValue: '2', label: 'CCT color scheme', hint: 'Forces the Custom Tab to a specific colour scheme.', family: 'cct', values: [ { label: 'System', value: '0' }, { label: 'Light', value: '1' }, { label: 'Dark', value: '2' }, ], }, { key: 'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB', type: 'bool', defaultValue: 'true', label: 'Open in incognito (Chromium)', hint: 'Brave / Vanadium / Edge / Vivaldi honor this.', family: 'chromium', }, { key: 'private_browsing_mode', type: 'bool', defaultValue: 'true', label: 'Open in private (Fenix)', hint: 'Current Firefox / IronFox / Focus honor this.', family: 'firefox', }, { key: 'is_private_tab', type: 'bool', defaultValue: 'true', label: 'Private tab (Fenix legacy)', hint: 'Older Fenix forks.', family: 'firefox', }, { key: 'org.mozilla.gecko.LOAD_IN_PRIVATE_TAB', type: 'bool', defaultValue: 'true', label: 'Private tab (legacy Gecko)', hint: 'Older Gecko-based browsers.', family: 'firefox', }, { key: 'android.intent.extra.REFERRER', type: 'string', defaultValue: '', label: 'Referrer override', // Empty by default — Warden auto-strips EXTRA_REFERRER on every // launch (Uri.EMPTY), so the receiving browser sees no referrer // at all. Set this extra to opt back in: // • {source} — pass the original calling-app referrer through // • any URI — send that exact referrer instead // • empty — stays stripped (same as not adding the extra) hint: 'Empty by default (referrer stripped). Add a URI to override, or use {source} to pass the calling app\'s referrer.', family: 'any', }, ]; export const BROWSER_FAMILY: Record = { 'com.android.chrome': ['chromium', 'cct'], 'com.brave.browser': ['chromium', 'cct'], 'app.vanadium.browser': ['chromium', 'cct'], 'io.github.jqssun.helium': ['chromium', 'cct'], 'com.duckduckgo.mobile.android': ['chromium', 'cct'], 'org.mozilla.firefox': ['firefox', 'cct'], 'org.mozilla.firefox_beta': ['firefox', 'cct'], 'org.mozilla.fenix': ['firefox', 'cct'], 'org.mozilla.focus': ['firefox', 'cct'], 'org.ironfoxoss.ironfox': ['firefox', 'cct'], 'org.torproject.torbrowser': ['firefox', 'cct'], 'org.mozilla.felice': ['firefox', 'cct'], }; export function suggestionsFor(pkg: string): ExtraSuggestion[] { const fams = new Set(BROWSER_FAMILY[pkg] ?? []); return EXTRA_SUGGESTIONS.filter( (s) => s.family === 'any' || fams.has(s.family), ); } /** * Returns true when a Flow already carries every key + value the * "Set max privacy" recipe would add for its browser. Used both to * surface a status badge on the Flow row and to gate the recipe * confirmation (so we can flag the Flow as already configured). */ export function isMaxPrivacy(flow: Flow): boolean { const expected = extrasForMode(flow.browserPkg, true, true); if (expected.length === 0) return false; const allExpected = expected.every((exp) => flow.extras.some((e) => e.key === exp.key && e.value === exp.value), ); if (!allExpected) return false; // A non-empty EXTRA_REFERRER override (e.g. `{source}` or a // literal URI) re-introduces the referrer — disqualifies the // Flow from "max privacy". An empty-value entry is a no-op // (matches the default strip) so it doesn't disqualify. const referrerOverride = flow.extras.some( (e) => e.key === REFERRER_EXTRA_KEY && e.value.length > 0, ); return !referrerOverride; } /** * Look up a curated suggestion by exact key match. Returns the * suggestion if we know about it (so the editor can surface its label / * hint as documentation), or null for user-custom keys. * * Optionally scoped to a browser: if `pkg` is provided, only suggestions * for that browser's family (plus 'any') match. Useful when the same * key technically exists in multiple families but the documentation * should reflect the current browser context. */ export function lookupSuggestion(key: string, pkg?: string): ExtraSuggestion | null { const candidates = pkg ? suggestionsFor(pkg) : EXTRA_SUGGESTIONS; return candidates.find((s) => s.key === key) ?? null; } // ──────────────────────────────────────────────────────────────────────── // Storage // ──────────────────────────────────────────────────────────────────────── export function loadFlows(): Flow[] { try { const raw = getStringPref(PREF_FLOWS, ''); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; // Coerce missing fields to defaults so older stored objects still // load. `mode` is intentionally re-derived from extras: it's a fully // derived field, and re-deriving on load cleans up stale values // baked in by previous template behavior. return parsed .filter(isFlowish) .map((x: any) => { // Per-entry validation. A malformed extra (missing fields, bad // type) is silently dropped — we used to flow it through and the // native side would coerce it into nothing, which looked like a // saved entry vanishing on next load. const rawExtras: unknown[] = Array.isArray(x.extras) ? x.extras : []; const extras: FlowExtra[] = []; for (const r of rawExtras) { if (!r || typeof r !== 'object') continue; const e = r as Record; if (typeof e.key !== 'string' || !e.key) continue; if (e.type !== 'bool' && e.type !== 'string' && e.type !== 'int') { // eslint-disable-next-line no-console console.warn('loadFlows: dropping extra with invalid type', e); continue; } // Value is loosely typed at storage time (the editor lets the // user type "true" / "1" etc); the native side coerces. Pass // through. extras.push(e as unknown as FlowExtra); } return { id: x.id, title: x.title, subtitle: x.subtitle, browserPkg: x.browserPkg, extras, profile: (x.profile === 'raw' || x.profile === 'privacy' || x.profile === 'work') ? x.profile : 'privacy', autoFire: x.autoFire === true, mode: deriveModeFromExtras(extras), }; }); } catch { return []; } } export function saveFlows(flows: Flow[]): void { try { setStringPref(PREF_FLOWS, JSON.stringify(flows)); } catch {} } function isFlowish(x: unknown): boolean { if (!x || typeof x !== 'object') return false; const o = x as Record; return ( typeof o.id === 'string' && typeof o.title === 'string' && typeof o.subtitle === 'string' && typeof o.browserPkg === 'string' && typeof o.mode === 'string' ); } export function newFlowId(): string { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } export function deriveFlowTitle( _f: Pick, browserDisplay: string, ): string { // The Flow's mode is already conveyed by row badges (Max Privacy / // mode pills) and by the configured extras list, so the derived // title is just the browser's display name. return browserDisplay; } /** * Map the two user-facing checkboxes (mini + incognito) to the single * PrivacyMode our launcher understands. Independent axes — mini=CCT * vs full tab; incognito=private vs regular session. */ export function modeFromToggles(mini: boolean, incognito: boolean): PrivacyMode { if (mini && incognito) return 'max'; if (mini) return 'mini'; if (incognito) return 'private'; return 'normal'; } export function togglesFromMode(m: PrivacyMode): { mini: boolean; incognito: boolean } { return { mini: m === 'mini' || m === 'max', incognito: m === 'private' || m === 'max', }; } // ──────────────────────────────────────────────────────────────────────── // Mode ↔ Extras synchronisation // // The Mode checkboxes (Mini / Incognito) write the canonical browser-family // intent extras into the Flow's extras list so the user can see — and // override — exactly what will be sent. The native dispatcher still sets // the same extras itself based on mode; user extras are applied last and // win on conflict, so manually editing a value in the list overrides the // mode-derived default. // ──────────────────────────────────────────────────────────────────────── /** * Canonical intent extras for a given (browser × mini × incognito) state. * One entry per logical concept; we don't surface every legacy alias the * native side also sets for older Fenix forks — those stay invisible * fallbacks. The CCT_SESSION marker that "mini" relies on is an IBinder * Bundle and can't be expressed as a plain extra; it stays implicit. */ export function extrasForMode( browserPkg: string, mini: boolean, incognito: boolean, ): FlowExtra[] { const out: FlowExtra[] = []; const fams = new Set(BROWSER_FAMILY[browserPkg] ?? []); if (incognito) { if (fams.has('chromium')) { out.push({ key: 'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB', type: 'bool', value: 'true', }); } if (fams.has('firefox')) { out.push({ key: 'private_browsing_mode', type: 'bool', value: 'true', }); } } if (mini && incognito && fams.has('cct')) { out.push({ key: 'androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING', type: 'bool', value: 'true', }); } // Referrer is *not* part of the max-privacy recipe anymore — the // native dispatcher strips Intent.EXTRA_REFERRER to Uri.EMPTY by // default on every launch. A Flow that wants to *opt back in* to // passing the original referrer adds its own EXTRA_REFERRER extra // (value `{source}` or a literal URI), which syncExtrasWithMode // explicitly clears when applying max privacy. return out; } /** * Every key any Fenix / Chromium fork has ever read for "open in private * tab". Single source of truth — referenced by deriveModeFromExtras (JS), * FlowModePills (JS), and the native incognito launcher's extras (Kotlin * reads them through pendingExtras, but the keys live here). Adding a * new fork's key here is the single edit needed. */ export const INCOGNITO_KEYS: string[] = [ 'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB', 'private_browsing_mode', // current Fenix 'is_private_tab', // some Fenix forks 'org.mozilla.gecko.LOAD_IN_PRIVATE_TAB', // legacy Gecko ]; /** * Every key the mode-toggle path might write. Derived from the canonical * recipe so the set can't drift from what extrasForMode emits — add a * key to a recipe, and this set picks it up automatically. */ export const MODE_MANAGED_KEYS: Set = (() => { // Sample both Chromium and Firefox families through every mini/incognito // combination — the union covers every key any recipe emits. const samples: { pkg: string; mini: boolean; incognito: boolean }[] = [ { pkg: 'com.android.chrome', mini: false, incognito: false }, { pkg: 'com.android.chrome', mini: true, incognito: false }, { pkg: 'com.android.chrome', mini: false, incognito: true }, { pkg: 'com.android.chrome', mini: true, incognito: true }, { pkg: 'org.mozilla.firefox', mini: false, incognito: false }, { pkg: 'org.mozilla.firefox', mini: true, incognito: false }, { pkg: 'org.mozilla.firefox', mini: false, incognito: true }, { pkg: 'org.mozilla.firefox', mini: true, incognito: true }, ]; const out = new Set(); for (const s of samples) { for (const e of extrasForMode(s.pkg, s.mini, s.incognito)) { out.add(e.key); } } return out; })(); /** * Apply the mode-derived recipe to an extras list, overriding any conflicts: * * 1. Drop every user-custom entry whose key is in MODE_MANAGED_KEYS — the * canonical recipe is authoritative for those keys, so we don't keep a * stale user value with the same key. * 2. De-duplicate by key, last entry wins. Canonical recipe entries are * appended last, so they override anything that slipped through. * * This is why the editor confirms before "Set max privacy" — applying it * is an explicit override of whatever the user previously had for those * keys, and we want the user to see exactly what changes. */ export function syncExtrasWithMode( current: FlowExtra[], browserPkg: string, mini: boolean, incognito: boolean, ): FlowExtra[] { const userCustom = current.filter((e) => !MODE_MANAGED_KEYS.has(e.key)); const merged: FlowExtra[] = [ ...userCustom, ...extrasForMode(browserPkg, mini, incognito), ]; const seen = new Map(); for (const e of merged) seen.set(e.key, e); let result = Array.from(seen.values()); // Max-privacy explicitly clears any user-supplied EXTRA_REFERRER // override so the default native strip (Uri.EMPTY) applies. A // {source} or custom-URI override carried over from before would // defeat the whole point of "max privacy". if (mini && incognito) { result = result.filter((e) => e.key !== REFERRER_EXTRA_KEY); } return result; } /** * Derive the launch mode purely from the extras list. The editor doesn't * expose mode as a separate field anymore — what the user puts in the * extras list is what gets sent, and the mode just picks the right intent * shape on the native side. Order of precedence: * - ENABLE_EPHEMERAL_BROWSING → 'max' (private CCT, ephemeral) * - any incognito extra → 'private' (full private tab) * - otherwise → 'normal' */ export function deriveModeFromExtras(extras: FlowExtra[]): PrivacyMode { const keys = new Set(extras.map((e) => e.key)); if (keys.has('androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING')) { return 'max'; } if (INCOGNITO_KEYS.some((k) => keys.has(k))) { return 'private'; } return 'normal'; } // ──────────────────────────────────────────────────────────────────────── // Profile constraints // ──────────────────────────────────────────────────────────────────────── /** * Which privacy modes are selectable for a given (profile × browser) pair. * Raw and Work profiles forbid incognito/mini modes — the point of the * profile is to declare intent upfront. Always-private browsers ignore * the constraint since they can't deliver a non-private session anyway. */ export function modesForProfile(profile: Profile, b: Browser): PrivacyMode[] { if (b.alwaysPrivate) return ['normal', 'mini', 'private', 'max']; switch (profile) { case 'raw': case 'work': return ['normal', 'mini']; case 'privacy': { const m: PrivacyMode[] = ['normal', 'mini']; if (b.nativePrivateWorks) m.push('private'); if (b.cctPrivateWorks) m.push('max'); return m; } } } /** * Canonical mode + extras for a new Flow in the given profile. The * default profile is `'privacy'` (strongest privacy the browser can * deliver), matching the existing UX posture. */ export function defaultsForProfile( profile: Profile, browserPkg: string, b?: Browser, ): { mode: PrivacyMode; mini: boolean; incognito: boolean; extras: FlowExtra[] } { const fams = new Set(BROWSER_FAMILY[browserPkg] ?? []); switch (profile) { case 'privacy': { const maxAvail = b?.cctPrivateWorks ?? fams.has('cct'); const privAvail = b?.nativePrivateWorks ?? (fams.has('chromium') || fams.has('firefox')); if (maxAvail && privAvail) { const extras = extrasForMode(browserPkg, true, true); return { mode: 'max', mini: true, incognito: true, extras }; } if (privAvail) { const extras = extrasForMode(browserPkg, false, true); return { mode: 'private', mini: false, incognito: true, extras }; } // Always-private or no private path — normal mode, no extras return { mode: 'normal', mini: false, incognito: false, extras: [] }; } case 'work': { const extras: FlowExtra[] = [{ key: REFERRER_EXTRA_KEY, type: 'string', value: REFERRER_SOURCE_PLACEHOLDER, }]; return { mode: 'normal', mini: false, incognito: false, extras }; } case 'raw': default: return { mode: 'normal', mini: false, incognito: false, extras: [] }; } }