diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index e437d757..5beba48c 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -3,7 +3,6 @@ import { ELECTRON_IPC_CHANNELS } from './channels' import { ELECTRON_IPC_VALIDATORS, isElectronIpcChannel, - isElectronIpcChannelAllowedForOpenProjectMenuWindow, isElectronIpcChannelAllowedForPetWindow, validateElectronIpcPayload, } from './capabilities' @@ -31,21 +30,6 @@ describe('Electron IPC capabilities', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuShow, { - anchor: { x: 900, y: 8, width: 44, height: 32 }, - targets: [ - { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, - { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, - ], - zoom: 1, - })).toBe(true) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuShow, { - anchor: { x: 900, y: 8, width: 44, height: 32 }, - targets: [{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }], - zoom: 1, - })).toBe(false) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuReady, 1)).toBe(true) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuReady, 0)).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true) @@ -178,21 +162,6 @@ describe('Electron IPC capabilities', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '../escape')).toBe(false) }) - it('limits the open-project popup to its narrow IPC surface', () => { - expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow( - ELECTRON_IPC_CHANNELS.openProjectMenuGetState, - )).toBe(true) - expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow( - ELECTRON_IPC_CHANNELS.openProjectMenuSelect, - )).toBe(true) - expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow( - ELECTRON_IPC_CHANNELS.shellOpen, - )).toBe(false) - expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow( - ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken, - )).toBe(false) - }) - it('pins the reported appearance colors to literal 6-digit hex', () => { // Both values reach BrowserWindow.setBackgroundColor, which also accepts // #AARRGGBB — an 8-digit value would let a compromised renderer make the diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 2cfbc959..01ed590a 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -181,52 +181,6 @@ const urlWithOptionalBounds: Validator = value => const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value) -const finiteUiCoordinate = (value: unknown) => - typeof value === 'number' - && Number.isFinite(value) - && Math.abs(value) <= 100_000 - -const openProjectMenuShow: Validator = value => { - if (!isRecord(value) || !hasOnlyKeys(value, ['anchor', 'targets', 'zoom'])) return false - if (!isRecord(value.anchor) || !hasOnlyKeys(value.anchor, ['x', 'y', 'width', 'height'])) return false - if ( - !finiteUiCoordinate(value.anchor.x) - || !finiteUiCoordinate(value.anchor.y) - || !finiteUiCoordinate(value.anchor.width) - || !finiteUiCoordinate(value.anchor.height) - || Number(value.anchor.width) <= 0 - || Number(value.anchor.height) <= 0 - ) return false - if (typeof value.zoom !== 'number' || !Number.isFinite(value.zoom) || value.zoom < 0.5 || value.zoom > 2) { - return false - } - if (!Array.isArray(value.targets) || value.targets.length < 2 || value.targets.length > 32) return false - return value.targets.every((target) => { - if (!isRecord(target) || !hasOnlyKeys(target, ['id', 'kind', 'label', 'icon', 'iconUrl', 'platform'])) { - return false - } - return typeof target.id === 'string' - && target.id.length > 0 - && target.id.length <= 120 - && !/[\u0000-\u001f\u007f-\u009f]/.test(target.id) - && (target.kind === 'ide' || target.kind === 'file_manager') - && isSafeUiLabel(target.label) - && typeof target.icon === 'string' - && target.icon.length <= 120 - && typeof target.platform === 'string' - && target.platform.length <= 40 - && ( - target.iconUrl === undefined - || (typeof target.iconUrl === 'string' && target.iconUrl.length <= 2_048) - ) - }) -} - -const openProjectMenuRequestId: Validator = value => - typeof value === 'number' - && Number.isSafeInteger(value) - && value > 0 - // The colors reach BrowserWindow.setBackgroundColor, so they are pinned to a // literal 6-digit #RRGGBB. This is load-bearing, not tidiness: that API also // accepts #AARRGGBB, so an 8-digit value would let a compromised renderer make @@ -270,11 +224,6 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload, [ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload, - [ELECTRON_IPC_CHANNELS.openProjectMenuShow]: openProjectMenuShow, - [ELECTRON_IPC_CHANNELS.openProjectMenuGetState]: noPayload, - [ELECTRON_IPC_CHANNELS.openProjectMenuSelect]: stringPayload, - [ELECTRON_IPC_CHANNELS.openProjectMenuDismiss]: noPayload, - [ELECTRON_IPC_CHANNELS.openProjectMenuReady]: openProjectMenuRequestId, [ELECTRON_IPC_CHANNELS.petsList]: noPayload, [ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas, [ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas, @@ -349,16 +298,6 @@ const petWindowChannels = new Set([ ELECTRON_IPC_CHANNELS.petsFocusSession, ]) -const openProjectMenuWindowChannels = new Set([ - ELECTRON_IPC_CHANNELS.appGetLocalePreference, - ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages, - ELECTRON_IPC_CHANNELS.openProjectMenuGetState, - ELECTRON_IPC_CHANNELS.openProjectMenuSelect, - ELECTRON_IPC_CHANNELS.openProjectMenuDismiss, - ELECTRON_IPC_CHANNELS.openProjectMenuReady, - ELECTRON_IPC_CHANNELS.zoomSet, -]) - export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel { return allowedChannels.has(channel as ElectronIpcChannel) } @@ -370,7 +309,3 @@ export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload: export function isElectronIpcChannelAllowedForPetWindow(channel: ElectronIpcChannel): boolean { return petWindowChannels.has(channel) } - -export function isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel: ElectronIpcChannel): boolean { - return openProjectMenuWindowChannels.has(channel) -} diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index ed1fd577..d10ac562 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -12,11 +12,6 @@ export const ELECTRON_IPC_CHANNELS = { shellOpen: 'desktop:shell:open', shellOpenPath: 'desktop:shell:open-path', traceOpenWindow: 'desktop:trace:open-window', - openProjectMenuShow: 'desktop:open-project-menu:show', - openProjectMenuGetState: 'desktop:open-project-menu:get-state', - openProjectMenuSelect: 'desktop:open-project-menu:select', - openProjectMenuDismiss: 'desktop:open-project-menu:dismiss', - openProjectMenuReady: 'desktop:open-project-menu:ready', petsList: 'desktop:pets:list', petsCreateFromImage: 'desktop:pets:create-from-image', petsCreateFromAtlas: 'desktop:pets:create-from-atlas', @@ -80,7 +75,6 @@ export const ELECTRON_EVENT_CHANNELS = { updateDownloadEvent: 'desktop:update:download-event', windowResized: 'desktop:window:resized', nativeMenuNavigate: 'desktop:window:native-menu-navigate', - openProjectMenuState: 'desktop:open-project-menu:state', terminalOutput: 'desktop:terminal:output', terminalExit: 'desktop:terminal:exit', previewEvent: 'desktop:preview:event', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 46983505..08980791 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -5,7 +5,6 @@ import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANN import { isElectronIpcChannel, isElectronIpcChannelAllowedForPetWindow, - isElectronIpcChannelAllowedForOpenProjectMenuWindow, validateElectronIpcPayload, } from './ipc/capabilities' import { ElectronServerRuntime } from './services/serverRuntime' @@ -24,7 +23,6 @@ import { ElectronUpdaterService, updaterSessionProxyConfig } from './services/up import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke' import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal' import { ElectronPreviewService, type PreviewBounds } from './services/preview' -import { OpenProjectMenuWindowController } from './services/openProjectMenuWindow' import { configureLocalServerRequestAuth, configurePreviewSessionPermissions, @@ -66,7 +64,6 @@ import { writeLocalePreference, } from './services/localePreference' import type { Locale } from '../src/i18n/locale' -import type { DesktopOpenProjectMenuInput } from '../src/lib/desktopHost/types' import { createCustomPetCatalogLoader, createCustomPetFromAtlas, @@ -95,7 +92,6 @@ let serverRuntime: ElectronServerRuntime | null = null let updaterService: ElectronUpdaterService | null = null let terminalService: ElectronTerminalService | null = null let previewService: ElectronPreviewService | null = null -let openProjectMenuWindowController: OpenProjectMenuWindowController | null = null let petWindowController: PetWindowController | null = null const traceWindows = new Map() let isQuitting = false @@ -127,10 +123,6 @@ function petPreloadPath() { return path.join(appRoot(), 'electron-dist', 'pet-preload.cjs') } -function openProjectMenuPreloadPath() { - return path.join(appRoot(), 'electron-dist', 'open-project-menu-preload.cjs') -} - function previewAgentPath() { return path.join(appRoot(), 'src-tauri', 'resources', 'preview-agent.js') } @@ -344,20 +336,6 @@ function getPetWindowController() { return petWindowController } -function getOpenProjectMenuWindowController() { - openProjectMenuWindowController ??= new OpenProjectMenuWindowController({ - createWindow: options => new BrowserWindow(options), - getWorkArea: parent => screen.getDisplayMatching(parent.getContentBounds()).workArea, - preloadPath: openProjectMenuPreloadPath(), - platform: process.platform, - onCreated: window => { - installMainWindowNavigationGuards(window.webContents, { openExternal: openExternalUrl }) - }, - load: window => loadRendererEntry(window as BrowserWindow, { openProjectMenuWindow: '1' }), - }) - return openProjectMenuWindowController -} - const loadCustomPetCatalog = createCustomPetCatalogLoader(() => loadCustomPets({ inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(), })) @@ -393,12 +371,6 @@ function registerHandler( ) { throw new Error(`Electron IPC channel ${channel} is not available to the pet window`) } - if ( - openProjectMenuWindowController?.owns(senderWindow) && - !isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel) - ) { - throw new Error(`Electron IPC channel ${channel} is not available to the open-project menu window`) - } return handler(event, payload) }) } @@ -481,25 +453,6 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload))) - registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuShow, (event, payload) => { - const window = currentWindow(event) - if (window !== mainWindow) throw new Error('Only the main window can show the open-project menu') - return getOpenProjectMenuWindowController().show(window, payload as DesktopOpenProjectMenuInput) - }) - registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuGetState, event => - getOpenProjectMenuWindowController().getState(currentWindow(event))) - registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, (event, payload) => - getOpenProjectMenuWindowController().select(currentWindow(event), String(payload))) - registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss, event => { - const window = currentWindow(event) - const controller = getOpenProjectMenuWindowController() - if (window !== mainWindow && !controller.owns(window)) { - throw new Error('Only the main or open-project menu window can dismiss the menu') - } - controller.dismiss(controller.owns(window) ? window : null) - }) - registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuReady, (event, payload) => - getOpenProjectMenuWindowController().markReady(currentWindow(event), Number(payload))) registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets()) registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => { const input = payload as { @@ -753,12 +706,9 @@ async function createMainWindow() { configureLocalServerRequestAuth( mainWindow.webContents.session.webRequest, resolveMainRendererServerAccess, - details => ( - isAllowlistedMainRendererMediaRequest(details, mainWindow!.webContents.id) - || ( - openProjectMenuWindowController?.ownsWebContentsId(details.webContentsId) === true - && isAllowlistedMainRendererMediaRequest(details, details.webContentsId!) - ) + details => isAllowlistedMainRendererMediaRequest( + details, + mainWindow!.webContents.id, ), ) installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl }) diff --git a/desktop/electron/open-project-menu-preload.ts b/desktop/electron/open-project-menu-preload.ts deleted file mode 100644 index d6336ff7..00000000 --- a/desktop/electron/open-project-menu-preload.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron' - -import { - isElectronIpcChannelAllowedForOpenProjectMenuWindow, - validateElectronIpcPayload, -} from './ipc/capabilities' -import { - ELECTRON_EVENT_CHANNELS, - ELECTRON_IPC_CHANNELS, - type ElectronIpcChannel, -} from './ipc/channels' -import type { - DesktopHost, - DesktopOpenProjectMenuInput, - DesktopOpenProjectMenuState, -} from '../src/lib/desktopHost/types' -import type { Locale } from '../src/i18n/locale' - -function invoke(channel: ElectronIpcChannel, payload?: unknown): Promise { - if (!isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel)) { - return Promise.reject(new Error(`Electron IPC channel ${channel} is not available to the open-project menu`)) - } - if (!validateElectronIpcPayload(channel, payload)) { - return Promise.reject(new Error(`Invalid Electron IPC payload for ${channel}`)) - } - return ipcRenderer.invoke(channel, payload) as Promise -} - -const openProjectMenuHost = { - kind: 'electron', - isDesktop: true, - capabilities: { - appMode: false, - clipboard: false, - dialogs: false, - notifications: false, - previewWebview: false, - shell: false, - terminal: false, - updates: false, - windowControls: false, - zoom: true, - }, - app: { - getLocalePreference: () => invoke(ELECTRON_IPC_CHANNELS.appGetLocalePreference), - getPreferredSystemLanguages: () => invoke(ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages), - onLocaleChanged: (handler: (locale: Locale) => void) => { - const listener = (_event: Electron.IpcRendererEvent, locale: Locale) => handler(locale) - ipcRenderer.on(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener) - return Promise.resolve(() => ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener)) - }, - }, - openProjectMenu: { - show: (_input: DesktopOpenProjectMenuInput) => Promise.reject(new Error('Nested open-project menus are unavailable')), - getState: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuGetState), - select: (targetId: string) => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, targetId), - dismiss: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss), - ready: (requestId: number) => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuReady, requestId), - onState: (handler: (state: DesktopOpenProjectMenuState) => void) => { - const listener = (_event: Electron.IpcRendererEvent, state: DesktopOpenProjectMenuState) => handler(state) - ipcRenderer.on(ELECTRON_EVENT_CHANNELS.openProjectMenuState, listener) - return Promise.resolve(() => ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.openProjectMenuState, listener)) - }, - }, - zoom: { - set: (level: number) => invoke(ELECTRON_IPC_CHANNELS.zoomSet, level), - }, - appearance: { - setApplied: () => Promise.resolve(), - }, -} as unknown as DesktopHost - -contextBridge.exposeInMainWorld('desktopHost', openProjectMenuHost) diff --git a/desktop/electron/services/openProjectMenuWindow.test.ts b/desktop/electron/services/openProjectMenuWindow.test.ts deleted file mode 100644 index 4502bd29..00000000 --- a/desktop/electron/services/openProjectMenuWindow.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - OPEN_PROJECT_MENU_PANEL_WIDTH, - OpenProjectMenuWindowController, - openProjectMenuWindowBounds, - openProjectMenuWindowOptions, -} from './openProjectMenuWindow' - -const input = { - anchor: { x: 700, y: 10, width: 50, height: 32 }, - targets: [ - { id: 'vscode', kind: 'ide' as const, label: 'VS Code', icon: 'vscode', platform: 'darwin' }, - { id: 'finder', kind: 'file_manager' as const, label: 'Finder', icon: 'finder', platform: 'darwin' }, - ], - zoom: 1, -} - -describe('open project menu native window', () => { - it('creates a transparent child window above the parent', () => { - const parent = { id: 'parent' } - - expect(openProjectMenuWindowOptions({ - parent: parent as never, - preload: '/app/open-project-menu-preload.cjs', - platform: 'darwin', - })).toMatchObject({ - parent, - width: 1, - height: 1, - frame: false, - transparent: true, - resizable: false, - movable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - hasShadow: false, - show: false, - type: 'panel', - webPreferences: { - preload: '/app/open-project-menu-preload.cjs', - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }) - }) - - it('aligns the reused React panel to the trigger while leaving room for its shadow', () => { - expect(openProjectMenuWindowBounds({ - anchor: { x: 1200, y: 10, width: 50, height: 32 }, - contentBounds: { x: 100, y: 50, width: 1400, height: 900 }, - itemCount: 7, - workArea: { x: 0, y: 0, width: 1600, height: 1000 }, - zoom: 1, - })).toEqual({ - x: 1114, - y: 82, - width: OPEN_PROJECT_MENU_PANEL_WIDTH + 32, - height: 378, - }) - }) - - it('flips above the trigger when the native window would leave the work area', () => { - const bounds = openProjectMenuWindowBounds({ - anchor: { x: 700, y: 710, width: 50, height: 32 }, - contentBounds: { x: 0, y: 0, width: 1000, height: 800 }, - itemCount: 7, - workArea: { x: 0, y: 0, width: 1000, height: 800 }, - zoom: 1, - }) - - expect(bounds.x).toBe(514) - expect(bounds.y).toBe(342) - expect(bounds.y + bounds.height).toBeLessThanOrEqual(800) - }) - - it('scales the window and anchor geometry with the app zoom', () => { - const bounds = openProjectMenuWindowBounds({ - anchor: { x: 400, y: 10, width: 25, height: 16 }, - contentBounds: { x: 100, y: 50, width: 1400, height: 900 }, - itemCount: 2, - workArea: { x: 0, y: 0, width: 1600, height: 1000 }, - zoom: 2, - }) - - expect(bounds).toEqual({ - x: 478, - y: 82, - width: 504, - height: 276, - }) - }) - - it('shows only after the shared renderer reports ready and resolves the selected target', async () => { - const popup = new FakePopupWindow() - const controller = createController(popup) - const parent = { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }) } - - const selection = controller.show(parent as never, input) - await vi.waitFor(() => expect(popup.webContents.send).toHaveBeenCalled()) - const state = controller.getState(popup as never) - expect(state?.targets).toEqual(input.targets) - expect(popup.show).not.toHaveBeenCalled() - - controller.markReady(popup as never, state!.requestId) - await vi.waitFor(() => expect(popup.show).toHaveBeenCalledTimes(1)) - controller.select(popup as never, 'finder') - - await expect(selection).resolves.toBe('finder') - expect(popup.hide).toHaveBeenCalled() - }) - - it('dismisses the pending menu when the native popup loses focus', async () => { - const popup = new FakePopupWindow() - const controller = createController(popup) - const parent = { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }) } - - const selection = controller.show(parent as never, input) - await vi.waitFor(() => expect(popup.webContents.send).toHaveBeenCalled()) - const state = controller.getState(popup as never) - controller.markReady(popup as never, state!.requestId) - await vi.waitFor(() => expect(popup.show).toHaveBeenCalled()) - popup.emit('blur') - - await expect(selection).resolves.toBeNull() - }) -}) - -function createController(popup: FakePopupWindow) { - return new OpenProjectMenuWindowController({ - createWindow: () => popup as never, - getWorkArea: () => ({ x: 0, y: 0, width: 1000, height: 800 }), - load: async () => {}, - platform: 'darwin', - preloadPath: '/app/open-project-menu-preload.cjs', - }) -} - -class FakePopupWindow { - readonly listeners = new Map void>>() - readonly webContents = { - id: 42, - send: vi.fn(), - } - readonly focus = vi.fn() - readonly hide = vi.fn() - readonly setBounds = vi.fn() - readonly show = vi.fn() - isDestroyed() { return false } - on(event: string, listener: () => void) { - const listeners = this.listeners.get(event) ?? [] - listeners.push(listener) - this.listeners.set(event, listeners) - } - emit(event: string) { - for (const listener of this.listeners.get(event) ?? []) listener() - } -} diff --git a/desktop/electron/services/openProjectMenuWindow.ts b/desktop/electron/services/openProjectMenuWindow.ts deleted file mode 100644 index cea65114..00000000 --- a/desktop/electron/services/openProjectMenuWindow.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { BrowserWindowConstructorOptions, Rectangle } from 'electron' - -import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels' -import type { - DesktopOpenProjectMenuInput, - DesktopOpenProjectMenuState, -} from '../../src/lib/desktopHost/types' - -export const OPEN_PROJECT_MENU_PANEL_WIDTH = 220 -export const OPEN_PROJECT_MENU_ROW_HEIGHT = 48 -const PANEL_VERTICAL_CHROME = 10 -const WINDOW_PADDING = 16 -const ANCHOR_GAP = 6 -const READY_TIMEOUT_MS = 3_000 - -type ParentWindow = { - getContentBounds(): Rectangle -} - -type PopupWebContents = { - id: number - isDestroyed?(): boolean - send(channel: string, payload: unknown): void -} - -type PopupWindow = { - webContents: PopupWebContents - focus(): void - hide(): void - isDestroyed(): boolean - on(event: 'blur' | 'closed', listener: () => void): void - setBounds(bounds: Rectangle): void - show(): void -} - -export type OpenProjectMenuWindowControllerOptions = { - createWindow(options: BrowserWindowConstructorOptions): PopupWindow - getWorkArea(parent: ParentWindow): Rectangle - load(window: PopupWindow): Promise - onCreated?(window: PopupWindow): void - platform?: NodeJS.Platform - preloadPath: string -} - -export function openProjectMenuWindowOptions({ - parent, - preload, - platform = process.platform, -}: { - parent: BrowserWindowConstructorOptions['parent'] - preload: string - platform?: NodeJS.Platform -}): BrowserWindowConstructorOptions { - return { - parent, - width: 1, - height: 1, - autoHideMenuBar: true, - backgroundColor: '#00000000', - frame: false, - fullscreenable: false, - hasShadow: false, - maximizable: false, - minimizable: false, - movable: false, - resizable: false, - show: false, - skipTaskbar: true, - transparent: true, - type: platform === 'darwin' ? 'panel' : undefined, - webPreferences: { - preload, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - } -} - -export function openProjectMenuWindowBounds({ - anchor, - contentBounds, - itemCount, - workArea, - zoom, -}: { - anchor: DesktopOpenProjectMenuInput['anchor'] - contentBounds: Rectangle - itemCount: number - workArea: Rectangle - zoom: number -}): Rectangle { - const scale = Math.min(2, Math.max(0.5, zoom)) - const padding = WINDOW_PADDING * scale - const panelWidth = OPEN_PROJECT_MENU_PANEL_WIDTH * scale - const panelHeight = (itemCount * OPEN_PROJECT_MENU_ROW_HEIGHT + PANEL_VERTICAL_CHROME) * scale - const width = panelWidth + padding * 2 - const height = panelHeight + padding * 2 - - const anchorLeft = contentBounds.x + anchor.x * scale - const anchorTop = contentBounds.y + anchor.y * scale - const anchorRight = anchorLeft + anchor.width * scale - const anchorBottom = anchorTop + anchor.height * scale - - const preferredX = anchorRight - panelWidth - padding - const preferredBelow = anchorBottom + ANCHOR_GAP * scale - padding - const preferredAbove = anchorTop - ANCHOR_GAP * scale - panelHeight - padding - const maxX = workArea.x + workArea.width - width - const maxY = workArea.y + workArea.height - height - - const x = clamp(preferredX, workArea.x, maxX) - const y = preferredBelow + height <= workArea.y + workArea.height - ? preferredBelow - : preferredAbove >= workArea.y - ? preferredAbove - : clamp(preferredBelow, workArea.y, maxY) - - return { - x: Math.round(x), - y: Math.round(y), - width: Math.round(width), - height: Math.round(height), - } -} - -export class OpenProjectMenuWindowController { - private readonly options: OpenProjectMenuWindowControllerOptions - private window: PopupWindow | null = null - private loadPromise: Promise | null = null - private state: DesktopOpenProjectMenuState | null = null - private nextRequestId = 0 - private result: { - requestId: number - resolve(value: string | null): void - reject(reason: unknown): void - } | null = null - private ready: { - requestId: number - resolve(): void - reject(reason: unknown): void - timeout: ReturnType - } | null = null - private readyRequestId: number | null = null - private visible = false - - constructor(options: OpenProjectMenuWindowControllerOptions) { - this.options = options - } - - owns(window: PopupWindow | null | undefined): boolean { - return !!window && window === this.window - } - - ownsWebContentsId(id: number | undefined): boolean { - return typeof id === 'number' && id === this.window?.webContents.id - } - - getState(window: PopupWindow): DesktopOpenProjectMenuState | null { - this.assertOwner(window) - return this.state - } - - async show( - parent: ParentWindow & BrowserWindowConstructorOptions['parent'], - input: DesktopOpenProjectMenuInput, - ): Promise { - this.finish(null) - const requestId = ++this.nextRequestId - this.readyRequestId = null - this.state = { requestId, targets: input.targets } - const popup = this.ensureWindow(parent) - const resultPromise = new Promise((resolve, reject) => { - this.result = { requestId, resolve, reject } - }) - - try { - await this.loadPromise - if (this.result?.requestId !== requestId) return await resultPromise - - popup.setBounds(openProjectMenuWindowBounds({ - anchor: input.anchor, - contentBounds: parent.getContentBounds(), - itemCount: input.targets.length, - workArea: this.options.getWorkArea(parent), - zoom: input.zoom, - })) - popup.webContents.send(ELECTRON_EVENT_CHANNELS.openProjectMenuState, this.state) - await this.waitUntilReady(requestId) - if (this.result?.requestId !== requestId) return await resultPromise - - this.visible = true - popup.show() - popup.focus() - return await resultPromise - } catch (error) { - if (this.result?.requestId !== requestId) return await resultPromise - if (this.result?.requestId === requestId) { - this.result = null - this.state = null - this.visible = false - popup.hide() - } - throw error - } - } - - markReady(window: PopupWindow, requestId: number): void { - this.assertOwner(window) - if (this.state?.requestId === requestId) this.readyRequestId = requestId - if (this.ready?.requestId !== requestId) return - clearTimeout(this.ready.timeout) - const { resolve } = this.ready - this.ready = null - resolve() - } - - select(window: PopupWindow, targetId: string): void { - this.assertOwner(window) - if (!this.state?.targets.some((target) => target.id === targetId)) { - throw new Error('Unknown open-project target') - } - this.finish(targetId) - } - - dismiss(window?: PopupWindow | null): void { - if (window && !this.owns(window)) throw new Error('Open-project menu window does not own this request') - this.finish(null) - } - - private ensureWindow(parent: ParentWindow & BrowserWindowConstructorOptions['parent']): PopupWindow { - if (this.window && !this.window.isDestroyed()) return this.window - - const popup = this.options.createWindow(openProjectMenuWindowOptions({ - parent, - preload: this.options.preloadPath, - platform: this.options.platform, - })) - this.window = popup - popup.on('blur', () => { - if (this.visible) this.finish(null) - }) - popup.on('closed', () => { - if (this.window !== popup) return - this.rejectPending(new Error('Open-project menu window closed')) - this.window = null - this.loadPromise = null - }) - this.options.onCreated?.(popup) - this.loadPromise = this.options.load(popup) - return popup - } - - private waitUntilReady(requestId: number): Promise { - if (this.readyRequestId === requestId) return Promise.resolve() - this.ready?.reject(new Error('Open-project menu state was replaced')) - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (this.ready?.requestId !== requestId) return - this.ready = null - reject(new Error('Open-project menu renderer did not become ready')) - }, READY_TIMEOUT_MS) - this.ready = { requestId, resolve, reject, timeout } - }) - } - - private finish(value: string | null): void { - const pending = this.result - this.result = null - this.state = null - this.visible = false - if (this.ready) { - clearTimeout(this.ready.timeout) - this.ready.reject(new Error('Open-project menu dismissed before it became ready')) - this.ready = null - } - if (this.window && !this.window.isDestroyed()) this.window.hide() - pending?.resolve(value) - } - - private rejectPending(error: Error): void { - const pending = this.result - this.result = null - this.state = null - this.visible = false - if (this.ready) { - clearTimeout(this.ready.timeout) - this.ready.reject(error) - this.ready = null - } - pending?.reject(error) - } - - private assertOwner(window: PopupWindow): void { - if (!this.owns(window)) throw new Error('IPC sender is not the open-project menu window') - } -} - -function clamp(value: number, min: number, max: number): number { - if (max < min) return min - return Math.min(max, Math.max(min, value)) -} diff --git a/desktop/package.json b/desktop/package.json index f7126797..3dd4ae9e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -80,7 +80,7 @@ "dev": "vite", "build": "bun run build:preview-agent && node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build", "prepare:node-pty": "bun run ./scripts/prepare-node-pty.ts", - "build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/pet-preload.ts --outfile ./electron-dist/pet-preload.cjs --target node --format cjs --external electron && bun build ./electron/open-project-menu-preload.ts --outfile ./electron-dist/open-project-menu-preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron", + "build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/pet-preload.ts --outfile ./electron-dist/pet-preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron", "build:preview-agent": "bun run ./scripts/build-preview-agent.ts", "prepare:ripgrep": "bun run ./scripts/prepare-ripgrep.ts", "build:sidecars": "bun run ./scripts/build-sidecars.ts", diff --git a/desktop/src/components/layout/OpenProjectMenu.test.tsx b/desktop/src/components/layout/OpenProjectMenu.test.tsx index f812c3e3..9d31a634 100644 --- a/desktop/src/components/layout/OpenProjectMenu.test.tsx +++ b/desktop/src/components/layout/OpenProjectMenu.test.tsx @@ -51,12 +51,9 @@ vi.mock('../../stores/openTargetStore', () => ({ import { OpenProjectMenu } from './OpenProjectMenu' import { useOverlayStore } from '../../stores/overlayStore' -import { browserHost } from '../../lib/desktopHost/browserHost' -import type { DesktopHost } from '../../lib/desktopHost/types' describe('OpenProjectMenu', () => { beforeEach(() => { - delete window.desktopHost useOverlayStore.setState(useOverlayStore.getInitialState(), true) storeMocks.ensureTargets.mockReset() storeMocks.openTarget.mockReset() @@ -69,7 +66,6 @@ describe('OpenProjectMenu', () => { }) afterEach(() => { - delete window.desktopHost vi.restoreAllMocks() }) @@ -114,59 +110,7 @@ describe('OpenProjectMenu', () => { expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo') }) - it('uses the native popup window in Electron without hiding the browser preview', async () => { - storeMocks.state.targets = [ - { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, - { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, - ] - storeMocks.state.primaryTargetId = 'vscode' - storeMocks.openTarget.mockResolvedValue(undefined) - let resolveSelection: (targetId: string | null) => void = () => {} - const show = vi.fn(() => new Promise((resolve) => { - resolveSelection = resolve - })) - const dismiss = vi.fn().mockResolvedValue(undefined) - window.desktopHost = { - ...browserHost, - kind: 'electron', - isDesktop: true, - openProjectMenu: { - ...browserHost.openProjectMenu, - show, - dismiss, - }, - } as DesktopHost - - render() - const trigger = screen.getByRole('button', { name: 'Open project' }) - trigger.getBoundingClientRect = () => domRect({ - top: 8, - right: 944, - bottom: 40, - left: 900, - width: 44, - height: 32, - }) - - await act(async () => { - fireEvent.click(trigger) - }) - - expect(show).toHaveBeenCalledWith(expect.objectContaining({ - anchor: { x: 900, y: 8, width: 44, height: 32 }, - targets: storeMocks.state.targets, - })) - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(useOverlayStore.getState().count).toBe(0) - - await act(async () => { - resolveSelection('finder') - await Promise.resolve() - }) - await waitFor(() => expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo')) - }) - - it('keeps the browser preview visible and positions the DOM fallback outside it', async () => { + it('keeps the native browser preview visible and positions the menu outside it', async () => { storeMocks.state.targets = [ { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, diff --git a/desktop/src/components/layout/OpenProjectMenu.tsx b/desktop/src/components/layout/OpenProjectMenu.tsx index f06592c5..44922c27 100644 --- a/desktop/src/components/layout/OpenProjectMenu.tsx +++ b/desktop/src/components/layout/OpenProjectMenu.tsx @@ -6,9 +6,6 @@ import { useOpenTargetStore } from '../../stores/openTargetStore' import { useAnchoredPosition } from '@/hooks/useAnchoredPosition' import { useDismissable } from '@/hooks/useDismissable' import { TargetIcon } from '@/components/composite/TargetIcon' -import { getDesktopHost } from '@/lib/desktopHost' -import { useSettingsStore } from '@/stores/settingsStore' -import { OpenProjectMenuPanel } from './OpenProjectMenuPanel' type Props = { path: string | null | undefined @@ -20,14 +17,10 @@ export function OpenProjectMenu({ path }: Props) { const primaryTargetId = useOpenTargetStore((state) => state.primaryTargetId) const ensureTargets = useOpenTargetStore((state) => state.ensureTargets) const openTarget = useOpenTargetStore((state) => state.openTarget) - const appZoom = useSettingsStore((state) => state.uiZoom) const [open, setOpen] = useState(false) - const [nativeMenuFailed, setNativeMenuFailed] = useState(false) const buttonRef = useRef(null) const menuRef = useRef(null) const browserPreviewRef = useRef(null) - const desktopHost = getDesktopHost() - const useNativeMenu = desktopHost.kind === 'electron' && !nativeMenuFailed useEffect(() => { if (!path) { @@ -37,14 +30,10 @@ export function OpenProjectMenu({ path }: Props) { void ensureTargets() }, [ensureTargets, path]) - useEffect(() => () => { - if (desktopHost.kind === 'electron') void desktopHost.openProjectMenu.dismiss() - }, [desktopHost]) - const handleDismiss = useCallback(() => setOpen(false), []) useDismissable({ - open: open && !useNativeMenu, + open, refs: [menuRef], triggerRef: buttonRef, onDismiss: handleDismiss, @@ -63,7 +52,7 @@ export function OpenProjectMenu({ path }: Props) { }, [open]) const { style: menuPosition } = useAnchoredPosition({ - open: open && hasMenu && !useNativeMenu, + open: open && hasMenu, anchorRef: buttonRef, floatingRef: menuRef, avoidRef: browserPreviewRef, @@ -81,45 +70,6 @@ export function OpenProjectMenu({ path }: Props) { } } - const handleMenuToggle = () => { - if (!hasMenu) { - void handleOpenTarget(primaryTarget!.id) - return - } - - if (!useNativeMenu) { - setOpen((value) => !value) - return - } - - if (open) { - setOpen(false) - void desktopHost.openProjectMenu.dismiss() - return - } - - const anchor = buttonRef.current?.getBoundingClientRect() - if (!anchor) return - setOpen(true) - void desktopHost.openProjectMenu.show({ - anchor: { - x: anchor.left, - y: anchor.top, - width: anchor.width, - height: anchor.height, - }, - targets, - zoom: appZoom, - }).then((targetId) => { - setOpen(false) - if (targetId) void handleOpenTarget(targetId) - }).catch(() => { - // Keep the in-page collision-avoiding menu as a runtime fallback. - setNativeMenuFailed(true) - setOpen(true) - }) - } - if (!path || !primaryTarget) return null const buttonLabel = hasMenu @@ -135,7 +85,13 @@ export function OpenProjectMenu({ path }: Props) { aria-haspopup={hasMenu ? 'menu' : undefined} aria-expanded={hasMenu ? open : undefined} title={buttonLabel} - onClick={handleMenuToggle} + onClick={() => { + if (hasMenu) { + setOpen((value) => !value) + return + } + void handleOpenTarget(primaryTarget.id) + }} className={`inline-flex h-8 items-center justify-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)] transition-[background-color,color,border-color,box-shadow] duration-150 ease-out hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] ${ hasMenu ? 'min-w-[2.75rem] px-2 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]' @@ -146,14 +102,28 @@ export function OpenProjectMenu({ path }: Props) { {hasMenu && } - {open && hasMenu && !useNativeMenu ? createPortal( - void handleOpenTarget(targetId)} - className="fixed z-[var(--z-dropdown)]" + role="menu" + className="glass-panel fixed z-[var(--z-dropdown)] min-w-[220px] overflow-hidden rounded-[var(--radius-lg)] py-1" style={menuPosition} - />, + > + {targets.map((target) => ( + + ))} + , document.body, ) : null} diff --git a/desktop/src/components/layout/OpenProjectMenuPanel.tsx b/desktop/src/components/layout/OpenProjectMenuPanel.tsx deleted file mode 100644 index 52f69043..00000000 --- a/desktop/src/components/layout/OpenProjectMenuPanel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { forwardRef, type CSSProperties } from 'react' - -import { TargetIcon } from '@/components/composite/TargetIcon' -import type { OpenTarget } from '@/stores/openTargetStore' - -type Props = { - targets: OpenTarget[] - onSelect(targetId: string): void - className?: string - style?: CSSProperties - autoFocusFirst?: boolean -} - -/** Shared markup for the in-page fallback and the native popup window. */ -export const OpenProjectMenuPanel = forwardRef(function OpenProjectMenuPanel({ - targets, - onSelect, - className = '', - style, - autoFocusFirst = false, -}, ref) { - return ( -
- {targets.map((target, index) => ( - - ))} -
- ) -}) diff --git a/desktop/src/components/layout/OpenProjectMenuWindow.test.tsx b/desktop/src/components/layout/OpenProjectMenuWindow.test.tsx deleted file mode 100644 index 1e221820..00000000 --- a/desktop/src/components/layout/OpenProjectMenuWindow.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { browserHost } from '@/lib/desktopHost/browserHost' -import type { DesktopHost, DesktopOpenProjectMenuState } from '@/lib/desktopHost/types' -import { OpenProjectMenuWindow } from './OpenProjectMenuWindow' - -const state: DesktopOpenProjectMenuState = { - requestId: 7, - targets: [ - { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, - { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, - ], -} - -describe('OpenProjectMenuWindow', () => { - afterEach(() => { - delete window.desktopHost - vi.restoreAllMocks() - }) - - it('reuses the exact project-menu panel and reports readiness to the native host', async () => { - const ready = vi.fn().mockResolvedValue(undefined) - const select = vi.fn().mockResolvedValue(undefined) - window.desktopHost = createHost({ ready, select }) - - render() - - const menu = await screen.findByRole('menu') - expect(menu).toHaveClass('glass-panel', 'min-w-[220px]', 'rounded-[var(--radius-lg)]', 'py-1') - expect(screen.getByRole('menuitem', { name: 'VS Code' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'Finder' })).toBeInTheDocument() - await waitFor(() => expect(ready).toHaveBeenCalledWith(7)) - - fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' })) - expect(select).toHaveBeenCalledWith('finder') - }) - - it('dismisses the native window on Escape', async () => { - const dismiss = vi.fn().mockResolvedValue(undefined) - window.desktopHost = createHost({ dismiss }) - - render() - await screen.findByRole('menu') - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(dismiss).toHaveBeenCalled() - }) -}) - -function createHost(overrides: Partial): DesktopHost { - return { - ...browserHost, - kind: 'electron', - isDesktop: true, - openProjectMenu: { - ...browserHost.openProjectMenu, - getState: vi.fn().mockResolvedValue(state), - onState: vi.fn().mockResolvedValue(() => {}), - ready: vi.fn().mockResolvedValue(undefined), - select: vi.fn().mockResolvedValue(undefined), - ...overrides, - }, - } -} diff --git a/desktop/src/components/layout/OpenProjectMenuWindow.tsx b/desktop/src/components/layout/OpenProjectMenuWindow.tsx deleted file mode 100644 index 39dbc247..00000000 --- a/desktop/src/components/layout/OpenProjectMenuWindow.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useLayoutEffect, useState } from 'react' - -import { getDesktopHost } from '@/lib/desktopHost' -import type { DesktopOpenProjectMenuState } from '@/lib/desktopHost/types' -import { OpenProjectMenuPanel } from './OpenProjectMenuPanel' - -export function OpenProjectMenuWindow() { - const [state, setState] = useState(null) - - useEffect(() => { - const host = getDesktopHost().openProjectMenu - let cancelled = false - let unlisten: (() => void) | undefined - - void host.onState((next) => setState(next)).then((stop) => { - if (cancelled) stop() - else unlisten = stop - }) - void host.getState().then((initial) => { - if (!cancelled && initial) setState(initial) - }) - - return () => { - cancelled = true - unlisten?.() - } - }, []) - - useEffect(() => { - const dismiss = (event: KeyboardEvent) => { - if (event.key === 'Escape') void getDesktopHost().openProjectMenu.dismiss() - } - document.addEventListener('keydown', dismiss) - return () => document.removeEventListener('keydown', dismiss) - }, []) - - useLayoutEffect(() => { - if (!state) return - void getDesktopHost().openProjectMenu.ready(state.requestId) - }, [state]) - - if (!state) return null - - return ( -
- void getDesktopHost().openProjectMenu.select(targetId)} - /> -
- ) -} diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index 2e79014e..24a0a93e 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -122,24 +122,6 @@ export const browserHost: DesktopHost = { unsupported('Opening trace windows') }, }, - openProjectMenu: { - async show() { - return null - }, - async getState() { - return null - }, - async select() { - unsupported('Native open-project menu') - }, - async dismiss() {}, - async ready() { - unsupported('Native open-project menu') - }, - async onState() { - return noopUnlisten - }, - }, pets: { async list() { unsupported('Custom pets') diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index 8f74fb8b..e8b11780 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -131,27 +131,6 @@ describe('electron desktop host', () => { expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123') }) - it('routes the native open-project popup through its dedicated IPC contract', async () => { - const invoke = vi.fn().mockResolvedValue('finder') - const subscribe = vi.fn().mockResolvedValue(() => {}) - const host = createElectronHost({ invoke, subscribe }) - const input = { - anchor: { x: 900, y: 8, width: 44, height: 32 }, - targets: [ - { id: 'vscode', kind: 'ide' as const, label: 'VS Code', icon: 'vscode', platform: 'darwin' }, - { id: 'finder', kind: 'file_manager' as const, label: 'Finder', icon: 'finder', platform: 'darwin' }, - ], - zoom: 1, - } - const onState = vi.fn() - - await expect(host.openProjectMenu.show(input)).resolves.toBe('finder') - await host.openProjectMenu.onState(onState) - - expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.openProjectMenuShow, input) - expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.openProjectMenuState, onState) - }) - it('routes preview zoom through the preview IPC channel', async () => { const invoke = vi.fn().mockResolvedValue(undefined) const host = createElectronHost({ diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index 52668dc6..1d57cf0f 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -113,14 +113,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { trace: { openWindow: sessionId => invoke(ELECTRON_IPC_CHANNELS.traceOpenWindow, sessionId), }, - openProjectMenu: { - show: input => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuShow, input), - getState: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuGetState), - select: targetId => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, targetId), - dismiss: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss), - ready: requestId => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuReady, requestId), - onState: handler => subscribe(ELECTRON_EVENT_CHANNELS.openProjectMenuState, handler), - }, pets: { list: () => invoke(ELECTRON_IPC_CHANNELS.petsList), createFromImage: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromImage, input), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index c0b4b555..32295d41 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -136,31 +136,6 @@ export type PreviewBounds = { height: number } -export type DesktopOpenProjectTarget = { - id: string - kind: 'ide' | 'file_manager' - label: string - icon: string - iconUrl?: string - platform: string -} - -export type DesktopOpenProjectMenuInput = { - anchor: { - x: number - y: number - width: number - height: number - } - targets: DesktopOpenProjectTarget[] - zoom: number -} - -export type DesktopOpenProjectMenuState = { - requestId: number - targets: DesktopOpenProjectTarget[] -} - export type PreviewEvent = { type: string payload?: unknown @@ -328,14 +303,6 @@ export type DesktopHost = { trace?: { openWindow(sessionId: string): Promise } - openProjectMenu: { - show(input: DesktopOpenProjectMenuInput): Promise - getState(): Promise - select(targetId: string): Promise - dismiss(): Promise - ready(requestId: number): Promise - onState(handler: (state: DesktopOpenProjectMenuState) => void): Promise - } pets: { list(): Promise createFromImage(input: DesktopPetCreateInput): Promise diff --git a/desktop/src/main.test.tsx b/desktop/src/main.test.tsx index c87c0201..e4535b2d 100644 --- a/desktop/src/main.test.tsx +++ b/desktop/src/main.test.tsx @@ -53,15 +53,12 @@ describe('desktop bootstrap', () => { expect(window.__CC_HAHA_BOOTSTRAPPED__).toBe(true) }) - it('recognizes dedicated auxiliary window queries', async () => { - const { isOpenProjectMenuWindowLocation, isPetWindowLocation } = await import('./main') + it('recognizes only the dedicated pet window query', async () => { + const { isPetWindowLocation } = await import('./main') expect(isPetWindowLocation('?petWindow=1')).toBe(true) expect(isPetWindowLocation('?petWindow=0')).toBe(false) expect(isPetWindowLocation('?traceWindow=1')).toBe(false) - expect(isOpenProjectMenuWindowLocation('?openProjectMenuWindow=1')).toBe(true) - expect(isOpenProjectMenuWindowLocation('?openProjectMenuWindow=0')).toBe(false) - expect(isOpenProjectMenuWindowLocation('?petWindow=1')).toBe(false) }) it('surfaces bootstrap failures in the root element', async () => { diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index d76d79ea..d7be7f80 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -26,16 +26,10 @@ export function isPetWindowLocation(search = window.location.search): boolean { return new URLSearchParams(search).get('petWindow') === '1' } -export function isOpenProjectMenuWindowLocation(search = window.location.search): boolean { - return new URLSearchParams(search).get('openProjectMenuWindow') === '1' -} - function loadDesktopBootstrapModules() { - const appModule = isOpenProjectMenuWindowLocation() - ? import('./components/layout/OpenProjectMenuWindow').then(({ OpenProjectMenuWindow }) => ({ App: OpenProjectMenuWindow })) - : isPetWindowLocation() - ? import('./features/pets/PetApp').then(({ PetApp }) => ({ App: PetApp })) - : import('./App') + const appModule = isPetWindowLocation() + ? import('./features/pets/PetApp').then(({ PetApp }) => ({ App: PetApp })) + : import('./App') return Promise.all([ appModule, import('./components/ErrorBoundary'), @@ -46,8 +40,6 @@ function loadDesktopBootstrapModules() { if (isPetWindowLocation()) { document.documentElement.dataset.windowKind = 'pet' -} else if (isOpenProjectMenuWindowLocation()) { - document.documentElement.dataset.windowKind = 'open-project-menu' } export async function bootstrapDesktopApp( @@ -86,9 +78,6 @@ export async function bootstrapDesktopApp( runDesktopPersistenceMigrations() initializeTouchH5() +void initializeAppZoom() -void (async () => { - if (isOpenProjectMenuWindowLocation()) await initializeAppZoom() - else void initializeAppZoom() - await bootstrapDesktopApp() -})() +void bootstrapDesktopApp() diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 476fb63a..ddf2d6ce 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -2148,13 +2148,6 @@ html[data-window-kind='pet'] #root { overflow: hidden; } -html[data-window-kind='open-project-menu'], -html[data-window-kind='open-project-menu'] body, -html[data-window-kind='open-project-menu'] #root { - background: transparent !important; - overflow: hidden; -} - .pet-window-root { position: relative; box-sizing: border-box;