diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 75717a03..cddb31e2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -40,7 +40,8 @@ "media-src": "'self' blob: data:", "style-src": "'self' 'unsafe-inline'", "font-src": "'self' data:", - "script-src": "'self' 'wasm-unsafe-eval'" + "script-src": "'self' 'wasm-unsafe-eval'", + "frame-src": "http://127.0.0.1:* http://localhost:*" }, "dangerousDisableAssetCspModification": [ "style-src" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c9f90780..1091fc0b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -25,6 +25,8 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page")); const AgentNewPage = lazy(() => import("@/pages/agents/agent-new")); const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail")); const FilesPage = lazy(() => import("@/pages/files/files-page")); +const ArtifactsPage = lazy(() => import("@/pages/artifacts/artifacts-page")); +const ArtifactPage = lazy(() => import("@/pages/artifacts/artifact-page")); const AutomationsPage = lazy(() => import("@/pages/automations/automations-page")); const PluginsPage = lazy(() => import("@/pages/plugins/plugins-page")); const ActivityPage = lazy(() => import("@/pages/activity/activity-page")); @@ -203,6 +205,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/artifacts/artifact-frame.tsx b/apps/desktop/src/components/artifacts/artifact-frame.tsx new file mode 100644 index 00000000..5710b328 --- /dev/null +++ b/apps/desktop/src/components/artifacts/artifact-frame.tsx @@ -0,0 +1,273 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { MonitorSmartphone } from "lucide-react"; +import { cloudContext, coreUrl } from "@/lib/core"; +import { openExternal } from "@/lib/desktop"; +import { useTheme } from "@/components/theme-provider"; +import { cn } from "@/lib/utils"; + +/** + * Runs an artifact's HTML in the core's sandbox page (`/api/artifacts/frame`): an opaque origin with its own policy, so + * the code can load CDN libraries but never reaches the app, its token or Godmode's API. The document gets + * `window.godmode` (ARTIFACT_BRIDGE_DOC in @godmode/shared) and talks to the app only through postMessage. + */ + +const LIGHT = { + bg: "#faf9f5", + card: "#ffffff", + fg: "#1a1a1a", + muted: "#75716a", + border: "rgb(38 32 22 / 0.1)", + accent: "#0eca7b", + "accent-fg": "#04130c", + positive: "#087f4d", + negative: "#d4101f", +}; + +const DARK = { + bg: "#1c1b19", + card: "#232220", + fg: "#f4f2ed", + muted: "#a29e96", + border: "rgb(255 250 240 / 0.09)", + accent: "#2fd690", + "accent-fg": "#06140d", + positive: "#2fd690", + negative: "#f0525b", +}; + +const FONT = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`; + +const vars = (p: Record) => Object.entries(p).map(([k, v]) => `--gm-${k}:${v};`).join("") + `--gm-font:${FONT};`; + +const BASE_STYLE = `:root{${vars(LIGHT)}color-scheme:light}:root.dark{${vars(DARK)}color-scheme:dark}html,body{margin:0}body{background:var(--gm-bg);color:var(--gm-fg);font-family:var(--gm-font);-webkit-font-smoothing:antialiased}`; + +/** Runs first in the artifact's document. Kept as plain ES5 so any page can carry it. */ +function bridgeScript(token: string, data: unknown, theme: "light" | "dark", standalone = false): string { + const json = (v: unknown) => JSON.stringify(v ?? null).replace(/ `\\u${c.charCodeAt(0).toString(16)}`); + return ``; +} + +/** The artifact's HTML with the bridge first in (a fragment gets a document around it). */ +export function artifactDocument(html: string, bridge: string): string { + // Tags inside comments and scripts don't count: blank them out (same length) before looking. + const plain = html.replace(/|/gi, (m) => " ".repeat(m.length)); + const head = /]*)?>/i.exec(plain); + if (head) return html.slice(0, head.index + head[0].length) + bridge + html.slice(head.index + head[0].length); + const root = /]*)?>/i.exec(plain); + if (root) return html.slice(0, root.index + root[0].length) + `${bridge}` + html.slice(root.index + root[0].length); + const doctype = /]*>/i.exec(plain); + const body = doctype ? html.slice(doctype.index + doctype[0].length) : html; + return `${bridge}${body}`; +} + +/** A standalone copy to download: same bridge, data saved in the file, setData kept in memory. */ +export function standaloneDocument(html: string, data: unknown, theme: "light" | "dark"): string { + return artifactDocument(html, STANDALONE_CSP + bridgeScript("standalone", data, theme, true)); +} + +/** The sandbox's rules for the downloaded copy too (the core's frame page sends them as a header). */ +const STANDALONE_CSP = ``; + +function hashOf(text: string): string { + let h = 5381; + for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0; + return (h >>> 0).toString(36); +} + +const randomToken = () => Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, "0")).join(""); + +export interface ArtifactFrameHandlers { + /** The app saved data (`godmode.setData`); reject to tell the app it wasn't saved. */ + onSetData?: (data: unknown) => Promise; + onAsk?: (text: string) => void; + onRefresh?: () => void; + onError?: (message: string) => void; +} + +const SAVE_DELAY_MS = 300; + +interface Session { + key: string; + token: string; + loaded: boolean; + /** The data the frame has (it sent or was sent): the same data coming back isn't sent again. */ + lastData: string; + /** Saves not answered yet: data from the server meanwhile may be older than what the frame has. */ + pending: number; + /** Saves close together become one: the newest data, answered for all of them. */ + queued: { data: unknown; ids: number[]; timer: ReturnType } | null; +} + +export function ArtifactFrame({ + html, + data, + title, + className, + style, + interactive = true, + lazy = false, + ...handlers +}: { + html: string; + data: unknown; + title: string; + className?: string; + style?: CSSProperties; + /** Off for thumbnails: no clicks, nothing saved or sent. */ + interactive?: boolean; + lazy?: boolean; +} & ArtifactFrameHandlers) { + const { resolved } = useTheme(); + const ref = useRef(null); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + const key = useMemo(() => hashOf(html), [html]); + // One token per loaded document; what it said it saved isn't sent back to it. + const session = useRef({ key: "", token: "", loaded: false, lastData: "", pending: 0, queued: null }); + if (session.current.key !== key) session.current = { key, token: randomToken(), loaded: false, lastData: JSON.stringify(data ?? null), pending: 0, queued: null }; + const latest = useRef({ html, data, theme: resolved }); + latest.current = { html, data, theme: resolved }; + + // Layout effect: listening before the frame's page can say it is there. + useLayoutEffect(() => { + const onMessage = (e: MessageEvent) => { + const frame = ref.current?.contentWindow; + if (!frame || e.source !== frame) return; + const m = e.data as { type?: string; __gm?: string; id?: number; data?: unknown; text?: string; message?: string; url?: string } | null; + if (!m || typeof m !== "object") return; + const s = session.current; + if (m.type === "gm:shell") { + const { html: h, data: d, theme } = latest.current; + s.loaded = true; + s.lastData = JSON.stringify(d ?? null); + frame.postMessage({ type: "gm:load", doc: artifactDocument(h, bridgeScript(s.token, d, theme)) }, "*"); + return; + } + if (m.__gm !== s.token) return; + const reply = (id: number | undefined, ok: boolean, error?: string) => frame.postMessage({ __gm: s.token, type: "reply", id, ok, error }, "*"); + const h = handlersRef.current; + if (m.type === "setData") { + if (!interactive || !h.onSetData) return reply(m.id, false, "This view can't save"); + s.lastData = JSON.stringify(m.data ?? null); + s.pending++; + if (s.queued) clearTimeout(s.queued.timer); + const ids = [...(s.queued?.ids ?? []), m.id ?? 0]; + const timer = setTimeout(() => { + const batch = s.queued!; + s.queued = null; + const done = (ok: boolean, error?: string) => { + s.pending -= batch.ids.length; + for (const id of batch.ids) reply(id, ok, error); + }; + (handlersRef.current.onSetData ?? (() => Promise.reject(new Error("This view can't save"))))(batch.data).then( + () => done(true), + (err: unknown) => done(false, err instanceof Error ? err.message : "Not saved"), + ); + }, SAVE_DELAY_MS); + s.queued = { data: m.data ?? null, ids, timer }; + return; + } + if (!interactive) return; + if (m.type === "ask" && m.text?.trim()) h.onAsk?.(m.text.trim().slice(0, 500)); + else if (m.type === "refresh") h.onRefresh?.(); + else if (m.type === "error" && m.message) h.onError?.(m.message.slice(0, 300)); + else if (m.type === "open" && m.url && /^https?:\/\//i.test(m.url)) void openExternal(m.url); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [interactive]); + + // New data from the agent or another window: hand it over without reloading. + useEffect(() => { + const s = session.current; + const text = JSON.stringify(data ?? null); + if (!s.loaded || s.pending > 0 || text === s.lastData) return; + s.lastData = text; + ref.current?.contentWindow?.postMessage({ __gm: s.token, type: "data", data: data ?? null }, "*"); + }, [data]); + + useEffect(() => { + const s = session.current; + if (s.loaded) ref.current?.contentWindow?.postMessage({ __gm: s.token, type: "theme", theme: resolved }, "*"); + }, [resolved]); + + if (cloudContext) { + return ( +
+
+ +

Open it on your computer

+

Artifacts run in the Godmode app or the dashboard on your computer, not through Godmode Cloud.

+
+
+ ); + } + + return ( +