From d9aef00e5d57f4c8ff13a988b8fed8da9e999161 Mon Sep 17 00:00:00 2001 From: Jeff Litt Date: Mon, 27 Jul 2026 15:34:34 -0400 Subject: [PATCH 1/2] Guard remaining unguarded Tauri calls for web deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several call sites into @tauri-apps/api still assume a native Tauri runtime and throw when the desktop chat UI is served as a plain web page instead (no window.__TAURI_INTERNALS__): "Cannot read properties of undefined (reading 'transformCallback'/'invoke')". Most of the codebase already guards this correctly with isTauri() before calling listen()/invoke() (e.g. audioWorklet.ts); these were the remaining gaps, found by exercising a web-served build end-to-end: - useAgentsDataRefresh, usePreventSleep, useNestNotifications, HuddleContext, HuddleBar, HuddleIndicator, MobilePairingCard: guard listen() registration with isTauri(). - deep-link.ts: drainPendingCommunityDeepLinks() calls invoke() directly (not via the listen()-based subscription already guarded in this file) to read the Rust-side pending-deep-link queue, which only exists in the native shell — guard it the same way. - tauri.ts: isSharedIdentity() is called unconditionally at app boot, before any other Tauri-availability gate — guard it to resolve to `false` in a web runtime instead of throwing. - legacyCommunityStorage.ts: the legacy Sprout WebKit SQLite database read only exists in the native shell — skip it in a web runtime. All of these failures were already caught (logged as console warnings, not uncaught exceptions), so this doesn't change behavior for native Tauri users — it just removes console noise and dead invoke attempts when the same bundle is served as a website. --- .../agents/lib/useAgentsDataRefresh.ts | 6 +++++ .../src/features/agents/usePreventSleep.ts | 5 +++++ .../communities/legacyCommunityStorage.ts | 8 +++++++ .../communities/useNestNotifications.ts | 5 +++++ desktop/src/features/huddle/HuddleContext.tsx | 6 ++++- .../features/huddle/components/HuddleBar.tsx | 7 +++++- .../huddle/components/HuddleIndicator.tsx | 6 +++++ .../settings/ui/MobilePairingCard.tsx | 8 +++++++ desktop/src/shared/api/tauri.ts | 7 +++++- desktop/src/shared/deep-link.ts | 22 ++++++++++++++++++- 10 files changed, 76 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..b65a816776 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -1,3 +1,4 @@ +import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -26,6 +27,11 @@ export function useAgentsDataRefresh(): void { const queryClient = useQueryClient(); useEffect(() => { + // Both events come from the local Rust agents backend — no on-disk agents + // data exists in a browser (web) runtime, and `listen()` throws without + // Tauri internals. + if (!isTauri()) return; + let timer: ReturnType | undefined; const unlistenRuntime = listen("managed-agent-runtime-status", () => { diff --git a/desktop/src/features/agents/usePreventSleep.ts b/desktop/src/features/agents/usePreventSleep.ts index 4c2d48b97d..5dc4db5c25 100644 --- a/desktop/src/features/agents/usePreventSleep.ts +++ b/desktop/src/features/agents/usePreventSleep.ts @@ -7,6 +7,7 @@ import { import { createPreventSleepActivityTracker } from "@/features/agents/preventSleepActivity"; import { setPreventSleepActive } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; // Intentionally not scoped per-pubkey — multi-user desktop is rare and the @@ -85,6 +86,10 @@ function usePreventSleepInternal() { void setPreventSleepActive(active); }, [active]); React.useEffect(() => { + // Sleep prevention is a native OS concept — no such event exists in a + // browser (web) runtime, and `listen()` throws without Tauri internals. + if (!isTauri()) return; + const unlisten = listen("prevent-sleep-expired", () => { setExpired(true); }); diff --git a/desktop/src/features/communities/legacyCommunityStorage.ts b/desktop/src/features/communities/legacyCommunityStorage.ts index d140fa4c62..2bc51c26ca 100644 --- a/desktop/src/features/communities/legacyCommunityStorage.ts +++ b/desktop/src/features/communities/legacyCommunityStorage.ts @@ -1,3 +1,5 @@ +import { isTauri } from "@tauri-apps/api/core"; + import { invokeTauri } from "@/shared/api/tauri"; import { migrateLegacyCommunityStorage } from "./communityStorage"; @@ -115,6 +117,12 @@ export async function migrateLegacyCommunityStorageBeforeRender(): Promise return; } + // The legacy Sprout WebKit SQLite database only exists in the native shell + // — there is nothing to migrate from in a browser (web) runtime. + if (!isTauri()) { + return; + } + migrateLegacyCommunityStorage(window.localStorage); const currentCommunitiesRaw = window.localStorage.getItem(BUZZ_COMMUNITIES_KEY); diff --git a/desktop/src/features/communities/useNestNotifications.ts b/desktop/src/features/communities/useNestNotifications.ts index d93bb89ad2..41ebd75ee6 100644 --- a/desktop/src/features/communities/useNestNotifications.ts +++ b/desktop/src/features/communities/useNestNotifications.ts @@ -1,3 +1,4 @@ +import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useEffect } from "react"; import { toast } from "sonner"; @@ -22,6 +23,10 @@ const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified"; */ export function useNestNotifications(): void { useEffect(() => { + // Both events come from the Rust nest backend — no local nest exists in a + // browser (web) runtime, and `listen()` throws without Tauri internals. + if (!isTauri()) return; + const unlistenReposError = listen("repos-dir-error", (event) => { toast.error("Repos directory not applied", { description: event.payload, diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 804eb4bc0e..7874b6f996 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import * as React from "react"; @@ -616,6 +616,10 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { // in-flight guard collapses duplicate disconnect events from failed dials. const audioReconnectInFlightRef = React.useRef(false); React.useEffect(() => { + // Huddle audio is a native Tauri pipeline — no such event exists in a + // browser (web) runtime, and `listen()` throws without Tauri internals. + if (!isTauri()) return; + let cancelled = false; let unlisten: (() => void) | null = null; listen("huddle-audio-disconnected", () => { diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 35d660e475..eab1a95e76 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { Bot, @@ -216,6 +216,11 @@ export function HuddleBar({ }, []); // Huddle state: event-driven + slow fallback poll. React.useEffect(() => { + // The huddle backend (state + audio pipeline) is native Tauri-only — none + // of this works in a browser (web) runtime, and `listen()` throws without + // Tauri internals. + if (!isTauri()) return; + let cancelled = false; let unlisten: (() => void) | null = null; diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index 19b504cf28..bf11d78417 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -1,3 +1,4 @@ +import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { Headphones } from "lucide-react"; import * as React from "react"; @@ -190,6 +191,11 @@ export function HuddleIndicator({ // waiting for the relay's 48103 event (which may arrive late or not at all // if the relay connection tears down first). React.useEffect(() => { + // Huddle state is emitted by the native Tauri audio pipeline — no such + // event exists in a browser (web) runtime, and `listen()` throws without + // Tauri internals. + if (!isTauri()) return; + let unlisten: (() => void) | null = null; let cancelled = false; diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index e15f54316b..2719f24397 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -8,6 +8,7 @@ import { TriangleAlert, X, } from "lucide-react"; +import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; @@ -220,6 +221,13 @@ export function MobilePairingCard({ return; } + // Mobile pairing is a native Tauri feature (QR/SAS handshake with a + // phone) — none of these events exist in a browser (web) runtime, and + // `listen()` throws without Tauri internals. + if (!isTauri()) { + return; + } + let cancelled = false; const unlisteners: (() => void)[] = []; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index e8a8b885cf..271e73aca5 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,4 +1,4 @@ -import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { invoke as tauriInvoke, isTauri } from "@tauri-apps/api/core"; import { activateRateLimit, parseRateLimitHint, @@ -374,7 +374,12 @@ export function autoConnectDefaultRelayEnabled(): Promise { return invokeTauri("auto_connect_default_relay_enabled"); } +// Called unconditionally at app boot, before any Tauri-availability gate — +// unlike most of this file's exports, which only run once the app has +// already reached a native-only screen. Guard so it resolves cleanly to +// `false` in a browser (web) runtime instead of throwing. export function isSharedIdentity(): Promise { + if (!isTauri()) return Promise.resolve(false); return invokeTauri("is_shared_identity"); } diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..e491ee09a9 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -1,5 +1,5 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import type { StartCommunityOnboardingInput } from "@/features/onboarding/communityOnboarding"; export type AddCommunityDeepLinkPayload = { @@ -84,6 +84,11 @@ function acceptPendingCommunityDeepLink( } async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) { + // The pending-deep-link queue is populated by the Rust backend and only + // exists in the native Tauri shell — invoke() throws without Tauri + // internals in a browser (web) runtime. + if (!isTauri()) return; + while (true) { const pending = await invoke( "take_pending_community_deep_link", @@ -134,6 +139,17 @@ export async function listenForDeepLinks( })(); }; const stopAvailabilityListener = deps.onAddCommunityAvailable(drain); + + // OS-level deep links (`buzz://…`) only exist in the native Tauri shell — + // `listen()` throws without Tauri internals in a browser (web) runtime. + // The in-app "add community available" wiring above still applies. + if (!isTauri()) { + drain(); + return () => { + stopAvailabilityListener(); + }; + } + const connectPromise = listen("deep-link-connect", drain); const joinPromise = listen("deep-link-join", drain); const addCommunityPromise = listen( @@ -160,6 +176,8 @@ export async function listenForDeepLinks( export function listenForMessageDeepLinks( onOpen: (payload: MessageDeepLinkPayload) => void, ): Promise { + // OS-level deep link — no-op outside the native Tauri shell. + if (!isTauri()) return Promise.resolve(() => {}); return listen("deep-link-message", (event) => { onOpen(event.payload); }); @@ -168,6 +186,8 @@ export function listenForMessageDeepLinks( export function listenForNostrBindDeepLinks( onOpen: (payload: NostrBindDeepLinkPayload) => void, ): Promise { + // OS-level deep link — no-op outside the native Tauri shell. + if (!isTauri()) return Promise.resolve(() => {}); return listen("deep-link-nostr-bind", (event) => { onOpen(event.payload); }); From 480ee5857f1ee9bf3b1e39881a35bd7d139a2f36 Mon Sep 17 00:00:00 2001 From: Jeff Litt Date: Mon, 27 Jul 2026 16:06:50 -0400 Subject: [PATCH 2/2] Guard 2 more boot-reachable calls in tauri.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDefaultRelayUrl/autoConnectDefaultRelayEnabled are called unconditionally by useCommunityInit's "no active community yet" branch on every boot, but that branch already wraps both calls in its own try/catch and falls back to needsSetup:true — so this is a console- noise reduction, not a behavior fix. setPreventSleepActive is different: usePreventSleep.ts calls it fire-and-forget (two call sites, both `void setPreventSleepActive(...)`) with no .catch of its own, so reaching invokeTauri here was a genuine unhandled promise rejection in a web (non-Tauri) runtime, not just noise. --- desktop/src/shared/api/tauri.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 271e73aca5..8ae7cec8e5 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -366,11 +366,17 @@ export async function getPresence(pubkeys: string[]): Promise { ); } +// Called unconditionally on every boot by useCommunityInit's "no active +// community yet" branch, which already wraps both calls in its own +// try/catch and falls back to needsSetup:true — so this guard is a +// console-noise reduction, not a behavior fix. export function getDefaultRelayUrl(): Promise { + if (!isTauri()) return Promise.reject(new Error("no Tauri runtime")); return invokeTauri("get_default_relay_url"); } export function autoConnectDefaultRelayEnabled(): Promise { + if (!isTauri()) return Promise.resolve(false); return invokeTauri("auto_connect_default_relay_enabled"); } @@ -1193,8 +1199,14 @@ export async function validateReposDir(dir: string): Promise { await invokeTauri("validate_repos_dir", { dir }); } +// Sleep prevention is a native OS concept with no browser equivalent. +// usePreventSleep.ts calls this fire-and-forget (`void setPreventSleepActive(...)`) +// with no .catch of its own, so an unguarded call here was a genuine unhandled +// promise rejection in a web (non-Tauri) runtime — not just console noise. export const setPreventSleepActive = (active: boolean) => - invokeTauri("set_prevent_sleep_active", { active }); + isTauri() + ? invokeTauri("set_prevent_sleep_active", { active }) + : Promise.resolve(); export const setAgentManagedProfiles = (enabled: boolean) => invokeTauri("set_agent_managed_profiles", { enabled });