mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
refactor desktop host bridge (#2246)
This commit is contained in:
parent
6a6959ed30
commit
2c128e0e91
40 changed files with 1040 additions and 232 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:*"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(...)`
|
||||
|
|
|
|||
25
apps/desktop/tests/main/preload-host-boundary.test.ts
Normal file
25
apps/desktop/tests/main/preload-host-boundary.test.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
54
apps/web/src/types/electron.d.ts
vendored
54
apps/web/src/types/electron.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
apps/web/tests/host-boundary.test.ts
Normal file
40
apps/web/tests/host-boundary.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
packages/host/esbuild.config.mjs
Normal file
28
packages/host/esbuild.config.mjs
Normal 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",
|
||||
}),
|
||||
]);
|
||||
40
packages/host/package.json
Normal file
40
packages/host/package.json
Normal 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
232
packages/host/src/index.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
99
packages/host/src/testing.ts
Normal file
99
packages/host/src/testing.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
198
packages/host/tests/index.test.ts
Normal file
198
packages/host/tests/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
packages/host/tsconfig.json
Normal file
21
packages/host/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
packages/host/tsconfig.tests.json
Normal file
9
packages/host/tsconfig.tests.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const repoRoot = resolve(scriptDir, "..");
|
|||
|
||||
const buildTargets = [
|
||||
"packages/contracts",
|
||||
"packages/host",
|
||||
"packages/registry-protocol",
|
||||
"packages/agui-adapter",
|
||||
"packages/plugin-runtime",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue