diff --git a/desktop/src/app/RootErrorBoundary.test.mjs b/desktop/src/app/RootErrorBoundary.test.mjs
new file mode 100644
index 0000000000..09cfed4746
--- /dev/null
+++ b/desktop/src/app/RootErrorBoundary.test.mjs
@@ -0,0 +1,84 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("
", {
+ url: "http://localhost",
+});
+const originalConsoleError = console.error;
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+ dom.window.matchMedia = () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+ console.error = originalConsoleError;
+});
+
+after(() => dom.window.close());
+
+test("ThemeProvider renders defaults when localStorage reads are denied", async () => {
+ const deniedStorage = {
+ getItem() {
+ throw new dom.window.DOMException("private diagnostic", "SecurityError");
+ },
+ setItem() {
+ throw new dom.window.DOMException("private diagnostic", "SecurityError");
+ },
+ removeItem() {
+ throw new dom.window.DOMException("private diagnostic", "SecurityError");
+ },
+ };
+ Object.defineProperty(dom.window, "localStorage", {
+ configurable: true,
+ value: deniedStorage,
+ });
+
+ const { createElement } = await import("react");
+ const { render, screen } = await import("@testing-library/react");
+ const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
+
+ render(
+ createElement(
+ ThemeProvider,
+ { defaultTheme: "buzz" },
+ createElement("p", null, "Buzz is visible"),
+ ),
+ );
+
+ assert.ok(screen.getByText("Buzz is visible"));
+});
+
+test("root boundary shows recovery UI without exposing error details", async () => {
+ const diagnostic = "/Users/alice/private/session-token";
+ console.error = () => {};
+
+ const { createElement } = await import("react");
+ const { render, screen } = await import("@testing-library/react");
+ const { RootErrorBoundary } = await import("./RootErrorBoundary.tsx");
+ function ThrowingProvider() {
+ throw new Error(diagnostic);
+ }
+
+ render(
+ createElement(RootErrorBoundary, null, createElement(ThrowingProvider)),
+ );
+
+ assert.ok(screen.getByText("Buzz failed to start"));
+ assert.ok(screen.getByRole("button", { name: "Reload" }));
+ assert.equal(document.body.textContent.includes(diagnostic), false);
+ assert.match(document.body.textContent, /contact support/i);
+});
diff --git a/desktop/src/app/RootErrorBoundary.tsx b/desktop/src/app/RootErrorBoundary.tsx
new file mode 100644
index 0000000000..0c4bbe80b0
--- /dev/null
+++ b/desktop/src/app/RootErrorBoundary.tsx
@@ -0,0 +1,58 @@
+import { Component, type ReactNode } from "react";
+
+type RootErrorBoundaryProps = {
+ children: ReactNode;
+};
+
+type RootErrorBoundaryState = {
+ error: Error | null;
+};
+
+/**
+ * Root-level render fence for the desktop app (block/buzz#5078).
+ *
+ * Any uncaught throw inside the React tree — in particular a WebKit
+ * `SecurityError` from `localStorage.getItem` under a denied-storage origin,
+ * before the `safeStorage` accessors have a chance to fence it — previously
+ * propagated to the reconciler's error boundary (there isn't one) and left
+ * the window blank. This boundary renders a degraded splash instead so the
+ * user always sees something actionable, and the throw is logged at least
+ * once for diagnosis.
+ */
+export class RootErrorBoundary extends Component<
+ RootErrorBoundaryProps,
+ RootErrorBoundaryState
+> {
+ override state: RootErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: unknown): RootErrorBoundaryState {
+ return { error: error instanceof Error ? error : new Error(String(error)) };
+ }
+
+ override componentDidCatch(error: unknown, info: React.ErrorInfo): void {
+ console.error("[RootErrorBoundary] uncaught render error:", error, info);
+ }
+
+ override render(): ReactNode {
+ const { error } = this.state;
+ if (error) {
+ return (
+
+
Buzz failed to start
+
+ Reload Buzz to try again. If this keeps happening, check that Buzz
+ can access website data, then contact support.
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts
index 7c8778712e..6f99ce8d33 100644
--- a/desktop/src/features/communities/communityStorage.ts
+++ b/desktop/src/features/communities/communityStorage.ts
@@ -1,6 +1,7 @@
import type { Community } from "./types";
import { homeDir } from "@tauri-apps/api/path";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
+import { getStorageItem, removeStorageItem } from "@/shared/lib/safeStorage";
const COMMUNITIES_KEY = "buzz-communities";
const ACTIVE_COMMUNITY_KEY = "buzz-active-community-id";
@@ -34,24 +35,36 @@ export async function expandTilde(input: string): Promise {
export function migrateLegacyCommunityStorage(
storage: Storage = localStorage,
): void {
- if (storage.getItem(COMMUNITIES_KEY) === null) {
- const legacyCommunities = storage.getItem(LEGACY_WORKSPACES_KEY);
- if (legacyCommunities !== null) {
- storage.setItem(COMMUNITIES_KEY, legacyCommunities);
+ try {
+ if (storage.getItem(COMMUNITIES_KEY) === null) {
+ const legacyCommunities = storage.getItem(LEGACY_WORKSPACES_KEY);
+ if (legacyCommunities !== null) {
+ storage.setItem(COMMUNITIES_KEY, legacyCommunities);
+ }
}
- }
- if (storage.getItem(ACTIVE_COMMUNITY_KEY) === null) {
- const legacyActiveCommunity = storage.getItem(LEGACY_ACTIVE_WORKSPACE_KEY);
- if (legacyActiveCommunity !== null) {
- storage.setItem(ACTIVE_COMMUNITY_KEY, legacyActiveCommunity);
+ if (storage.getItem(ACTIVE_COMMUNITY_KEY) === null) {
+ const legacyActiveCommunity = storage.getItem(
+ LEGACY_ACTIVE_WORKSPACE_KEY,
+ );
+ if (legacyActiveCommunity !== null) {
+ storage.setItem(ACTIVE_COMMUNITY_KEY, legacyActiveCommunity);
+ }
}
+ } catch (error) {
+ // WebKit throws SecurityError from getItem when storage access is denied
+ // for the origin (block/buzz#5078). Fencing here so the app can still
+ // boot with an empty/default community list instead of a blank window.
+ console.warn(
+ "[communityStorage] migrateLegacyCommunityStorage failed (storage denied?):",
+ error,
+ );
}
}
export function loadCommunities(): Community[] {
try {
migrateLegacyCommunityStorage();
- const raw = localStorage.getItem(COMMUNITIES_KEY);
+ const raw = getStorageItem(COMMUNITIES_KEY);
if (!raw) {
return [];
}
@@ -60,7 +73,7 @@ export function loadCommunities(): Community[] {
return [];
}
if (parsed.length > 0) {
- localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY);
+ removeStorageItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY);
}
// Migration: older builds stored the user's `nsec` in localStorage and
// re-applied it to the backend on every reload, which silently overwrote
@@ -100,7 +113,17 @@ export function saveCommunities(communities: Community[]): boolean {
export function loadCommunityDiscoveryAfterLeave(
storage: Storage = localStorage,
): boolean {
- return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1";
+ try {
+ return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1";
+ } catch (error) {
+ // block/buzz#5078 — storage access can be denied for the origin; degrade
+ // to the default ("didn't just leave") instead of crashing the boot path.
+ console.warn(
+ "[communityStorage] loadCommunityDiscoveryAfterLeave failed:",
+ error,
+ );
+ return false;
+ }
}
export function markCommunityDiscoveryAfterLeave(
@@ -129,7 +152,10 @@ export function clearCommunityStorage(storage: Storage = localStorage): void {
export function loadActiveCommunityId(): string | null {
migrateLegacyCommunityStorage();
- return localStorage.getItem(ACTIVE_COMMUNITY_KEY);
+ // block/buzz#5078 — WebKit can throw SecurityError from a denied-storage
+ // getItem. Fail closed so the boot path renders the default community UI
+ // instead of unmounting the root.
+ return getStorageItem(ACTIVE_COMMUNITY_KEY);
}
export function saveActiveCommunityId(id: string): boolean {
@@ -199,7 +225,10 @@ export function initFirstCommunity(
pubkey,
addedAt: new Date().toISOString(),
};
- const previousActiveCommunityId = localStorage.getItem(ACTIVE_COMMUNITY_KEY);
+ // block/buzz#5078 — read the prior active id through the throw-safe helper;
+ // a denied-storage origin would otherwise kill onboarding before a single
+ // write is attempted.
+ const previousActiveCommunityId = getStorageItem(ACTIVE_COMMUNITY_KEY);
const didSaveActiveCommunity = saveActiveCommunityId(community.id);
if (!didSaveActiveCommunity) {
return null;
diff --git a/desktop/src/features/communities/legacyCommunityStorage.ts b/desktop/src/features/communities/legacyCommunityStorage.ts
index d140fa4c62..b871ec0bbe 100644
--- a/desktop/src/features/communities/legacyCommunityStorage.ts
+++ b/desktop/src/features/communities/legacyCommunityStorage.ts
@@ -1,4 +1,5 @@
import { invokeTauri } from "@/shared/api/tauri";
+import { getStorageItem } from "@/shared/lib/safeStorage";
import { migrateLegacyCommunityStorage } from "./communityStorage";
const BUZZ_COMMUNITIES_KEY = "buzz-communities";
@@ -116,11 +117,10 @@ export async function migrateLegacyCommunityStorageBeforeRender(): Promise
}
migrateLegacyCommunityStorage(window.localStorage);
- const currentCommunitiesRaw =
- window.localStorage.getItem(BUZZ_COMMUNITIES_KEY);
- const hasCurrentActiveCommunity = window.localStorage.getItem(
- BUZZ_ACTIVE_COMMUNITY_KEY,
- );
+ // block/buzz#5078 — read through the throw-safe accessor so a denied-storage
+ // origin degrades to "no community state" instead of crashing pre-render.
+ const currentCommunitiesRaw = getStorageItem(BUZZ_COMMUNITIES_KEY);
+ const hasCurrentActiveCommunity = getStorageItem(BUZZ_ACTIVE_COMMUNITY_KEY);
if (
currentCommunitiesRaw &&
hasCurrentActiveCommunity &&
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index d2751dfe16..0092c086e0 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
+import { RootErrorBoundary } from "@/app/RootErrorBoundary";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
import "@fontsource-variable/inter/wght.css";
import "@fontsource/jetbrains-mono/400.css";
@@ -76,23 +77,29 @@ function configureDevE2eBridgeFromUrl() {
function renderApp() {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* block/buzz#5078 — catch any uncaught render error so a WebKit
+ SecurityError from localStorage can't blank the whole window. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
);
}
diff --git a/desktop/src/shared/lib/safeStorage.test.mjs b/desktop/src/shared/lib/safeStorage.test.mjs
new file mode 100644
index 0000000000..9d0c10c8a8
--- /dev/null
+++ b/desktop/src/shared/lib/safeStorage.test.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ __resetSafeStorageWarningsForTests,
+ getStorageItem,
+ removeStorageItem,
+ setStorageItem,
+} from "./safeStorage.ts";
+
+function createThrowingStorage(initial = {}) {
+ const values = new Map(Object.entries(initial));
+ return {
+ values,
+ getItem: (key) => {
+ if (key === "__THROW__") throw new Error("SecurityError");
+ return values.get(key) ?? null;
+ },
+ setItem: (key, value) => {
+ if (key === "__THROW__") throw new Error("SecurityError");
+ values.set(key, String(value));
+ },
+ removeItem: (key) => {
+ if (key === "__THROW__") throw new Error("SecurityError");
+ values.delete(key);
+ },
+ };
+}
+
+function patchLocalStorage(storage) {
+ globalThis.window ??= {};
+ const original = globalThis.window.localStorage;
+ Object.defineProperty(globalThis.window, "localStorage", {
+ configurable: true,
+ writable: true,
+ value: storage,
+ });
+ return () => {
+ if (original === undefined) {
+ delete globalThis.window.localStorage;
+ return;
+ }
+ Object.defineProperty(globalThis.window, "localStorage", {
+ configurable: true,
+ writable: true,
+ value: original,
+ });
+ };
+}
+
+test.beforeEach(() => {
+ __resetSafeStorageWarningsForTests();
+});
+
+test("getStorageItem returns the stored value when storage is healthy", () => {
+ const restore = patchLocalStorage(
+ createThrowingStorage({ "buzz-theme": "buzz" }),
+ );
+ try {
+ assert.equal(getStorageItem("buzz-theme"), "buzz");
+ assert.equal(getStorageItem("missing"), null);
+ } finally {
+ restore();
+ }
+});
+
+test("getStorageItem returns null when the key is absent", () => {
+ const restore = patchLocalStorage(createThrowingStorage());
+ try {
+ assert.equal(getStorageItem("buzz-theme"), null);
+ } finally {
+ restore();
+ }
+});
+
+test("getStorageItem swallows SecurityError and returns the fallback", () => {
+ const restore = patchLocalStorage(createThrowingStorage());
+ try {
+ assert.equal(getStorageItem("__THROW__"), null);
+ assert.equal(getStorageItem("__THROW__", "fallback"), "fallback");
+ } finally {
+ restore();
+ }
+});
+
+test("setStorageItem returns true on a healthy write", () => {
+ const storage = createThrowingStorage();
+ const restore = patchLocalStorage(storage);
+ try {
+ assert.equal(setStorageItem("buzz-theme", "buzz-dark"), true);
+ assert.equal(storage.values.get("buzz-theme"), "buzz-dark");
+ } finally {
+ restore();
+ }
+});
+
+test("setStorageItem returns false instead of throwing on denied storage", () => {
+ const restore = patchLocalStorage(createThrowingStorage());
+ try {
+ assert.equal(setStorageItem("__THROW__", "x"), false);
+ } finally {
+ restore();
+ }
+});
+
+test("removeStorageItem returns false instead of throwing on denied storage", () => {
+ const restore = patchLocalStorage(createThrowingStorage());
+ try {
+ assert.equal(removeStorageItem("__THROW__"), false);
+ } finally {
+ restore();
+ }
+});
diff --git a/desktop/src/shared/lib/safeStorage.ts b/desktop/src/shared/lib/safeStorage.ts
new file mode 100644
index 0000000000..3dc4784bdb
--- /dev/null
+++ b/desktop/src/shared/lib/safeStorage.ts
@@ -0,0 +1,86 @@
+/**
+ * Throw-safe localStorage accessors.
+ *
+ * WKWebView throws `SecurityError` from `localStorage.getItem` (not just
+ * `setItem`) when storage access is denied for the origin — e.g. the user
+ * disabled website data, or the Tauri webview runs under a restricted
+ * storage policy. A raw `window.localStorage.getItem(...)` executed inside
+ * a React `useState`/`useMemo` initializer or provider render propagates
+ * that throw up the reconcile path and unmounts the whole tree (there is
+ * no `ErrorBoundary` in `desktop/src`), leaving a dead window.
+ *
+ * These helpers make reads fail-closed (return the fallback / `null`) and
+ * writes fail-silently-with-a-warning, preserving the app's ability to start
+ * and degrade to in-memory state instead of crashing to a blank screen.
+ *
+ * Issue contexts: block/buzz#5078.
+ */
+
+// Keep the console noise down on repeated reads — one warn per (action, key)
+// pair per process is enough. This also keeps unit tests deterministic.
+const WARNED_KEYS = new Set();
+
+function warnOnce(action: "read" | "write", key: string, error: unknown): void {
+ if (WARNED_KEYS.has(`${action}:${key}`)) return;
+ WARNED_KEYS.add(`${action}:${key}`);
+ console.warn(
+ `[safeStorage] localStorage.${action === "read" ? "getItem" : "setItem"} threw for key "${key}" (storage access denied?):`,
+ error,
+ );
+}
+
+/** Reset the warn-once dedup — test-only helper. */
+export function __resetSafeStorageWarningsForTests(): void {
+ WARNED_KEYS.clear();
+}
+
+/**
+ * Read a localStorage entry, resolving `fallback` when the underlying call
+ * throws (e.g. WebKit `SecurityError` under a restricted storage policy) or
+ * storage is unavailable. Never throws.
+ */
+export function getStorageItem(
+ key: string,
+ fallback: string | null = null,
+): string | null {
+ try {
+ return window.localStorage.getItem(key) ?? fallback;
+ } catch (error) {
+ warnOnce("read", key, error);
+ return fallback;
+ }
+}
+
+/**
+ * Write a localStorage entry; returns `false` when the underlying call throws
+ * (quota exceeded or storage denied) instead of propagating. Prefer
+ * `setLocalStorageItemWithRecovery` from `./localStorageQuota` for writes that
+ * need cache-eviction recovery — this wrapper exists for call sites that only
+ * need the throw converted to a boolean result.
+ */
+export function setStorageItem(key: string, value: string): boolean {
+ try {
+ window.localStorage.setItem(key, value);
+ return true;
+ } catch (error) {
+ warnOnce("write", key, error);
+ return false;
+ }
+}
+
+/**
+ * Remove a localStorage entry; never throws. Returns `false` when removal
+ * threw (treated as best-effort, matching `localStorage.removeItem` semantics
+ * callers assume).
+ */
+export function removeStorageItem(key: string): boolean {
+ try {
+ window.localStorage.removeItem(key);
+ return true;
+ } catch (error) {
+ // Reads/writes share the SecurityError class; log under the read bucket
+ // because a denied-storage origin will fail all three the same way.
+ warnOnce("read", key, error);
+ return false;
+ }
+}
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 4596694345..1150f3baca 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -11,6 +11,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invokeTauri } from "@/shared/api/tauri";
import { isMacPlatform } from "@/shared/lib/platform";
+import { getStorageItem } from "@/shared/lib/safeStorage";
import { createThemeVars, hexToHsl } from "./adaptive-theme";
import {
SYNTAX_THEMES,
@@ -80,7 +81,10 @@ function isValidThemeName(name: string): name is SyntaxThemeName {
/** Read stored theme, migrating legacy "light"/"dark"/"system" values. */
function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName {
- const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
+ // block/buzz#5078 — WebKit throws SecurityError from getItem under a
+ // denied-storage origin; the throw-safe helper lets the provider degrade to
+ // the fallback instead of unmounting the root during first render.
+ const stored = getStorageItem(THEME_STORAGE_KEY);
if (!stored) return fallback;
// Migrate legacy values
@@ -416,8 +420,7 @@ function applyCachedVars(): string | null {
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(themeName);
- const accent =
- window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
+ const accent = getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
// Pin Buzz themes to the neutral accent here too, matching applyTheme.
// Otherwise a cached Buzz theme + non-neutral stored accent flashes the
// old accent on reload until the async applyTheme effect runs.
@@ -471,7 +474,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{
applyAccentColor(
resolveEffectiveAccent(
name,
- window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
+ getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
),
);
@@ -506,15 +509,17 @@ export function ThemeProvider({
>(null);
const loadingRef = useRef(null);
const [accentColor, setAccentColorState] = useState(() => {
- return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
+ // block/buzz#5078 — use the throw-safe accessor for init-time reads; a
+ // denied-storage origin would otherwise kill the root on first mount.
+ return getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
});
const [followSystem, setFollowSystemState] = useState(() => {
- const stored = window.localStorage.getItem(FOLLOW_SYSTEM_KEY);
+ const stored = getStorageItem(FOLLOW_SYSTEM_KEY);
if (stored !== null) return stored === "true";
// Fresh profiles (no saved theme) default to System mode so the Buzz
// default tracks the OS light/dark scheme. Profiles that picked a theme
// before this toggle existed keep their fixed theme until they opt in.
- return window.localStorage.getItem(THEME_STORAGE_KEY) === null;
+ return getStorageItem(THEME_STORAGE_KEY) === null;
});
const [systemIsDark, setSystemIsDark] = useState(() => {
return window.matchMedia("(prefers-color-scheme: dark)").matches;