Fix desktop preview and packaged app interactions (#879)

* Fix packaged deck navigation interactions

* Fix connector auth in packaged app and localized content coverage

* Fix Electron connector browser handoff contract
This commit is contained in:
shangxinyu1 2026-05-08 14:26:10 +08:00 committed by GitHub
parent b9d30aa30e
commit aec9428b08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 96 additions and 10 deletions

View file

@ -1,6 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
openExternal: (url: string): Promise<boolean> =>
ipcRenderer.invoke('shell:open-external', url),
pickFolder: (): Promise<string | null> => pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('dialog:pick-folder'), ipcRenderer.invoke('dialog:pick-folder'),
}); });

View file

@ -107,6 +107,10 @@ const MAC_WINDOW_CHROME_CSS = `
.entry-header [role="button"], .entry-header [role="button"],
.entry-tabs, .entry-tabs,
.entry-tabs *, .entry-tabs *,
.viewer-toolbar,
.viewer-toolbar *,
.deck-nav,
.deck-nav *,
.ds-modal-header, .ds-modal-header,
.ds-modal-header *, .ds-modal-header *,
.ds-modal-actions, .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 // a second handler" on the second createDesktopRuntime() call (e.g. dev
// hot-reload). removeHandler is a no-op when nothing is registered. // hot-reload). removeHandler is a no-op when nothing is registered.
ipcMain.removeHandler("dialog:pick-folder"); ipcMain.removeHandler("dialog:pick-folder");
ipcMain.removeHandler("shell:open-external");
ipcMain.handle("dialog:pick-folder", async () => { ipcMain.handle("dialog:pick-folder", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]; 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 consoleEntries: DesktopConsoleEntry[] = [];
const window = new BrowserWindow({ const window = new BrowserWindow({

View file

@ -4,7 +4,8 @@ import type { ConnectorDetail } from '@open-design/contracts';
declare global { declare global {
interface Window { interface Window {
electronAPI?: { electronAPI?: {
pickFolder: () => Promise<string | null>; openExternal?: (url: string) => Promise<boolean>;
pickFolder?: () => Promise<string | null>;
}; };
} }
} }
@ -376,7 +377,7 @@ export function NewProjectPanel({
if (!onImportFolder) return; if (!onImportFolder) return;
let pathToOpen: string; let pathToOpen: string;
if (hasElectronPicker) { if (hasElectronPicker) {
const picked = await window.electronAPI!.pickFolder(); const picked = await window.electronAPI!.pickFolder!();
if (!picked) return; if (!picked) return;
pathToOpen = picked; pathToOpen = picked;
} else { } else {

View file

@ -37,6 +37,15 @@ import type {
} from '../types'; } from '../types';
import type { ArtifactManifest } from '../artifacts/types'; import type { ArtifactManifest } from '../artifacts/types';
declare global {
interface Window {
electronAPI?: {
openExternal?: (url: string) => Promise<boolean>;
pickFolder?: () => Promise<string | null>;
};
}
}
export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self'; export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self';
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
export const DEPLOY_PROVIDER_IDS = [ export const DEPLOY_PROVIDER_IDS = [
@ -289,9 +298,13 @@ async function decodeConnectorError(resp: Response): Promise<string> {
export async function connectConnector(connectorId: string): Promise<ConnectorActionResult> { export async function connectConnector(connectorId: string): Promise<ConnectorActionResult> {
let authWindow: Window | null = null; let authWindow: Window | null = null;
const openExternal = window.electronAPI?.openExternal;
const useExternalBrowser = typeof openExternal === 'function';
try { try {
authWindow = window.open('about:blank', '_blank'); if (!useExternalBrowser) {
renderConnectorAuthLoading(authWindow); authWindow = window.open('about:blank', '_blank');
renderConnectorAuthLoading(authWindow);
}
const resp = await fetch(`/api/connectors/${encodeURIComponent(connectorId)}/connect`, { const resp = await fetch(`/api/connectors/${encodeURIComponent(connectorId)}/connect`, {
method: 'POST', method: 'POST',
}); });
@ -301,7 +314,12 @@ export async function connectConnector(connectorId: string): Promise<ConnectorAc
} }
const json = (await resp.json()) as ConnectorConnectResponse; const json = (await resp.json()) as ConnectorConnectResponse;
if (json.auth?.kind === 'redirect_required' && json.auth.redirectUrl) { if (json.auth?.kind === 'redirect_required' && json.auth.redirectUrl) {
if (authWindow) { if (useExternalBrowser) {
const opened = await openExternal(json.auth.redirectUrl);
if (!opened) {
return { connector: json.connector ?? null, error: popupBlockedMessage() };
}
} else if (authWindow) {
authWindow.location.href = json.auth.redirectUrl; authWindow.location.href = json.auth.redirectUrl;
} else { } else {
const redirected = window.open(json.auth.redirectUrl, '_blank'); const redirected = window.open(json.auth.redirectUrl, '_blank');

View file

@ -157,6 +157,51 @@ describe('connectConnector', () => {
}); });
expect(open).toHaveBeenCalledTimes(2); 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', () => { describe('uploadProjectFiles', () => {

View file

@ -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_ENTRYPOINTS_DIR_NAME = "prebundle-entrypoints";
export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = { export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = {
"blake3-wasm": "2.1.5",
"better-sqlite3": "12.9.0", "better-sqlite3": "12.9.0",
} as const; } as const;
@ -42,9 +43,10 @@ export const MAC_PREBUNDLE_POLICIES = {
label: "packaged main", label: "packaged main",
}, },
daemonCli: { daemonCli: {
externals: ["better-sqlite3"], externals: ["better-sqlite3", "blake3-wasm"],
forbiddenInputs: [ forbiddenInputs: [
"/node_modules/@open-design/daemon/", "/node_modules/@open-design/daemon/",
"/node_modules/blake3-wasm/",
"/node_modules/better-sqlite3/", "/node_modules/better-sqlite3/",
"/node_modules/electron/", "/node_modules/electron/",
"/node_modules/next/", "/node_modules/next/",
@ -55,9 +57,10 @@ export const MAC_PREBUNDLE_POLICIES = {
label: "daemon cli", label: "daemon cli",
}, },
daemonSidecar: { daemonSidecar: {
externals: ["better-sqlite3"], externals: ["better-sqlite3", "blake3-wasm"],
forbiddenInputs: [ forbiddenInputs: [
"/node_modules/@open-design/daemon/", "/node_modules/@open-design/daemon/",
"/node_modules/blake3-wasm/",
"/node_modules/better-sqlite3/", "/node_modules/better-sqlite3/",
"/node_modules/electron/", "/node_modules/electron/",
"/node_modules/next/", "/node_modules/next/",

View file

@ -63,11 +63,14 @@ describe("mac standalone prebundle policy", () => {
it("documents the explicit code-level bundle boundaries", () => { it("documents the explicit code-level bundle boundaries", () => {
expect(MAC_PREBUNDLE_ESBUILD_TARGET).toBe("node24"); expect(MAC_PREBUNDLE_ESBUILD_TARGET).toBe("node24");
expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]); expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]);
expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual(["better-sqlite3"]); expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual(["better-sqlite3", "blake3-wasm"]);
expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual(["better-sqlite3"]); expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual(["better-sqlite3", "blake3-wasm"]);
expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]); expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]);
expect(MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER).toContain("createRequire"); 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_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_DAEMON_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-sidecar.mjs");
expect(MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/web-sidecar.mjs"); expect(MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/web-sidecar.mjs");