Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion components/options/GlobalSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Browser } from 'wxt/browser';
import { useBrowserName } from '../../hooks/useBrowserName';
import { useTranslation } from '../../hooks/useTranslation';
import { DATA_TYPES, type DataTypeId } from '../../utils/data-types';
import { DangerConfirmButton } from '../DangerConfirmButton';
Expand Down Expand Up @@ -36,6 +37,7 @@ export function GlobalSection({
hasProtectedSites,
}: GlobalSectionProps) {
const t = useTranslation();
const browserName = useBrowserName();

return (
<section className="mb-8">
Expand All @@ -53,7 +55,13 @@ export function GlobalSection({
onConfirm={onClear}
/>
</div>
{hasProtectedSites && <p className="text-xs text-stone-500 mt-2">{t('protectedSitesGlobalNote')}</p>}
{hasProtectedSites && (
<p className="text-xs text-stone-500 mt-2">
{browserName
? t('protectedSitesGlobalNoteFirefox', { browser: browserName })
: t('protectedSitesGlobalNoteChrome')}
</p>
)}

{showHistory && (
<HistorySection
Expand Down
9 changes: 2 additions & 7 deletions components/options/VisitedSitesSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useBrowserName } from '../../hooks/useBrowserName';
import { useTranslation } from '../../hooks/useTranslation';
import { getBrowserName, isGeckoBased } from '../../utils/browser-info';
import type { VisitedSite } from '../../utils/clearing';
import { DangerConfirmButton } from '../DangerConfirmButton';
import { type ClearStatus } from '../StatusButton';
Expand Down Expand Up @@ -28,11 +27,7 @@ export function VisitedSitesSection({
onClearSite,
}: VisitedSitesSectionProps) {
const t = useTranslation();
const [browserName, setBrowserName] = useState<string | null>(null);

useEffect(() => {
if (isGeckoBased()) getBrowserName().then(setBrowserName);
}, []);
const browserName = useBrowserName();

return (
<div className="mt-4">
Expand Down
17 changes: 14 additions & 3 deletions entrypoints/options/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,17 @@ export default function App() {
return history.filter((item) => (item.title ?? item.url ?? '').toLowerCase().includes(q));
}, [history, historyFilter]);

const allVisitedSites = useMemo(
() => dedupeSitesByHostname(visitedHistory.map((item) => item.url)),
[visitedHistory],
);

const visitedSites = useMemo(
() =>
dedupeSitesByHostname(visitedHistory.map((item) => item.url)).filter(
allVisitedSites.filter(
(site) => !clearedHostnames.has(site.hostname) && !isProtectedSite(protectedSites, site.hostname),
),
[visitedHistory, clearedHostnames, protectedSites],
[allVisitedSites, clearedHostnames, protectedSites],
);

const filteredVisitedSites = useMemo(() => {
Expand All @@ -128,7 +133,13 @@ export default function App() {
async function handleGlobalClear() {
setGlobalStatus('clearing');
try {
await clearGlobal(selectedTypesGlobal);
// Only Chrome can exclude specific origins from an unscoped clear at all — this
// only excludes protected sites we actually know the real origin of (i.e. ones
// in your history); Firefox has no such option and clears everything regardless.
const excludeOrigins = allVisitedSites
.filter((site) => isProtectedSite(protectedSites, site.hostname))
.map((site) => site.origin);
await clearGlobal(selectedTypesGlobal, excludeOrigins);
recordClear('(global)', selectedTypesGlobal);
setGlobalStatus('done');
} catch (err) {
Expand Down
12 changes: 11 additions & 1 deletion entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
clearGlobal,
clearLinkedOrigin,
clearTabData,
dedupeSitesByHostname,
filterProtectedTargets,
isProtectedSite,
linkedOriginsFor,
Expand Down Expand Up @@ -56,7 +57,16 @@ export default function App() {
async function handleClear() {
setStatus('clearing');
try {
await clearGlobal(selectedTypesGlobal);
// Only fetch history (needed to resolve protected hostnames to real origins) if
// there's actually something to exclude — most popup opens won't need this.
let excludeOrigins: string[] = [];
if (protectedSites.trim()) {
const historyItems = await browser.history.search({ text: '', maxResults: 1000 });
excludeOrigins = dedupeSitesByHostname(historyItems.map((item) => item.url))
.filter((site) => isProtectedSite(protectedSites, site.hostname))
.map((site) => site.origin);
}
await clearGlobal(selectedTypesGlobal, excludeOrigins);
recordClear('(global)', selectedTypesGlobal);
setStatus('done');
} catch (err) {
Expand Down
15 changes: 15 additions & 0 deletions hooks/useBrowserName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
import { getBrowserName, isGeckoBased } from '../utils/browser-info';

/** The real self-reported browser name (e.g. "Firefox", "Zen", "LibreWolf") on
* Gecko-based browsers, or null on Chromium-family browsers (which don't implement
* getBrowserInfo) — use this instead of hardcoding "Firefox" in Gecko-only copy. */
export function useBrowserName(): string | null {
const [name, setName] = useState<string | null>(null);

useEffect(() => {
if (isGeckoBased()) getBrowserName().then(setName);
}, []);

return name;
}
11 changes: 9 additions & 2 deletions utils/browser-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ describe('isGeckoBased', () => {
});

describe('getBrowserName', () => {
it('returns the self-reported name from getBrowserInfo', async () => {
it('returns the self-reported name from getBrowserInfo (e.g. a fork with no name override)', async () => {
(browser.runtime as { getBrowserInfo?: () => Promise<{ name: string }> }).getBrowserInfo = async () => ({
name: 'Zen',
name: 'Firefox',
});
expect(await getBrowserName()).toBe('Firefox');
});

it('overrides to "Zen" when the info object has Zen\'s own marker field, since Zen self-reports name: "Firefox"', async () => {
(
browser.runtime as { getBrowserInfo?: () => Promise<{ name: string; zen?: unknown }> }
).getBrowserInfo = async () => ({ name: 'Firefox', zen: { version: '1.21.15b' } });
expect(await getBrowserName()).toBe('Zen');
});

Expand Down
16 changes: 12 additions & 4 deletions utils/browser-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ export function isGeckoBased(): boolean {

/** The actual browser's self-reported name (e.g. "Firefox", "Zen", "LibreWolf") for
* Gecko-based forks — only call after isGeckoBased() confirms getBrowserInfo exists.
* Chromium-family browsers don't implement this API at all. */
* Chromium-family browsers don't implement this API at all.
*
* Some forks pass the Gecko app identity through unchanged, so `name` alone isn't
* reliable — Zen self-reports `name: "Firefox"` but adds its own `zen: {version}`
* field to the same object (verified live: Zen 1.21.15b on Firefox 154 base), which
* we use to override the name. Other forks (LibreWolf, Waterfox, etc.) aren't known
* to add a similar marker, so they fall through to whatever they self-report. */
export async function getBrowserName(): Promise<string> {
const getBrowserInfo = (browser.runtime as { getBrowserInfo?: () => Promise<{ name: string }> })
.getBrowserInfo;
const getBrowserInfo = (
browser.runtime as { getBrowserInfo?: () => Promise<{ name: string; zen?: unknown }> }
).getBrowserInfo;
if (!getBrowserInfo) return 'Firefox';
try {
return (await getBrowserInfo()).name;
const info = await getBrowserInfo();
return info.zen ? 'Zen' : info.name;
} catch {
return 'Firefox';
}
Expand Down
13 changes: 13 additions & 0 deletions utils/clearing.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ describe('clearGlobal', () => {
await clearGlobal(['history', 'downloads']);
expect(browser.browsingData.remove).not.toHaveBeenCalled();
});

it('passes excludeOrigins through on Chrome', async () => {
await clearGlobal(['cache', 'cookies'], ['https://protected.com']);
expect(browser.browsingData.remove).toHaveBeenCalledWith(
{ since: 0, excludeOrigins: ['https://protected.com'] },
{ cache: true, cookies: true },
);
});

it('omits excludeOrigins entirely when the list is empty', async () => {
await clearGlobal(['cache', 'cookies'], []);
expect(browser.browsingData.remove).toHaveBeenCalledWith({ since: 0 }, { cache: true, cookies: true });
});
});

describe('clearSiteViaBrowsingData', () => {
Expand Down
36 changes: 35 additions & 1 deletion utils/clearing.firefox.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { browser } from 'wxt/browser';
import { clearLinkedOrigin, clearTabData } from './clearing';
import { clearGlobal, clearLinkedOrigin, clearTabData } from './clearing';

beforeEach(() => {
vi.stubEnv('FIREFOX', 'true');
Expand Down Expand Up @@ -166,3 +166,37 @@ describe('clearTabData (Firefox path)', () => {
expect(browser.scripting.executeScript).not.toHaveBeenCalled();
});
});

describe('clearGlobal (Firefox path)', () => {
it('ignores excludeOrigins — Firefox has no such option, so Global stays all-or-nothing', async () => {
(browser.browsingData.settings as ReturnType<typeof vi.fn>).mockResolvedValue({
dataToRemove: { cache: true },
});
await clearGlobal(['cache'], ['https://protected.com']);
expect(browser.browsingData.remove).toHaveBeenCalledWith({ since: 0 }, { cache: true });
});
});

describe('clearCookiesForOrigin (via clearTabData, Firefox path)', () => {
it('fetches every partition in one call using an empty partitionKey', async () => {
await clearTabData({ id: 42, url: 'https://domain.com' }, ['cookies']);
expect(browser.cookies.getAll).toHaveBeenCalledTimes(1);
expect(browser.cookies.getAll).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://domain.com', partitionKey: {} }),
);
});

it('falls back to an unpartitioned-only call if the partitioned getAll rejects', async () => {
(browser.cookies.getAll as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error('partitionKey unsupported'))
.mockResolvedValueOnce([]);

const ok = await clearTabData({ id: 42, url: 'https://domain.com' }, ['cookies']);

expect(ok).toBe(true);
expect(browser.cookies.getAll).toHaveBeenCalledTimes(2);
expect(browser.cookies.getAll).toHaveBeenLastCalledWith(
expect.objectContaining({ url: 'https://domain.com' }),
);
});
});
36 changes: 20 additions & 16 deletions utils/clearing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ export async function getSupportedKeys(): Promise<Set<string>> {
return new Set(Object.keys(settings.dataToRemove));
}

export async function clearGlobal(ids: DataTypeId[]): Promise<void> {
/** Chrome (since Chrome 74) supports excludeOrigins on an unscoped clear — Firefox
* has no equivalent (Bugzilla 1632796, still open), so exclusion there is a no-op:
* Global stays all-or-nothing on Firefox regardless of a protected-sites list. */
export async function clearGlobal(ids: DataTypeId[], excludeOrigins: string[] = []): Promise<void> {
const supported = await getSupportedKeys();
const dataToRemove = toRemovalOptions(ids, supported);
if (Object.keys(dataToRemove).length === 0) return;
await browser.browsingData.remove({ since: 0 }, dataToRemove);
const options: Record<string, unknown> = { since: 0 };
if (!import.meta.env.FIREFOX && excludeOrigins.length > 0) {
options.excludeOrigins = excludeOrigins;
}
await browser.browsingData.remove(options as Parameters<typeof browser.browsingData.remove>[0], dataToRemove);
}

export function siteScopedIds(ids: DataTypeId[]): DataTypeId[] {
Expand Down Expand Up @@ -166,23 +173,20 @@ export async function requestOriginPermission(): Promise<boolean> {

/** document.cookie can't see HttpOnly cookies; the privileged cookies API can.
* Firefox's Total Cookie Protection also partitions third-party cookies (e.g. an
* SSO/silent-renew iframe from another domain) by top-level site — those live in a
* separate jar getAll() won't return without an explicit partitionKey, so a plain
* getAll() here would silently miss a still-alive session cookie set that way. */
* SSO/silent-renew iframe from another domain, partitioned under whatever OTHER
* top-level site embeds it) — a plain getAll() only sees the unpartitioned jar and
* would silently miss a still-alive session cookie set that way. Passing an empty
* partitionKey (per MDN) matches every partition regardless of its topLevelSite in
* one call — matching on `partitionKey: { topLevelSite: origin }` would only catch
* the origin partitioning itself, not the actual leak case this exists for. */
async function clearCookiesForOrigin(origin: string, storeId?: string): Promise<void> {
const base = { url: origin, storeId } as Parameters<typeof browser.cookies.getAll>[0];
const partitioned = {
...base,
partitionKey: { topLevelSite: origin },
} as Parameters<typeof browser.cookies.getAll>[0];

const [unpartitioned, fromPartition] = await Promise.all([
browser.cookies.getAll(base),
browser.cookies.getAll(partitioned).catch(() => []),
]);
const options = { url: origin, storeId, partitionKey: {} } as Parameters<typeof browser.cookies.getAll>[0];
const cookies = await browser.cookies
.getAll(options)
.catch(() => browser.cookies.getAll({ url: origin, storeId } as Parameters<typeof browser.cookies.getAll>[0]));

await Promise.all(
[...unpartitioned, ...fromPartition].map((cookie) =>
cookies.map((cookie) =>
browser.cookies.remove({
url: origin,
name: cookie.name,
Expand Down
23 changes: 16 additions & 7 deletions utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ const en = {
protectedSitesPlaceholder: 'One hostname per line, e.g. app.your-company.com',
protectedSitesHint:
"Never cleared by any per-site action (tabs, visited sites, or as a mapped origin target) — a source also protects its subdomains. Doesn't apply to Global's \"Clear all sites\", which has no way to exclude specific sites.",
protectedSitesGlobalNote: "Protected sites list doesn't apply here — Global clear always clears everything.",
protectedSitesGlobalNoteChrome:
"Protected sites you've visited before are excluded here — but only ones already in your history; an unvisited protected site can't be excluded from Global clear.",
protectedSitesGlobalNoteFirefox:
"Protected sites list doesn't apply here — {browser} has no way to exclude specific sites, so Global clear always clears everything.",

backupTitle: 'Backup',
backupHint: 'Export your settings (including origin mappings) to a file, or import one on another machine.',
Expand Down Expand Up @@ -218,8 +221,10 @@ const ru: typeof en = {
protectedSitesPlaceholder: 'По одному домену на строку, напр. app.your-company.com',
protectedSitesHint:
'Никогда не очищаются никакими действиями для сайта (вкладки, посещённые сайты или как связанный целевой домен) — источник также защищает свои поддомены. Не действует для «Очистить все сайты» в разделе «Глобально» — там нет способа исключить отдельные сайты.',
protectedSitesGlobalNote:
'Список защищённых сайтов здесь не действует — глобальная очистка всегда очищает всё.',
protectedSitesGlobalNoteChrome:
'Здесь исключаются защищённые сайты, которые вы уже посещали — но только те, что есть в истории; непосещённый защищённый сайт нельзя исключить из глобальной очистки.',
protectedSitesGlobalNoteFirefox:
'Список защищённых сайтов здесь не действует — в {browser} нет способа исключить отдельные сайты, поэтому глобальная очистка всегда очищает всё.',

backupTitle: 'Резервная копия',
backupHint: 'Экспортируйте настройки (включая сопоставления источников) в файл или импортируйте их на другом устройстве.',
Expand Down Expand Up @@ -340,8 +345,10 @@ const ro: typeof en = {
protectedSitesPlaceholder: 'Câte un domeniu pe linie, ex. app.compania-ta.com',
protectedSitesHint:
'Nu sunt șterse niciodată de nicio acțiune per site (file, site-uri vizitate sau ca țintă de corespondență) — o sursă protejează și subdomeniile ei. Nu se aplică la „Șterge toate site-urile” din Global, care nu poate exclude anumite site-uri.',
protectedSitesGlobalNote:
'Lista de site-uri protejate nu se aplică aici — ștergerea globală șterge întotdeauna totul.',
protectedSitesGlobalNoteChrome:
'Site-urile protejate pe care le-ai vizitat deja sunt excluse aici — dar doar cele din istoric; un site protejat nevizitat nu poate fi exclus din ștergerea globală.',
protectedSitesGlobalNoteFirefox:
'Lista de site-uri protejate nu se aplică aici — {browser} nu poate exclude anumite site-uri, așa că ștergerea globală șterge întotdeauna totul.',

backupTitle: 'Backup',
backupHint: 'Exportă setările (inclusiv corespondențele de origini) într-un fișier sau importă-le pe alt dispozitiv.',
Expand Down Expand Up @@ -462,8 +469,10 @@ const uk: typeof en = {
protectedSitesPlaceholder: 'По одному домену на рядок, напр. app.your-company.com',
protectedSitesHint:
'Ніколи не очищаються жодною дією для сайту (вкладки, відвідані сайти або як зіставлена ціль) — джерело також захищає свої піддомени. Не діє для «Очистити всі сайти» у Глобальному розділі — там немає способу виключити окремі сайти.',
protectedSitesGlobalNote:
'Список захищених сайтів тут не діє — глобальне очищення завжди очищає все.',
protectedSitesGlobalNoteChrome:
'Захищені сайти, які ви вже відвідували, виключаються тут — але лише ті, що є в історії; невідвіданий захищений сайт не можна виключити з глобального очищення.',
protectedSitesGlobalNoteFirefox:
'Список захищених сайтів тут не діє — у {browser} немає способу виключити окремі сайти, тому глобальне очищення завжди очищає все.',

backupTitle: 'Резервна копія',
backupHint: 'Експортуйте налаштування (включно зі зіставленнями джерел) у файл або імпортуйте їх на іншому пристрої.',
Expand Down
Loading