diff --git a/.ade/db.sqlite b/.ade/db.sqlite new file mode 100644 index 000000000..e69de29bb diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f465ed47c..d7f544316 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, shell } from "electron"; +import { app, BrowserWindow, nativeImage, protocol, shell } from "electron"; import path from "node:path"; type NodePtyType = typeof import("node-pty"); import { registerIpc } from "./services/ipc/registerIpc"; @@ -38,6 +38,7 @@ import { detectDefaultBaseRef, resolveRepoRoot, toProjectInfo, upsertProjectRow import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; +import { resolveAdeLayout } from "../shared/adeLayout"; import type { PortLease, ProjectInfo } from "../shared/types"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; @@ -228,7 +229,8 @@ async function createWindow(logger?: Logger): Promise { `frame-src 'none'`, `script-src ${cspSources} 'unsafe-inline'`, `style-src ${cspSources} 'unsafe-inline'`, - `img-src ${cspImageSources} data: blob:`, + `img-src ${cspImageSources} ade-artifact: data: blob:`, + `media-src ade-artifact:`, `font-src ${cspSources} data:`, `connect-src ${cspSources}${cspWsSources} https:`, `worker-src 'self' blob:`, @@ -394,7 +396,125 @@ async function createWindow(logger?: Logger): Promise { return win; } +// Register custom protocol for serving local artifact files (images, videos) to the renderer. +// Must be called before app.whenReady(). +protocol.registerSchemesAsPrivileged([ + { scheme: "ade-artifact", privileges: { standard: false, supportFetchAPI: true, stream: true } }, +]); + app.whenReady().then(async () => { + /** Canonical artifacts dir for the active project; ade-artifact:// only serves under this path. */ + let adeArtifactAllowedDir: string | null = null; + + const isPathInsideArtifactAllowRoot = (resolvedFile: string, allowedDir: string): boolean => { + let allowed: string; + try { + allowed = fs.realpathSync(allowedDir); + } catch { + return false; + } + const normFile = path.normalize(resolvedFile); + const normAllowed = path.normalize(allowed); + if (process.platform === "win32") { + return normFile.toLowerCase().startsWith(normAllowed.toLowerCase() + path.sep) + || normFile.toLowerCase() === normAllowed.toLowerCase(); + } + return normFile === normAllowed || normFile.startsWith(normAllowed + path.sep); + }; + + // Handle ade-artifact:// requests — serves local files for proof drawer previews. + // Path is encoded in the URL: ade-artifact:///absolute/path/to/file.png + protocol.handle("ade-artifact", (request) => { + const url = new URL(request.url); + let filePath = decodeURIComponent(url.pathname); + // On Windows, pathname starts with /C:/... — strip leading slash + if (process.platform === "win32" && /^\/[a-zA-Z]:/.test(filePath)) { + filePath = filePath.slice(1); + } + filePath = path.resolve(filePath); + let resolvedFile: string; + try { + resolvedFile = fs.realpathSync(filePath); + } catch { + console.warn("[ade-artifact] realpath failed", { filePath }); + return new Response("Not found", { status: 404 }); + } + const allowedDir = adeArtifactAllowedDir; + if (!allowedDir || !isPathInsideArtifactAllowRoot(resolvedFile, allowedDir)) { + console.warn("[ade-artifact] rejected path outside artifacts dir", { resolvedFile, allowedDir }); + return new Response("Not found", { status: 404 }); + } + try { + const stat = fs.statSync(resolvedFile); + if (!stat.isFile()) return new Response("Not found", { status: 404 }); + const fileSize = stat.size; + const ext = path.extname(resolvedFile).replace(/^\./, "").toLowerCase(); + const mimeMap: Record = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", + gif: "image/gif", bmp: "image/bmp", svg: "image/svg+xml", + mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", avi: "video/x-msvideo", mkv: "video/x-matroska", + }; + const mime = mimeMap[ext] ?? "application/octet-stream"; + + // Support Range requests — required for