diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..8f88e98 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useSettingsStore } from '../store/settings'; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('Bleep UI crashed:', error, info.componentStack); + } + + override render() { + if (!this.state.error) return this.props.children; + return ( +
+

Something went wrong.

+

{this.state.error.message}

+ +
+ ); + } +} diff --git a/components/HelpPanel.tsx b/components/HelpPanel.tsx index b1319a4..8c489ed 100644 --- a/components/HelpPanel.tsx +++ b/components/HelpPanel.tsx @@ -1,7 +1,10 @@ import { useTranslation } from '../hooks/useTranslation'; +import { isGeckoBased } from '../utils/browser-info'; import type { TranslationKey } from '../utils/i18n'; const GITHUB_ISSUES_URL = 'https://github.com/utopiaeh/bleep/issues'; +const CHROME_REVIEW_URL = 'https://chromewebstore.google.com/detail/dnihnhdbmgjfkdlkahlbbfhginfglomj/reviews'; +const FIREFOX_REVIEW_URL = 'https://addons.mozilla.org/en-US/firefox/addon/bleep-cache-cleaner/reviews/'; const ITEMS: Array<{ title: TranslationKey; text: TranslationKey }> = [ { title: 'helpPopupTitle', text: 'helpPopupText' }, @@ -37,6 +40,17 @@ export function HelpPanel() { {t('helpFeedbackLink')}

+

+ {t('helpReviewText')}{' '} + + {t('helpReviewLink')} + +

diff --git a/components/OriginMappingsEditor.tsx b/components/OriginMappingsEditor.tsx index 9872762..f1ede2a 100644 --- a/components/OriginMappingsEditor.tsx +++ b/components/OriginMappingsEditor.tsx @@ -89,6 +89,7 @@ export function OriginMappingsEditor({ value, onChange }: OriginMappingsEditorPr value={row.source} onChange={(e) => updateRow(i, { source: e.target.value })} placeholder={t('linkedOriginSourcePlaceholder')} + maxLength={253} className="w-full min-w-0 rounded-md border border-stone-300 bg-stone-50 dark:border-stone-600 dark:bg-stone-800 px-3 py-1.5 text-sm placeholder:text-stone-500 focus:outline-none focus:border-blue-500" /> {sourceInvalid(row) && ( @@ -107,6 +108,7 @@ export function OriginMappingsEditor({ value, onChange }: OriginMappingsEditorPr if (el) autoResize(el); }} placeholder={t('linkedOriginTargetPlaceholder')} + maxLength={2000} rows={1} className="w-full min-w-0 rounded-md border border-stone-300 bg-stone-50 dark:border-stone-600 dark:bg-stone-800 px-3 py-1.5 text-sm placeholder:text-stone-500 focus:outline-none focus:border-blue-500 resize-y overflow-y-auto" /> diff --git a/components/options/ErrorLogSection.tsx b/components/options/ErrorLogSection.tsx new file mode 100644 index 0000000..3763a52 --- /dev/null +++ b/components/options/ErrorLogSection.tsx @@ -0,0 +1,38 @@ +import { useErrorLogStore } from '../../store/errorLog'; + +export function ErrorLogSection() { + const entries = useErrorLogStore((s) => s.entries); + const clearErrors = useErrorLogStore((s) => s.clearErrors); + + return ( +
+
+

Error log

+ {entries.length > 0 && ( + + )} +
+ {entries.length === 0 ? ( +

Nothing logged.

+ ) : ( + + )} +
+ ); +} diff --git a/components/options/ProtectedSitesSection.tsx b/components/options/ProtectedSitesSection.tsx index ea50eb7..3e2e816 100644 --- a/components/options/ProtectedSitesSection.tsx +++ b/components/options/ProtectedSitesSection.tsx @@ -15,6 +15,7 @@ export function ProtectedSitesSection({ value, onChange }: ProtectedSitesSection value={value} onChange={(e) => onChange(e.target.value)} placeholder={t('protectedSitesPlaceholder')} + maxLength={5000} rows={2} className="w-full rounded-md border border-stone-300 bg-stone-50 dark:border-stone-600 dark:bg-stone-800 px-3 py-1.5 text-sm placeholder:text-stone-500 focus:outline-none focus:border-blue-500 resize-y" /> diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index 9cb851c..b959606 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -1,8 +1,11 @@ import { browser } from 'wxt/browser'; import { defineBackground } from 'wxt/utils/define-background'; import { linkedOriginsFor, tabOrigin } from '../../utils/clearing'; +import { installGlobalErrorLogging } from '../../utils/error-log'; import { readPersistedSettingsRaw } from '../../utils/storage-adapter'; +installGlobalErrorLogging(); + const SETTINGS_KEY = 'cache-cleaner-settings'; const DEFAULT_ICON = { @@ -81,10 +84,13 @@ export default defineBackground(() => { if (areaName !== 'sync' || !Object.keys(changes).some((key) => key.startsWith(SETTINGS_KEY))) return; invalidateMappingSettingsCache(); - browser.tabs.query({ active: true }).then((tabs) => { - for (const tab of tabs) { - if (tab.id != null) updateIcon(tab.id, tab.url); - } - }); + browser.tabs + .query({ active: true }) + .then((tabs) => { + for (const tab of tabs) { + if (tab.id != null) updateIcon(tab.id, tab.url); + } + }) + .catch(() => {}); }); }); diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index d6e7b3a..3326c70 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -5,6 +5,7 @@ import { HelpPanel } from '../../components/HelpPanel'; import { OriginMappingsEditor } from '../../components/OriginMappingsEditor'; import { BackupSection } from '../../components/options/BackupSection'; import { ClearLogSection } from '../../components/options/ClearLogSection'; +import { ErrorLogSection } from '../../components/options/ErrorLogSection'; import { GlobalSection } from '../../components/options/GlobalSection'; import { LanguageSection } from '../../components/options/LanguageSection'; import { ProtectedSitesSection } from '../../components/options/ProtectedSitesSection'; @@ -19,6 +20,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useTranslation } from '../../hooks/useTranslation'; import { recordClear } from '../../store/clearLog'; import { useSettingsStore } from '../../store/settings'; +import { useStorageErrorStore } from '../../store/storageError'; import { isGeckoBased } from '../../utils/browser-info'; import { runClear } from '../../utils/clear-status'; import { @@ -138,8 +140,6 @@ export default function App() { }, [visitedSites, visitedSiteFilter]); async function handleGlobalClear() { - // Only Chrome can exclude specific origins from an unscoped clear at all, and only - // for sites we actually know the real origin of (i.e. ones in your history). const excludeOrigins = allVisitedSites .filter((site) => isProtectedSite(protectedSites, site.hostname)) .map((site) => site.origin); @@ -244,8 +244,6 @@ export default function App() { async function clearVisitedSite(site: VisitedSite): Promise { const ids = siteScopedIds(selectedTypesSite); - // Prefer an already-open tab over clearLinkedOrigin's throwaway background tab — - // picks up the tab's real cookieStoreId (Firefox container) too. const openTab = (await browser.tabs.query({})).find((tab) => tabHostname(tab) === site.hostname); const cookieStoreId = openTab ? tabCookieStoreId(openTab) : undefined; @@ -355,6 +353,9 @@ export default function App() { setTimeout(() => setHistoryBulkStatus('idle'), 1500); } + const storageError = useStorageErrorStore((s) => s.message); + const clearStorageError = useStorageErrorStore((s) => s.clear); + return (
@@ -363,6 +364,15 @@ export default function App() {

{t('settingsTitle')}

+ {storageError && ( +
+ {storageError} + +
+ )} + {activeTab === 'general' && ( @@ -373,6 +383,7 @@ export default function App() {
+ )} diff --git a/entrypoints/options/main.tsx b/entrypoints/options/main.tsx index ecc1b1e..a8641d4 100644 --- a/entrypoints/options/main.tsx +++ b/entrypoints/options/main.tsx @@ -1,10 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { ErrorBoundary } from '../../components/ErrorBoundary.tsx'; +import { installGlobalErrorLogging } from '../../utils/error-log.ts'; import App from './App.tsx'; import '../popup/style.css'; +installGlobalErrorLogging(); + ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/entrypoints/popup/main.tsx b/entrypoints/popup/main.tsx index 4cc9737..5a4725f 100644 --- a/entrypoints/popup/main.tsx +++ b/entrypoints/popup/main.tsx @@ -1,10 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { ErrorBoundary } from '../../components/ErrorBoundary.tsx'; +import { installGlobalErrorLogging } from '../../utils/error-log.ts'; import App from './App.tsx'; import './style.css'; +installGlobalErrorLogging(); + ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/hooks/useBrowserName.ts b/hooks/useBrowserName.ts index 8d04990..b7c4d65 100644 --- a/hooks/useBrowserName.ts +++ b/hooks/useBrowserName.ts @@ -1,9 +1,6 @@ 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); diff --git a/package.json b/package.json index 0b0ebca..d4ea14e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "Bleep - Clear cache, storage, and browsing data — globally or per-site.", "author": "utopiaeh01@gmail.com", "private": true, - "version": "0.4.0", + "version": "0.4.2", "type": "module", "scripts": { "dev": "wxt", diff --git a/public/icon/128-linked.png b/public/icon/128-linked.png index 3b15469..e5d6753 100644 Binary files a/public/icon/128-linked.png and b/public/icon/128-linked.png differ diff --git a/public/icon/16-linked.png b/public/icon/16-linked.png index b58b972..6965490 100644 Binary files a/public/icon/16-linked.png and b/public/icon/16-linked.png differ diff --git a/public/icon/32-linked.png b/public/icon/32-linked.png index d4e2f65..776f9ea 100644 Binary files a/public/icon/32-linked.png and b/public/icon/32-linked.png differ diff --git a/public/icon/48-linked.png b/public/icon/48-linked.png index 665c7ff..0116a70 100644 Binary files a/public/icon/48-linked.png and b/public/icon/48-linked.png differ diff --git a/public/icon/96-linked.png b/public/icon/96-linked.png index 8aa1105..4700d67 100644 Binary files a/public/icon/96-linked.png and b/public/icon/96-linked.png differ diff --git a/store/clearLog.ts b/store/clearLog.ts index 143e2b8..0b23647 100644 --- a/store/clearLog.ts +++ b/store/clearLog.ts @@ -19,9 +19,6 @@ interface ClearLogState { clearLog: () => void; } -// Device-specific activity, not a preference — kept in local storage rather than the -// synced settings store so it doesn't consume the sync quota or show up on other -// machines as "stuff that happened somewhere else". export const useClearLogStore = create()( persist( (set, get) => ({ @@ -35,6 +32,14 @@ export const useClearLogStore = create()( { name: 'cache-cleaner-clear-log', storage: createJSONStorage(() => browserLocalStorage), + version: 1, + migrate: (persisted) => persisted as ClearLogState, + merge: (persisted, current) => ({ + ...current, + entries: Array.isArray((persisted as Partial | null)?.entries) + ? (persisted as ClearLogState).entries + : current.entries, + }), }, ), ); diff --git a/store/errorLog.ts b/store/errorLog.ts new file mode 100644 index 0000000..366f03a --- /dev/null +++ b/store/errorLog.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { browserLocalStorage } from '../utils/storage-adapter'; + +export interface ErrorLogEntry { + id: string; + timestamp: number; + message: string; +} + +const MAX_ERROR_LOG_ENTRIES = 30; + +interface ErrorLogState { + entries: ErrorLogEntry[]; + logError: (message: string) => void; + clearErrors: () => void; +} + +export const useErrorLogStore = create()( + persist( + (set, get) => ({ + entries: [], + logError: (message) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + set({ entries: [{ id, timestamp: Date.now(), message }, ...get().entries].slice(0, MAX_ERROR_LOG_ENTRIES) }); + }, + clearErrors: () => set({ entries: [] }), + }), + { + name: 'cache-cleaner-error-log', + storage: createJSONStorage(() => browserLocalStorage), + version: 1, + migrate: (persisted) => persisted as ErrorLogState, + merge: (persisted, current) => ({ + ...current, + entries: Array.isArray((persisted as Partial | null)?.entries) + ? (persisted as ErrorLogState).entries + : current.entries, + }), + }, + ), +); diff --git a/store/settings.test.ts b/store/settings.test.ts index 7ed9b45..1f329bd 100644 --- a/store/settings.test.ts +++ b/store/settings.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { DEFAULTS, useSettingsStore } from './settings'; +import { DEFAULTS, mergeSettings, useSettingsStore } from './settings'; beforeEach(() => { useSettingsStore.setState(DEFAULTS); @@ -57,6 +57,35 @@ describe('importSettings', () => { }); }); +describe('mergeSettings (persist rehydration guard)', () => { + it('applies a valid persisted blob over the current state', () => { + const current = useSettingsStore.getState(); + const merged = mergeSettings({ theme: 'dark', linkedOrigins: 'a.com => b.com' }, current); + expect(merged.theme).toBe('dark'); + expect(merged.linkedOrigins).toBe('a.com => b.com'); + }); + + it('falls back to defaults for a corrupted field instead of propagating it (e.g. a version update that changed a field type)', () => { + const current = useSettingsStore.getState(); + const merged = mergeSettings({ theme: 42, selectedTypesGlobal: 'not-an-array' }, current); + expect(merged.theme).toBe(DEFAULTS.theme); + expect(merged.selectedTypesGlobal).toEqual(DEFAULTS.selectedTypesGlobal); + }); + + it('handles a completely empty/missing persisted value (first install) without throwing', () => { + const current = useSettingsStore.getState(); + expect(() => mergeSettings(undefined, current)).not.toThrow(); + expect(mergeSettings(undefined, current)).toMatchObject(DEFAULTS); + }); + + it('never drops the action functions from current state', () => { + const current = useSettingsStore.getState(); + const merged = mergeSettings({ theme: 'dark' }, current); + expect(typeof merged.setTheme).toBe('function'); + expect(typeof merged.resetSettings).toBe('function'); + }); +}); + describe('resetSettings', () => { it('restores every field to DEFAULTS', () => { const s = useSettingsStore.getState(); diff --git a/store/settings.ts b/store/settings.ts index b4107c9..c073e65 100644 --- a/store/settings.ts +++ b/store/settings.ts @@ -2,28 +2,12 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { DataTypeId } from '../utils/data-types'; import type { Language } from '../utils/i18n'; +import { DEFAULTS, type Theme } from '../utils/settings-defaults'; +import { sanitizeSettings } from '../utils/settings-transfer'; import { browserSyncStorage } from '../utils/storage-adapter'; -export type Theme = 'system' | 'light' | 'dark'; - -const DEFAULT_TYPES: DataTypeId[] = [ - 'cacheStorage', - 'cache', - 'indexedDB', - 'localStorage', - 'sessionStorage', -]; - -export const DEFAULTS = { - selectedTypesGlobal: [...DEFAULT_TYPES], - selectedTypesSite: [...DEFAULT_TYPES], - autoReloadAfterClear: true, - linkedOrigins: '', - useOriginMappings: true, - protectedSites: '', - theme: 'system' as Theme, - language: 'auto' as Language, -}; +export type { Theme }; +export { DEFAULTS }; interface SettingsState { selectedTypesGlobal: DataTypeId[]; @@ -56,6 +40,10 @@ function toggleIn( set({ [field]: current.includes(id) ? current.filter((t) => t !== id) : [...current, id] }); } +export function mergeSettings(persisted: unknown, current: SettingsState): SettingsState { + return { ...current, ...sanitizeSettings(persisted) }; +} + export const useSettingsStore = create()( persist( (set, get) => ({ @@ -74,6 +62,9 @@ export const useSettingsStore = create()( { name: 'cache-cleaner-settings', storage: createJSONStorage(() => browserSyncStorage), + version: 1, + migrate: (persisted) => persisted as SettingsState, + merge: mergeSettings, }, ), ); diff --git a/store/storageError.ts b/store/storageError.ts new file mode 100644 index 0000000..5b161f4 --- /dev/null +++ b/store/storageError.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface StorageErrorState { + message: string | null; + setError: (message: string) => void; + clear: () => void; +} + +export const useStorageErrorStore = create((set) => ({ + message: null, + setError: (message) => set({ message }), + clear: () => set({ message: null }), +})); diff --git a/utils/browser-info.ts b/utils/browser-info.ts index 1f91004..a04307c 100644 --- a/utils/browser-info.ts +++ b/utils/browser-info.ts @@ -4,15 +4,6 @@ export function isGeckoBased(): boolean { return typeof (browser.runtime as { getBrowserInfo?: unknown }).getBrowserInfo === 'function'; } -/** 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. - * - * 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; zen?: unknown }> } diff --git a/utils/clear-status.ts b/utils/clear-status.ts index 2010b1c..43d16e4 100644 --- a/utils/clear-status.ts +++ b/utils/clear-status.ts @@ -12,8 +12,6 @@ export function clearAllLabel( return t('clearAllCount', { count }); } -/** clearing → run fn → done/failed → back to idle after a short flash, used by every - * all-or-nothing clear action (Global clear in both popup and options). */ export async function runClear(setStatus: (status: ClearStatus) => void, fn: () => Promise): Promise { setStatus('clearing'); try { diff --git a/utils/clearing.browser.test.ts b/utils/clearing.browser.test.ts index 557945d..64175ac 100644 --- a/utils/clearing.browser.test.ts +++ b/utils/clearing.browser.test.ts @@ -9,8 +9,6 @@ import { requestOriginPermission, } from './clearing'; -// fake-browser (wxt/testing) doesn't implement browsingData/cookies/permissions — -// stub them directly on the shared fake `browser` object per test. function stubBrowserApis() { Object.assign(browser, { browsingData: { diff --git a/utils/clearing.firefox.test.ts b/utils/clearing.firefox.test.ts index 3c2cf4d..d046d6c 100644 --- a/utils/clearing.firefox.test.ts +++ b/utils/clearing.firefox.test.ts @@ -139,7 +139,6 @@ describe('clearLinkedOrigin (Firefox path)', () => { it('removes the tabs.onUpdated listener even when the tab never reaches "complete" (no leak on timeout)', async () => { vi.useFakeTimers(); - // Override the default mock: never fire the "complete" event for this tab. (browser.tabs.create as ReturnType).mockImplementation(async (props: { url: string }) => ({ id: nextTabId++, url: props.url, diff --git a/utils/clearing.ts b/utils/clearing.ts index 66a8357..55bbb90 100644 --- a/utils/clearing.ts +++ b/utils/clearing.ts @@ -15,9 +15,6 @@ export async function getSupportedKeys(): Promise> { return new Set(Object.keys(settings.dataToRemove)); } -/** 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); @@ -34,7 +31,6 @@ export function siteScopedIds(ids: DataTypeId[]): DataTypeId[] { return ids.filter((id) => scoped.has(id)); } -/** Chrome/Chromium: browsingData's `origins` param scopes removal to one site. On Firefox it does not work. */ export async function clearSiteViaBrowsingData(origin: string, ids: DataTypeId[]): Promise { const supported = await getSupportedKeys(); const dataToRemove = toRemovalOptions(siteScopedIds(ids), supported); @@ -59,9 +55,6 @@ function withTimeout(promise: Promise, ms: number): Promise { } async function clearInMainWorld(ids: string[]): Promise<{ failed: Record }> { - // Duplicated from the module-level withTimeout: this function is serialized and - // injected into the page by scripting.executeScript, so it can't close over - // anything outside itself. function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms); @@ -78,9 +71,6 @@ async function clearInMainWorld(ids: string[]): Promise<{ failed: Record Promise | void> = { async cacheStorage() { if (!('caches' in self)) return; @@ -105,10 +95,6 @@ async function clearInMainWorld(ids: string[]): Promise<{ failed: Record> = { indexedDB: 'indexedDB', localStorage: 'localStorage', @@ -128,8 +114,6 @@ async function clearHostnameScopedNative(hostname: string, ids: DataTypeId[]): P ); } -/** allFrames: an auth widget commonly lives in an iframe on a different origin than - * the top page — its storage is otherwise unreachable here. */ async function runContentScriptClear(tabId: number, scriptIds: DataTypeId[]): Promise { const injections = await browser.scripting.executeScript({ target: { tabId, allFrames: true }, @@ -171,14 +155,6 @@ export async function requestOriginPermission(): Promise { return browser.permissions.request({ origins: ['*://*/*'] }); } -/** 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, 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 options = { url: origin, storeId, partitionKey: {} } as Parameters[0]; const cookies = await browser.cookies @@ -221,18 +197,12 @@ function extractHostname(input: string): string { const DOMAIN_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i; -/** Loose check for "does this look like a domain" (rejects e.g. "asdasd") — used - * only for inline validation hints, not for matching. */ export function isValidHost(input: string): boolean { const host = extractHostname(input); if (!host) return false; return host === 'localhost' || DOMAIN_RE.test(host); } -/** One mapping per line: `source => target1, target2`. Lines with no `=>` (or an - * empty source/target) are ignored. `source` is kept as typed (matching extracts - * its hostname on demand); targets are normalized to full origins up front since - * they're passed straight to the clearing calls. */ export interface OriginMapping { source: string; targets: string[]; @@ -251,11 +221,6 @@ export function parseOriginMappings(raw: string): OriginMapping[] { .filter((m) => m.source && m.targets.length > 0); } -/** Matches regardless of scheme or path, and treats the mapping's source as covering - * its own subdomains too — e.g. a source of "domain.com" also matches - * "sso.domain.com". A site with no matching mapping gets none. A mapping with an - * invalid-looking source or any invalid-looking target is skipped entirely — the - * editor shows these as errors instead of silently acting on them. */ export function linkedOriginsFor(raw: string, activeOrigin: string): string[] { const activeHost = extractHostname(activeOrigin); return parseOriginMappings(raw) @@ -274,11 +239,6 @@ function parseHostList(raw: string): string[] { .filter(Boolean); } -/** Same subdomain-covering semantics as linkedOriginsFor's source matching — a - * protected entry of "domain.com" also protects "sso.domain.com". Doesn't apply to - * the Global scope: browsingData.remove has no way to exclude specific origins from - * an unscoped clear, so protection only guards the per-site clear surfaces (tabs, - * visited sites, and mapped linked-origin targets). */ export function isProtectedSite(raw: string, hostname: string): boolean { const host = hostname.toLowerCase(); return parseHostList(raw).some((protectedHost) => host === protectedHost || host.endsWith(`.${protectedHost}`)); @@ -353,7 +313,6 @@ export async function clearLinkedOrigin( } } -/** Firefox-only field, absent from the shared cross-browser Tab type. */ export function tabCookieStoreId(tab: object): string | undefined { return (tab as { cookieStoreId?: string }).cookieStoreId; } @@ -387,14 +346,10 @@ export function dedupeSitesByHostname(urls: Array): VisitedS if (!url) continue; try { const parsed = new URL(url); - // Only http(s) sites are real per-site clear targets. chrome://, about:, and - // file:// all parse without throwing, but either have no hostname at all (blank - // row) or a bogus one (e.g. "chrome://extensions/" parses to hostname - // "extensions", which isn't a site anyone meant to clear). if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; if (!seen.has(parsed.hostname)) seen.set(parsed.hostname, parsed.origin); } catch { - // skip + continue; } } return Array.from(seen, ([hostname, origin]) => ({ hostname, origin })); diff --git a/utils/data-types.ts b/utils/data-types.ts index 0715286..d17fd49 100644 --- a/utils/data-types.ts +++ b/utils/data-types.ts @@ -32,9 +32,6 @@ export const DATA_TYPES: DataTypeDef[] = [ { id: 'formData', browsingDataKey: 'formData', quick: false, siteScoped: false }, ]; -/** Types actually usable for a per-site clear on the current browser — excludes - * HTTP Cache on Firefox, which has no per-site clearing API at all (see - * clearInMainWorld in utils/clearing.ts). */ export function siteScopedDataTypes(): DataTypeDef[] { return DATA_TYPES.filter((type) => type.siteScoped && !(type.id === 'cache' && isGeckoBased())); } diff --git a/utils/error-log.ts b/utils/error-log.ts new file mode 100644 index 0000000..34333dc --- /dev/null +++ b/utils/error-log.ts @@ -0,0 +1,31 @@ +import { useErrorLogStore } from '../store/errorLog'; + +let installed = false; + +function messageFrom(value: unknown): string { + if (value instanceof Error) return value.message; + try { + return String(value); + } catch { + return 'Unknown error'; + } +} + +export function installGlobalErrorLogging(): void { + if (installed) return; + installed = true; + + const originalConsoleError = console.error.bind(console); + console.error = (...args: unknown[]) => { + originalConsoleError(...args); + useErrorLogStore.getState().logError(args.map(messageFrom).join(' ')); + }; + + globalThis.addEventListener?.('error', (event) => { + useErrorLogStore.getState().logError(messageFrom(event.error ?? event.message)); + }); + + globalThis.addEventListener?.('unhandledrejection', (event) => { + useErrorLogStore.getState().logError(messageFrom(event.reason)); + }); +} diff --git a/utils/i18n.ts b/utils/i18n.ts index 46c1e4d..58dd38b 100644 --- a/utils/i18n.ts +++ b/utils/i18n.ts @@ -134,6 +134,8 @@ const en = { helpFeedbackTitle: 'Feedback', helpFeedbackText: 'Found a bug or have a feature request?', helpFeedbackLink: 'Open an issue on GitHub', + helpReviewText: 'Like Bleep? A review helps others find it.', + helpReviewLink: 'Leave a review', }; const ru: typeof en = { @@ -258,6 +260,8 @@ const ru: typeof en = { helpFeedbackTitle: 'Обратная связь', helpFeedbackText: 'Нашли баг или есть предложение?', helpFeedbackLink: 'Открыть issue на GitHub', + helpReviewText: 'Нравится Bleep? Отзыв поможет другим найти его.', + helpReviewLink: 'Оставить отзыв', }; const ro: typeof en = { @@ -382,6 +386,8 @@ const ro: typeof en = { helpFeedbackTitle: 'Feedback', helpFeedbackText: 'Ai găsit un bug sau ai o sugestie?', helpFeedbackLink: 'Deschide un issue pe GitHub', + helpReviewText: 'Îți place Bleep? O recenzie ajută alți utilizatori să-l găsească.', + helpReviewLink: 'Lasă o recenzie', }; const uk: typeof en = { @@ -506,6 +512,8 @@ const uk: typeof en = { helpFeedbackTitle: 'Зворотний зв’язок', helpFeedbackText: 'Знайшли помилку чи маєте пропозицію?', helpFeedbackLink: 'Відкрити issue на GitHub', + helpReviewText: 'Подобається Bleep? Відгук допоможе іншим його знайти.', + helpReviewLink: 'Залишити відгук', }; export type TranslationKey = keyof typeof en; diff --git a/utils/settings-defaults.ts b/utils/settings-defaults.ts new file mode 100644 index 0000000..f79c310 --- /dev/null +++ b/utils/settings-defaults.ts @@ -0,0 +1,23 @@ +import type { DataTypeId } from './data-types'; +import type { Language } from './i18n'; + +export type Theme = 'system' | 'light' | 'dark'; + +const DEFAULT_TYPES: DataTypeId[] = [ + 'cacheStorage', + 'cache', + 'indexedDB', + 'localStorage', + 'sessionStorage', +]; + +export const DEFAULTS = { + selectedTypesGlobal: [...DEFAULT_TYPES], + selectedTypesSite: [...DEFAULT_TYPES], + autoReloadAfterClear: true, + linkedOrigins: '', + useOriginMappings: true, + protectedSites: '', + theme: 'system' as Theme, + language: 'auto' as Language, +}; diff --git a/utils/settings-transfer.test.ts b/utils/settings-transfer.test.ts index c5259ca..973dc25 100644 --- a/utils/settings-transfer.test.ts +++ b/utils/settings-transfer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULTS } from '../store/settings'; -import { exportSettingsJson, parseImportedSettings } from './settings-transfer'; +import { DEFAULTS } from './settings-defaults'; +import { exportSettingsJson, parseImportedSettings, sanitizeSettings } from './settings-transfer'; describe('exportSettingsJson', () => { it('serializes only the known settings fields', () => { @@ -51,3 +51,18 @@ describe('parseImportedSettings', () => { expect(() => parseImportedSettings('not json')).toThrow(); }); }); + +describe('sanitizeSettings', () => { + it('passes through a valid object unchanged (field-wise)', () => { + expect(sanitizeSettings({ ...DEFAULTS, theme: 'dark' })).toEqual({ ...DEFAULTS, theme: 'dark' }); + }); + + it('treats null/undefined as an empty object instead of throwing — the shape a persist store sees on first load', () => { + expect(sanitizeSettings(null)).toEqual({}); + expect(sanitizeSettings(undefined)).toEqual({}); + }); + + it('drops a corrupted field instead of propagating it (e.g. a partial/garbled storage write)', () => { + expect(sanitizeSettings({ ...DEFAULTS, theme: 42 })).not.toHaveProperty('theme'); + }); +}); diff --git a/utils/settings-transfer.ts b/utils/settings-transfer.ts index f294321..d7b4db2 100644 --- a/utils/settings-transfer.ts +++ b/utils/settings-transfer.ts @@ -1,4 +1,4 @@ -import { DEFAULTS, type Theme } from '../store/settings'; +import { DEFAULTS, type Theme } from './settings-defaults'; import type { Language } from './i18n'; export type ExportableSettings = typeof DEFAULTS; @@ -24,17 +24,18 @@ function isValidField(key: keyof ExportableSettings, value: unknown): boolean { return typeof value === typeof defaultValue; } -/** Only known keys with the right shape survive — an untrusted/edited-by-hand file - * can't inject unexpected fields or crash the UI with e.g. an unrecognized theme or - * language string (translate() would throw on a language not in its dictionary). */ -export function parseImportedSettings(json: string): Partial { - const parsed = JSON.parse(json) as Record; +export function sanitizeSettings(value: unknown): Partial { + const parsed = (value ?? {}) as Record; const result: Partial = {}; for (const key of Object.keys(DEFAULTS) as (keyof ExportableSettings)[]) { - const value = parsed[key]; - if (value !== undefined && isValidField(key, value)) { - (result as Record)[key] = value; + const v = parsed[key]; + if (v !== undefined && isValidField(key, v)) { + (result as Record)[key] = v; } } return result; } + +export function parseImportedSettings(json: string): Partial { + return sanitizeSettings(JSON.parse(json)); +} diff --git a/utils/storage-adapter.test.ts b/utils/storage-adapter.test.ts index b4b900d..6dc1114 100644 --- a/utils/storage-adapter.test.ts +++ b/utils/storage-adapter.test.ts @@ -2,8 +2,6 @@ import { browser } from 'wxt/browser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { browserSyncStorage } from './storage-adapter'; -// fake-browser doesn't implement storage.sync at all — back it with a plain in-memory -// object so get/set/remove behave like the real StorageArea API. function stubSyncStorage() { const data: Record = {}; Object.assign(browser, { @@ -42,7 +40,6 @@ describe('browserSyncStorage', () => { const big = 'x'.repeat(20000); await browserSyncStorage.setItem('settings', big); expect(await browserSyncStorage.getItem('settings')).toBe(big); - // sanity: confirm it's actually split, not stored as one oversized item expect(browser.storage.sync.set).toHaveBeenCalled(); const lastCallArgs = (browser.storage.sync.set as ReturnType).mock.calls.at(-1)![0]; expect(Object.keys(lastCallArgs).length).toBeGreaterThan(1); @@ -76,7 +73,6 @@ describe('browserSyncStorage', () => { .mockResolvedValue({ settings: '{"legacy":true}' }); expect(await browserSyncStorage.getItem('settings')).toBe('{"legacy":true}'); - // migrated forward — a second read shouldn't need storage.local anymore expect(await browserSyncStorage.getItem('settings')).toBe('{"legacy":true}'); }); }); diff --git a/utils/storage-adapter.ts b/utils/storage-adapter.ts index 47da5e6..3c60476 100644 --- a/utils/storage-adapter.ts +++ b/utils/storage-adapter.ts @@ -1,12 +1,8 @@ import { browser } from 'wxt/browser'; import type { Browser } from 'wxt/browser'; import type { StateStorage } from 'zustand/middleware'; +import { useStorageErrorStore } from '../store/storageError'; -// storage.sync caps each item at ~8KB (Chrome: 8192 bytes/item, 100KB total) — a -// mapping-heavy settings blob stored under one key can exceed that. Split into -// fixed-size string chunks under a `${name}__N` key each, with `${name}__len` -// tracking how many chunks exist, so the actual quota that matters is the ~100KB -// total, not the ~8KB per-item cap. const CHUNK_SIZE = 6000; async function readChunked(area: Browser.storage.StorageArea, name: string): Promise { @@ -32,8 +28,6 @@ async function writeChunked(area: Browser.storage.StorageArea, name: string, val }); await area.set(payload); - // A shrinking value (fewer chunks than before) must drop the now-unused trailing - // keys, or they sit there forever silently eating into the ~100KB total quota. if (oldLen > chunks.length) { const staleKeys = Array.from({ length: oldLen - chunks.length }, (_, i) => `${name}__${chunks.length + i}`); await area.remove(staleKeys); @@ -60,10 +54,6 @@ export const browserLocalStorage: StateStorage = { }, }; -/** Settings synced across the user's browsers via storage.sync. Falls back to - * reading (and migrating forward) a pre-sync plain-JSON value that older versions - * of Bleep stored directly under `name` in storage.local — a one-time, read-triggered - * migration with no separate "have I migrated" flag needed. */ export const browserSyncStorage: StateStorage = { getItem: async (name) => { const fromSync = await readChunked(browser.storage.sync, name); @@ -77,16 +67,21 @@ export const browserSyncStorage: StateStorage = { return legacyValue ?? null; }, setItem: async (name, value) => { - await writeChunked(browser.storage.sync, name, value); + try { + await writeChunked(browser.storage.sync, name, value); + } catch (e) { + console.error('Bleep: failed to save settings', e); + useStorageErrorStore + .getState() + .setError('Could not save — the change may be too large or sync storage is full.'); + throw e; + } }, removeItem: async (name) => { await removeChunked(browser.storage.sync, name); }, }; -/** Reads the persisted settings JSON directly, bypassing zustand — used by the - * background script, which can't rely on the store's React-oriented hydration - * lifecycle inside a short-lived MV3 service worker. */ export async function readPersistedSettingsRaw(name: string): Promise { const raw = await browserSyncStorage.getItem(name); if (!raw) return null;