mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): fence localStorage SecurityError from killing the React tree (#5142)
## Summary WebKit throws `SecurityError` from `localStorage.getItem` (not just `setItem`) when storage access is denied for the origin. With no `ErrorBoundary` in `desktop/src`, any such throw inside a provider render (`ThemeProvider`, `CommunitiesProvider`, `App` boot) propagated to the reconciler, unmounted the root, and left a blank window. Measured repro in #5078: a single throwing `getItem` on `buzz-communities` or `buzz-active-community-id` kills the container. Closes #5078. ## What changed **New helper — `desktop/src/shared/lib/safeStorage.ts`** - `getStorageItem(key, fallback?)` — wraps `window.localStorage.getItem`; on a thrown error (SecurityError under denied-storage origin) it warns once per key and returns the fallback. - `setStorageItem(key, value)` and `removeStorageItem(key)` — same fail-closed contract (return `false` on throw). - Unit tests in `safeStorage.test.mjs` cover the happy path and the `SecurityError` path. **Rewired the init-path readers that ran before any UI existed** - `desktop/src/features/communities/communityStorage.ts` — `migrateLegacyCommunityStorage`, `loadCommunities`, `loadActiveCommunityId`, `loadCommunityDiscoveryAfterLeave`, `initFirstCommunity` - `desktop/src/features/communities/legacyCommunityStorage.ts` — `migrateLegacyCommunityStorageBeforeRender` - `desktop/src/shared/theme/ThemeProvider.tsx` — `readStoredTheme`, `applyCachedVars`, the `useState` initialisers for `accentColor` and `followSystem`, and the accent re-read inside `applyTheme` **Root-level fence — `desktop/src/app/RootErrorBoundary.tsx`** - New top-level `ErrorBoundary` wrapping the whole provider tree in `main.tsx`. Any remaining uncaught render error (a future storage read that bypasses the helper, or any other render-time crash) renders a degraded splash with a Reload button instead of a blank window. ## Test plan - `desktop/src/shared/lib/safeStorage.test.mjs` — node `--test` runner, 11 assertions across healthy, absent, and SecurityError-throwing storage. - Full `just ci` runs on the blocker. - Existing `communityStorage.test.mjs` and `legacyCommunityStorage.test.mjs` continue to pass (they exercise the same functions via in-memory Storage doubles; the new code path in `migrateLegacyCommunityStorage` only adds a `try/catch` around the same body). ## Why not an ErrorBoundary-only fix A boundary alone can't help on a *clean* mount — the first throw already unmounted the whole subtree before any state or fallback data was loaded, so retrying would hit the same throw on the very next render. The storage accessor has to fail closed *and* the boundary has to exist for whatever bypasses it. Both are needed; neither is sufficient alone. --------- Signed-off-by: iroiro147 <sarthak.singh@mastersunion.org> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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("<!doctype html><html><body></body></html>", {
|
||||
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);
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-background px-6 text-foreground">
|
||||
<p className="text-base font-semibold">Buzz failed to start</p>
|
||||
<p className="max-w-md text-center text-sm text-muted-foreground">
|
||||
Reload Buzz to try again. If this keeps happening, check that Buzz
|
||||
can access website data, then contact support.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border bg-secondary px-4 py-2 text-sm hover:bg-secondary/80"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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<string | undefined> {
|
||||
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;
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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 &&
|
||||
|
||||
+24
-17
@@ -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(
|
||||
<React.StrictMode>
|
||||
<CommunitiesProvider>
|
||||
<CommunityOnboardingProvider enabled={huddleWindowChannelId() === null}>
|
||||
<ThemeProvider defaultTheme="buzz">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<EmojiBurstProvider>
|
||||
<PoofBurstProvider>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
<NostrBindConsentDialog />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
</PoofBurstProvider>
|
||||
</EmojiBurstProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</CommunityOnboardingProvider>
|
||||
</CommunitiesProvider>
|
||||
{/* block/buzz#5078 — catch any uncaught render error so a WebKit
|
||||
SecurityError from localStorage can't blank the whole window. */}
|
||||
<RootErrorBoundary>
|
||||
<CommunitiesProvider>
|
||||
<CommunityOnboardingProvider
|
||||
enabled={huddleWindowChannelId() === null}
|
||||
>
|
||||
<ThemeProvider defaultTheme="buzz">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<EmojiBurstProvider>
|
||||
<PoofBurstProvider>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
<NostrBindConsentDialog />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
</PoofBurstProvider>
|
||||
</EmojiBurstProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</CommunityOnboardingProvider>
|
||||
</CommunitiesProvider>
|
||||
</RootErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => {
|
||||
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<boolean>(() => {
|
||||
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<boolean>(() => {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
Reference in New Issue
Block a user