diff --git a/components/options/GlobalSection.tsx b/components/options/GlobalSection.tsx index 9420bda..cd08373 100644 --- a/components/options/GlobalSection.tsx +++ b/components/options/GlobalSection.tsx @@ -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'; @@ -36,6 +37,7 @@ export function GlobalSection({ hasProtectedSites, }: GlobalSectionProps) { const t = useTranslation(); + const browserName = useBrowserName(); return (
@@ -53,7 +55,13 @@ export function GlobalSection({ onConfirm={onClear} /> - {hasProtectedSites &&

{t('protectedSitesGlobalNote')}

} + {hasProtectedSites && ( +

+ {browserName + ? t('protectedSitesGlobalNoteFirefox', { browser: browserName }) + : t('protectedSitesGlobalNoteChrome')} +

+ )} {showHistory && ( (null); - - useEffect(() => { - if (isGeckoBased()) getBrowserName().then(setBrowserName); - }, []); + const browserName = useBrowserName(); return (
diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 90d6bce..140afe7 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -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(() => { @@ -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) { diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 599936d..4265d2c 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -13,6 +13,7 @@ import { clearGlobal, clearLinkedOrigin, clearTabData, + dedupeSitesByHostname, filterProtectedTargets, isProtectedSite, linkedOriginsFor, @@ -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) { diff --git a/hooks/useBrowserName.ts b/hooks/useBrowserName.ts new file mode 100644 index 0000000..8d04990 --- /dev/null +++ b/hooks/useBrowserName.ts @@ -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(null); + + useEffect(() => { + if (isGeckoBased()) getBrowserName().then(setName); + }, []); + + return name; +} diff --git a/utils/browser-info.test.ts b/utils/browser-info.test.ts index d729723..6d69e0e 100644 --- a/utils/browser-info.test.ts +++ b/utils/browser-info.test.ts @@ -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'); }); diff --git a/utils/browser-info.ts b/utils/browser-info.ts index fb1f242..1f91004 100644 --- a/utils/browser-info.ts +++ b/utils/browser-info.ts @@ -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 { - 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'; } diff --git a/utils/clearing.browser.test.ts b/utils/clearing.browser.test.ts index 0dee13a..557945d 100644 --- a/utils/clearing.browser.test.ts +++ b/utils/clearing.browser.test.ts @@ -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', () => { diff --git a/utils/clearing.firefox.test.ts b/utils/clearing.firefox.test.ts index 58c02b7..6491f59 100644 --- a/utils/clearing.firefox.test.ts +++ b/utils/clearing.firefox.test.ts @@ -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'); @@ -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).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) + .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' }), + ); + }); +}); diff --git a/utils/clearing.ts b/utils/clearing.ts index b65f61f..617486f 100644 --- a/utils/clearing.ts +++ b/utils/clearing.ts @@ -15,11 +15,18 @@ export async function getSupportedKeys(): Promise> { return new Set(Object.keys(settings.dataToRemove)); } -export async function clearGlobal(ids: DataTypeId[]): Promise { +/** 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 { 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 = { since: 0 }; + if (!import.meta.env.FIREFOX && excludeOrigins.length > 0) { + options.excludeOrigins = excludeOrigins; + } + await browser.browsingData.remove(options as Parameters[0], dataToRemove); } export function siteScopedIds(ids: DataTypeId[]): DataTypeId[] { @@ -166,23 +173,20 @@ export async function requestOriginPermission(): Promise { /** 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 { - const base = { url: origin, storeId } as Parameters[0]; - const partitioned = { - ...base, - partitionKey: { topLevelSite: origin }, - } as Parameters[0]; - - const [unpartitioned, fromPartition] = await Promise.all([ - browser.cookies.getAll(base), - browser.cookies.getAll(partitioned).catch(() => []), - ]); + const options = { url: origin, storeId, partitionKey: {} } as Parameters[0]; + const cookies = await browser.cookies + .getAll(options) + .catch(() => browser.cookies.getAll({ url: origin, storeId } as Parameters[0])); await Promise.all( - [...unpartitioned, ...fromPartition].map((cookie) => + cookies.map((cookie) => browser.cookies.remove({ url: origin, name: cookie.name, diff --git a/utils/i18n.ts b/utils/i18n.ts index 340d60f..46c1e4d 100644 --- a/utils/i18n.ts +++ b/utils/i18n.ts @@ -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.', @@ -218,8 +221,10 @@ const ru: typeof en = { protectedSitesPlaceholder: 'По одному домену на строку, напр. app.your-company.com', protectedSitesHint: 'Никогда не очищаются никакими действиями для сайта (вкладки, посещённые сайты или как связанный целевой домен) — источник также защищает свои поддомены. Не действует для «Очистить все сайты» в разделе «Глобально» — там нет способа исключить отдельные сайты.', - protectedSitesGlobalNote: - 'Список защищённых сайтов здесь не действует — глобальная очистка всегда очищает всё.', + protectedSitesGlobalNoteChrome: + 'Здесь исключаются защищённые сайты, которые вы уже посещали — но только те, что есть в истории; непосещённый защищённый сайт нельзя исключить из глобальной очистки.', + protectedSitesGlobalNoteFirefox: + 'Список защищённых сайтов здесь не действует — в {browser} нет способа исключить отдельные сайты, поэтому глобальная очистка всегда очищает всё.', backupTitle: 'Резервная копия', backupHint: 'Экспортируйте настройки (включая сопоставления источников) в файл или импортируйте их на другом устройстве.', @@ -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.', @@ -462,8 +469,10 @@ const uk: typeof en = { protectedSitesPlaceholder: 'По одному домену на рядок, напр. app.your-company.com', protectedSitesHint: 'Ніколи не очищаються жодною дією для сайту (вкладки, відвідані сайти або як зіставлена ціль) — джерело також захищає свої піддомени. Не діє для «Очистити всі сайти» у Глобальному розділі — там немає способу виключити окремі сайти.', - protectedSitesGlobalNote: - 'Список захищених сайтів тут не діє — глобальне очищення завжди очищає все.', + protectedSitesGlobalNoteChrome: + 'Захищені сайти, які ви вже відвідували, виключаються тут — але лише ті, що є в історії; невідвіданий захищений сайт не можна виключити з глобального очищення.', + protectedSitesGlobalNoteFirefox: + 'Список захищених сайтів тут не діє — у {browser} немає способу виключити окремі сайти, тому глобальне очищення завжди очищає все.', backupTitle: 'Резервна копія', backupHint: 'Експортуйте налаштування (включно зі зіставленнями джерел) у файл або імпортуйте їх на іншому пристрої.',