@@ -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;