refactor desktop host bridge (#2246)

This commit is contained in:
PerishFire 2026-05-19 18:27:05 +08:00 committed by GitHub
parent 6a6959ed30
commit 2c128e0e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1040 additions and 232 deletions

View file

@ -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

View file

@ -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:*"

View file

@ -74,6 +74,7 @@ export type DesktopMainOptions = {
* Node fetch can hit.
*/
discoverDaemonUrl?: () => Promise<string | null>;
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

View file

@ -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

View file

@ -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<string, unknown> {
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<boolean> =>
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<unknown> =>
ipcRenderer.invoke('dialog:pick-and-import', init ?? null),
): Promise<OpenDesignHostProjectImportResult> =>
ipcRenderer.invoke('dialog:pick-and-import', init ?? null)
.then(normalizeProjectImportResult)
.catch((error: unknown) => importFailure(reasonFromError(error))),
};
const shell = {
openExternal: async (url: string): Promise<OpenDesignHostActionResult> => {
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<string> =>
ipcRenderer.invoke('shell:open-path', projectId),
setDesktopPetVisible: (visible: boolean): void =>
ipcRenderer.send('desktop-pet:set-visible', Boolean(visible)),
});
openPath: async (projectId: string): Promise<OpenDesignHostActionResult> => {
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<OpenDesignHostActionResult> => {
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);

View file

@ -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<string | null>;
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<DesktopRuntime> {
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(...)`

View file

@ -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'");
});
});

View file

@ -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<void> {
async discoverDaemonUrl() {
return sidecars.daemon.url;
},
preloadPath: join(app.getAppPath(), "preload.cjs"),
update: {
currentVersion: config.appVersion,
downloadRoot: paths.updateRoot,

View file

@ -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:*",

View file

@ -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,
});
}, []);

View file

@ -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

View file

@ -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<PluginShareProjectOutcome>;
onImportClaudeDesign: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onImportFolderResponse?: (response: ImportFolderResponse) => Promise<void> | void;
onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise<void> | 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));

View file

@ -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<PluginShareProjectOutcome>;
onImportClaudeDesign: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onImportFolderResponse?: (response: ImportFolderResponse) => Promise<void> | void;
onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise<void> | void;
onOpenProject: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDeleteProject: (id: string) => void;

View file

@ -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> | 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> | void;
// host-owned project identifiers and forwards them here so App-level
// state can refresh through the daemon API.
onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise<void> | void;
mediaProviders?: Record<string, MediaProviderCredentials>;
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({
</button>
</>
) : null}
{(hasElectronPickAndImport ? onImportFolderResponse : onImportFolder) ? (
{(hasHostPickAndImport ? onImportFolderResponse : onImportFolder) ? (
<div className="newproj-open-folder">
{!hasElectronPickAndImport ? (
{!hasHostPickAndImport ? (
<input
type="text"
className="newproj-folder-input"
@ -870,7 +868,7 @@ export function NewProjectPanel({
<button
type="button"
className="ghost newproj-import"
disabled={(!hasElectronPickAndImport && !baseDir.trim()) || importingFolder}
disabled={(!hasHostPickAndImport && !baseDir.trim()) || importingFolder}
onClick={() => void handleOpenFolder()}
>
<Icon name="folder" size={13} />

View file

@ -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(() => {

View file

@ -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<TerminalLaunchResult>;
}
export function useTerminalLaunch(): TerminalLauncher {
return useMemo<TerminalLauncher>(() => {
const isElectron =
typeof window !== 'undefined' &&
typeof window.electronAPI?.openPath === 'function';
const isHost = isOpenDesignHostAvailable();
async function open(projectId: string): Promise<TerminalLaunchResult> {
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 };
}, []);
}

View file

@ -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,

View file

@ -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<string> {
export async function connectConnector(connectorId: string): Promise<ConnectorActionResult> {
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<ConnectorAc
const json = (await resp.json()) as ConnectorConnectResponse;
if (json.auth?.kind === 'redirect_required' && json.auth.redirectUrl) {
if (useExternalBrowser) {
const opened = await openExternal(json.auth.redirectUrl);
if (!opened) {
const opened = await openHostExternalUrl(json.auth.redirectUrl);
if (!opened.ok) {
return {
connector: json.connector ?? null,
auth: json.auth,

View file

@ -16,6 +16,10 @@ import { buildSrcdoc, type SrcdocOptions } from './srcdoc';
import { buildReactComponentSrcdoc } from './react-component';
import { buildZip } from './zip';
import { randomUUID } from '../utils/uuid';
import {
isOpenDesignHostAvailable,
printHostPdf,
} from '@open-design/host';
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
@ -578,10 +582,6 @@ export function openSandboxedPreviewInNewTab(
setTimeout(() => 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<string, unknown>).__odDesktop as
| {
printPdf?: (
html: string,
nonce?: string,
options?: DesktopPrintPdfOptions,
) => Promise<void>;
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.');

View file

@ -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<boolean>;
// 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<DesktopPickAndImportResult>;
// 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<string>;
setDesktopPetVisible?: (visible: boolean) => void;
};
}
}

View file

@ -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

View file

@ -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([]);
});
});

View file

@ -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.",

View file

@ -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', {

View file

@ -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('<script>window.parent.document.body.innerHTML="owned"</script>', 'Desktop PDF');
try {
await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', '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('<section class="slide">One</section>', 'Desktop Deck', { deck: true });
try {
await exportAsPdf('<section class="slide">One</section>', '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 = '<div><img src="https://example.com/will-not-load.png" alt="test"/></div>';
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('<main>Trusted local document</main>', 'Trusted', {
sandboxedPreview: false,
});
try {
await exportAsPdf('<main>Trusted local document</main>', 'Trusted', {
sandboxedPreview: false,
});
} finally {
restoreHost();
}
expect(printPdfMock).toHaveBeenCalledTimes(1);
const htmlArg = printPdfMock.mock.calls[0]![0];

View file

@ -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

View file

@ -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",
}),
]);

View file

@ -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"
}
}

232
packages/host/src/index.ts Normal file
View file

@ -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<OpenDesignHostActionResult>;
};
pet: {
setVisible(visible: boolean): void;
};
project: {
pickAndImport(init?: OpenDesignHostProjectImportInit): Promise<OpenDesignHostProjectImportResult>;
};
shell: {
openExternal(url: string): Promise<OpenDesignHostActionResult>;
openPath(projectId: string): Promise<OpenDesignHostActionResult>;
};
version: typeof OPEN_DESIGN_HOST_VERSION;
};
export type OpenDesignHostGlobalScope = Record<string, unknown> & {
window?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>, 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<OpenDesignHostActionResult> {
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<OpenDesignHostActionResult> {
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<OpenDesignHostProjectImportResult> {
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<OpenDesignHostActionResult> {
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));
}
}

View file

@ -0,0 +1,99 @@
import {
OPEN_DESIGN_HOST_GLOBAL,
OPEN_DESIGN_HOST_VERSION,
type OpenDesignHostBridge,
type OpenDesignHostGlobalScope,
} from "./index.js";
export type MockOpenDesignHost = Partial<Omit<OpenDesignHostBridge, "client" | "pdf" | "pet" | "project" | "shell">> & {
client?: Partial<OpenDesignHostBridge["client"]>;
pdf?: Partial<OpenDesignHostBridge["pdf"]>;
pet?: Partial<OpenDesignHostBridge["pet"]>;
project?: Partial<OpenDesignHostBridge["project"]>;
shell?: Partial<OpenDesignHostBridge["shell"]>;
};
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];
}
}
};
}

View file

@ -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<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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("<html></html>", "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("<html></html>", "nonce", { deck: true });
expect(setVisible).toHaveBeenCalledWith(true);
});
it("installs and restores test hosts without exposing callers to the global key", () => {
const scope: Record<string, unknown> = {};
const restore = installMockOpenDesignHost({ scope });
expect(getOpenDesignHost(scope)).not.toBeNull();
restore();
expect(getOpenDesignHost(scope)).toBeNull();
});
});

View file

@ -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"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View file

@ -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':

View file

@ -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",

View file

@ -8,6 +8,7 @@ const repoRoot = resolve(scriptDir, "..");
const buildTargets = [
"packages/contracts",
"packages/host",
"packages/registry-protocol",
"packages/agui-adapter",
"packages/plugin-runtime",

View file

@ -440,6 +440,10 @@ async function writeAssembledApp(
): Promise<void> {
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<string, string> = {};
for (const tarball of packed) {

View file

@ -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),
);

View file

@ -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<string, string>; usePrebundle?: boolean } = {},
): Promise<void> {
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(

View file

@ -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<string, { default?: string; types?: string }>;
@ -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")');
}
});
});