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
42 changes: 42 additions & 0 deletions components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<div className="flex flex-col gap-3 p-4 text-sm">
<p className="font-medium text-red-600 dark:text-red-400">Something went wrong.</p>
<p className="text-gray-500 dark:text-gray-400">{this.state.error.message}</p>
<button
type="button"
className="rounded-md bg-blue-600 px-3 py-1.5 text-white hover:bg-blue-700"
onClick={() => {
useSettingsStore.getState().resetSettings();
window.location.reload();
}}
>
Reset settings and reload
</button>
</div>
);
}
}
14 changes: 14 additions & 0 deletions components/HelpPanel.tsx
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -37,6 +40,17 @@ export function HelpPanel() {
{t('helpFeedbackLink')}
</a>
</p>
<p className="text-sm text-stone-500 mt-1">
{t('helpReviewText')}{' '}
<a
href={isGeckoBased() ? FIREFOX_REVIEW_URL : CHROME_REVIEW_URL}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline"
>
{t('helpReviewLink')}
</a>
</p>
</li>
</ul>
</div>
Expand Down
2 changes: 2 additions & 0 deletions components/OriginMappingsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) && (
Expand All @@ -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"
/>
Expand Down
38 changes: 38 additions & 0 deletions components/options/ErrorLogSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm uppercase tracking-wide text-stone-500 dark:text-stone-400">Error log</h3>
{entries.length > 0 && (
<button
onClick={clearErrors}
className="text-xs text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 cursor-pointer underline"
>
Clear
</button>
)}
</div>
{entries.length === 0 ? (
<p className="text-xs text-stone-500">Nothing logged.</p>
) : (
<ul className="divide-y divide-stone-200 rounded-md border border-stone-200 dark:divide-stone-700 dark:border-stone-700 max-h-60 overflow-y-auto">
{entries.map((entry) => (
<li key={entry.id} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-red-600 dark:text-red-400 truncate">{entry.message}</span>
<span className="text-xs text-stone-500 shrink-0">
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
</li>
))}
</ul>
)}
</div>
);
}
1 change: 1 addition & 0 deletions components/options/ProtectedSitesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
16 changes: 11 additions & 5 deletions entrypoints/background/index.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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(() => {});
});
});
19 changes: 15 additions & 4 deletions entrypoints/options/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -244,8 +244,6 @@ export default function App() {

async function clearVisitedSite(site: VisitedSite): Promise<string[]> {
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;

Expand Down Expand Up @@ -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 (
<div className="min-h-screen w-full bg-stone-50 dark:bg-stone-900">
<div className="text-stone-900 dark:text-stone-100 p-8 max-w-4xl mx-auto">
Expand All @@ -363,6 +364,15 @@ export default function App() {
<h1 className="text-2xl font-semibold whitespace-nowrap">{t('settingsTitle')}</h1>
</div>

{storageError && (
<div className="flex items-center justify-between gap-3 mb-4 rounded-md border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300">
<span>{storageError}</span>
<button type="button" onClick={clearStorageError} className="shrink-0 cursor-pointer underline">
{t('remove')}
</button>
</div>
)}

<SettingsHeader activeTab={activeTab} onTabChange={setActiveTab} onReset={resetSettings} />

{activeTab === 'general' && (
Expand All @@ -373,6 +383,7 @@ export default function App() {
<BackupSection />
<hr className="border-stone-200 dark:border-stone-700 mb-3" />
<ClearLogSection />
<ErrorLogSection />
</>
)}

Expand Down
8 changes: 7 additions & 1 deletion entrypoints/options/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
8 changes: 7 additions & 1 deletion entrypoints/popup/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
3 changes: 0 additions & 3 deletions hooks/useBrowserName.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file modified public/icon/128-linked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/16-linked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/32-linked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/48-linked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon/96-linked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 8 additions & 3 deletions store/clearLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClearLogState>()(
persist(
(set, get) => ({
Expand All @@ -35,6 +32,14 @@ export const useClearLogStore = create<ClearLogState>()(
{
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<ClearLogState> | null)?.entries)
? (persisted as ClearLogState).entries
: current.entries,
}),
},
),
);
Expand Down
42 changes: 42 additions & 0 deletions store/errorLog.ts
Original file line number Diff line number Diff line change
@@ -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<ErrorLogState>()(
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<ErrorLogState> | null)?.entries)
? (persisted as ErrorLogState).entries
: current.entries,
}),
},
),
);
31 changes: 30 additions & 1 deletion store/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading