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