diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 0706d084dc..cfb0e31850 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -14,6 +14,30 @@ background-color: transparent !important; } + + + + +
diff --git a/apps/desktop/src/auth.tsx b/apps/desktop/src/auth.tsx index 8e5143b3a8..ac5d226396 100644 --- a/apps/desktop/src/auth.tsx +++ b/apps/desktop/src/auth.tsx @@ -19,26 +19,37 @@ import { import { env } from "./env"; -const tauriStorage: SupportedStorage = { - async getItem(key: string): Promise { - const store = await load("auth.json"); - const val = await store.get(key); - return val ?? null; - }, - async setItem(key: string, value: string): Promise { - const store = await load("auth.json"); - await store.set(key, value); - await store.save(); - }, - async removeItem(key: string): Promise { - const store = await load("auth.json"); - await store.delete(key); - await store.save(); - }, -}; +// Check if we're in an iframe (extension host) context where Tauri APIs are not available +const isIframeContext = + typeof window !== "undefined" && window.self !== window.top; + +// Only create Tauri storage if we're not in an iframe context +const tauriStorage: SupportedStorage | null = isIframeContext + ? null + : { + async getItem(key: string): Promise { + const store = await load("auth.json"); + const val = await store.get(key); + return val ?? null; + }, + async setItem(key: string, value: string): Promise { + const store = await load("auth.json"); + await store.set(key, value); + await store.save(); + }, + async removeItem(key: string): Promise { + const store = await load("auth.json"); + await store.delete(key); + await store.save(); + }, + }; +// Only create Supabase client if we're not in an iframe context and have valid config const supabase = - env.VITE_SUPABASE_URL && env.VITE_SUPABASE_ANON_KEY + !isIframeContext && + env.VITE_SUPABASE_URL && + env.VITE_SUPABASE_ANON_KEY && + tauriStorage ? createClient(env.VITE_SUPABASE_URL, env.VITE_SUPABASE_ANON_KEY, { auth: { storage: tauriStorage, diff --git a/apps/desktop/src/components/main-app-layout.tsx b/apps/desktop/src/components/main-app-layout.tsx new file mode 100644 index 0000000000..c905fc8659 --- /dev/null +++ b/apps/desktop/src/components/main-app-layout.tsx @@ -0,0 +1,60 @@ +import { Outlet, useNavigate } from "@tanstack/react-router"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { useEffect } from "react"; + +import { events as deeplink2Events } from "@hypr/plugin-deeplink2"; +import { events as windowsEvents } from "@hypr/plugin-windows"; + +import { AuthProvider } from "../auth"; +import { BillingProvider } from "../billing"; + +/** + * Main app layout component that wraps routes with auth/billing providers. + * This is loaded dynamically to prevent auth.tsx from being imported in iframe context. + * auth.tsx creates Supabase client at module level which uses Tauri APIs that aren't + * available in iframes. + */ +export default function MainAppLayout() { + useNavigationEvents(); + + return ( + + + + + + ); +} + +const useNavigationEvents = () => { + const navigate = useNavigate(); + + useEffect(() => { + let unlistenNavigate: (() => void) | undefined; + let unlistenDeepLink: (() => void) | undefined; + + const webview = getCurrentWebviewWindow(); + + windowsEvents + .navigate(webview) + .listen(({ payload }) => { + navigate({ to: payload.path, search: payload.search ?? undefined }); + }) + .then((fn) => { + unlistenNavigate = fn; + }); + + deeplink2Events.deepLinkEvent + .listen(({ payload }) => { + navigate({ to: payload.to, search: payload.search }); + }) + .then((fn) => { + unlistenDeepLink = fn; + }); + + return () => { + unlistenNavigate?.(); + unlistenDeepLink?.(); + }; + }, [navigate]); +}; diff --git a/apps/desktop/src/components/main/body/extensions/index.tsx b/apps/desktop/src/components/main/body/extensions/index.tsx index a9afc6439c..4e46ac0cd2 100644 --- a/apps/desktop/src/components/main/body/extensions/index.tsx +++ b/apps/desktop/src/components/main/body/extensions/index.tsx @@ -1,6 +1,9 @@ -import { LoaderIcon, PuzzleIcon, XIcon } from "lucide-react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { PuzzleIcon, XIcon } from "lucide-react"; import { Reorder, useDragControls } from "motion/react"; -import { type PointerEvent, useEffect, useState } from "react"; +import { type PointerEvent, useCallback, useEffect, useRef } from "react"; +import type { MergeableStore } from "tinybase"; +import { useStores } from "tinybase/ui-react"; import { Button } from "@hypr/ui/components/ui/button"; import { @@ -12,13 +15,11 @@ import { } from "@hypr/ui/components/ui/context-menu"; import { cn } from "@hypr/utils"; +import { createIframeSynchronizer } from "../../../../store/tinybase/iframe-sync"; +import { type Store, STORE_ID } from "../../../../store/tinybase/main"; import type { Tab } from "../../../../store/zustand/tabs"; import { StandardTabWrapper } from "../index"; -import { - getExtensionComponent, - getPanelInfoByExtensionId, - loadExtensionUI, -} from "./registry"; +import { getPanelInfoByExtensionId } from "./registry"; type ExtensionTab = Extract; @@ -92,77 +93,74 @@ export function TabItemExtension({ } export function TabContentExtension({ tab }: { tab: ExtensionTab }) { - const [loadState, setLoadState] = useState< - "idle" | "loading" | "loaded" | "error" - >("idle"); - const [, forceUpdate] = useState({}); - - const Component = getExtensionComponent(tab.extensionId); + const stores = useStores(); + const store = stores[STORE_ID] as unknown as Store | undefined; const panelInfo = getPanelInfoByExtensionId(tab.extensionId); + const iframeRef = useRef(null); + const synchronizerRef = useRef | null>(null); - useEffect(() => { - if (Component) { - setLoadState("loaded"); - return; - } + const handleIframeLoad = useCallback(() => { + if (!iframeRef.current || !store) return; - if (!panelInfo?.entry_path) { - setLoadState("error"); - return; + if (synchronizerRef.current) { + synchronizerRef.current.destroy(); } - setLoadState("loading"); - loadExtensionUI(tab.extensionId).then((success) => { - setLoadState(success ? "loaded" : "error"); - if (success) { - forceUpdate({}); - } - }); - }, [tab.extensionId, Component, panelInfo?.entry_path]); - - if (loadState === "loading") { - return ( - -
-
- -

Loading extension...

-
-
-
+ const synchronizer = createIframeSynchronizer( + store as unknown as MergeableStore, + iframeRef.current, ); - } + synchronizerRef.current = synchronizer; + synchronizer.startSync().catch((err) => { + console.error( + `[extensions] Failed to start sync for extension ${tab.extensionId}:`, + err, + ); + }); + }, [store, tab.extensionId]); - const LoadedComponent = getExtensionComponent(tab.extensionId); + useEffect(() => { + return () => { + if (synchronizerRef.current) { + synchronizerRef.current.destroy(); + synchronizerRef.current = null; + } + }; + }, []); - if (!LoadedComponent) { + if (!panelInfo?.entry_path) { return (

- {panelInfo - ? `Extension panel "${panelInfo.title}" failed to load` - : `Extension not found: ${tab.extensionId}`} + Extension not found: {tab.extensionId}

- {panelInfo?.entry && ( -

- Entry: {panelInfo.entry} -

- )}
); } + const scriptUrl = convertFileSrc(panelInfo.entry_path); + const iframeSrc = `/app/ext-host?${new URLSearchParams({ + extensionId: tab.extensionId, + scriptUrl: scriptUrl, + }).toString()}`; + return ( - +