feat(electron): error handling foundation
Adds the infrastructure for a coherent client-side error story: - `lib/errors.ts`: canonical ApiError + QuotaExceededError, `toUserMessage` (friendly strings for ApiError/ZodError/network errors, strips Electron IPC message prefixes), `logError` (expected), `reportError` (unexpected). - `lib/query-client.ts`: QueryClient factory with sane retry defaults (no retry on 4xx except 408/429, 2 retries otherwise; 0 mutation retries), `QueryCache` onError logs + opts in via `meta.toastOnError`, and `MutationCache` onError toasts `toUserMessage(err)` by default with `meta.suppressToast` as the opt-out. - `components/app-error-boundary.tsx` + `error-fallback.tsx`: two boundaries (top-level outside the router, route-level inside) with a Card-based fallback offering 'Go home' + 'Try again'. Route boundary resets on pathname change and clears React Query error cache on retry. - `main/ipc-utils.ts` + main.ts migration: `safeHandle` wraps ipcMain.handle so main-process failures log with full stack and surface a sanitized message to the renderer. `link:fetch-metadata` keeps its null contract but now logs. - `useCreateParticle` opts out of the global toast (compose-overlay renders its own quota UX) so nothing double-toasts. Render crashes now have a recovery UI, every mutation gets a free error toast, and silent-catch cleanup + Sentry land in follow-up PRs.
This commit is contained in:
@@ -65,6 +65,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-error-boundary": "^6.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"react-use": "^17.6.0",
|
"react-use": "^17.6.0",
|
||||||
|
|||||||
+28
-22
@@ -3,10 +3,7 @@ import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
import {
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
import SettingsPage from "@/features/settings-page";
|
import SettingsPage from "@/features/settings-page";
|
||||||
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
|
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
|
||||||
import NetworkSelector from "@/features/network-selector";
|
import NetworkSelector from "@/features/network-selector";
|
||||||
@@ -16,8 +13,13 @@ import Layout from "@/features/layout";
|
|||||||
import NetworkSettingsPage from "@/features/network-settings";
|
import NetworkSettingsPage from "@/features/network-settings";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { PusherProvider } from "@/lib/pusher-provider";
|
import { PusherProvider } from "@/lib/pusher-provider";
|
||||||
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
|
import {
|
||||||
|
RouteErrorBoundary,
|
||||||
|
TopLevelErrorBoundary,
|
||||||
|
} from "@/components/app-error-boundary";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
@@ -62,30 +64,34 @@ function AuthenticatedApp() {
|
|||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<AutoplayNavigationListener />
|
<AutoplayNavigationListener />
|
||||||
<Routes>
|
<RouteErrorBoundary>
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Routes>
|
||||||
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
|
||||||
|
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<Route index element={<Layout><NetworkSelector /></Layout>} />
|
<Route index element={<Layout><NetworkSelector /></Layout>} />
|
||||||
<Route path=":networkId">
|
<Route path=":networkId">
|
||||||
<Route index element={<Layout><NetworkRoot /></Layout>} />
|
<Route index element={<Layout><NetworkRoot /></Layout>} />
|
||||||
<Route path="settings" element={<NetworkSettingsPage />} />
|
<Route path="settings" element={<NetworkSettingsPage />} />
|
||||||
<Route path="*" element={<ParticleViewResolver />} />
|
<Route path="*" element={<ParticleViewResolver />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Routes>
|
||||||
</Routes>
|
</RouteErrorBoundary>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppWithProviders = () => (
|
const AppWithProviders = () => (
|
||||||
<TooltipProvider>
|
<TopLevelErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<TooltipProvider>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster />
|
<App />
|
||||||
</QueryClientProvider>
|
<Toaster />
|
||||||
</TooltipProvider>
|
</QueryClientProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TopLevelErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default AppWithProviders;
|
export default AppWithProviders;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from "@/config/env";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { ApiError } from "@/lib/errors";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
BillingStatusSchema,
|
BillingStatusSchema,
|
||||||
@@ -27,15 +28,8 @@ import type {
|
|||||||
SignInRequest,
|
SignInRequest,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
// Re-export for back-compat with existing `import { ApiError } from "@/api/client"`.
|
||||||
constructor(
|
export { ApiError };
|
||||||
public status: number,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiClientConfig {
|
interface ApiClientConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
|
||||||
|
import { reportError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
RouteErrorFallback,
|
||||||
|
TopLevelErrorFallback,
|
||||||
|
} from "@/components/error-fallback";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches render crashes OUTSIDE react-router. Used once at the root so a
|
||||||
|
* broken bootstrap still shows a recovery UI instead of a white screen.
|
||||||
|
*/
|
||||||
|
export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={TopLevelErrorFallback}
|
||||||
|
onError={(error, info) =>
|
||||||
|
reportError(error, {
|
||||||
|
boundary: "top",
|
||||||
|
componentStack: info.componentStack,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches render crashes INSIDE the router. Auto-resets when the route
|
||||||
|
* changes (so clicking "Go home" clears the error) and resets the React
|
||||||
|
* Query error cache on retry so stale failures don't stick.
|
||||||
|
*/
|
||||||
|
export function RouteErrorBoundary({ children }: PropsWithChildren) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { reset: resetQueries } = useQueryErrorResetBoundary();
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={RouteErrorFallback}
|
||||||
|
onError={(error, info) =>
|
||||||
|
reportError(error, {
|
||||||
|
boundary: "route",
|
||||||
|
pathname: location.pathname,
|
||||||
|
componentStack: info.componentStack,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onReset={() => resetQueries()}
|
||||||
|
resetKeys={[location.pathname]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
|
import { appEnv } from "@/config/env";
|
||||||
|
|
||||||
|
function ErrorCard({
|
||||||
|
error,
|
||||||
|
onGoHome,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
onGoHome: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="text-destructive size-4" />
|
||||||
|
<CardTitle>Something went wrong</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{toUserMessage(error)}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{appEnv === "dev" && error instanceof Error ? (
|
||||||
|
<CardContent>
|
||||||
|
<details className="text-muted-foreground text-xs">
|
||||||
|
<summary className="cursor-pointer select-none">
|
||||||
|
Technical details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all">
|
||||||
|
{error.stack ?? error.message}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
|
<CardFooter className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={onGoHome}>
|
||||||
|
Go home
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for the boundary mounted OUTSIDE the router. `useNavigate` isn't
|
||||||
|
* available here, so "Go home" drops the hash fragment directly.
|
||||||
|
*/
|
||||||
|
export function TopLevelErrorFallback({
|
||||||
|
error,
|
||||||
|
resetErrorBoundary,
|
||||||
|
}: FallbackProps) {
|
||||||
|
const goHome = () => {
|
||||||
|
window.location.hash = "#/";
|
||||||
|
resetErrorBoundary();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
error={error}
|
||||||
|
onGoHome={goHome}
|
||||||
|
onRetry={resetErrorBoundary}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for boundaries mounted INSIDE the router. Uses react-router's
|
||||||
|
* navigate so the browser history stays consistent.
|
||||||
|
*/
|
||||||
|
export function RouteErrorFallback({
|
||||||
|
error,
|
||||||
|
resetErrorBoundary,
|
||||||
|
}: FallbackProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goHome = () => {
|
||||||
|
navigate("/");
|
||||||
|
resetErrorBoundary();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
error={error}
|
||||||
|
onGoHome={goHome}
|
||||||
|
onRetry={resetErrorBoundary}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import type { Particle } from "@/api/types";
|
|||||||
import { PropsWithChildren, useCallback } from "react";
|
import { PropsWithChildren, useCallback } from "react";
|
||||||
import { useDockBadge } from "@/hooks/use-dock-badge";
|
import { useDockBadge } from "@/hooks/use-dock-badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { RouteErrorBoundary } from "@/components/app-error-boundary";
|
||||||
|
|
||||||
function getParticleDisplayName(particle: Particle): string {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -177,7 +178,7 @@ export default function Layout({ children }: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
{children}
|
<RouteErrorBoundary>{children}</RouteErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
||||||
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
||||||
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
||||||
|
import { QuotaExceededError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
isUsageExhausted,
|
isUsageExhausted,
|
||||||
networkUsageQueryKey,
|
networkUsageQueryKey,
|
||||||
@@ -9,18 +10,8 @@ import {
|
|||||||
useInvalidateNetworkUsage,
|
useInvalidateNetworkUsage,
|
||||||
} from "./use-network-usage";
|
} from "./use-network-usage";
|
||||||
|
|
||||||
/**
|
// Re-export for back-compat with existing `import { QuotaExceededError } from "@/hooks/use-create-particle"`.
|
||||||
* Thrown when a free-plan network attempts to create a non-container particle
|
export { QuotaExceededError };
|
||||||
* after hitting its daily message limit. Callers should surface an upgrade
|
|
||||||
* prompt; compose UI should also disable triggers proactively via
|
|
||||||
* `useNetworkUsage` rather than relying on this throw.
|
|
||||||
*/
|
|
||||||
export class QuotaExceededError extends Error {
|
|
||||||
constructor(public readonly networkId: string) {
|
|
||||||
super("Daily message limit reached");
|
|
||||||
this.name = "QuotaExceededError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||||
// Path to which the new particle will be added as a child
|
// Path to which the new particle will be added as a child
|
||||||
@@ -36,6 +27,9 @@ export function useCreateParticle() {
|
|||||||
const invalidateUsage = useInvalidateNetworkUsage();
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
// Compose UI renders a custom quota-exceeded toast + cancels the overlay.
|
||||||
|
// Opt out of the global mutation error toast to avoid a double-toast.
|
||||||
|
meta: { suppressToast: true },
|
||||||
mutationFn: async (params: CreateParticleParams) => {
|
mutationFn: async (params: CreateParticleParams) => {
|
||||||
const { networkId } = parseParticlePath(params.path);
|
const { networkId } = parseParticlePath(params.path);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { appEnv } from "@/config/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the API client for any non-2xx response.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a free-plan network attempts to create a non-container particle
|
||||||
|
* after hitting its daily message limit. Callers should surface an upgrade
|
||||||
|
* prompt; compose UI should also disable triggers proactively via
|
||||||
|
* `useNetworkUsage` rather than relying on this throw.
|
||||||
|
*/
|
||||||
|
export class QuotaExceededError extends Error {
|
||||||
|
constructor(public readonly networkId: string) {
|
||||||
|
super("Daily message limit reached");
|
||||||
|
this.name = "QuotaExceededError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const IPC_PREFIX = /^Error invoking remote method '[^']+':\s*/;
|
||||||
|
|
||||||
|
function normalizeMessage(message: string): string {
|
||||||
|
return message.replace(IPC_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps any thrown value to a short, user-facing string. Used by the global
|
||||||
|
* MutationCache toast handler, the error-boundary fallback, and any call site
|
||||||
|
* that wants to surface an error in the UI.
|
||||||
|
*/
|
||||||
|
export function toUserMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 401) return "Please sign in again.";
|
||||||
|
if (err.status === 403) return "You don't have permission to do that.";
|
||||||
|
if (err.status === 404) return "Not found.";
|
||||||
|
if (err.status === 408 || err.status === 429) {
|
||||||
|
return "Please try again in a moment.";
|
||||||
|
}
|
||||||
|
if (err.status >= 500) {
|
||||||
|
return "Something went wrong on our end. Please try again.";
|
||||||
|
}
|
||||||
|
return normalizeMessage(err.message) || "Request failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return "Received unexpected data from the server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
||||||
|
return "Network error. Check your connection.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return normalizeMessage(err.message) || "Something went wrong.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Something went wrong.";
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For expected-but-worth-recording failures (silent-catch sites). In dev,
|
||||||
|
* prints to console. In prod today this is a no-op; PR 4 wires this up to
|
||||||
|
* Sentry breadcrumbs without touching call sites.
|
||||||
|
*/
|
||||||
|
export function logError(err: unknown, context?: ErrorContext): void {
|
||||||
|
if (appEnv === "dev") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[error]", err, context ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For unexpected failures the user may not have seen (render crashes, query
|
||||||
|
* errors, background mutations). Always surfaces somewhere. PR 4 wires this
|
||||||
|
* up to `Sentry.captureException`.
|
||||||
|
*/
|
||||||
|
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[error]", err, context ?? {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
MutationCache,
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
declare module "@tanstack/react-query" {
|
||||||
|
interface Register {
|
||||||
|
queryMeta: {
|
||||||
|
/** Show a sonner error toast automatically on query failure. */
|
||||||
|
toastOnError?: boolean;
|
||||||
|
};
|
||||||
|
mutationMeta: {
|
||||||
|
/** Opt out of the default sonner error toast (caller handles feedback). */
|
||||||
|
suppressToast?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// Retry only on transient status codes; 4xx generally won't succeed on retry.
|
||||||
|
if (err.status === 408 || err.status === 429) return failureCount < 2;
|
||||||
|
if (err.status >= 400 && err.status < 500) return false;
|
||||||
|
}
|
||||||
|
return failureCount < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: shouldRetryQuery,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
// Mutations have side effects — never auto-retry.
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (err, query) => {
|
||||||
|
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||||
|
if (query.meta?.toastOnError) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (err, _variables, _context, mutation) => {
|
||||||
|
reportError(err, {
|
||||||
|
scope: "mutation",
|
||||||
|
mutationKey: mutation.options.mutationKey,
|
||||||
|
});
|
||||||
|
if (mutation.meta?.suppressToast) return;
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+10
-5
@@ -5,6 +5,7 @@ import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
|||||||
|
|
||||||
import type { LinkMetadata } from './lib/link-metadata';
|
import type { LinkMetadata } from './lib/link-metadata';
|
||||||
import { appConfig } from './config/env';
|
import { appConfig } from './config/env';
|
||||||
|
import { safeHandle } from './main/ipc-utils';
|
||||||
|
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
updateElectronApp({
|
updateElectronApp({
|
||||||
@@ -291,7 +292,7 @@ ipcMain.on('window:close-huddle', () => {
|
|||||||
huddleWindow?.close();
|
huddleWindow?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('screen:get-sources', async () => {
|
safeHandle('screen:get-sources', async () => {
|
||||||
const sources = await desktopCapturer.getSources({
|
const sources = await desktopCapturer.getSources({
|
||||||
types: ['screen', 'window'],
|
types: ['screen', 'window'],
|
||||||
thumbnailSize: { width: 320, height: 180 },
|
thumbnailSize: { width: 320, height: 180 },
|
||||||
@@ -457,7 +458,11 @@ async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
|||||||
|
|
||||||
metadataCache.set(url, metadata);
|
metadataCache.set(url, metadata);
|
||||||
return metadata;
|
return metadata;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// Metadata is a progressive enhancement — keep the null contract, but log
|
||||||
|
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[ipc:link.fetch-metadata]', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,9 +475,9 @@ ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('app:get-version', () => app.getVersion());
|
safeHandle('app:get-version', () => app.getVersion());
|
||||||
|
|
||||||
ipcMain.handle('link:fetch-metadata', async (_event, url: string) => {
|
safeHandle('link:fetch-metadata', async (_event, url) => {
|
||||||
if (typeof url !== 'string') return null;
|
if (typeof url !== 'string') return null;
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
@@ -482,7 +487,7 @@ ipcMain.handle('link:fetch-metadata', async (_event, url: string) => {
|
|||||||
return fetchLinkMetadata(url);
|
return fetchLinkMetadata(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('link:open-external', async (_event, url: string) => {
|
safeHandle('link:open-external', async (_event, url) => {
|
||||||
if (typeof url !== 'string') return;
|
if (typeof url !== 'string') return;
|
||||||
// Only allow http(s) URLs for security
|
// Only allow http(s) URLs for security
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ipcMain, type IpcMainInvokeEvent } from "electron";
|
||||||
|
|
||||||
|
type InvokeHandler = (
|
||||||
|
event: IpcMainInvokeEvent,
|
||||||
|
...args: unknown[]
|
||||||
|
) => Promise<unknown> | unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `ipcMain.handle` so handler failures log the full stack in the main
|
||||||
|
* process and re-throw a sanitized message to the renderer. Without this,
|
||||||
|
* main-process exceptions silently reject the renderer promise with no trace.
|
||||||
|
*/
|
||||||
|
export function safeHandle(channel: string, handler: InvokeHandler): void {
|
||||||
|
ipcMain.handle(channel, async (event, ...args) => {
|
||||||
|
try {
|
||||||
|
return await handler(event, ...(args as unknown[]));
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[ipc:${channel}]`, err);
|
||||||
|
const message = err instanceof Error ? err.message : "IPC error";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8291,6 +8291,11 @@ react-dom@^19.2.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
scheduler "^0.27.0"
|
scheduler "^0.27.0"
|
||||||
|
|
||||||
|
react-error-boundary@^6.1.1:
|
||||||
|
version "6.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.1.tgz#491d655e86c32434ede852755bb649119fdddd89"
|
||||||
|
integrity sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==
|
||||||
|
|
||||||
react-markdown@^10.1.0:
|
react-markdown@^10.1.0:
|
||||||
version "10.1.0"
|
version "10.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
|
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
|
||||||
|
|||||||
Reference in New Issue
Block a user