mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
This commit is contained in:
parent
8bca6985fe
commit
98b79ce51a
@ -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.shellOpen, { url: 'https://example.com' })).toBe(false)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
|
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
|
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(true)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: '1', data: 'pwd\n' })).toBe(false)
|
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)
|
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: 80, rows: 24, cwd: '/tmp' })).toBe(true)
|
||||||
|
|||||||
@ -50,6 +50,17 @@ const boundsPayload: Validator = value =>
|
|||||||
&& typeof value.width === 'number'
|
&& typeof value.width === 'number'
|
||||||
&& typeof value.height === '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 =>
|
const urlWithOptionalBounds: Validator = value =>
|
||||||
isRecord(value)
|
isRecord(value)
|
||||||
&& typeof value.url === 'string'
|
&& typeof value.url === 'string'
|
||||||
@ -84,7 +95,7 @@ export const ELECTRON_IPC_VALIDATORS = {
|
|||||||
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowStartDragging]: windowDragMovePayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
|
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
restoreWindowMaximized,
|
restoreWindowMaximized,
|
||||||
saveWindowState,
|
saveWindowState,
|
||||||
showMainWindow,
|
showMainWindow,
|
||||||
|
windowChromeOptionsForPlatform,
|
||||||
windowOptionsFromState,
|
windowOptionsFromState,
|
||||||
MIN_WINDOW_HEIGHT,
|
MIN_WINDOW_HEIGHT,
|
||||||
MIN_WINDOW_WIDTH,
|
MIN_WINDOW_WIDTH,
|
||||||
@ -243,7 +244,19 @@ function registerIpcHandlers() {
|
|||||||
else window.maximize()
|
else window.maximize()
|
||||||
})
|
})
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.windowClose, event => currentWindow(event).close())
|
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.windowRequestAttention, event => currentWindow(event).flashFrame(true))
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.windowFocus, event => currentWindow(event).focus())
|
registerHandler(ELECTRON_IPC_CHANNELS.windowFocus, event => currentWindow(event).focus())
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.windowIsMaximized, event => currentWindow(event).isMaximized())
|
registerHandler(ELECTRON_IPC_CHANNELS.windowIsMaximized, event => currentWindow(event).isMaximized())
|
||||||
@ -293,8 +306,7 @@ async function createMainWindow() {
|
|||||||
minWidth: MIN_WINDOW_WIDTH,
|
minWidth: MIN_WINDOW_WIDTH,
|
||||||
minHeight: MIN_WINDOW_HEIGHT,
|
minHeight: MIN_WINDOW_HEIGHT,
|
||||||
show: false,
|
show: false,
|
||||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
...windowChromeOptionsForPlatform(process.platform),
|
||||||
fullscreenable: process.platform !== 'darwin',
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: preloadPath(),
|
preload: preloadPath(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|||||||
@ -125,6 +125,7 @@ describe('Electron application menu service', () => {
|
|||||||
await installApplicationMenu(
|
await installApplicationMenu(
|
||||||
{ name: 'Claude Code Haha' } as never,
|
{ name: 'Claude Code Haha' } as never,
|
||||||
() => ({ webContents: { send } }) as never,
|
() => ({ webContents: { send } }) as never,
|
||||||
|
'darwin',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1)
|
expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1)
|
||||||
@ -142,6 +143,39 @@ describe('Electron application menu service', () => {
|
|||||||
expect(send).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, 'settings')
|
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 () => {
|
it('installs hide as a safe fullscreen-aware window hide before app hide', async () => {
|
||||||
const appHide = vi.fn()
|
const appHide = vi.fn()
|
||||||
const onceHandlers = new Map<string, (...args: never[]) => void>()
|
const onceHandlers = new Map<string, (...args: never[]) => void>()
|
||||||
@ -161,6 +195,7 @@ describe('Electron application menu service', () => {
|
|||||||
await installApplicationMenu(
|
await installApplicationMenu(
|
||||||
{ name: 'Claude Code Haha', hide: appHide } as never,
|
{ name: 'Claude Code Haha', hide: appHide } as never,
|
||||||
() => window as never,
|
() => window as never,
|
||||||
|
'darwin',
|
||||||
)
|
)
|
||||||
|
|
||||||
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
|
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
|
||||||
@ -191,6 +226,7 @@ describe('Electron application menu service', () => {
|
|||||||
await installApplicationMenu(
|
await installApplicationMenu(
|
||||||
{ name: 'Claude Code Haha' } as never,
|
{ name: 'Claude Code Haha' } as never,
|
||||||
() => window as never,
|
() => window as never,
|
||||||
|
'darwin',
|
||||||
)
|
)
|
||||||
|
|
||||||
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
|
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
|
||||||
|
|||||||
@ -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')
|
const { Menu } = await import('electron')
|
||||||
|
if (platform === 'win32') {
|
||||||
|
Menu.setApplicationMenu(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => {
|
const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => {
|
||||||
getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination)
|
getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination)
|
||||||
}, process.platform, {
|
}, platform, {
|
||||||
hide: () => {
|
hide: () => {
|
||||||
const window = getMainWindow()
|
const window = getMainWindow()
|
||||||
if (!window) {
|
if (!window) {
|
||||||
@ -94,7 +103,7 @@ export async function installApplicationMenu(app: App, getMainWindow: () => Brow
|
|||||||
},
|
},
|
||||||
toggleFullScreen: () => {
|
toggleFullScreen: () => {
|
||||||
const window = getMainWindow()
|
const window = getMainWindow()
|
||||||
if (window) toggleWindowFullScreen(window)
|
if (window) toggleWindowFullScreen(window, platform)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
restoreWindowMaximized,
|
restoreWindowMaximized,
|
||||||
showMainWindow,
|
showMainWindow,
|
||||||
toggleWindowFullScreen,
|
toggleWindowFullScreen,
|
||||||
|
windowChromeOptionsForPlatform,
|
||||||
windowOptionsFromState,
|
windowOptionsFromState,
|
||||||
windowStatePath,
|
windowStatePath,
|
||||||
writeWindowState,
|
writeWindowState,
|
||||||
@ -126,6 +127,22 @@ describe('Electron window service', () => {
|
|||||||
expect(maximize).toHaveBeenCalledTimes(1)
|
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', () => {
|
it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => {
|
||||||
const window = {
|
const window = {
|
||||||
isVisible: () => false,
|
isVisible: () => false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
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 WINDOW_STATE_FILE = 'window-state.json'
|
||||||
export const DEFAULT_WINDOW_WIDTH = 1280
|
export const DEFAULT_WINDOW_WIDTH = 1280
|
||||||
@ -21,6 +21,10 @@ export type WindowStateBounds = Pick<StoredWindowState, 'x' | 'y' | 'width' | 'h
|
|||||||
export type WindowCreateBounds =
|
export type WindowCreateBounds =
|
||||||
& Partial<Pick<StoredWindowState, 'x' | 'y'>>
|
& Partial<Pick<StoredWindowState, 'x' | 'y'>>
|
||||||
& Pick<StoredWindowState, 'width' | 'height'>
|
& Pick<StoredWindowState, 'width' | 'height'>
|
||||||
|
export type WindowChromeOptions = Pick<
|
||||||
|
BrowserWindowConstructorOptions,
|
||||||
|
'autoHideMenuBar' | 'frame' | 'fullscreenable' | 'titleBarStyle'
|
||||||
|
>
|
||||||
|
|
||||||
export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string {
|
export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
return path.join(env.CLAUDE_CONFIG_DIR || app.getPath('userData'), WINDOW_STATE_FILE)
|
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 }
|
: { 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) {
|
export function restoreWindowMaximized(window: BrowserWindow, state: StoredWindowState | null) {
|
||||||
if (state?.maximized) window.maximize()
|
if (state?.maximized) window.maximize()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'"
|
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'"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Claude Code Companion</title>
|
<title>Claude Code Haha</title>
|
||||||
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
|
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
|
||||||
<style>
|
<style>
|
||||||
/* Prevent a warm flash before the theme is hydrated. */
|
/* Prevent a warm flash before the theme is hydrated. */
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { UpdateChecker } from '../shared/UpdateChecker'
|
|||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||||
|
import { useElectronWindowDragRegions } from '../../hooks/useElectronWindowDragRegions'
|
||||||
import {
|
import {
|
||||||
H5ConnectionRequiredError,
|
H5ConnectionRequiredError,
|
||||||
initializeDesktopServerUrl,
|
initializeDesktopServerUrl,
|
||||||
@ -130,6 +131,7 @@ export function AppShell() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
|
useElectronWindowDragRegions()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileShell && !wasMobileShellRef.current) {
|
if (isMobileShell && !wasMobileShellRef.current) {
|
||||||
|
|||||||
@ -310,7 +310,9 @@ describe('TabBar', () => {
|
|||||||
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-desktop-drag-region')
|
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-desktop-drag-region')
|
||||||
expect(screen.getByTestId('tab-bar-scroll-region')).toHaveAttribute('data-desktop-drag-region')
|
expect(screen.getByTestId('tab-bar-scroll-region')).toHaveAttribute('data-desktop-drag-region')
|
||||||
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-desktop-drag-region')
|
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-desktop-drag-region')
|
||||||
expect(screen.getByText('Untitled Session').closest('.tab-bar-interactive')).toBeInTheDocument()
|
const tab = screen.getByText('Untitled Session').closest('.tab-bar-interactive')
|
||||||
|
expect(tab).toBeInTheDocument()
|
||||||
|
expect(tab).not.toHaveAttribute('data-desktop-drag-region')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the desktop tab strip at a roomier titlebar height', async () => {
|
it('keeps the desktop tab strip at a roomier titlebar height', async () => {
|
||||||
|
|||||||
159
desktop/src/hooks/useElectronWindowDragRegions.test.tsx
Normal file
159
desktop/src/hooks/useElectronWindowDragRegions.test.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { shouldUseManualWindowDrag, useElectronWindowDragRegions } from './useElectronWindowDragRegions'
|
||||||
|
|
||||||
|
const desktopHostMock = vi.hoisted(() => ({
|
||||||
|
startDragging: vi.fn().mockResolvedValue(undefined),
|
||||||
|
host: {
|
||||||
|
isDesktop: true,
|
||||||
|
capabilities: {
|
||||||
|
windowControls: true,
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
startDragging: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../lib/desktopHost', () => ({
|
||||||
|
getDesktopHost: () => desktopHostMock.host,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
useElectronWindowDragRegions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="drag-region" data-desktop-drag-region>
|
||||||
|
<div data-testid="blank-space">blank</div>
|
||||||
|
<button type="button">Button</button>
|
||||||
|
<div data-testid="tab" className="tab-bar-interactive">
|
||||||
|
Tab
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useElectronWindowDragRegions', () => {
|
||||||
|
const originalPlatform = navigator.platform
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'Win32',
|
||||||
|
})
|
||||||
|
delete document.documentElement.dataset.desktopDragMode
|
||||||
|
desktopHostMock.host.isDesktop = true
|
||||||
|
desktopHostMock.host.capabilities.windowControls = true
|
||||||
|
desktopHostMock.host.window.startDragging.mockReset()
|
||||||
|
desktopHostMock.host.window.startDragging.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalPlatform,
|
||||||
|
})
|
||||||
|
delete document.documentElement.dataset.desktopDragMode
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the manual drag fallback on Windows only', () => {
|
||||||
|
expect(shouldUseManualWindowDrag('Win32')).toBe(true)
|
||||||
|
expect(shouldUseManualWindowDrag('MacIntel')).toBe(false)
|
||||||
|
expect(shouldUseManualWindowDrag('Linux x86_64')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the native window while dragging a desktop drag region', async () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(document.documentElement.dataset.desktopDragMode).toBe('manual')
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('blank-space'), {
|
||||||
|
button: 0,
|
||||||
|
screenX: 100,
|
||||||
|
screenY: 120,
|
||||||
|
})
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 130,
|
||||||
|
screenY: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(desktopHostMock.host.window.startDragging).toHaveBeenCalledWith({
|
||||||
|
deltaX: 30,
|
||||||
|
deltaY: 30,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.mouseUp(window)
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 180,
|
||||||
|
screenY: 210,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(desktopHostMock.host.window.startDragging).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps buttons and tab reorder targets out of the window drag fallback', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByRole('button', { name: 'Button' }), {
|
||||||
|
button: 0,
|
||||||
|
screenX: 100,
|
||||||
|
screenY: 120,
|
||||||
|
})
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 130,
|
||||||
|
screenY: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('tab'), {
|
||||||
|
button: 0,
|
||||||
|
screenX: 100,
|
||||||
|
screenY: 120,
|
||||||
|
})
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 130,
|
||||||
|
screenY: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(desktopHostMock.host.window.startDragging).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing outside the Electron custom chrome runtime', () => {
|
||||||
|
desktopHostMock.host.isDesktop = false
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('blank-space'), {
|
||||||
|
button: 0,
|
||||||
|
screenX: 100,
|
||||||
|
screenY: 120,
|
||||||
|
})
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 130,
|
||||||
|
screenY: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(desktopHostMock.host.window.startDragging).not.toHaveBeenCalled()
|
||||||
|
expect(document.documentElement.dataset.desktopDragMode).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves native app-region handling active on non-Windows platforms', () => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'MacIntel',
|
||||||
|
})
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('blank-space'), {
|
||||||
|
button: 0,
|
||||||
|
screenX: 100,
|
||||||
|
screenY: 120,
|
||||||
|
})
|
||||||
|
fireEvent.mouseMove(window, {
|
||||||
|
screenX: 130,
|
||||||
|
screenY: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(desktopHostMock.host.window.startDragging).not.toHaveBeenCalled()
|
||||||
|
expect(document.documentElement.dataset.desktopDragMode).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
86
desktop/src/hooks/useElectronWindowDragRegions.ts
Normal file
86
desktop/src/hooks/useElectronWindowDragRegions.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { getDesktopHost } from '../lib/desktopHost'
|
||||||
|
|
||||||
|
const DRAG_REGION_SELECTOR = '[data-desktop-drag-region]'
|
||||||
|
const MANUAL_DRAG_MODE = 'manual'
|
||||||
|
const NO_DRAG_SELECTOR = [
|
||||||
|
'[data-desktop-no-drag-region]',
|
||||||
|
'button',
|
||||||
|
'input',
|
||||||
|
'textarea',
|
||||||
|
'select',
|
||||||
|
'a',
|
||||||
|
'[role="button"]',
|
||||||
|
'[draggable="true"]',
|
||||||
|
'.tab-bar-interactive',
|
||||||
|
'.tab-bar-interactive *',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
export function shouldUseManualWindowDrag(platform = typeof navigator === 'undefined' ? '' : navigator.platform) {
|
||||||
|
return /Win/i.test(platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktopDragStartTarget(target: EventTarget | null): target is Element {
|
||||||
|
if (!(target instanceof Element)) return false
|
||||||
|
if (target.closest(NO_DRAG_SELECTOR)) return false
|
||||||
|
return Boolean(target.closest(DRAG_REGION_SELECTOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useElectronWindowDragRegions() {
|
||||||
|
useEffect(() => {
|
||||||
|
const host = getDesktopHost()
|
||||||
|
if (!host.isDesktop || !host.capabilities?.windowControls || !host.window?.startDragging) return
|
||||||
|
if (!shouldUseManualWindowDrag()) return
|
||||||
|
|
||||||
|
const previousDragMode = document.documentElement.dataset.desktopDragMode
|
||||||
|
document.documentElement.dataset.desktopDragMode = MANUAL_DRAG_MODE
|
||||||
|
|
||||||
|
let dragging = false
|
||||||
|
let lastScreenX = 0
|
||||||
|
let lastScreenY = 0
|
||||||
|
|
||||||
|
const stopDragging = () => {
|
||||||
|
dragging = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if (!isDesktopDragStartTarget(event.target)) return
|
||||||
|
dragging = true
|
||||||
|
lastScreenX = event.screenX
|
||||||
|
lastScreenY = event.screenY
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!dragging) return
|
||||||
|
const deltaX = event.screenX - lastScreenX
|
||||||
|
const deltaY = event.screenY - lastScreenY
|
||||||
|
if (deltaX === 0 && deltaY === 0) return
|
||||||
|
|
||||||
|
lastScreenX = event.screenX
|
||||||
|
lastScreenY = event.screenY
|
||||||
|
void host.window.startDragging({ deltaX, deltaY }).catch(error => {
|
||||||
|
console.error('Window drag fallback failed', error)
|
||||||
|
stopDragging()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleMouseDown, true)
|
||||||
|
window.addEventListener('mousemove', handleMouseMove, true)
|
||||||
|
window.addEventListener('mouseup', stopDragging, true)
|
||||||
|
window.addEventListener('blur', stopDragging)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (previousDragMode === undefined) {
|
||||||
|
delete document.documentElement.dataset.desktopDragMode
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset.desktopDragMode = previousDragMode
|
||||||
|
}
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove, true)
|
||||||
|
window.removeEventListener('mouseup', stopDragging, true)
|
||||||
|
window.removeEventListener('blur', stopDragging)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@ -36,13 +36,28 @@ describe('electron desktop host', () => {
|
|||||||
expect(invoke).not.toHaveBeenCalled()
|
expect(invoke).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not advertise custom window chrome until Electron frameless drag is implemented', () => {
|
it('advertises custom window chrome for the Electron frameless shell', () => {
|
||||||
const host = createElectronHost({
|
const host = createElectronHost({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(host.capabilities.windowControls).toBe(false)
|
expect(host.capabilities.windowControls).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards drag-region fallback movement through the window dragging IPC channel', async () => {
|
||||||
|
const invoke = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const host = createElectronHost({
|
||||||
|
invoke,
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await host.window.startDragging({ deltaX: 12, deltaY: -8 })
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.windowStartDragging, {
|
||||||
|
deltaX: 12,
|
||||||
|
deltaY: -8,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps event subscriptions behind named event channels', async () => {
|
it('keeps event subscriptions behind named event channels', async () => {
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
shell: true,
|
shell: true,
|
||||||
terminal: true,
|
terminal: true,
|
||||||
updates: true,
|
updates: true,
|
||||||
windowControls: false,
|
windowControls: true,
|
||||||
zoom: true,
|
zoom: true,
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
@ -115,7 +115,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
minimize: () => invoke(ELECTRON_IPC_CHANNELS.windowMinimize),
|
minimize: () => invoke(ELECTRON_IPC_CHANNELS.windowMinimize),
|
||||||
toggleMaximize: () => invoke(ELECTRON_IPC_CHANNELS.windowToggleMaximize),
|
toggleMaximize: () => invoke(ELECTRON_IPC_CHANNELS.windowToggleMaximize),
|
||||||
close: () => invoke(ELECTRON_IPC_CHANNELS.windowClose),
|
close: () => invoke(ELECTRON_IPC_CHANNELS.windowClose),
|
||||||
startDragging: () => invoke(ELECTRON_IPC_CHANNELS.windowStartDragging),
|
startDragging: input => invoke(ELECTRON_IPC_CHANNELS.windowStartDragging, input),
|
||||||
requestAttention: () => invoke(ELECTRON_IPC_CHANNELS.windowRequestAttention),
|
requestAttention: () => invoke(ELECTRON_IPC_CHANNELS.windowRequestAttention),
|
||||||
focus: () => invoke(ELECTRON_IPC_CHANNELS.windowFocus),
|
focus: () => invoke(ELECTRON_IPC_CHANNELS.windowFocus),
|
||||||
isMaximized: () => invoke(ELECTRON_IPC_CHANNELS.windowIsMaximized),
|
isMaximized: () => invoke(ELECTRON_IPC_CHANNELS.windowIsMaximized),
|
||||||
|
|||||||
@ -130,6 +130,11 @@ export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage
|
|||||||
|
|
||||||
export type AppModeConfig = SettingsAppModeConfig
|
export type AppModeConfig = SettingsAppModeConfig
|
||||||
|
|
||||||
|
export type WindowDragMoveInput = {
|
||||||
|
deltaX: number
|
||||||
|
deltaY: number
|
||||||
|
}
|
||||||
|
|
||||||
export type AppModeSetInput = {
|
export type AppModeSetInput = {
|
||||||
mode: SettingsAppMode
|
mode: SettingsAppMode
|
||||||
portableDir: string | null
|
portableDir: string | null
|
||||||
@ -184,7 +189,7 @@ export type DesktopHost = {
|
|||||||
minimize(): Promise<void>
|
minimize(): Promise<void>
|
||||||
toggleMaximize(): Promise<void>
|
toggleMaximize(): Promise<void>
|
||||||
close(): Promise<void>
|
close(): Promise<void>
|
||||||
startDragging(): Promise<void>
|
startDragging(input?: WindowDragMoveInput): Promise<void>
|
||||||
requestAttention(): Promise<void>
|
requestAttention(): Promise<void>
|
||||||
focus(): Promise<void>
|
focus(): Promise<void>
|
||||||
isMaximized(): Promise<boolean>
|
isMaximized(): Promise<boolean>
|
||||||
|
|||||||
@ -959,10 +959,14 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop drag region */
|
/* Desktop drag region */
|
||||||
[data-desktop-drag-region] {
|
html:not([data-desktop-drag-mode="manual"]) [data-desktop-drag-region] {
|
||||||
app-region: drag;
|
app-region: drag;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
html[data-desktop-drag-mode="manual"] [data-desktop-drag-region] {
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
button, input, textarea, select, a, [role="button"] {
|
button, input, textarea, select, a, [role="button"] {
|
||||||
app-region: no-drag;
|
app-region: no-drag;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user