diff --git a/apps/web/app/api/apps/route.ts b/apps/web/app/api/apps/route.ts new file mode 100644 index 00000000000..1653543f053 --- /dev/null +++ b/apps/web/app/api/apps/route.ts @@ -0,0 +1,154 @@ +import { access, readFile, stat } from "node:fs/promises"; +import { join, extname, resolve, relative } from "node:path"; +import { resolveWorkspaceRoot, parseSimpleYaml } from "@/lib/workspace"; +import { injectBridgeIntoHtml } from "@/lib/app-bridge"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".wasm": "application/wasm", + ".map": "application/json", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".xml": "application/xml", + ".yaml": "text/yaml; charset=utf-8", + ".yml": "text/yaml; charset=utf-8", +}; + +function getMimeType(filepath: string): string { + const ext = extname(filepath).toLowerCase(); + return MIME_TYPES[ext] || "application/octet-stream"; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const appPath = url.searchParams.get("app"); + const filePath = url.searchParams.get("file"); + const metaOnly = url.searchParams.get("meta") === "1"; + + if (!appPath) { + return Response.json({ error: "Missing 'app' parameter" }, { status: 400 }); + } + + const workspaceRoot = resolveWorkspaceRoot(); + if (!workspaceRoot) { + return Response.json({ error: "No workspace configured" }, { status: 404 }); + } + + const appAbsPath = resolve(join(workspaceRoot, appPath)); + + // Security: ensure the resolved path is within the workspace + const relToWorkspace = relative(workspaceRoot, appAbsPath); + if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) { + return Response.json({ error: "Path traversal denied" }, { status: 403 }); + } + + if (!await pathExists(appAbsPath)) { + return Response.json({ error: "App not found" }, { status: 404 }); + } + + // Meta-only request: return parsed .dench.yaml manifest + if (metaOnly) { + const manifestPath = join(appAbsPath, ".dench.yaml"); + if (!await pathExists(manifestPath)) { + return Response.json({ name: appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App" }); + } + try { + const content = await readFile(manifestPath, "utf-8"); + const parsed = parseSimpleYaml(content); + return Response.json({ + name: parsed.name || appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App", + description: parsed.description, + icon: parsed.icon, + version: parsed.version, + author: parsed.author, + entry: parsed.entry || "index.html", + runtime: parsed.runtime || "static", + permissions: parsed.permissions, + }); + } catch { + return Response.json({ name: "App" }); + } + } + + // Serve a specific file from the app directory + if (!filePath) { + return Response.json({ error: "Missing 'file' parameter" }, { status: 400 }); + } + + const fileAbsPath = resolve(join(appAbsPath, filePath)); + + // Security: ensure file is within the app directory + const relToApp = relative(appAbsPath, fileAbsPath); + if (relToApp.startsWith("..") || relToApp.startsWith("/")) { + return Response.json({ error: "Path traversal denied" }, { status: 403 }); + } + + if (!await pathExists(fileAbsPath)) { + return Response.json({ error: "File not found" }, { status: 404 }); + } + + try { + const fileStat = await stat(fileAbsPath); + if (!fileStat.isFile()) { + return Response.json({ error: "Not a file" }, { status: 400 }); + } + + const mimeType = getMimeType(filePath); + const ext = extname(filePath).toLowerCase(); + + // For HTML files, inject the DenchClaw bridge SDK + if (ext === ".html" || ext === ".htm") { + const htmlContent = await readFile(fileAbsPath, "utf-8"); + const injected = injectBridgeIntoHtml(htmlContent); + return new Response(injected, { + status: 200, + headers: { + "Content-Type": mimeType, + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + } + + const content = await readFile(fileAbsPath); + return new Response(content, { + status: 200, + headers: { + "Content-Type": mimeType, + "Content-Length": String(content.length), + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} diff --git a/apps/web/app/api/apps/serve/[...path]/route.ts b/apps/web/app/api/apps/serve/[...path]/route.ts new file mode 100644 index 00000000000..7270292f4df --- /dev/null +++ b/apps/web/app/api/apps/serve/[...path]/route.ts @@ -0,0 +1,148 @@ +/** + * Path-based app file server. + * + * Serves files from .dench.app folders via path-based URLs so that relative + * references (CSS, JS, images) in HTML files resolve correctly. + * + * URL format: /api/apps/serve// + * Example: /api/apps/serve/apps/pacman.dench.app/style.css + * + * The app path is everything up to and including ".dench.app". + * The file path is everything after that. + */ +import { access, readFile, stat } from "node:fs/promises"; +import { join, extname, resolve, relative } from "node:path"; +import { resolveWorkspaceRoot } from "@/lib/workspace"; +import { injectBridgeIntoHtml } from "@/lib/app-bridge"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".wasm": "application/wasm", + ".map": "application/json", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".xml": "application/xml", + ".yaml": "text/yaml; charset=utf-8", + ".yml": "text/yaml; charset=utf-8", +}; + +function getMimeType(filepath: string): string { + const ext = extname(filepath).toLowerCase(); + return MIME_TYPES[ext] || "application/octet-stream"; +} + +async function pathExists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} + +/** + * Split a URL path into app path and file path. + * The app path is everything up to and including ".dench.app". + * e.g. "apps/pacman.dench.app/style.css" -> ["apps/pacman.dench.app", "style.css"] + */ +function splitAppPath(segments: string[]): { appPath: string; filePath: string } | null { + const joined = segments.join("/"); + const marker = ".dench.app"; + const idx = joined.indexOf(marker); + if (idx === -1) return null; + + const appEnd = idx + marker.length; + const appPath = joined.slice(0, appEnd); + const filePath = joined.slice(appEnd + 1) || "index.html"; + return { appPath, filePath }; +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path: segments } = await params; + + const split = splitAppPath(segments); + if (!split) { + return Response.json({ error: "Invalid app path — must contain .dench.app" }, { status: 400 }); + } + + const { appPath, filePath } = split; + + const workspaceRoot = resolveWorkspaceRoot(); + if (!workspaceRoot) { + return Response.json({ error: "No workspace configured" }, { status: 404 }); + } + + const appAbsPath = resolve(join(workspaceRoot, appPath)); + const relToWorkspace = relative(workspaceRoot, appAbsPath); + if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) { + return Response.json({ error: "Path traversal denied" }, { status: 403 }); + } + + const fileAbsPath = resolve(join(appAbsPath, filePath)); + const relToApp = relative(appAbsPath, fileAbsPath); + if (relToApp.startsWith("..") || relToApp.startsWith("/")) { + return Response.json({ error: "Path traversal denied" }, { status: 403 }); + } + + if (!await pathExists(fileAbsPath)) { + return Response.json({ error: "File not found" }, { status: 404 }); + } + + try { + const fileStat = await stat(fileAbsPath); + if (!fileStat.isFile()) { + return Response.json({ error: "Not a file" }, { status: 400 }); + } + + const mimeType = getMimeType(filePath); + const ext = extname(filePath).toLowerCase(); + + if (ext === ".html" || ext === ".htm") { + const htmlContent = await readFile(fileAbsPath, "utf-8"); + const injected = injectBridgeIntoHtml(htmlContent); + return new Response(injected, { + status: 200, + headers: { + "Content-Type": mimeType, + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + } + + const content = await readFile(fileAbsPath); + return new Response(content, { + status: 200, + headers: { + "Content-Type": mimeType, + "Content-Length": String(content.length), + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index d05187f594c..0d44792c2e5 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -16,7 +16,7 @@ export const runtime = "nodejs"; export type TreeNode = { name: string; path: string; // relative to workspace root (or ~skills/ for virtual nodes) - type: "object" | "document" | "folder" | "file" | "database" | "report"; + type: "object" | "document" | "folder" | "file" | "database" | "report" | "app"; icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; @@ -24,6 +24,15 @@ export type TreeNode = { virtual?: boolean; /** True when the entry is a symbolic link. */ symlink?: boolean; + /** App manifest metadata (only for type: "app"). */ + appManifest?: { + name: string; + description?: string; + icon?: string; + version?: string; + entry?: string; + runtime?: string; + }; }; type DbObject = { @@ -97,6 +106,29 @@ async function resolveEntryType( return null; } +/** Read .dench.yaml manifest from a .dench.app directory. */ +async function readAppManifest( + dirPath: string, +): Promise { + const yamlPath = join(dirPath, ".dench.yaml"); + if (!await pathExists(yamlPath)) return null; + + try { + const content = await readFile(yamlPath, "utf-8"); + const parsed = parseSimpleYaml(content); + return { + name: (parsed.name as string) || dirPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App", + description: parsed.description as string | undefined, + icon: parsed.icon as string | undefined, + version: parsed.version as string | undefined, + entry: (parsed.entry as string) || "index.html", + runtime: (parsed.runtime as string) || "static", + }; + } catch { + return null; + } +} + /** Recursively build a tree from a workspace directory. */ async function buildTree( absDir: string, @@ -145,6 +177,21 @@ async function buildTree( const isSymlink = entry.isSymbolicLink(); if (effectiveType === "directory") { + // Detect .dench.app folders -- treat as app nodes (no children exposed) + if (entry.name.endsWith(".dench.app")) { + const manifest = await readAppManifest(absPath); + const displayName = manifest?.name || entry.name.replace(/\.dench\.app$/, ""); + nodes.push({ + name: displayName, + path: relPath, + type: "app", + icon: manifest?.icon, + appManifest: manifest ?? { name: displayName, entry: "index.html", runtime: "static" }, + ...(isSymlink && { symlink: true }), + }); + continue; + } + const objectMeta = await readObjectMeta(absPath); const dbObject = dbObjects.get(entry.name); const children = await buildTree(absPath, relPath, dbObjects, showHidden); diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index 6b4feed7115..b62ea1c2ba1 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -28,7 +28,7 @@ type MemoryFile = { type TreeNode = { name: string; path: string; - type: "object" | "document" | "folder" | "file" | "database" | "report"; + type: "object" | "document" | "folder" | "file" | "database" | "report" | "app"; icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; diff --git a/apps/web/app/components/workspace/app-viewer.tsx b/apps/web/app/components/workspace/app-viewer.tsx new file mode 100644 index 00000000000..7acf48aa07f --- /dev/null +++ b/apps/web/app/components/workspace/app-viewer.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import type { DenchAppManifest } from "../../workspace/workspace-content"; + +/** Build a path-based URL for serving files from a .dench.app folder. */ +export function appServeUrl(appPath: string, filePath: string): string { + return `/api/apps/serve/${appPath}/${filePath}`; +} + +type AppViewerProps = { + appPath: string; + manifest: DenchAppManifest; +}; + +export function AppViewer({ appPath, manifest }: AppViewerProps) { + const iframeRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const entryFile = manifest.entry || "index.html"; + const appUrl = appServeUrl(appPath, entryFile); + + const handleReload = useCallback(() => { + setLoading(true); + setError(null); + if (iframeRef.current) { + iframeRef.current.src = `${appUrl}?_t=${Date.now()}`; + } + }, [appUrl]); + + const handleIframeLoad = useCallback(() => { + setLoading(false); + }, []); + + const handleIframeError = useCallback(() => { + setLoading(false); + setError("Failed to load app"); + }, []); + + // Set up postMessage bridge listener + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (!event.data || event.data.type !== "dench:request") return; + + const { id, method, params } = event.data; + const iframe = iframeRef.current; + if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return; + + const permissions = manifest.permissions || []; + + try { + let result: unknown; + + if (method === "app.getManifest") { + result = manifest; + } else if (method === "app.getTheme") { + result = document.documentElement.classList.contains("dark") ? "dark" : "light"; + } else if (method === "db.query" && permissions.includes("database")) { + const res = await fetch("/api/workspace/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: params?.sql }), + }); + result = await res.json(); + } else if (method === "db.execute" && permissions.includes("database")) { + const res = await fetch("/api/workspace/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: params?.sql }), + }); + result = await res.json(); + } else if (method === "files.read" && permissions.includes("files")) { + const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(params?.path)}`); + result = await res.json(); + } else if (method === "files.list" && permissions.includes("files")) { + const res = await fetch(`/api/workspace/tree?showHidden=0`); + result = await res.json(); + } else { + iframe.contentWindow.postMessage({ + type: "dench:response", + id, + error: `Unknown method or insufficient permissions: ${method}`, + }, "*"); + return; + } + + iframe.contentWindow.postMessage({ + type: "dench:response", + id, + result, + }, "*"); + } catch (err) { + iframe.contentWindow?.postMessage({ + type: "dench:response", + id, + error: err instanceof Error ? err.message : "Unknown error", + }, "*"); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [manifest]); + + const iconIsImage = manifest.icon && ( + manifest.icon.endsWith(".png") || + manifest.icon.endsWith(".svg") || + manifest.icon.endsWith(".jpg") || + manifest.icon.endsWith(".jpeg") || + manifest.icon.endsWith(".webp") + ); + + return ( +
+ {/* App header bar */} +
+ {iconIsImage ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} + + + {manifest.name} + + + {manifest.version && ( + + v{manifest.version} + + )} + + + APP + + +
+ {/* Reload button */} + + + {/* Open in new tab */} + { + (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "transparent"; + }} + > + + + + + +
+
+ + {/* App content */} +
+ {loading && ( +
+ {iconIsImage ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} +
+

+ Loading {manifest.name}... +

+
+ )} + + {error && ( +
+
+ + + + + +
+

+ {error} +

+ +
+ )} + +