From 2c128e0e913c5d691b334e01c1fed3db750052b9 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Tue, 19 May 2026 18:27:05 +0800 Subject: [PATCH] refactor desktop host bridge (#2246) --- .github/workflows/ci.yml | 5 +- apps/desktop/package.json | 1 + apps/desktop/src/main/index.ts | 2 + apps/desktop/src/main/pdf-export.ts | 5 +- apps/desktop/src/main/preload.cts | 141 +++++++++-- apps/desktop/src/main/runtime.ts | 5 +- .../tests/main/preload-host-boundary.test.ts | 25 ++ apps/packaged/src/index.ts | 2 + apps/web/package.json | 1 + apps/web/src/App.tsx | 23 +- apps/web/src/analytics/identity.ts | 15 +- apps/web/src/components/EntryShell.tsx | 22 +- apps/web/src/components/EntryView.tsx | 4 +- apps/web/src/components/NewProjectPanel.tsx | 44 ++-- .../src/components/pet/DesktopPetSurface.tsx | 3 +- apps/web/src/hooks/useTerminalLaunch.ts | 33 ++- .../src/lib/build-continue-in-cli-toast.ts | 4 +- apps/web/src/providers/registry.ts | 13 +- apps/web/src/runtime/exports.ts | 29 +-- apps/web/src/types/electron.d.ts | 54 ---- apps/web/src/utils/pickAndImportError.ts | 4 +- apps/web/tests/host-boundary.test.ts | 40 +++ .../lib/build-continue-in-cli-toast.test.ts | 4 +- apps/web/tests/providers/registry.test.ts | 43 ++-- apps/web/tests/runtime/exports.test.ts | 67 ++--- packages/AGENTS.md | 3 + packages/host/esbuild.config.mjs | 28 +++ packages/host/package.json | 40 +++ packages/host/src/index.ts | 232 ++++++++++++++++++ packages/host/src/testing.ts | 99 ++++++++ packages/host/tests/index.test.ts | 198 +++++++++++++++ packages/host/tsconfig.json | 21 ++ packages/host/tsconfig.tests.json | 9 + pnpm-lock.yaml | 21 ++ scripts/guard.ts | 1 + scripts/postinstall.mjs | 1 + tools/pack/src/linux.ts | 4 + tools/pack/src/mac/app.ts | 4 + tools/pack/src/win/app.ts | 6 +- .../tests/desktop-package-runtime.test.ts | 16 ++ 40 files changed, 1040 insertions(+), 232 deletions(-) create mode 100644 apps/desktop/tests/main/preload-host-boundary.test.ts delete mode 100644 apps/web/src/types/electron.d.ts create mode 100644 apps/web/tests/host-boundary.test.ts create mode 100644 packages/host/esbuild.config.mjs create mode 100644 packages/host/package.json create mode 100644 packages/host/src/index.ts create mode 100644 packages/host/src/testing.ts create mode 100644 packages/host/tests/index.test.ts create mode 100644 packages/host/tsconfig.json create mode 100644 packages/host/tsconfig.tests.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3489356e4..3d24a7edf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then daemon_tests_required=true fi - if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then web_tests_required=true fi if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then @@ -68,7 +68,7 @@ jobs: if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then tools_dev_tests_required=true fi - if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then tools_pack_tests_required=true fi if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then @@ -222,6 +222,7 @@ jobs: - name: Core package tests run: | pnpm --filter @open-design/contracts test + pnpm --filter @open-design/host test pnpm --filter @open-design/platform test pnpm --filter @open-design/sidecar test pnpm --filter @open-design/sidecar-proto test diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 774150ff2..c61ad89da 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" }, "dependencies": { + "@open-design/host": "workspace:*", "@open-design/platform": "workspace:*", "@open-design/sidecar": "workspace:*", "@open-design/sidecar-proto": "workspace:*" diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1fff970fa..dfe01d89b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -74,6 +74,7 @@ export type DesktopMainOptions = { * Node fetch can hit. */ discoverDaemonUrl?: () => Promise; + preloadPath?: string; update?: { currentVersion?: string | null; downloadRoot?: string | null; @@ -378,6 +379,7 @@ export async function runDesktopMain( desktopAuthSecret, discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime), discoverDaemonUrl: options.discoverDaemonUrl, + preloadPath: options.preloadPath, // Round-5 (lefarcen P1, mrcfps): runtime hands this back to itself // on `503 DESKTOP_AUTH_PENDING` to re-handshake with the daemon // (after a daemon restart, or after a missed startup window). The diff --git a/apps/desktop/src/main/pdf-export.ts b/apps/desktop/src/main/pdf-export.ts index 359658d3a..4849c139b 100644 --- a/apps/desktop/src/main/pdf-export.ts +++ b/apps/desktop/src/main/pdf-export.ts @@ -132,9 +132,8 @@ export type PrintReadyPdfTarget = { }; /** - * Direct Save-as-PDF flow for the renderer's - * `window.__odDesktop.printPdf()` bridge (the `od:print-pdf` IPC - * handler). + * Direct Save-as-PDF flow for the renderer host PDF bridge (the + * `od:print-pdf` IPC handler). * * Unlike {@link exportPdfFromHtml}, the document handed over here is * already a fully-wrapped sandboxed preview carrying the print-ready diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 584051c35..37eac674f 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -1,26 +1,102 @@ const { contextBridge, ipcRenderer } = require('electron'); +import type { + OpenDesignHostBridge, + OpenDesignHostActionResult, + OpenDesignHostFailure, + OpenDesignHostProjectImportResult, +} from '@open-design/host'; + +const OPEN_DESIGN_HOST_GLOBAL: typeof import('@open-design/host').OPEN_DESIGN_HOST_GLOBAL = '__od__'; +const OPEN_DESIGN_HOST_VERSION: typeof import('@open-design/host').OPEN_DESIGN_HOST_VERSION = 1; + type PrintPdfOptions = { deck?: boolean; }; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null && !Array.isArray(value); +} + +function reasonFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function failure(reason: string, details?: unknown): OpenDesignHostFailure { + return { + ...(details === undefined ? {} : { details }), + ok: false, + reason, + }; +} + +function actionFailure(reason: string, details?: unknown): OpenDesignHostActionResult { + return failure(reason, details); +} + +function importFailure(reason: string): OpenDesignHostProjectImportResult { + return failure(reason); +} + +function normalizeProjectImportResult(input: unknown): OpenDesignHostProjectImportResult { + if (!isRecord(input)) return failure('desktop import returned an invalid response', input); + if (input.ok !== true) { + if (input.canceled === true) return { canceled: true, ok: false }; + return failure( + typeof input.reason === 'string' && input.reason.length > 0 ? input.reason : 'unknown failure', + input.details, + ); + } + + const response = input.response; + if (!isRecord(response)) return failure('daemon import response was not an object', response); + const project = response.project; + const rawProjectId = isRecord(project) ? project.id : null; + const projectId = typeof rawProjectId === 'string' ? rawProjectId : null; + const conversationId = typeof response.conversationId === 'string' ? response.conversationId : null; + const entryFile = typeof response.entryFile === 'string' ? response.entryFile : null; + if (projectId == null || conversationId == null || entryFile == null) { + return failure('daemon import response did not include host project identifiers', response); + } + + return { + conversationId, + entryFile, + ok: true, + projectId, + }; +} + // PR #974 trust boundary. The renderer no longer receives a raw // filesystem path from the main process: `pickFolder` was deleted from // this bridge and replaced with `pickAndImport`, which shows the // folder picker, mints an HMAC token bound to the chosen path, and // POSTs `/api/import/folder` from the main process — all atomically. -// The renderer only ever sees the daemon's response shape (project, -// conversationId, entryFile) or a structured error envelope. A -// compromised renderer cannot name an arbitrary baseDir even -// indirectly because the picker dialog is the single source of paths -// crossing into the daemon, and it lives in the main process. -contextBridge.exposeInMainWorld('electronAPI', { - openExternal: (url: string): Promise => - ipcRenderer.invoke('shell:open-external', url), +// The renderer only ever sees the host-owned project identifiers or a +// structured error envelope. A compromised renderer cannot name an +// arbitrary baseDir even indirectly because the picker dialog is the +// single source of paths crossing into the daemon, and it lives in the +// main process. +const project = { pickAndImport: ( init?: { name?: string; skillId?: string | null; designSystemId?: string | null }, - ): Promise => - ipcRenderer.invoke('dialog:pick-and-import', init ?? null), + ): Promise => + ipcRenderer.invoke('dialog:pick-and-import', init ?? null) + .then(normalizeProjectImportResult) + .catch((error: unknown) => importFailure(reasonFromError(error))), +}; + +const shell = { + openExternal: async (url: string): Promise => { + try { + const opened = await ipcRenderer.invoke('shell:open-external', url); + return opened === true + ? { ok: true } + : actionFailure('external URL was not opened'); + } catch (error) { + return actionFailure(reasonFromError(error)); + } + }, // Reveals the named project's working directory in the OS file // manager. The renderer passes a project ID; the main process asks // the daemon for the canonical resolvedDir and forwards that path @@ -29,14 +105,39 @@ contextBridge.exposeInMainWorld('electronAPI', { // to be true (set by the HMAC-gated import flow), so renderer code // cannot ask the bridge to open arbitrary local paths even // indirectly through legacy or future project-creation routes. - openPath: (projectId: string): Promise => - ipcRenderer.invoke('shell:open-path', projectId), - setDesktopPetVisible: (visible: boolean): void => - ipcRenderer.send('desktop-pet:set-visible', Boolean(visible)), -}); + openPath: async (projectId: string): Promise => { + try { + const result = await ipcRenderer.invoke('shell:open-path', projectId); + if (typeof result === 'string' && result.length > 0) return actionFailure(result); + return { ok: true }; + } catch (error) { + return actionFailure(reasonFromError(error)); + } + }, +}; -contextBridge.exposeInMainWorld('__odDesktop', { - printPdf: (html: string, nonce?: string, options?: PrintPdfOptions) => - ipcRenderer.invoke('od:print-pdf', html, nonce, options ?? null), - isDesktop: true, -}); +const hostBridge = { + version: OPEN_DESIGN_HOST_VERSION, + client: { + type: 'desktop', + platform: process.platform, + }, + shell, + project, + pdf: { + print: async (html: string, nonce?: string, options?: PrintPdfOptions): Promise => { + try { + await ipcRenderer.invoke('od:print-pdf', html, nonce, options ?? null); + return { ok: true }; + } catch (error) { + return actionFailure(reasonFromError(error)); + } + }, + }, + pet: { + setVisible: (visible: boolean): void => + ipcRenderer.send('desktop-pet:set-visible', Boolean(visible)), + }, +} satisfies OpenDesignHostBridge; + +contextBridge.exposeInMainWorld(OPEN_DESIGN_HOST_GLOBAL, hostBridge); diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 9010e3c6c..34d67fc06 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -287,8 +287,9 @@ export type DesktopRuntimeOptions = { * calls bypass the protocol handler entirely. Optional so tools-dev * (where webUrl IS an http:// URL Node fetch can hit) can omit it * and the runtime falls back to `discoverUrl` for API calls too. - */ + */ discoverDaemonUrl?: () => Promise; + preloadPath?: string; /** * Round-5 (lefarcen P1, mrcfps): lazy re-handshake hook. The runtime * calls this when the daemon answers `503 DESKTOP_AUTH_PENDING` so a @@ -776,7 +777,7 @@ function parsePrintReadyPdfOptions(value: unknown): PrintReadyPdfOptions { } export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise { - const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.cjs"); + const preloadPath = options.preloadPath ?? join(dirname(fileURLToPath(import.meta.url)), "preload.cjs"); // ipcMain.handle() registers a handler in an internal map that is *not* // surfaced via eventNames(); the previous `!eventNames().includes(...)` diff --git a/apps/desktop/tests/main/preload-host-boundary.test.ts b/apps/desktop/tests/main/preload-host-boundary.test.ts new file mode 100644 index 000000000..1866c1a6b --- /dev/null +++ b/apps/desktop/tests/main/preload-host-boundary.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("desktop preload host boundary", () => { + it("exposes only the canonical Open Design host global", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(here, "../../src/main/preload.cts"), "utf8"); + const exposedGlobals = Array.from(source.matchAll(/contextBridge\.exposeInMainWorld\(([^,\n]+)/g)) + .map((match) => match[1]?.trim()); + const runtimeRequires = Array.from(source.matchAll(/require\((['"][^'"]+['"])\)/g)) + .map((match) => match[1]); + + expect(exposedGlobals).toEqual(["OPEN_DESIGN_HOST_GLOBAL"]); + expect(runtimeRequires).toEqual(["'electron'"]); + expect(source).toContain("OPEN_DESIGN_HOST_GLOBAL"); + expect(source).toContain("satisfies OpenDesignHostBridge"); + expect(source).not.toContain("@open-design/contracts"); + expect(source).not.toContain("exposeInMainWorld('electronAPI'"); + expect(source).not.toContain('exposeInMainWorld("__odDesktop"'); + expect(source).not.toContain("exposeInMainWorld('__odDesktop'"); + }); +}); diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index 3943cd582..2a2ab4e13 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -11,6 +11,7 @@ import { resolveAppIpcPath, } from "@open-design/sidecar"; import { readProcessStamp } from "@open-design/platform"; +import { join } from "node:path"; import { app, dialog } from "electron"; import { readPackagedConfig } from "./config.js"; @@ -117,6 +118,7 @@ async function main(): Promise { async discoverDaemonUrl() { return sidecars.daemon.url; }, + preloadPath: join(app.getAppPath(), "preload.cjs"), update: { currentVersion: config.appVersion, downloadRoot: paths.updateRoot, diff --git a/apps/web/package.json b/apps/web/package.json index cf110bb72..970f54294 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.32.1", "@open-design/contracts": "workspace:*", + "@open-design/host": "workspace:*", "@open-design/platform": "workspace:*", "@open-design/sidecar": "workspace:*", "@open-design/sidecar-proto": "workspace:*", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bb0309878..63a5c02f5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -61,6 +61,7 @@ import { createProject, createPluginShareProject, deleteProject as deleteProjectApi, + getProject, importClaudeDesignZip, importFolderProject, listProjects, @@ -72,6 +73,7 @@ import type { PluginShareAction, PluginShareProjectOutcome, } from './state/projects'; +import type { OpenDesignHostProjectImportSuccess } from '@open-design/host'; import { useI18n } from './i18n'; import { liveArtifactTabId } from './types'; import type { @@ -916,16 +918,21 @@ export function App() { }); }, []); - // PR #974: on Electron, the desktop main process owns the picker and - // the import POST atomically (`pickAndImport`). The renderer never - // sees the path or the HMAC token; it just receives the same - // ImportFolderResponse shape that `importFolderProject` would - // produce on web, and the App-level state update is identical. - const handleImportFolderResponse = useCallback(async (result: import('@open-design/contracts').ImportFolderResponse) => { - setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]); + // PR #974: on desktop, the host bridge owns the picker and import POST + // atomically. The renderer never sees the path, token, or daemon DTO; + // it receives host-owned project identifiers and refreshes project state + // through the normal daemon API. + const handleImportFolderResponse = useCallback(async (result: OpenDesignHostProjectImportSuccess) => { + const project = await getProject(result.projectId); + if (project != null) { + setProjects((curr) => [project, ...curr.filter((p) => p.id !== project.id)]); + } else { + const list = await listProjects(); + setProjects(list); + } navigate({ kind: 'project', - projectId: result.project.id, + projectId: result.projectId, fileName: result.entryFile, }); }, []); diff --git a/apps/web/src/analytics/identity.ts b/apps/web/src/analytics/identity.ts index 26a45473d..bb625edad 100644 --- a/apps/web/src/analytics/identity.ts +++ b/apps/web/src/analytics/identity.ts @@ -5,6 +5,7 @@ // headers (see @open-design/contracts/analytics). import type { AnalyticsClientType } from '@open-design/contracts/analytics'; +import { detectOpenDesignHostClientType } from '@open-design/host'; const ANONYMOUS_ID_KEY = 'open-design:analytics.anonymous_id'; const SESSION_ID_KEY = 'open-design:analytics.session_id'; @@ -52,18 +53,12 @@ export function getSessionId(): string { } } -// Desktop packaged builds set this marker on window in a preload script so -// the same web bundle can distinguish desktop runs from browser visits. -// Falls back to 'web' when the marker isn't present. +// Desktop packaged builds install the Open Design host bridge so the +// same web bundle can distinguish desktop runs from browser visits. +// Falls back to 'web' when the host bridge isn't present. export function detectClientType(): AnalyticsClientType { if (typeof window === 'undefined') return 'web'; - const w = window as Window & { - __OD_CLIENT_TYPE__?: AnalyticsClientType; - electronAPI?: unknown; - }; - if (w.__OD_CLIENT_TYPE__ === 'desktop') return 'desktop'; - if (w.electronAPI) return 'desktop'; - return 'web'; + return detectOpenDesignHostClientType(); } // Read the launch_source for app_launch. Best-effort: PerformanceNavigation diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 347c1688d..0bf418ef2 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -12,9 +12,13 @@ import { useEffect, useMemo, useState } from 'react'; import { defaultScenarioPluginIdForKind, type ConnectorDetail, - type ImportFolderResponse, type InstalledPluginRecord, } from '@open-design/contracts'; +import { + isOpenDesignHostAvailable, + pickAndImportHostProject, + type OpenDesignHostProjectImportSuccess, +} from '@open-design/host'; import { useT } from '../i18n'; import { navigate, useRoute } from '../router'; import type { @@ -158,7 +162,7 @@ interface Props { ) => Promise; onImportClaudeDesign: (file: File) => Promise | void; onImportFolder?: (baseDir: string) => Promise | void; - onImportFolderResponse?: (response: ImportFolderResponse) => Promise | void; + onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise | void; onOpenProject: (id: string) => void; onOpenLiveArtifact: (projectId: string, artifactId: string) => void; onDeleteProject: (id: string) => void; @@ -362,21 +366,19 @@ export function EntryShell({ async function handleChipFolderImport() { if (chipImporting) return; // PR #974 trust boundary: the renderer cannot pick a folder directly - // anymore — the bridge exposes `pickAndImport` instead (atomic - // pick + HMAC-gated import). On the web (no electronAPI) or when - // the bridge is older, fall back to opening the New Project modal - // so the user can paste a baseDir manually. + // anymore — the host exposes `pickAndImport` instead (atomic pick + + // HMAC-gated import). On the web, fall back to opening the New + // Project modal so the user can paste a baseDir manually. if ( - typeof window !== 'undefined' && - typeof window.electronAPI?.pickAndImport === 'function' && + isOpenDesignHostAvailable() && onImportFolderResponse ) { setChipImporting(true); try { - const result = await window.electronAPI.pickAndImport(); + const result = await pickAndImportHostProject(); if (!result || ('canceled' in result && result.canceled === true)) return; if (result.ok === true) { - await onImportFolderResponse(result.response); + await onImportFolderResponse(result); return; } setFolderImportError(formatPickAndImportFailure(result)); diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 8d7ed565b..c1bfe62ab 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from 'react'; import type { ConnectorDetail, ConnectorStatusResponse, - ImportFolderResponse, } from '@open-design/contracts'; +import type { OpenDesignHostProjectImportSuccess } from '@open-design/host'; import { DEFAULT_AUDIO_MODEL, DEFAULT_IMAGE_MODEL, @@ -100,7 +100,7 @@ interface Props { ) => Promise; onImportClaudeDesign: (file: File) => Promise | void; onImportFolder?: (baseDir: string) => Promise | void; - onImportFolderResponse?: (response: ImportFolderResponse) => Promise | void; + onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise | void; onOpenProject: (id: string) => void; onOpenLiveArtifact: (projectId: string, artifactId: string) => void; onDeleteProject: (id: string) => void; diff --git a/apps/web/src/components/NewProjectPanel.tsx b/apps/web/src/components/NewProjectPanel.tsx index 394edec60..56583a7f5 100644 --- a/apps/web/src/components/NewProjectPanel.tsx +++ b/apps/web/src/components/NewProjectPanel.tsx @@ -3,15 +3,14 @@ import { createTabToTracking, projectKindToTracking, } from '@open-design/contracts/analytics'; +import { + isOpenDesignHostAvailable, + pickAndImportHostProject, + type OpenDesignHostProjectImportSuccess, +} from '@open-design/host'; import { useAnalytics } from '../analytics/provider'; import { trackHomeClickCreateButton } from '../analytics/events'; -import type { ConnectorDetail, ImportFolderResponse } from '@open-design/contracts'; - -// Window.electronAPI is declared globally in apps/web/src/types/electron.d.ts -// so the new openPath + pickAndImport methods (#451 / PR #974) and -// existing openExternal stay in one place. PR #974 deleted the raw -// `pickFolder` bridge: the renderer no longer receives a filesystem -// path from the main process, only the daemon's import response. +import type { ConnectorDetail } from '@open-design/contracts'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; @@ -122,12 +121,12 @@ interface Props { // builds have no `shell.openPath` surface, so the renderer naming a // path here cannot escalate (PR #974 trust model). onImportFolder?: (baseDir: string) => Promise | void; - // Electron flow: the desktop main process owns the picker dialog and + // Host flow: the desktop main process owns the picker dialog and // the import call atomically (`pickAndImport` IPC). The renderer // never sees the path or the HMAC token; it only receives the - // daemon's import response and forwards it here so App-level state - // can update without a second fetch. - onImportFolderResponse?: (response: ImportFolderResponse) => Promise | void; + // host-owned project identifiers and forwards them here so App-level + // state can refresh through the daemon API. + onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise | void; mediaProviders?: Record; connectors?: ConnectorDetail[]; connectorsLoading?: boolean; @@ -548,28 +547,27 @@ export function NewProjectPanel({ } } - // PR #974: the bridge no longer exposes `pickFolder` (raw path - // crossing to the renderer). The Electron flow now uses - // `pickAndImport`, which performs the picker + the HMAC-gated import - // atomically in the main process and returns the daemon response. + // PR #974: the host bridge does not expose raw folder paths to the + // renderer. The desktop flow uses `pickAndImport`, which performs the + // picker + the HMAC-gated import atomically in the main process and + // returns host-owned project identifiers. // The web fallback continues to use the manual baseDir input — // browser builds have no `shell.openPath` surface so a renderer-named // path cannot escalate. - const hasElectronPickAndImport = - typeof window !== 'undefined' && typeof window.electronAPI?.pickAndImport === 'function'; + const hasHostPickAndImport = isOpenDesignHostAvailable(); async function handleOpenFolder() { - if (hasElectronPickAndImport) { + if (hasHostPickAndImport) { if (!onImportFolderResponse) return; setImportFolderError(null); setImportingFolder(true); try { - const result = await window.electronAPI!.pickAndImport!({ + const result = await pickAndImportHostProject({ skillId: skillIdForTab, }); if (!result) return; if (result.ok === true) { - await onImportFolderResponse(result.response); + await onImportFolderResponse(result); return; } // Round-4 (mrcfps #2): every non-OK shape used to fall through @@ -854,9 +852,9 @@ export function NewProjectPanel({ ) : null} - {(hasElectronPickAndImport ? onImportFolderResponse : onImportFolder) ? ( + {(hasHostPickAndImport ? onImportFolderResponse : onImportFolder) ? (
- {!hasElectronPickAndImport ? ( + {!hasHostPickAndImport ? ( void handleOpenFolder()} > diff --git a/apps/web/src/components/pet/DesktopPetSurface.tsx b/apps/web/src/components/pet/DesktopPetSurface.tsx index b04a4a416..91b72043c 100644 --- a/apps/web/src/components/pet/DesktopPetSurface.tsx +++ b/apps/web/src/components/pet/DesktopPetSurface.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { setHostPetVisible } from '@open-design/host'; import { RUNS_CHANGED_EVENT, listProjectRuns } from '../../providers/daemon'; import { loadConfig } from '../../state/config'; import { listProjects } from '../../state/projects'; @@ -36,7 +37,7 @@ export function DesktopPetSurface() { }, []); useEffect(() => { - window.electronAPI?.setDesktopPetVisible?.(Boolean(pet)); + setHostPetVisible(Boolean(pet)); }, [pet]); useEffect(() => { diff --git a/apps/web/src/hooks/useTerminalLaunch.ts b/apps/web/src/hooks/useTerminalLaunch.ts index bacd0d55c..c1d719edc 100644 --- a/apps/web/src/hooks/useTerminalLaunch.ts +++ b/apps/web/src/hooks/useTerminalLaunch.ts @@ -1,6 +1,6 @@ -// Capability-detected wrapper around the Electron shell.openPath +// Capability-detected wrapper around the Open Design host shell.openPath // bridge for the Continue in CLI button (#451). On desktop builds the -// preload exposes window.electronAPI.openPath; the renderer hands it +// host bridge exposes shell.openPath; the renderer hands it // a *project ID* (not a path) and the desktop main process asks the // daemon for the canonical resolvedDir before forwarding to // shell.openPath. The bridge opens the OS file manager at the @@ -8,44 +8,39 @@ // paths; it is NOT a terminal launcher). On the browser fallback, // the hook reports `web-fallback` so the caller can render a // manual-instruction toast naming the working directory. -// -// Note that shell.openPath resolves to the empty string on success and -// to a non-empty error string on failure; we treat any non-empty -// string return as `ok: false` so the caller can render the manual -// fallback toast. import { useMemo } from 'react'; +import { + isOpenDesignHostAvailable, + openHostProjectPath, +} from '@open-design/host'; export interface TerminalLaunchResult { - kind: 'electron' | 'web-fallback'; + kind: 'host' | 'web-fallback'; ok: boolean; } export interface TerminalLauncher { - isElectron: boolean; + isHost: boolean; open: (projectId: string) => Promise; } export function useTerminalLaunch(): TerminalLauncher { return useMemo(() => { - const isElectron = - typeof window !== 'undefined' && - typeof window.electronAPI?.openPath === 'function'; + const isHost = isOpenDesignHostAvailable(); async function open(projectId: string): Promise { - if (!isElectron) { + if (!isHost) { return { kind: 'web-fallback', ok: true }; } try { - const out = await window.electronAPI!.openPath!(projectId); - // Electron's shell.openPath resolves to '' on success. - const ok = typeof out === 'string' ? out.length === 0 : true; - return { kind: 'electron', ok }; + const result = await openHostProjectPath(projectId); + return { kind: 'host', ok: result.ok }; } catch { - return { kind: 'electron', ok: false }; + return { kind: 'host', ok: false }; } } - return { isElectron, open }; + return { isHost, open }; }, []); } diff --git a/apps/web/src/lib/build-continue-in-cli-toast.ts b/apps/web/src/lib/build-continue-in-cli-toast.ts index c485a946f..da2628b71 100644 --- a/apps/web/src/lib/build-continue-in-cli-toast.ts +++ b/apps/web/src/lib/build-continue-in-cli-toast.ts @@ -11,14 +11,14 @@ export function buildContinueInCliToast( projectDir: string, launched: TerminalLaunchResult, ): ContinueInCliToast { - if (launched.kind === 'electron' && launched.ok) { + if (launched.kind === 'host' && launched.ok) { return { message: `${CLIPBOARD_PREFIX}Folder opened. Run \`claude\` in your terminal here and paste the prompt.`, details: null, }; } - if (launched.kind === 'electron' && !launched.ok) { + if (launched.kind === 'host' && !launched.ok) { return { message: `${CLIPBOARD_PREFIX}Couldn't open the folder. Open your terminal at ${projectDir}, run \`claude\`, and paste the prompt.`, details: null, diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index d35013656..92a44b04f 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -54,8 +54,10 @@ import type { UpdateDeployConfigRequest, } from '../types'; import type { ArtifactManifest } from '../artifacts/types'; - -// Window.electronAPI is declared globally in apps/web/src/types/electron.d.ts. +import { + isOpenDesignHostAvailable, + openHostExternalUrl, +} from '@open-design/host'; export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self'; export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; @@ -753,8 +755,7 @@ async function decodeConnectorError(resp: Response): Promise { export async function connectConnector(connectorId: string): Promise { let authWindow: Window | null = null; - const openExternal = window.electronAPI?.openExternal; - const useExternalBrowser = typeof openExternal === 'function'; + const useExternalBrowser = isOpenDesignHostAvailable(); try { if (!useExternalBrowser) { authWindow = window.open('about:blank', '_blank'); @@ -783,8 +784,8 @@ export async function connectConnector(connectorId: string): Promise URL.revokeObjectURL(url), 60_000); } -type DesktopPrintPdfOptions = { - deck?: boolean; -}; - // Open the artifact in a new tab via a Blob URL with a self-printing // script injected. Going through a Blob URL (rather than `window.open('')` // + `document.write`) avoids two failure modes we hit before: @@ -617,26 +617,17 @@ export async function exportAsPdf( // omits allow-modals here because the native flow never calls // window.print(); granting it would let untrusted artifact code call // alert()/confirm() and stall the hidden Electron window indefinitely. - const desktopApi = - typeof window !== 'undefined' - ? (window as unknown as Record).__odDesktop as - | { - printPdf?: ( - html: string, - nonce?: string, - options?: DesktopPrintPdfOptions, - ) => Promise; - isDesktop?: boolean; - } - | undefined - : undefined; - if (desktopApi?.printPdf) { + if (isOpenDesignHostAvailable()) { if (sandboxedPreview) { doc = buildSandboxedPreviewDocument(doc, title); } doc = injectParentPrintReadyCache(doc, nonce); try { - await desktopApi.printPdf(doc, nonce, opts?.deck ? { deck: true } : undefined); + const result = await printHostPdf(doc, nonce, opts?.deck ? { deck: true } : undefined); + if (result.ok) return; + if (typeof alert !== 'undefined') { + alert('Print failed. Please try Export PDF again or use the browser version.'); + } } catch { if (typeof alert !== 'undefined') { alert('Print failed. Please try Export PDF again or use the browser version.'); diff --git a/apps/web/src/types/electron.d.ts b/apps/web/src/types/electron.d.ts deleted file mode 100644 index 33dc48114..000000000 --- a/apps/web/src/types/electron.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Single source of truth for the Electron preload bridge as seen from -// the web client. The bridge is exposed via contextBridge in -// apps/desktop/src/main/preload.cts; method shapes are kept in sync -// here so any web-side caller (NewProjectPanel, useTerminalLaunch, -// future consumers) shares one declaration. -// -// PR #974 trust boundary: `pickFolder` is intentionally absent. The -// renderer cannot receive a raw filesystem path from the main -// process — it can only ask the main process to show the picker and -// import the chosen folder atomically (`pickAndImport`). The -// `openPath` bridge additionally enforces a trusted-picker check on -// the main side so even legacy projects with a `metadata.baseDir` set -// outside the HMAC-gated flow cannot be opened. - -import type { ImportFolderResponse } from '@open-design/contracts'; - -export {}; - -export type DesktopPickAndImportResult = - | { ok: true; response: ImportFolderResponse } - | { canceled: true; ok: false } - | { details?: unknown; ok: false; reason: string }; - -declare global { - interface Window { - electronAPI?: { - openExternal?: (url: string) => Promise; - // Atomic main-process flow: show the native folder picker, mint - // an HMAC token bound to the chosen path, POST - // /api/import/folder with the token + body, return the daemon - // response (or a structured failure). Renderer never sees the - // path or the token. - pickAndImport?: (init?: { - name?: string; - skillId?: string | null; - designSystemId?: string | null; - }) => Promise; - // Reveals the project's working directory in the OS file - // manager. The argument is a project ID (not a filesystem - // path) — the desktop main process asks the daemon for the - // canonical resolvedDir and forwards that path to - // shell.openPath. Renderer never names the path directly so a - // compromised renderer cannot ask the bridge to open arbitrary - // local paths. For folder-imported projects, the main process - // additionally requires `metadata.fromTrustedPicker === true`, - // the marker stamped by the desktop HMAC-gated import flow. - // Resolves to '' on success and a non-empty error string on - // failure (Electron's shell.openPath contract, plus PR #974 - // trust-boundary failures). - openPath?: (projectId: string) => Promise; - setDesktopPetVisible?: (visible: boolean) => void; - }; - } -} diff --git a/apps/web/src/utils/pickAndImportError.ts b/apps/web/src/utils/pickAndImportError.ts index 5cc9eaeee..89c7f2597 100644 --- a/apps/web/src/utils/pickAndImportError.ts +++ b/apps/web/src/utils/pickAndImportError.ts @@ -1,4 +1,4 @@ -import type { DesktopPickAndImportResult } from '../types/electron'; +import type { OpenDesignHostProjectImportResult } from '@open-design/host'; /** * Best-effort flattening of the `details` field that the @@ -31,7 +31,7 @@ export function formatPickAndImportErrorDetails(details: unknown): string | unde } export function formatPickAndImportFailure( - result: DesktopPickAndImportResult, + result: OpenDesignHostProjectImportResult, ): { message: string; details?: string } { const reason = 'reason' in result && typeof result.reason === 'string' ? result.reason diff --git a/apps/web/tests/host-boundary.test.ts b/apps/web/tests/host-boundary.test.ts new file mode 100644 index 000000000..b2a5b6b8e --- /dev/null +++ b/apps/web/tests/host-boundary.test.ts @@ -0,0 +1,40 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const webRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function filesUnder(dir: string): string[] { + const entries = readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) return filesUnder(path); + return path; + }); + return entries.filter((path) => /\.(ts|tsx|cts|mts)$/.test(path)); +} + +describe('host bridge boundary', () => { + it('keeps web source and tests from directly reading preload globals', () => { + const forbidden = [ + 'electronAPI', + '__odDesktop', + '__OD_CLIENT_TYPE__', + '__od__', + 'OPEN_DESIGN_HOST_GLOBAL', + ]; + const candidates = [ + ...filesUnder(join(webRoot, 'src')), + ...filesUnder(join(webRoot, 'tests')).filter((path) => !path.endsWith('host-boundary.test.ts')), + ]; + const offenders = candidates.flatMap((path) => { + const source = readFileSync(path, 'utf8'); + return forbidden + .filter((token) => source.includes(token)) + .map((token) => `${path.replace(`${webRoot}/`, '')}: ${token}`); + }); + expect(offenders).toEqual([]); + }); +}); diff --git a/apps/web/tests/lib/build-continue-in-cli-toast.test.ts b/apps/web/tests/lib/build-continue-in-cli-toast.test.ts index de56bcf60..a9e5c8751 100644 --- a/apps/web/tests/lib/build-continue-in-cli-toast.test.ts +++ b/apps/web/tests/lib/build-continue-in-cli-toast.test.ts @@ -5,7 +5,7 @@ import { buildContinueInCliToast } from '../../src/lib/build-continue-in-cli-toa describe('buildContinueInCliToast', () => { it('prefixes every success path with clipboard confirmation', () => { expect( - buildContinueInCliToast('/work/acme', { kind: 'electron', ok: true }), + buildContinueInCliToast('/work/acme', { kind: 'host', ok: true }), ).toEqual({ message: 'Copied to clipboard. Folder opened. Run `claude` in your terminal here and paste the prompt.', @@ -13,7 +13,7 @@ describe('buildContinueInCliToast', () => { }); expect( - buildContinueInCliToast('/work/acme', { kind: 'electron', ok: false }), + buildContinueInCliToast('/work/acme', { kind: 'host', ok: false }), ).toEqual({ message: "Copied to clipboard. Couldn't open the folder. Open your terminal at /work/acme, run `claude`, and paste the prompt.", diff --git a/apps/web/tests/providers/registry.test.ts b/apps/web/tests/providers/registry.test.ts index bf887cb65..35bb9178b 100644 --- a/apps/web/tests/providers/registry.test.ts +++ b/apps/web/tests/providers/registry.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { installMockOpenDesignHost } from '@open-design/host/testing'; import { cancelConnectorAuthorization, @@ -439,13 +440,15 @@ describe('connectConnector', () => { expect(authWindow.document.body.innerHTML).toContain('Authorization pending'); }); - it('opens connector auth in the system browser when Electron returns a success boolean', async () => { + it('opens connector auth in the system browser when the host bridge succeeds', async () => { const open = vi.fn(); - const openExternal = vi.fn(async () => true); + const openExternal = vi.fn(async () => ({ ok: true as const })); vi.stubGlobal('window', { open, - electronAPI: { openExternal }, } as unknown as Window & typeof globalThis); + const restoreHost = installMockOpenDesignHost({ + host: { shell: { openExternal } }, + }); const fetchMock = vi.fn(async (url: string) => { if (url === '/api/connectors/auth-configs/prepare') { return new Response(JSON.stringify({ @@ -461,21 +464,27 @@ describe('connectConnector', () => { }); vi.stubGlobal('fetch', fetchMock); - await expect(connectConnector('github')).resolves.toEqual({ - connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, - auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, - }); + try { + await expect(connectConnector('github')).resolves.toEqual({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, + }); + } finally { + restoreHost(); + } expect(open).not.toHaveBeenCalled(); expect(openExternal).toHaveBeenCalledWith('https://example.com/oauth'); }); - it('surfaces an error when Electron cannot confirm that the system browser opened', async () => { + it('surfaces an error when the host bridge cannot confirm that the system browser opened', async () => { const open = vi.fn(); - const openExternal = vi.fn(async () => false); + const openExternal = vi.fn(async () => ({ ok: false as const, reason: 'blocked' })); vi.stubGlobal('window', { open, - electronAPI: { openExternal }, } as unknown as Window & typeof globalThis); + const restoreHost = installMockOpenDesignHost({ + host: { shell: { openExternal } }, + }); const fetchMock = vi.fn(async (url: string) => { if (url === '/api/connectors/auth-configs/prepare') { return new Response(JSON.stringify({ @@ -491,11 +500,15 @@ describe('connectConnector', () => { }); vi.stubGlobal('fetch', fetchMock); - await expect(connectConnector('github')).resolves.toEqual({ - connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, - auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, - error: 'Popup blocked. Allow popups for Open Design and try again.', - }); + try { + await expect(connectConnector('github')).resolves.toEqual({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, + error: 'Popup blocked. Allow popups for Open Design and try again.', + }); + } finally { + restoreHost(); + } expect(open).not.toHaveBeenCalled(); expect(openExternal).toHaveBeenCalledWith('https://example.com/oauth'); expect(fetchMock).not.toHaveBeenCalledWith('/api/connectors/github/authorization/cancel', { diff --git a/apps/web/tests/runtime/exports.test.ts b/apps/web/tests/runtime/exports.test.ts index 1458f1c6a..926aac357 100644 --- a/apps/web/tests/runtime/exports.test.ts +++ b/apps/web/tests/runtime/exports.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installMockOpenDesignHost } from '@open-design/host/testing'; import { archiveFilenameFrom, archiveRootFromFilePath, @@ -430,19 +431,17 @@ describe('sandboxed preview Blob exports', () => { expect(revokeSpy).toHaveBeenCalledWith('blob:test'); }); - it('uses the desktop native print bridge when __odDesktop.printPdf is available', async () => { - const printPdfMock = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('window', { - open: (_url: string, _target: string, features?: string) => { - openCalls.push([_url, _target]); - openedFeatures = features; - return mockWin; - }, - addEventListener: () => {}, - __odDesktop: { printPdf: printPdfMock, isDesktop: true }, + it('uses the desktop native print bridge when the host PDF bridge is available', async () => { + const printPdfMock = vi.fn().mockResolvedValue({ ok: true }); + const restoreHost = installMockOpenDesignHost({ + host: { pdf: { print: printPdfMock } }, }); - await exportAsPdf('', 'Desktop PDF'); + try { + await exportAsPdf('', 'Desktop PDF'); + } finally { + restoreHost(); + } expect(printPdfMock).toHaveBeenCalledTimes(1); expect(openCalls).toEqual([]); @@ -465,14 +464,16 @@ describe('sandboxed preview Blob exports', () => { }); it('passes deck intent through the desktop native print bridge', async () => { - const printPdfMock = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('window', { - open: () => mockWin, - addEventListener: () => {}, - __odDesktop: { printPdf: printPdfMock, isDesktop: true }, + const printPdfMock = vi.fn().mockResolvedValue({ ok: true }); + const restoreHost = installMockOpenDesignHost({ + host: { pdf: { print: printPdfMock } }, }); - await exportAsPdf('
One
', 'Desktop Deck', { deck: true }); + try { + await exportAsPdf('
One
', 'Desktop Deck', { deck: true }); + } finally { + restoreHost(); + } expect(printPdfMock).toHaveBeenCalledTimes(1); expect(printPdfMock.mock.calls[0]![2]).toEqual({ deck: true }); @@ -480,17 +481,19 @@ describe('sandboxed preview Blob exports', () => { }); it('injects image-waiting logic into the print-ready handshake for the desktop bridge', async () => { - const printPdfMock = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('window', { - open: () => mockWin, - addEventListener: () => {}, - __odDesktop: { printPdf: printPdfMock, isDesktop: true }, + const printPdfMock = vi.fn().mockResolvedValue({ ok: true }); + const restoreHost = installMockOpenDesignHost({ + host: { pdf: { print: printPdfMock } }, }); // HTML with an intentionally non-loadable image to exercise the // incomplete-image detection in the injected handshake. const html = '
test
'; - await exportAsPdf(html, 'Image Test'); + try { + await exportAsPdf(html, 'Image Test'); + } finally { + restoreHost(); + } const htmlArg = printPdfMock.mock.calls[0]![0]; // In the sandboxed wrapper the srcdoc attribute is HTML-escaped, so the @@ -517,16 +520,18 @@ describe('sandboxed preview Blob exports', () => { }); it('injects the readiness cache for non-sandboxed desktop exports too', async () => { - const printPdfMock = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('window', { - open: () => mockWin, - addEventListener: () => {}, - __odDesktop: { printPdf: printPdfMock, isDesktop: true }, + const printPdfMock = vi.fn().mockResolvedValue({ ok: true }); + const restoreHost = installMockOpenDesignHost({ + host: { pdf: { print: printPdfMock } }, }); - await exportAsPdf('
Trusted local document
', 'Trusted', { - sandboxedPreview: false, - }); + try { + await exportAsPdf('
Trusted local document
', 'Trusted', { + sandboxedPreview: false, + }); + } finally { + restoreHost(); + } expect(printPdfMock).toHaveBeenCalledTimes(1); const htmlArg = printPdfMock.mock.calls[0]![0]; diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 80b473f9e..2b1c12353 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -5,6 +5,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie ## Package responsibilities - `packages/contracts`: web/daemon app contract layer. Keep it pure TypeScript; it must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol. +- `packages/host`: web/desktop host bridge contract. It models renderer-facing host capabilities and helpers while keeping `window.__od__` access out of app UI code. - `packages/sidecar-proto`: Open Design sidecar business protocol. Owns app/mode/source constants, namespace validation, stamp descriptor/fields/flags, IPC message schema, status shapes, error semantics, and default product path constants. - `packages/sidecar`: generic sidecar runtime primitives. Includes bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime file helpers; it must not hard-code Open Design app keys or IPC business messages. - `packages/platform`: generic OS process primitives. Includes stamp serialization, command parsing, process matching/search, and well-known user-toolchain bin discovery; it must consume the `sidecar-proto` descriptor and must not hard-code `--od-stamp-*` details. The toolchain helper is the single source of truth shared by the daemon agent resolver (`apps/daemon/src/agents.ts`) and the packaged sidecar PATH builder (`apps/packaged/src/sidecars.ts`) so neither layer can drift the search list. @@ -26,6 +27,8 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie ```bash pnpm --filter @open-design/contracts typecheck +pnpm --filter @open-design/host typecheck +pnpm --filter @open-design/host test pnpm --filter @open-design/sidecar-proto typecheck pnpm --filter @open-design/sidecar-proto test pnpm --filter @open-design/sidecar typecheck diff --git a/packages/host/esbuild.config.mjs b/packages/host/esbuild.config.mjs new file mode 100644 index 000000000..5123d1caa --- /dev/null +++ b/packages/host/esbuild.config.mjs @@ -0,0 +1,28 @@ +import { build } from "esbuild"; + +const entryPoints = ["./src/index.ts", "./src/testing.ts"]; + +await Promise.all([ + build({ + bundle: true, + entryPoints, + format: "esm", + outbase: "./src", + outdir: "./dist", + outExtension: { ".js": ".mjs" }, + packages: "external", + platform: "neutral", + target: "es2024", + }), + build({ + bundle: true, + entryPoints, + format: "cjs", + outbase: "./src", + outdir: "./dist", + outExtension: { ".js": ".cjs" }, + packages: "external", + platform: "node", + target: "node24", + }), +]); diff --git a/packages/host/package.json b/packages/host/package.json new file mode 100644 index 000000000..d05156d3a --- /dev/null +++ b/packages/host/package.json @@ -0,0 +1,40 @@ +{ + "name": "@open-design/host", + "version": "0.7.0", + "private": true, + "type": "module", + "description": "Open Design renderer host bridge protocol.", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "default": "./dist/index.mjs" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.mjs", + "require": "./dist/testing.cjs", + "default": "./dist/testing.mjs" + } + }, + "scripts": { + "build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.28.0", + "typescript": "6.0.3", + "vitest": "4.1.6" + }, + "engines": { + "node": "~24" + } +} diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts new file mode 100644 index 000000000..b03d8dd2e --- /dev/null +++ b/packages/host/src/index.ts @@ -0,0 +1,232 @@ +export const OPEN_DESIGN_HOST_GLOBAL = "__od__"; +export const OPEN_DESIGN_HOST_VERSION = 1; + +export const OPEN_DESIGN_HOST_CLIENT_TYPES = Object.freeze({ + DESKTOP: "desktop", +} as const); + +export type OpenDesignHostClientType = + (typeof OPEN_DESIGN_HOST_CLIENT_TYPES)[keyof typeof OPEN_DESIGN_HOST_CLIENT_TYPES]; + +export type OpenDesignHostClient = { + platform?: string; + type: OpenDesignHostClientType; +}; + +export type OpenDesignHostFailure = { + details?: unknown; + ok: false; + reason: string; +}; + +export type OpenDesignHostActionResult = + | { ok: true } + | OpenDesignHostFailure; + +export type OpenDesignHostProjectImportInit = { + designSystemId?: string | null; + name?: string; + skillId?: string | null; +}; + +export type OpenDesignHostProjectImportSuccess = { + conversationId: string; + entryFile: string; + ok: true; + projectId: string; +}; + +export type OpenDesignHostProjectImportResult = + | OpenDesignHostProjectImportSuccess + | { + canceled: true; + ok: false; + } + | OpenDesignHostFailure; + +export type OpenDesignHostPdfPrintOptions = { + deck?: boolean; +}; + +export type OpenDesignHostBridge = { + client: OpenDesignHostClient; + pdf: { + print(html: string, nonce?: string, options?: OpenDesignHostPdfPrintOptions): Promise; + }; + pet: { + setVisible(visible: boolean): void; + }; + project: { + pickAndImport(init?: OpenDesignHostProjectImportInit): Promise; + }; + shell: { + openExternal(url: string): Promise; + openPath(projectId: string): Promise; + }; + version: typeof OPEN_DESIGN_HOST_VERSION; +}; + +export type OpenDesignHostGlobalScope = Record & { + window?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function failure(reason: string, details?: unknown): OpenDesignHostFailure { + return { + ...(details === undefined ? {} : { details }), + ok: false, + reason, + }; +} + +function hasFunction(record: Record, key: string): boolean { + return typeof record[key] === "function"; +} + +export function isOpenDesignHostBridge(value: unknown): value is OpenDesignHostBridge { + if (!isRecord(value)) return false; + if (value.version !== OPEN_DESIGN_HOST_VERSION) return false; + const client = value.client; + if (!isRecord(client) || client.type !== OPEN_DESIGN_HOST_CLIENT_TYPES.DESKTOP) return false; + if (client.platform != null && typeof client.platform !== "string") return false; + + const shell = value.shell; + if (!isRecord(shell) || !hasFunction(shell, "openExternal") || !hasFunction(shell, "openPath")) return false; + + const project = value.project; + if (!isRecord(project) || !hasFunction(project, "pickAndImport")) return false; + + const pdf = value.pdf; + if (!isRecord(pdf) || !hasFunction(pdf, "print")) return false; + + const pet = value.pet; + if (!isRecord(pet) || !hasFunction(pet, "setVisible")) return false; + + return true; +} + +/** + * Converts a privileged host adapter's raw project-import result into the + * host-owned renderer contract. The adapter may internally call daemon APIs, + * but only project identifiers cross the host bridge. + */ +export function normalizeOpenDesignHostProjectImportResult(input: unknown): OpenDesignHostProjectImportResult { + if (!isRecord(input)) { + return failure("desktop import returned an invalid response", input); + } + if (input.ok !== true) { + if (input.canceled === true) return { canceled: true, ok: false }; + const reason = typeof input.reason === "string" && input.reason.length > 0 + ? input.reason + : "unknown failure"; + return failure(reason, input.details); + } + + const response = input.response; + if (!isRecord(response)) { + return failure("daemon import response was not an object", response); + } + const project = response.project; + const rawProjectId = isRecord(project) ? project.id : null; + const projectId = typeof rawProjectId === "string" ? rawProjectId : null; + const conversationId = typeof response.conversationId === "string" ? response.conversationId : null; + const entryFile = typeof response.entryFile === "string" ? response.entryFile : null; + if (projectId == null || conversationId == null || entryFile == null) { + return failure("daemon import response did not include host project identifiers", response); + } + + return { + conversationId, + entryFile, + ok: true, + projectId, + }; +} + +function candidateFromScope(scope: OpenDesignHostGlobalScope): unknown { + if (OPEN_DESIGN_HOST_GLOBAL in scope) return scope[OPEN_DESIGN_HOST_GLOBAL]; + const windowValue = scope.window; + if (isRecord(windowValue) && OPEN_DESIGN_HOST_GLOBAL in windowValue) { + return windowValue[OPEN_DESIGN_HOST_GLOBAL]; + } + return undefined; +} + +export function getOpenDesignHost(scope: OpenDesignHostGlobalScope = globalThis): OpenDesignHostBridge | null { + const candidate = candidateFromScope(scope); + return isOpenDesignHostBridge(candidate) ? candidate : null; +} + +export function isOpenDesignHostAvailable(scope: OpenDesignHostGlobalScope = globalThis): boolean { + return getOpenDesignHost(scope) != null; +} + +export function detectOpenDesignHostClientType(scope: OpenDesignHostGlobalScope = globalThis): OpenDesignHostClientType | "web" { + return getOpenDesignHost(scope)?.client.type ?? "web"; +} + +function unavailable(reason: string): OpenDesignHostFailure { + return failure(reason); +} + +export async function openHostExternalUrl(url: string, scope: OpenDesignHostGlobalScope = globalThis): Promise { + const host = getOpenDesignHost(scope); + if (host == null) return unavailable("Open Design host is not available"); + try { + return await host.shell.openExternal(url); + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } +} + +export async function openHostProjectPath(projectId: string, scope: OpenDesignHostGlobalScope = globalThis): Promise { + const host = getOpenDesignHost(scope); + if (host == null) return unavailable("Open Design host is not available"); + try { + return await host.shell.openPath(projectId); + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } +} + +export async function pickAndImportHostProject( + init?: OpenDesignHostProjectImportInit, + scope: OpenDesignHostGlobalScope = globalThis, +): Promise { + const host = getOpenDesignHost(scope); + if (host == null) return unavailable("Open Design host is not available"); + try { + return await host.project.pickAndImport(init); + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } +} + +export async function printHostPdf( + html: string, + nonce?: string, + options?: OpenDesignHostPdfPrintOptions, + scope: OpenDesignHostGlobalScope = globalThis, +): Promise { + const host = getOpenDesignHost(scope); + if (host == null) return unavailable("Open Design host is not available"); + try { + return await host.pdf.print(html, nonce, options); + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } +} + +export function setHostPetVisible(visible: boolean, scope: OpenDesignHostGlobalScope = globalThis): OpenDesignHostActionResult { + const host = getOpenDesignHost(scope); + if (host == null) return unavailable("Open Design host is not available"); + try { + host.pet.setVisible(visible); + return { ok: true }; + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } +} diff --git a/packages/host/src/testing.ts b/packages/host/src/testing.ts new file mode 100644 index 000000000..5fa1d7490 --- /dev/null +++ b/packages/host/src/testing.ts @@ -0,0 +1,99 @@ +import { + OPEN_DESIGN_HOST_GLOBAL, + OPEN_DESIGN_HOST_VERSION, + type OpenDesignHostBridge, + type OpenDesignHostGlobalScope, +} from "./index.js"; + +export type MockOpenDesignHost = Partial> & { + client?: Partial; + pdf?: Partial; + pet?: Partial; + project?: Partial; + shell?: Partial; +}; + +export type MockOpenDesignHostOptions = { + host?: MockOpenDesignHost; + scope?: OpenDesignHostGlobalScope; +}; + +function defaultHost(): OpenDesignHostBridge { + return { + version: OPEN_DESIGN_HOST_VERSION, + client: { + type: "desktop", + platform: "test", + }, + shell: { + openExternal: async () => ({ ok: true }), + openPath: async () => ({ ok: true }), + }, + project: { + pickAndImport: async () => ({ + ok: true, + projectId: "project-test", + conversationId: "conversation-test", + entryFile: "index.html", + }), + }, + pdf: { + print: async () => ({ ok: true }), + }, + pet: { + setVisible: () => undefined, + }, + }; +} + +export function createMockOpenDesignHost(overrides: MockOpenDesignHost = {}): OpenDesignHostBridge { + const base = defaultHost(); + return { + ...base, + ...overrides, + client: { ...base.client, ...overrides.client }, + shell: { ...base.shell, ...overrides.shell }, + project: { ...base.project, ...overrides.project }, + pdf: { ...base.pdf, ...overrides.pdf }, + pet: { ...base.pet, ...overrides.pet }, + }; +} + +export function installMockOpenDesignHost(options: MockOpenDesignHostOptions = {}): () => void { + const scope = (options.scope ?? globalThis) as OpenDesignHostGlobalScope; + const host = createMockOpenDesignHost(options.host); + const windowValue = scope.window; + const targets = [ + scope, + ...(typeof windowValue === "object" && windowValue != null && windowValue !== scope + ? [windowValue as OpenDesignHostGlobalScope] + : []), + ]; + const previous = targets.map((target) => ({ + had: Object.prototype.hasOwnProperty.call(target, OPEN_DESIGN_HOST_GLOBAL), + target, + value: target[OPEN_DESIGN_HOST_GLOBAL], + })); + + for (const target of targets) { + Object.defineProperty(target, OPEN_DESIGN_HOST_GLOBAL, { + configurable: true, + value: host, + writable: true, + }); + } + + return () => { + for (const entry of previous) { + if (entry.had) { + Object.defineProperty(entry.target, OPEN_DESIGN_HOST_GLOBAL, { + configurable: true, + value: entry.value, + writable: true, + }); + } else { + delete entry.target[OPEN_DESIGN_HOST_GLOBAL]; + } + } + }; +} diff --git a/packages/host/tests/index.test.ts b/packages/host/tests/index.test.ts new file mode 100644 index 000000000..204106b40 --- /dev/null +++ b/packages/host/tests/index.test.ts @@ -0,0 +1,198 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it, vi } from "vitest"; + +import { + OPEN_DESIGN_HOST_GLOBAL, + OPEN_DESIGN_HOST_VERSION, + detectOpenDesignHostClientType, + getOpenDesignHost, + isOpenDesignHostAvailable, + isOpenDesignHostBridge, + normalizeOpenDesignHostProjectImportResult, + openHostExternalUrl, + pickAndImportHostProject, + printHostPdf, + openHostProjectPath, + setHostPetVisible, +} from "../src/index.js"; +import { createMockOpenDesignHost, installMockOpenDesignHost } from "../src/testing.js"; + +const hostRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function filesUnder(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) return filesUnder(path); + return /\.(ts|tsx|cts|mts)$/.test(path) ? [path] : []; + }); +} + +describe("open-design host contract", () => { + it("stays independent from daemon/web contracts", () => { + const pkg = JSON.parse(readFileSync(join(hostRoot, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }; + expect({ + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.optionalDependencies, + ...pkg.peerDependencies, + }).not.toHaveProperty("@open-design/contracts"); + + const offenders = filesUnder(join(hostRoot, "src")).filter((path) => + readFileSync(path, "utf8").includes("@open-design/contracts"), + ); + expect(offenders).toEqual([]); + }); + + it("recognizes the canonical bridge shape", () => { + const host = createMockOpenDesignHost(); + expect(isOpenDesignHostBridge(host)).toBe(true); + expect(host.version).toBe(OPEN_DESIGN_HOST_VERSION); + }); + + it("rejects legacy or incomplete bridge shapes", () => { + expect(isOpenDesignHostBridge({ version: OPEN_DESIGN_HOST_VERSION })).toBe(false); + expect(isOpenDesignHostBridge({ ...createMockOpenDesignHost(), version: 2 })).toBe(false); + expect(isOpenDesignHostBridge({ + ...createMockOpenDesignHost(), + shell: { openExternal: async () => ({ ok: true }) }, + })).toBe(false); + }); + + it("reads the bridge through the package-owned global accessor", () => { + const scope: Record = {}; + scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost(); + expect(getOpenDesignHost(scope)?.client.type).toBe("desktop"); + expect(isOpenDesignHostAvailable(scope)).toBe(true); + expect(detectOpenDesignHostClientType(scope)).toBe("desktop"); + }); + + it("falls back to web when no host is installed", () => { + expect(getOpenDesignHost({})).toBeNull(); + expect(isOpenDesignHostAvailable({})).toBe(false); + expect(detectOpenDesignHostClientType({})).toBe("web"); + }); + + it("wraps host action throws into structured failures", async () => { + const scope: Record = {}; + scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost({ + shell: { + openPath: vi.fn(async () => { + throw new Error("failed"); + }), + }, + }); + + await expect(openHostProjectPath("project-1", scope)).resolves.toEqual({ + ok: false, + reason: "failed", + }); + }); + + it("normalizes privileged project-import results into host-owned identifiers", () => { + const result = normalizeOpenDesignHostProjectImportResult({ + ok: true, + response: { + project: { + id: "project-1", + name: "Imported project", + resolvedDir: "/private/path/that-must-not-cross", + }, + conversationId: "conversation-1", + entryFile: "index.html", + }, + }); + + expect(result).toEqual({ + ok: true, + projectId: "project-1", + conversationId: "conversation-1", + entryFile: "index.html", + }); + expect(JSON.stringify(result)).not.toContain("resolvedDir"); + }); + + it("preserves canceled and structured failure project-import results", () => { + expect(normalizeOpenDesignHostProjectImportResult({ canceled: true, ok: false })).toEqual({ + canceled: true, + ok: false, + }); + expect(normalizeOpenDesignHostProjectImportResult({ + ok: false, + reason: "daemon returned HTTP 500", + details: { code: "boom" }, + })).toEqual({ + ok: false, + reason: "daemon returned HTTP 500", + details: { code: "boom" }, + }); + }); + + it("rejects malformed successful project-import results before they reach web callers", () => { + expect(normalizeOpenDesignHostProjectImportResult({ + ok: true, + response: { + project: { id: "project-1" }, + conversationId: "conversation-1", + }, + })).toEqual({ + ok: false, + reason: "daemon import response did not include host project identifiers", + details: { + project: { id: "project-1" }, + conversationId: "conversation-1", + }, + }); + }); + + it("routes all host actions through package-owned helpers", async () => { + const openExternal = vi.fn(async () => ({ ok: true as const })); + const openPath = vi.fn(async () => ({ ok: true as const })); + const pickAndImport = vi.fn(async () => ({ + ok: true as const, + projectId: "project-2", + conversationId: "conversation-2", + entryFile: "app.html", + })); + const print = vi.fn(async () => ({ ok: true as const })); + const setVisible = vi.fn(); + const scope: Record = {}; + scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost({ + shell: { openExternal, openPath }, + project: { pickAndImport }, + pdf: { print }, + pet: { setVisible }, + }); + + await expect(openHostExternalUrl("https://example.com", scope)).resolves.toEqual({ ok: true }); + await expect(openHostProjectPath("project-2", scope)).resolves.toEqual({ ok: true }); + await expect(pickAndImportHostProject({ skillId: "skill-1" }, scope)).resolves.toMatchObject({ + ok: true, + projectId: "project-2", + }); + await expect(printHostPdf("", "nonce", { deck: true }, scope)).resolves.toEqual({ ok: true }); + expect(setHostPetVisible(true, scope)).toEqual({ ok: true }); + + expect(openExternal).toHaveBeenCalledWith("https://example.com"); + expect(openPath).toHaveBeenCalledWith("project-2"); + expect(pickAndImport).toHaveBeenCalledWith({ skillId: "skill-1" }); + expect(print).toHaveBeenCalledWith("", "nonce", { deck: true }); + expect(setVisible).toHaveBeenCalledWith(true); + }); + + it("installs and restores test hosts without exposing callers to the global key", () => { + const scope: Record = {}; + const restore = installMockOpenDesignHost({ scope }); + expect(getOpenDesignHost(scope)).not.toBeNull(); + restore(); + expect(getOpenDesignHost(scope)).toBeNull(); + }); +}); diff --git a/packages/host/tsconfig.json b/packages/host/tsconfig.json new file mode 100644 index 000000000..61134e989 --- /dev/null +++ b/packages/host/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/host/tsconfig.tests.json b/packages/host/tsconfig.tests.json new file mode 100644 index 000000000..4579ff78a --- /dev/null +++ b/packages/host/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02ac20750..0a84cdcb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: apps/desktop: dependencies: + '@open-design/host': + specifier: workspace:* + version: link:../../packages/host '@open-design/platform': specifier: workspace:* version: link:../../packages/platform @@ -237,6 +240,9 @@ importers: '@open-design/contracts': specifier: workspace:* version: link:../../packages/contracts + '@open-design/host': + specifier: workspace:* + version: link:../../packages/host '@open-design/platform': specifier: workspace:* version: link:../../packages/platform @@ -352,6 +358,21 @@ importers: specifier: 4.1.6 version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.2)(yaml@2.9.0)) + packages/host: + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.28.0 + version: 0.28.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.2)(yaml@2.9.0)) + packages/platform: devDependencies: '@types/node': diff --git a/scripts/guard.ts b/scripts/guard.ts index b6cba8e69..ee0efa732 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -57,6 +57,7 @@ const residualAllowedExactPaths = new Set([ // dist output exists. "packages/agui-adapter/esbuild.config.mjs", "packages/contracts/esbuild.config.mjs", + "packages/host/esbuild.config.mjs", "packages/platform/esbuild.config.mjs", "packages/plugin-runtime/esbuild.config.mjs", "packages/registry-protocol/esbuild.config.mjs", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index a92c945b2..6ee125cba 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -8,6 +8,7 @@ const repoRoot = resolve(scriptDir, ".."); const buildTargets = [ "packages/contracts", + "packages/host", "packages/registry-protocol", "packages/agui-adapter", "packages/plugin-runtime", diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index ce055b885..2d08cd494 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -440,6 +440,10 @@ async function writeAssembledApp( ): Promise { await rm(paths.assembledAppRoot, { force: true, recursive: true }); await mkdir(paths.assembledAppRoot, { recursive: true }); + await cp( + join(config.workspaceRoot, "apps", "desktop", "dist", "main", "preload.cjs"), + join(paths.assembledAppRoot, "preload.cjs"), + ); const dependencies: Record = {}; for (const tarball of packed) { diff --git a/tools/pack/src/mac/app.ts b/tools/pack/src/mac/app.ts index e300d4734..ec688705c 100644 --- a/tools/pack/src/mac/app.ts +++ b/tools/pack/src/mac/app.ts @@ -180,6 +180,10 @@ export async function writeAssembledApp( const identity = resolveMacInstallIdentity(config); await rm(join(config.roots.output.namespaceRoot, "assembled"), { force: true, recursive: true }); await mkdir(paths.assembledAppRoot, { recursive: true }); + await cp( + join(config.workspaceRoot, "apps", "desktop", "dist", "main", "preload.cjs"), + join(paths.assembledAppRoot, "preload.cjs"), + ); const tarballByPackage = Object.fromEntries( packedTarballs.map((entry) => [entry.packageName, entry.fileName] as const), ); diff --git a/tools/pack/src/win/app.ts b/tools/pack/src/win/app.ts index f9df0cd1e..337baae00 100644 --- a/tools/pack/src/win/app.ts +++ b/tools/pack/src/win/app.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { promisify } from "node:util"; @@ -235,6 +235,10 @@ async function writeAssembledAppEntrypoints( options: { dependencies?: Record; usePrebundle?: boolean } = {}, ): Promise { await mkdir(paths.assembledAppRoot, { recursive: true }); + await cp( + join(config.workspaceRoot, "apps", "desktop", "dist", "main", "preload.cjs"), + join(paths.assembledAppRoot, "preload.cjs"), + ); await writeFile( paths.assembledPackageJsonPath, `${JSON.stringify( diff --git a/tools/pack/tests/desktop-package-runtime.test.ts b/tools/pack/tests/desktop-package-runtime.test.ts index 3d1dc6563..a9b5a05ec 100644 --- a/tools/pack/tests/desktop-package-runtime.test.ts +++ b/tools/pack/tests/desktop-package-runtime.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const desktopPackageRoot = join(repoRoot, "apps", "desktop"); +const packagedSourcePath = join(repoRoot, "apps", "packaged", "src", "index.ts"); function readDesktopPackageJson(): { exports?: Record; @@ -22,4 +23,19 @@ describe("desktop package runtime shape", () => { expect(pkg.exports?.["./main"]?.default).toBe("./dist/main/index.js"); expect(pkg.exports?.["./main"]?.types).toBe("./dist/main/index.d.ts"); }); + + it("places the sandbox preload next to packaged app entrypoints", () => { + const packagedSource = readFileSync(packagedSourcePath, "utf8"); + expect(packagedSource).toContain('preloadPath: join(app.getAppPath(), "preload.cjs")'); + + for (const relativePath of [ + "tools/pack/src/mac/app.ts", + "tools/pack/src/win/app.ts", + "tools/pack/src/linux.ts", + ]) { + const source = readFileSync(join(repoRoot, relativePath), "utf8"); + expect(source).toContain('"apps", "desktop", "dist", "main", "preload.cjs"'); + expect(source).toContain('join(paths.assembledAppRoot, "preload.cjs")'); + } + }); });