diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 72494597b..f2a2f7ac9 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -1,6 +1,8 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { + openExternal: (url: string): Promise => + ipcRenderer.invoke('shell:open-external', url), pickFolder: (): Promise => ipcRenderer.invoke('dialog:pick-folder'), }); diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 5594763f2..8a2f036ec 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -107,6 +107,10 @@ const MAC_WINDOW_CHROME_CSS = ` .entry-header [role="button"], .entry-tabs, .entry-tabs *, + .viewer-toolbar, + .viewer-toolbar *, + .deck-nav, + .deck-nav *, .ds-modal-header, .ds-modal-header *, .ds-modal-actions, @@ -254,10 +258,20 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom // a second handler" on the second createDesktopRuntime() call (e.g. dev // hot-reload). removeHandler is a no-op when nothing is registered. ipcMain.removeHandler("dialog:pick-folder"); + ipcMain.removeHandler("shell:open-external"); ipcMain.handle("dialog:pick-folder", async () => { const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]; }); + ipcMain.handle("shell:open-external", async (_event, url: string) => { + if (!isHttpUrl(url)) return false; + try { + await shell.openExternal(url); + return true; + } catch { + return false; + } + }); const consoleEntries: DesktopConsoleEntry[] = []; const window = new BrowserWindow({ diff --git a/apps/web/src/components/NewProjectPanel.tsx b/apps/web/src/components/NewProjectPanel.tsx index fa0e6836c..da7bff743 100644 --- a/apps/web/src/components/NewProjectPanel.tsx +++ b/apps/web/src/components/NewProjectPanel.tsx @@ -4,7 +4,8 @@ import type { ConnectorDetail } from '@open-design/contracts'; declare global { interface Window { electronAPI?: { - pickFolder: () => Promise; + openExternal?: (url: string) => Promise; + pickFolder?: () => Promise; }; } } @@ -376,7 +377,7 @@ export function NewProjectPanel({ if (!onImportFolder) return; let pathToOpen: string; if (hasElectronPicker) { - const picked = await window.electronAPI!.pickFolder(); + const picked = await window.electronAPI!.pickFolder!(); if (!picked) return; pathToOpen = picked; } else { diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index a03c8d8c3..0b710bf10 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -37,6 +37,15 @@ import type { } from '../types'; import type { ArtifactManifest } from '../artifacts/types'; +declare global { + interface Window { + electronAPI?: { + openExternal?: (url: string) => Promise; + pickFolder?: () => Promise; + }; + } +} + export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self'; export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; export const DEPLOY_PROVIDER_IDS = [ @@ -289,9 +298,13 @@ async function decodeConnectorError(resp: Response): Promise { export async function connectConnector(connectorId: string): Promise { let authWindow: Window | null = null; + const openExternal = window.electronAPI?.openExternal; + const useExternalBrowser = typeof openExternal === 'function'; try { - authWindow = window.open('about:blank', '_blank'); - renderConnectorAuthLoading(authWindow); + if (!useExternalBrowser) { + authWindow = window.open('about:blank', '_blank'); + renderConnectorAuthLoading(authWindow); + } const resp = await fetch(`/api/connectors/${encodeURIComponent(connectorId)}/connect`, { method: 'POST', }); @@ -301,7 +314,12 @@ export async function connectConnector(connectorId: string): Promise { }); expect(open).toHaveBeenCalledTimes(2); }); + + it('opens connector auth in the system browser when Electron returns a success boolean', async () => { + const open = vi.fn(); + const openExternal = vi.fn(async () => true); + vi.stubGlobal('window', { + open, + electronAPI: { openExternal }, + } as unknown as Window & typeof globalThis); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, + }), { status: 200 })), + ); + + await expect(connectConnector('github')).resolves.toEqual({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + }); + 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 () => { + const open = vi.fn(); + const openExternal = vi.fn(async () => false); + vi.stubGlobal('window', { + open, + electronAPI: { openExternal }, + } as unknown as Window & typeof globalThis); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' }, + }), { status: 200 })), + ); + + await expect(connectConnector('github')).resolves.toEqual({ + connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] }, + error: 'Popup blocked. Allow popups for Open Design and try again.', + }); + expect(open).not.toHaveBeenCalled(); + expect(openExternal).toHaveBeenCalledWith('https://example.com/oauth'); + }); }); describe('uploadProjectFiles', () => { diff --git a/tools/pack/src/mac-prebundle.ts b/tools/pack/src/mac-prebundle.ts index a061665a2..137b9b417 100644 --- a/tools/pack/src/mac-prebundle.ts +++ b/tools/pack/src/mac-prebundle.ts @@ -14,6 +14,7 @@ export const MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER = export const MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME = "prebundle-entrypoints"; export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = { + "blake3-wasm": "2.1.5", "better-sqlite3": "12.9.0", } as const; @@ -42,9 +43,10 @@ export const MAC_PREBUNDLE_POLICIES = { label: "packaged main", }, daemonCli: { - externals: ["better-sqlite3"], + externals: ["better-sqlite3", "blake3-wasm"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", + "/node_modules/blake3-wasm/", "/node_modules/better-sqlite3/", "/node_modules/electron/", "/node_modules/next/", @@ -55,9 +57,10 @@ export const MAC_PREBUNDLE_POLICIES = { label: "daemon cli", }, daemonSidecar: { - externals: ["better-sqlite3"], + externals: ["better-sqlite3", "blake3-wasm"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", + "/node_modules/blake3-wasm/", "/node_modules/better-sqlite3/", "/node_modules/electron/", "/node_modules/next/", diff --git a/tools/pack/tests/mac-prebundle.test.ts b/tools/pack/tests/mac-prebundle.test.ts index 281f6e73a..983410b82 100644 --- a/tools/pack/tests/mac-prebundle.test.ts +++ b/tools/pack/tests/mac-prebundle.test.ts @@ -63,11 +63,14 @@ describe("mac standalone prebundle policy", () => { it("documents the explicit code-level bundle boundaries", () => { expect(MAC_PREBUNDLE_ESBUILD_TARGET).toBe("node24"); expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]); - expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual(["better-sqlite3"]); - expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual(["better-sqlite3"]); + expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual(["better-sqlite3", "blake3-wasm"]); + expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual(["better-sqlite3", "blake3-wasm"]); expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]); expect(MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER).toContain("createRequire"); - expect(MAC_PREBUNDLE_RUNTIME_DEPENDENCIES).toEqual({ "better-sqlite3": "12.9.0" }); + expect(MAC_PREBUNDLE_RUNTIME_DEPENDENCIES).toEqual({ + "better-sqlite3": "12.9.0", + "blake3-wasm": "2.1.5", + }); expect(MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-cli.mjs"); expect(MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-sidecar.mjs"); expect(MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/web-sidecar.mjs");