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
84 changes: 84 additions & 0 deletions desktop/src/app/RootErrorBoundary.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
58 changes: 58 additions & 0 deletions desktop/src/app/RootErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
}
57 changes: 43 additions & 14 deletions desktop/src/features/communities/communityStorage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 [];
}
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions desktop/src/features/communities/legacyCommunityStorage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 &&
Expand Down
41 changes: 24 additions & 17 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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>,
);
}
Expand Down
Loading
Loading