flows.ts raw

   1  /**
   2   * A Flow is a saved launch recipe — browser + mode + optional intent
   3   * extras — that opens the current URL in one tap. Stored as a single
   4   * JSON array under SharedPrefs key 'flows_v1'.
   5   */
   6  
   7  import type { Browser, PrivacyMode } from './browsers';
   8  import { getStringPref, setStringPref } from '@/modules/default-browser';
   9  
  10  export type Profile = 'raw' | 'privacy' | 'work';
  11  
  12  export const PROFILES: readonly Profile[] = ['raw', 'privacy', 'work'] as const;
  13  
  14  export const PROFILE_LABEL: Record<Profile, string> = {
  15    raw: 'Raw',
  16    privacy: 'Privacy',
  17    work: 'Work',
  18  };
  19  
  20  export const PREF_FLOWS = 'flows_v1';
  21  
  22  /**
  23   * Intent extra key for the calling-app referrer. Warden always
  24   * strips the referrer (Uri.EMPTY) at launch by default. A Flow can
  25   * override that by adding an EXTRA_REFERRER extra with a non-empty
  26   * value — most commonly the literal placeholder `{source}`, which
  27   * the native module resolves to the original referrer Android
  28   * delivered with the incoming VIEW intent. Any other value is
  29   * parsed as a URI and passed through verbatim.
  30   */
  31  export const REFERRER_EXTRA_KEY = 'android.intent.extra.REFERRER';
  32  export const REFERRER_SOURCE_PLACEHOLDER = '{source}';
  33  
  34  export type FlowExtra = {
  35    key: string;
  36    type: 'bool' | 'string' | 'int';
  37    value: string;
  38  };
  39  
  40  export type Flow = {
  41    id: string;
  42    title: string;
  43    subtitle: string;
  44    browserPkg: string;
  45    mode: PrivacyMode;
  46    extras: FlowExtra[];
  47    /**
  48     * Which privacy-posture profile this Flow belongs to.
  49     *   - raw:      normal tab only, no incognito/mini, referrer stripped
  50     *   - privacy:  incognito/ephemeral/max, referrer stripped (default)
  51     *   - work:     normal tab only, referrer preserved (for {source})
  52     */
  53    profile: Profile;
  54    /**
  55     * When a URL arrives via share / VIEW intent, the *single* Flow marked
  56     * autoFire=true fires immediately and Warden exits its task. Zero or
  57     * multiple matches → fall back to showing the Flow list (URL prefilled).
  58     * Mutual exclusion is enforced softly at save time, not by the type.
  59     */
  60    autoFire: boolean;
  61  };
  62  
  63  /**
  64   * Curated extras a user is likely to want, grouped by which browser
  65   * family understands them. The editor surfaces these as quick-picks
  66   * before falling back to a freeform key/type/value triple.
  67   */
  68  export type ExtraSuggestion = {
  69    key: string;
  70    type: 'bool' | 'string' | 'int';
  71    defaultValue: string;
  72    label: string;
  73    hint?: string;
  74    family: 'chromium' | 'firefox' | 'cct' | 'any';
  75    /** Enumerated valid values for the extra (e.g. color-scheme ints). When
  76     *  set, the editor renders a tap-to-cycle pill instead of a free-text
  77     *  input. The `value` is the string written to the FlowExtra. */
  78    values?: { label: string; value: string }[];
  79  };
  80  
  81  export const EXTRA_SUGGESTIONS: ExtraSuggestion[] = [
  82    {
  83      key: 'androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING',
  84      type: 'bool',
  85      defaultValue: 'true',
  86      label: 'Ephemeral Custom Tab',
  87      hint: 'Chrome 137+ honors this — no history/cookies/cache persisted.',
  88      family: 'cct',
  89    },
  90    {
  91      key: 'androidx.browser.customtabs.extra.COLOR_SCHEME',
  92      type: 'int',
  93      defaultValue: '2',
  94      label: 'CCT color scheme',
  95      hint: 'Forces the Custom Tab to a specific colour scheme.',
  96      family: 'cct',
  97      values: [
  98        { label: 'System', value: '0' },
  99        { label: 'Light',  value: '1' },
 100        { label: 'Dark',   value: '2' },
 101      ],
 102    },
 103    {
 104      key: 'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB',
 105      type: 'bool',
 106      defaultValue: 'true',
 107      label: 'Open in incognito (Chromium)',
 108      hint: 'Brave / Vanadium / Edge / Vivaldi honor this.',
 109      family: 'chromium',
 110    },
 111    {
 112      key: 'private_browsing_mode',
 113      type: 'bool',
 114      defaultValue: 'true',
 115      label: 'Open in private (Fenix)',
 116      hint: 'Current Firefox / IronFox / Focus honor this.',
 117      family: 'firefox',
 118    },
 119    {
 120      key: 'is_private_tab',
 121      type: 'bool',
 122      defaultValue: 'true',
 123      label: 'Private tab (Fenix legacy)',
 124      hint: 'Older Fenix forks.',
 125      family: 'firefox',
 126    },
 127    {
 128      key: 'org.mozilla.gecko.LOAD_IN_PRIVATE_TAB',
 129      type: 'bool',
 130      defaultValue: 'true',
 131      label: 'Private tab (legacy Gecko)',
 132      hint: 'Older Gecko-based browsers.',
 133      family: 'firefox',
 134    },
 135    {
 136      key: 'android.intent.extra.REFERRER',
 137      type: 'string',
 138      defaultValue: '',
 139      label: 'Referrer override',
 140      // Empty by default — Warden auto-strips EXTRA_REFERRER on every
 141      // launch (Uri.EMPTY), so the receiving browser sees no referrer
 142      // at all. Set this extra to opt back in:
 143      //   • {source} — pass the original calling-app referrer through
 144      //   • any URI  — send that exact referrer instead
 145      //   • empty    — stays stripped (same as not adding the extra)
 146      hint: 'Empty by default (referrer stripped). Add a URI to override, or use {source} to pass the calling app\'s referrer.',
 147      family: 'any',
 148    },
 149  ];
 150  
 151  export const BROWSER_FAMILY: Record<string, ('chromium' | 'firefox' | 'cct')[]> = {
 152    'com.android.chrome':            ['chromium', 'cct'],
 153    'com.brave.browser':             ['chromium', 'cct'],
 154    'app.vanadium.browser':          ['chromium', 'cct'],
 155    'io.github.jqssun.helium':       ['chromium', 'cct'],
 156    'com.duckduckgo.mobile.android': ['chromium', 'cct'],
 157    'org.mozilla.firefox':           ['firefox', 'cct'],
 158    'org.mozilla.firefox_beta':      ['firefox', 'cct'],
 159    'org.mozilla.fenix':             ['firefox', 'cct'],
 160    'org.mozilla.focus':             ['firefox', 'cct'],
 161    'org.ironfoxoss.ironfox':        ['firefox', 'cct'],
 162    'org.torproject.torbrowser':     ['firefox', 'cct'],
 163    'org.mozilla.felice':              ['firefox', 'cct'],
 164  };
 165  
 166  export function suggestionsFor(pkg: string): ExtraSuggestion[] {
 167    const fams = new Set<string>(BROWSER_FAMILY[pkg] ?? []);
 168    return EXTRA_SUGGESTIONS.filter(
 169      (s) => s.family === 'any' || fams.has(s.family),
 170    );
 171  }
 172  
 173  /**
 174   * Returns true when a Flow already carries every key + value the
 175   * "Set max privacy" recipe would add for its browser. Used both to
 176   * surface a status badge on the Flow row and to gate the recipe
 177   * confirmation (so we can flag the Flow as already configured).
 178   */
 179  export function isMaxPrivacy(flow: Flow): boolean {
 180    const expected = extrasForMode(flow.browserPkg, true, true);
 181    if (expected.length === 0) return false;
 182    const allExpected = expected.every((exp) =>
 183      flow.extras.some((e) => e.key === exp.key && e.value === exp.value),
 184    );
 185    if (!allExpected) return false;
 186    // A non-empty EXTRA_REFERRER override (e.g. `{source}` or a
 187    // literal URI) re-introduces the referrer — disqualifies the
 188    // Flow from "max privacy". An empty-value entry is a no-op
 189    // (matches the default strip) so it doesn't disqualify.
 190    const referrerOverride = flow.extras.some(
 191      (e) => e.key === REFERRER_EXTRA_KEY && e.value.length > 0,
 192    );
 193    return !referrerOverride;
 194  }
 195  
 196  /**
 197   * Look up a curated suggestion by exact key match. Returns the
 198   * suggestion if we know about it (so the editor can surface its label /
 199   * hint as documentation), or null for user-custom keys.
 200   *
 201   * Optionally scoped to a browser: if `pkg` is provided, only suggestions
 202   * for that browser's family (plus 'any') match. Useful when the same
 203   * key technically exists in multiple families but the documentation
 204   * should reflect the current browser context.
 205   */
 206  export function lookupSuggestion(key: string, pkg?: string): ExtraSuggestion | null {
 207    const candidates = pkg ? suggestionsFor(pkg) : EXTRA_SUGGESTIONS;
 208    return candidates.find((s) => s.key === key) ?? null;
 209  }
 210  
 211  // ────────────────────────────────────────────────────────────────────────
 212  // Storage
 213  // ────────────────────────────────────────────────────────────────────────
 214  
 215  export function loadFlows(): Flow[] {
 216    try {
 217      const raw = getStringPref(PREF_FLOWS, '');
 218      if (!raw) return [];
 219      const parsed = JSON.parse(raw);
 220      if (!Array.isArray(parsed)) return [];
 221      // Coerce missing fields to defaults so older stored objects still
 222      // load. `mode` is intentionally re-derived from extras: it's a fully
 223      // derived field, and re-deriving on load cleans up stale values
 224      // baked in by previous template behavior.
 225      return parsed
 226        .filter(isFlowish)
 227        .map((x: any) => {
 228          // Per-entry validation. A malformed extra (missing fields, bad
 229          // type) is silently dropped — we used to flow it through and the
 230          // native side would coerce it into nothing, which looked like a
 231          // saved entry vanishing on next load.
 232          const rawExtras: unknown[] = Array.isArray(x.extras) ? x.extras : [];
 233          const extras: FlowExtra[] = [];
 234          for (const r of rawExtras) {
 235            if (!r || typeof r !== 'object') continue;
 236            const e = r as Record<string, unknown>;
 237            if (typeof e.key !== 'string' || !e.key) continue;
 238            if (e.type !== 'bool' && e.type !== 'string' && e.type !== 'int') {
 239              // eslint-disable-next-line no-console
 240              console.warn('loadFlows: dropping extra with invalid type', e);
 241              continue;
 242            }
 243            // Value is loosely typed at storage time (the editor lets the
 244            // user type "true" / "1" etc); the native side coerces. Pass
 245            // through.
 246            extras.push(e as unknown as FlowExtra);
 247          }
 248          return {
 249            id: x.id,
 250            title: x.title,
 251            subtitle: x.subtitle,
 252            browserPkg: x.browserPkg,
 253            extras,
 254            profile: (x.profile === 'raw' || x.profile === 'privacy' || x.profile === 'work')
 255              ? x.profile
 256              : 'privacy',
 257            autoFire: x.autoFire === true,
 258            mode: deriveModeFromExtras(extras),
 259          };
 260        });
 261    } catch {
 262      return [];
 263    }
 264  }
 265  
 266  export function saveFlows(flows: Flow[]): void {
 267    try {
 268      setStringPref(PREF_FLOWS, JSON.stringify(flows));
 269    } catch {}
 270  }
 271  
 272  function isFlowish(x: unknown): boolean {
 273    if (!x || typeof x !== 'object') return false;
 274    const o = x as Record<string, unknown>;
 275    return (
 276      typeof o.id === 'string' &&
 277      typeof o.title === 'string' &&
 278      typeof o.subtitle === 'string' &&
 279      typeof o.browserPkg === 'string' &&
 280      typeof o.mode === 'string'
 281    );
 282  }
 283  
 284  export function newFlowId(): string {
 285    return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
 286  }
 287  
 288  export function deriveFlowTitle(
 289    _f: Pick<Flow, 'browserPkg' | 'mode'>,
 290    browserDisplay: string,
 291  ): string {
 292    // The Flow's mode is already conveyed by row badges (Max Privacy /
 293    // mode pills) and by the configured extras list, so the derived
 294    // title is just the browser's display name.
 295    return browserDisplay;
 296  }
 297  
 298  /**
 299   * Map the two user-facing checkboxes (mini + incognito) to the single
 300   * PrivacyMode our launcher understands. Independent axes — mini=CCT
 301   * vs full tab; incognito=private vs regular session.
 302   */
 303  export function modeFromToggles(mini: boolean, incognito: boolean): PrivacyMode {
 304    if (mini && incognito) return 'max';
 305    if (mini)              return 'mini';
 306    if (incognito)         return 'private';
 307    return 'normal';
 308  }
 309  
 310  export function togglesFromMode(m: PrivacyMode): { mini: boolean; incognito: boolean } {
 311    return {
 312      mini:      m === 'mini' || m === 'max',
 313      incognito: m === 'private' || m === 'max',
 314    };
 315  }
 316  
 317  // ────────────────────────────────────────────────────────────────────────
 318  // Mode ↔ Extras synchronisation
 319  //
 320  // The Mode checkboxes (Mini / Incognito) write the canonical browser-family
 321  // intent extras into the Flow's extras list so the user can see — and
 322  // override — exactly what will be sent. The native dispatcher still sets
 323  // the same extras itself based on mode; user extras are applied last and
 324  // win on conflict, so manually editing a value in the list overrides the
 325  // mode-derived default.
 326  // ────────────────────────────────────────────────────────────────────────
 327  
 328  /**
 329   * Canonical intent extras for a given (browser × mini × incognito) state.
 330   * One entry per logical concept; we don't surface every legacy alias the
 331   * native side also sets for older Fenix forks — those stay invisible
 332   * fallbacks. The CCT_SESSION marker that "mini" relies on is an IBinder
 333   * Bundle and can't be expressed as a plain extra; it stays implicit.
 334   */
 335  export function extrasForMode(
 336    browserPkg: string,
 337    mini: boolean,
 338    incognito: boolean,
 339  ): FlowExtra[] {
 340    const out: FlowExtra[] = [];
 341    const fams = new Set<string>(BROWSER_FAMILY[browserPkg] ?? []);
 342    if (incognito) {
 343      if (fams.has('chromium')) {
 344        out.push({
 345          key: 'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB',
 346          type: 'bool',
 347          value: 'true',
 348        });
 349      }
 350      if (fams.has('firefox')) {
 351        out.push({
 352          key: 'private_browsing_mode',
 353          type: 'bool',
 354          value: 'true',
 355        });
 356      }
 357    }
 358    if (mini && incognito && fams.has('cct')) {
 359      out.push({
 360        key: 'androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING',
 361        type: 'bool',
 362        value: 'true',
 363      });
 364    }
 365    // Referrer is *not* part of the max-privacy recipe anymore — the
 366    // native dispatcher strips Intent.EXTRA_REFERRER to Uri.EMPTY by
 367    // default on every launch. A Flow that wants to *opt back in* to
 368    // passing the original referrer adds its own EXTRA_REFERRER extra
 369    // (value `{source}` or a literal URI), which syncExtrasWithMode
 370    // explicitly clears when applying max privacy.
 371    return out;
 372  }
 373  
 374  /**
 375   * Every key any Fenix / Chromium fork has ever read for "open in private
 376   * tab". Single source of truth — referenced by deriveModeFromExtras (JS),
 377   * FlowModePills (JS), and the native incognito launcher's extras (Kotlin
 378   * reads them through pendingExtras, but the keys live here). Adding a
 379   * new fork's key here is the single edit needed.
 380   */
 381  export const INCOGNITO_KEYS: string[] = [
 382    'com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB',
 383    'private_browsing_mode',                  // current Fenix
 384    'is_private_tab',                          // some Fenix forks
 385    'org.mozilla.gecko.LOAD_IN_PRIVATE_TAB',   // legacy Gecko
 386  ];
 387  
 388  /**
 389   * Every key the mode-toggle path might write. Derived from the canonical
 390   * recipe so the set can't drift from what extrasForMode emits — add a
 391   * key to a recipe, and this set picks it up automatically.
 392   */
 393  export const MODE_MANAGED_KEYS: Set<string> = (() => {
 394    // Sample both Chromium and Firefox families through every mini/incognito
 395    // combination — the union covers every key any recipe emits.
 396    const samples: { pkg: string; mini: boolean; incognito: boolean }[] = [
 397      { pkg: 'com.android.chrome',   mini: false, incognito: false },
 398      { pkg: 'com.android.chrome',   mini: true,  incognito: false },
 399      { pkg: 'com.android.chrome',   mini: false, incognito: true  },
 400      { pkg: 'com.android.chrome',   mini: true,  incognito: true  },
 401      { pkg: 'org.mozilla.firefox',  mini: false, incognito: false },
 402      { pkg: 'org.mozilla.firefox',  mini: true,  incognito: false },
 403      { pkg: 'org.mozilla.firefox',  mini: false, incognito: true  },
 404      { pkg: 'org.mozilla.firefox',  mini: true,  incognito: true  },
 405    ];
 406    const out = new Set<string>();
 407    for (const s of samples) {
 408      for (const e of extrasForMode(s.pkg, s.mini, s.incognito)) {
 409        out.add(e.key);
 410      }
 411    }
 412    return out;
 413  })();
 414  
 415  /**
 416   * Apply the mode-derived recipe to an extras list, overriding any conflicts:
 417   *
 418   *  1. Drop every user-custom entry whose key is in MODE_MANAGED_KEYS — the
 419   *     canonical recipe is authoritative for those keys, so we don't keep a
 420   *     stale user value with the same key.
 421   *  2. De-duplicate by key, last entry wins. Canonical recipe entries are
 422   *     appended last, so they override anything that slipped through.
 423   *
 424   * This is why the editor confirms before "Set max privacy" — applying it
 425   * is an explicit override of whatever the user previously had for those
 426   * keys, and we want the user to see exactly what changes.
 427   */
 428  export function syncExtrasWithMode(
 429    current: FlowExtra[],
 430    browserPkg: string,
 431    mini: boolean,
 432    incognito: boolean,
 433  ): FlowExtra[] {
 434    const userCustom = current.filter((e) => !MODE_MANAGED_KEYS.has(e.key));
 435    const merged: FlowExtra[] = [
 436      ...userCustom,
 437      ...extrasForMode(browserPkg, mini, incognito),
 438    ];
 439    const seen = new Map<string, FlowExtra>();
 440    for (const e of merged) seen.set(e.key, e);
 441    let result = Array.from(seen.values());
 442    // Max-privacy explicitly clears any user-supplied EXTRA_REFERRER
 443    // override so the default native strip (Uri.EMPTY) applies. A
 444    // {source} or custom-URI override carried over from before would
 445    // defeat the whole point of "max privacy".
 446    if (mini && incognito) {
 447      result = result.filter((e) => e.key !== REFERRER_EXTRA_KEY);
 448    }
 449    return result;
 450  }
 451  
 452  /**
 453   * Derive the launch mode purely from the extras list. The editor doesn't
 454   * expose mode as a separate field anymore — what the user puts in the
 455   * extras list is what gets sent, and the mode just picks the right intent
 456   * shape on the native side. Order of precedence:
 457   *   - ENABLE_EPHEMERAL_BROWSING → 'max' (private CCT, ephemeral)
 458   *   - any incognito extra       → 'private' (full private tab)
 459   *   - otherwise                 → 'normal'
 460   */
 461  export function deriveModeFromExtras(extras: FlowExtra[]): PrivacyMode {
 462    const keys = new Set(extras.map((e) => e.key));
 463    if (keys.has('androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING')) {
 464      return 'max';
 465    }
 466    if (INCOGNITO_KEYS.some((k) => keys.has(k))) {
 467      return 'private';
 468    }
 469    return 'normal';
 470  }
 471  
 472  // ────────────────────────────────────────────────────────────────────────
 473  // Profile constraints
 474  // ────────────────────────────────────────────────────────────────────────
 475  
 476  /**
 477   * Which privacy modes are selectable for a given (profile × browser) pair.
 478   * Raw and Work profiles forbid incognito/mini modes — the point of the
 479   * profile is to declare intent upfront. Always-private browsers ignore
 480   * the constraint since they can't deliver a non-private session anyway.
 481   */
 482  export function modesForProfile(profile: Profile, b: Browser): PrivacyMode[] {
 483    if (b.alwaysPrivate) return ['normal', 'mini', 'private', 'max'];
 484    switch (profile) {
 485      case 'raw':
 486      case 'work':
 487        return ['normal', 'mini'];
 488      case 'privacy': {
 489        const m: PrivacyMode[] = ['normal', 'mini'];
 490        if (b.nativePrivateWorks) m.push('private');
 491        if (b.cctPrivateWorks) m.push('max');
 492        return m;
 493      }
 494    }
 495  }
 496  
 497  /**
 498   * Canonical mode + extras for a new Flow in the given profile. The
 499   * default profile is `'privacy'` (strongest privacy the browser can
 500   * deliver), matching the existing UX posture.
 501   */
 502  export function defaultsForProfile(
 503    profile: Profile,
 504    browserPkg: string,
 505    b?: Browser,
 506  ): { mode: PrivacyMode; mini: boolean; incognito: boolean; extras: FlowExtra[] } {
 507    const fams = new Set<string>(BROWSER_FAMILY[browserPkg] ?? []);
 508    switch (profile) {
 509      case 'privacy': {
 510        const maxAvail = b?.cctPrivateWorks ?? fams.has('cct');
 511        const privAvail = b?.nativePrivateWorks ?? (fams.has('chromium') || fams.has('firefox'));
 512        if (maxAvail && privAvail) {
 513          const extras = extrasForMode(browserPkg, true, true);
 514          return { mode: 'max', mini: true, incognito: true, extras };
 515        }
 516        if (privAvail) {
 517          const extras = extrasForMode(browserPkg, false, true);
 518          return { mode: 'private', mini: false, incognito: true, extras };
 519        }
 520        // Always-private or no private path — normal mode, no extras
 521        return { mode: 'normal', mini: false, incognito: false, extras: [] };
 522      }
 523      case 'work': {
 524        const extras: FlowExtra[] = [{
 525          key: REFERRER_EXTRA_KEY,
 526          type: 'string',
 527          value: REFERRER_SOURCE_PLACEHOLDER,
 528        }];
 529        return { mode: 'normal', mini: false, incognito: false, extras };
 530      }
 531      case 'raw':
 532      default:
 533        return { mode: 'normal', mini: false, incognito: false, extras: [] };
 534    }
 535  }
 536