Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
24 changes: 24 additions & 0 deletions apps/desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@
background-color: transparent !important;
}
</style>
<!-- Polyfill for __TAURI_INTERNALS__ in iframe context (extension host) -->
<!-- This must run BEFORE any module imports to prevent Tauri API access errors -->
<!-- In iframe context, Tauri may inject a partial __TAURI_INTERNALS__ object, so we -->
<!-- always overwrite it completely to ensure all required fields are present -->
<script>
(function() {
var isIframeContext = window.self !== window.top;
if (!isIframeContext) return;

// Always overwrite __TAURI_INTERNALS__ in iframe context to ensure
// all required fields are present (Tauri may inject a partial object)
window.__TAURI_INTERNALS__ = {
_metadata: {
currentWindow: { label: "iframe", kind: "WebviewWindow" },
currentWebview: { label: "iframe", windowLabel: "iframe" },
windows: [],
webviews: []
},
invoke: function() { return Promise.reject(new Error("Tauri not available in iframe")); },
transformCallback: function() { return 0; },
convertFileSrc: function(path) { return path; }
};
})();
</script>
</head>
<body spellcheck="false">
<div id="root"></div>
Expand Down
47 changes: 29 additions & 18 deletions apps/desktop/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,37 @@ import {

import { env } from "./env";

const tauriStorage: SupportedStorage = {
async getItem(key: string): Promise<string | null> {
const store = await load("auth.json");
const val = await store.get<string>(key);
return val ?? null;
},
async setItem(key: string, value: string): Promise<void> {
const store = await load("auth.json");
await store.set(key, value);
await store.save();
},
async removeItem(key: string): Promise<void> {
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<string | null> {
const store = await load("auth.json");
const val = await store.get<string>(key);
return val ?? null;
},
async setItem(key: string, value: string): Promise<void> {
const store = await load("auth.json");
await store.set(key, value);
await store.save();
},
async removeItem(key: string): Promise<void> {
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,
Expand Down
60 changes: 60 additions & 0 deletions apps/desktop/src/components/main-app-layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthProvider>
<BillingProvider>
<Outlet />
</BillingProvider>
</AuthProvider>
);
}

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]);
};
108 changes: 53 additions & 55 deletions apps/desktop/src/components/main/body/extensions/index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Tab, { type: "extension" }>;

Expand Down Expand Up @@ -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<HTMLIFrameElement>(null);
const synchronizerRef = useRef<ReturnType<
typeof createIframeSynchronizer
> | 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 (
<StandardTabWrapper>
<div className="flex items-center justify-center h-full">
<div className="text-center">
<LoaderIcon
size={48}
className="mx-auto text-neutral-300 mb-4 animate-spin"
/>
<p className="text-neutral-500">Loading extension...</p>
</div>
</div>
</StandardTabWrapper>
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 (
<StandardTabWrapper>
<div className="flex items-center justify-center h-full">
<div className="text-center">
<PuzzleIcon size={48} className="mx-auto text-neutral-300 mb-4" />
<p className="text-neutral-500">
{panelInfo
? `Extension panel "${panelInfo.title}" failed to load`
: `Extension not found: ${tab.extensionId}`}
Extension not found: {tab.extensionId}
</p>
{panelInfo?.entry && (
<p className="text-neutral-400 text-sm mt-2">
Entry: {panelInfo.entry}
</p>
)}
</div>
</div>
</StandardTabWrapper>
);
}

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

return (
<StandardTabWrapper>
<LoadedComponent extensionId={tab.extensionId} state={tab.state} />
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={handleIframeLoad}
className="w-full h-full border-0"
sandbox="allow-scripts"
title={`Extension: ${tab.extensionId}`}
/>
</StandardTabWrapper>
);
}
3 changes: 3 additions & 0 deletions apps/desktop/src/extension-globals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as jsxRuntime from "react/jsx-runtime";
import * as tinybaseUiReact from "tinybase/ui-react";

import * as Button from "@hypr/ui/components/ui/button";
import * as Card from "@hypr/ui/components/ui/card";
Expand All @@ -13,6 +14,7 @@ declare global {
__hypr_jsx_runtime: typeof jsxRuntime;
__hypr_ui: Record<string, unknown>;
__hypr_utils: typeof utils;
__hypr_tinybase_ui_react: typeof tinybaseUiReact;
}
}

Expand All @@ -21,6 +23,7 @@ export function initExtensionGlobals() {
window.__hypr_react_dom = ReactDOM;
window.__hypr_jsx_runtime = jsxRuntime;
window.__hypr_utils = utils;
window.__hypr_tinybase_ui_react = tinybaseUiReact;

window.__hypr_ui = {
"components/ui/button": Button,
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,35 @@ function App() {
);
}

// Check if we're in an iframe context (extension host)
const isIframeContext =
typeof window !== "undefined" && window.self !== window.top;

function AppWithTiny() {
const manager = useCreateManager(() => {
return createManager().start();
});

const isMainWindow = getCurrentWebviewWindowLabel() === "main";
// In iframe context, we're not the main window and shouldn't persist the store
// (the parent window handles persistence, iframe syncs via postMessage)
const isMainWindow = isIframeContext
? false
: getCurrentWebviewWindowLabel() === "main";

return (
<QueryClientProvider client={queryClient}>
<TinyTickProvider manager={manager}>
<TinyBaseProvider>
<App />
<StoreComponent persist={isMainWindow} />
<TaskManager />
{!isIframeContext && <TaskManager />}
</TinyBaseProvider>
</TinyTickProvider>
</QueryClientProvider>
);
}

// Initialize plugins - the polyfill in index.html handles iframe context
initWindowsPlugin();
initExtensionGlobals();

Expand Down
Loading
Loading