From 98b79ce51a4a41581131d8cfda16cbabeb2d44d2 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Wed, 3 Jun 2026 23:22:35 +0800 Subject: [PATCH] fix: restore Windows custom desktop chrome Use frameless Electron chrome only on Windows and remove the native Windows application menu so the packaged app matches the previous Tauri-style desktop surface. Add a Windows-only manual drag fallback for desktop drag regions, while excluding tab reorder targets and preserving macOS/Linux native chrome and menu behavior. Tested: cd desktop && bun run test --run electron/services/menu.test.ts electron/services/windows.test.ts src/hooks/useElectronWindowDragRegions.test.tsx src/components/layout/TabBar.test.tsx src/components/layout/Sidebar.test.tsx src/lib/desktopHost/electronHost.test.ts electron/ipc/capabilities.test.ts Tested: cd desktop && SKIP_INSTALL=1 bun run build:windows-x64 Tested: Computer Use Windows packaged app smoke verified no native menu, custom controls, drag regions, tab reorder, close-to-background, and deepseek-v4-pro provider response FINAL_WINDOWS_ELECTRON_REAL_PROVIDER_OK. Not-tested: full bun run verify. Constraint: keep custom frameless behavior scoped to win32 so macOS and Linux native chrome paths remain intact. Confidence: high Scope-risk: moderate --- desktop/electron/ipc/capabilities.test.ts | 4 + desktop/electron/ipc/capabilities.ts | 13 +- desktop/electron/main.ts | 18 +- desktop/electron/services/menu.test.ts | 36 ++++ desktop/electron/services/menu.ts | 15 +- desktop/electron/services/windows.test.ts | 17 ++ desktop/electron/services/windows.ts | 30 +++- desktop/index.html | 2 +- desktop/src/components/layout/AppShell.tsx | 2 + desktop/src/components/layout/TabBar.test.tsx | 4 +- .../useElectronWindowDragRegions.test.tsx | 159 ++++++++++++++++++ .../src/hooks/useElectronWindowDragRegions.ts | 86 ++++++++++ .../src/lib/desktopHost/electronHost.test.ts | 19 ++- desktop/src/lib/desktopHost/electronHost.ts | 4 +- desktop/src/lib/desktopHost/types.ts | 7 +- desktop/src/theme/globals.css | 6 +- 16 files changed, 406 insertions(+), 16 deletions(-) create mode 100644 desktop/src/hooks/useElectronWindowDragRegions.test.tsx create mode 100644 desktop/src/hooks/useElectronWindowDragRegions.ts diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index 044c22a1..a4db3038 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -23,6 +23,10 @@ describe('Electron IPC capabilities', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).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) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, { deltaX: 4, deltaY: -2 })).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, { deltaX: 4, deltaY: Number.NaN })).toBe(false) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, { deltaX: 4, deltaY: -2, extra: true })).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: 1, data: 'pwd\n' })).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: '1', data: 'pwd\n' })).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: 80, rows: 24, cwd: '/tmp' })).toBe(true) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 6721d8a5..857e31a7 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -50,6 +50,17 @@ const boundsPayload: Validator = value => && typeof value.width === 'number' && typeof value.height === 'number' +const windowDragMovePayload: Validator = value => + value === undefined + || ( + isRecord(value) + && hasOnlyKeys(value, ['deltaX', 'deltaY']) + && typeof value.deltaX === 'number' + && Number.isFinite(value.deltaX) + && typeof value.deltaY === 'number' + && Number.isFinite(value.deltaY) + ) + const urlWithOptionalBounds: Validator = value => isRecord(value) && typeof value.url === 'string' @@ -84,7 +95,7 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload, [ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload, [ELECTRON_IPC_CHANNELS.windowClose]: noPayload, - [ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload, + [ELECTRON_IPC_CHANNELS.windowStartDragging]: windowDragMovePayload, [ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload, [ELECTRON_IPC_CHANNELS.windowFocus]: noPayload, [ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload, diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 69b1f365..cb20b5da 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -38,6 +38,7 @@ import { restoreWindowMaximized, saveWindowState, showMainWindow, + windowChromeOptionsForPlatform, windowOptionsFromState, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, @@ -243,7 +244,19 @@ function registerIpcHandlers() { else window.maximize() }) registerHandler(ELECTRON_IPC_CHANNELS.windowClose, event => currentWindow(event).close()) - registerHandler(ELECTRON_IPC_CHANNELS.windowStartDragging, () => undefined) + registerHandler(ELECTRON_IPC_CHANNELS.windowStartDragging, (event, payload) => { + if (!payload || typeof payload !== 'object') return undefined + const { deltaX, deltaY } = payload as { deltaX?: number, deltaY?: number } + if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return undefined + const window = currentWindow(event) + if (window.isMaximized()) window.unmaximize() + const bounds = window.getBounds() + window.setPosition( + Math.round(bounds.x + deltaX!), + Math.round(bounds.y + deltaY!), + ) + return undefined + }) registerHandler(ELECTRON_IPC_CHANNELS.windowRequestAttention, event => currentWindow(event).flashFrame(true)) registerHandler(ELECTRON_IPC_CHANNELS.windowFocus, event => currentWindow(event).focus()) registerHandler(ELECTRON_IPC_CHANNELS.windowIsMaximized, event => currentWindow(event).isMaximized()) @@ -293,8 +306,7 @@ async function createMainWindow() { minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT, show: false, - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', - fullscreenable: process.platform !== 'darwin', + ...windowChromeOptionsForPlatform(process.platform), webPreferences: { preload: preloadPath(), contextIsolation: true, diff --git a/desktop/electron/services/menu.test.ts b/desktop/electron/services/menu.test.ts index 4b826237..e6d171b3 100644 --- a/desktop/electron/services/menu.test.ts +++ b/desktop/electron/services/menu.test.ts @@ -125,6 +125,7 @@ describe('Electron application menu service', () => { await installApplicationMenu( { name: 'Claude Code Haha' } as never, () => ({ webContents: { send } }) as never, + 'darwin', ) expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1) @@ -142,6 +143,39 @@ describe('Electron application menu service', () => { expect(send).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, 'settings') }) + it('clears the native application menu on Windows so custom chrome owns the top bar', async () => { + const menuMocks = getElectronMenuMocks() + menuMocks.buildFromTemplate.mockClear() + menuMocks.setApplicationMenu.mockClear() + + await installApplicationMenu( + { name: 'Claude Code Haha' } as never, + () => ({ webContents: { send: vi.fn() } }) as never, + 'win32', + ) + + expect(menuMocks.buildFromTemplate).not.toHaveBeenCalled() + expect(menuMocks.setApplicationMenu).toHaveBeenCalledWith(null) + }) + + it('keeps the native application menu installed on Linux', async () => { + const menuMocks = getElectronMenuMocks() + menuMocks.buildFromTemplate.mockClear() + menuMocks.setApplicationMenu.mockClear() + const send = vi.fn() + + await installApplicationMenu( + { name: 'Claude Code Haha' } as never, + () => ({ webContents: { send } }) as never, + 'linux', + ) + + expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1) + expect(menuMocks.setApplicationMenu).toHaveBeenCalledWith({ + template: menuMocks.buildFromTemplate.mock.calls[0]?.[0], + }) + }) + it('installs hide as a safe fullscreen-aware window hide before app hide', async () => { const appHide = vi.fn() const onceHandlers = new Map void>() @@ -161,6 +195,7 @@ describe('Electron application menu service', () => { await installApplicationMenu( { name: 'Claude Code Haha', hide: appHide } as never, () => window as never, + 'darwin', ) const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[] @@ -191,6 +226,7 @@ describe('Electron application menu service', () => { await installApplicationMenu( { name: 'Claude Code Haha' } as never, () => window as never, + 'darwin', ) const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[] diff --git a/desktop/electron/services/menu.ts b/desktop/electron/services/menu.ts index b65c5ab1..de5b9409 100644 --- a/desktop/electron/services/menu.ts +++ b/desktop/electron/services/menu.ts @@ -76,11 +76,20 @@ export function buildApplicationMenuTemplate( ] } -export async function installApplicationMenu(app: App, getMainWindow: () => BrowserWindow | null) { +export async function installApplicationMenu( + app: App, + getMainWindow: () => BrowserWindow | null, + platform: NodeJS.Platform = process.platform, +) { const { Menu } = await import('electron') + if (platform === 'win32') { + Menu.setApplicationMenu(null) + return + } + const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => { getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination) - }, process.platform, { + }, platform, { hide: () => { const window = getMainWindow() if (!window) { @@ -94,7 +103,7 @@ export async function installApplicationMenu(app: App, getMainWindow: () => Brow }, toggleFullScreen: () => { const window = getMainWindow() - if (window) toggleWindowFullScreen(window) + if (window) toggleWindowFullScreen(window, platform) }, }) Menu.setApplicationMenu(Menu.buildFromTemplate(template)) diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index ae3336ca..74d25e5b 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -14,6 +14,7 @@ import { restoreWindowMaximized, showMainWindow, toggleWindowFullScreen, + windowChromeOptionsForPlatform, windowOptionsFromState, windowStatePath, writeWindowState, @@ -126,6 +127,22 @@ describe('Electron window service', () => { expect(maximize).toHaveBeenCalledTimes(1) }) + it('uses frameless custom chrome only on Windows', () => { + expect(windowChromeOptionsForPlatform('win32')).toEqual({ + frame: false, + autoHideMenuBar: true, + fullscreenable: true, + }) + expect(windowChromeOptionsForPlatform('darwin')).toEqual({ + titleBarStyle: 'hiddenInset', + fullscreenable: false, + }) + expect(windowChromeOptionsForPlatform('linux')).toEqual({ + titleBarStyle: 'default', + fullscreenable: true, + }) + }) + it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => { const window = { isVisible: () => false, diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index a3f01839..435383ff 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { App, BrowserWindow, Display } from 'electron' +import type { App, BrowserWindow, BrowserWindowConstructorOptions, Display } from 'electron' export const WINDOW_STATE_FILE = 'window-state.json' export const DEFAULT_WINDOW_WIDTH = 1280 @@ -21,6 +21,10 @@ export type WindowStateBounds = Pick> & Pick +export type WindowChromeOptions = Pick< + BrowserWindowConstructorOptions, + 'autoHideMenuBar' | 'frame' | 'fullscreenable' | 'titleBarStyle' +> export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string { return path.join(env.CLAUDE_CONFIG_DIR || app.getPath('userData'), WINDOW_STATE_FILE) @@ -134,6 +138,30 @@ export function windowOptionsFromState(state: StoredWindowState | null): WindowC : { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT } } +export function windowChromeOptionsForPlatform( + platform: NodeJS.Platform = process.platform, +): WindowChromeOptions { + if (platform === 'darwin') { + return { + titleBarStyle: 'hiddenInset', + fullscreenable: false, + } + } + + if (platform === 'win32') { + return { + frame: false, + autoHideMenuBar: true, + fullscreenable: true, + } + } + + return { + titleBarStyle: 'default', + fullscreenable: true, + } +} + export function restoreWindowMaximized(window: BrowserWindow, state: StoredWindowState | null) { if (state?.maximized) window.maximize() } diff --git a/desktop/index.html b/desktop/index.html index 12d7107d..1652aeec 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -15,7 +15,7 @@ content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" /> - Claude Code Companion + Claude Code Haha