mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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.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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, (...args: never[]) => 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[]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<StoredWindowState, 'x' | 'y' | 'width' | 'h
|
||||
export type WindowCreateBounds =
|
||||
& Partial<Pick<StoredWindowState, 'x' | 'y'>>
|
||||
& Pick<StoredWindowState, 'width' | 'height'>
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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'"
|
||||
/>
|
||||
<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 -->
|
||||
<style>
|
||||
/* Prevent a warm flash before the theme is hydrated. */
|
||||
|
||||
@ -6,6 +6,7 @@ import { UpdateChecker } from '../shared/UpdateChecker'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { useElectronWindowDragRegions } from '../../hooks/useElectronWindowDragRegions'
|
||||
import {
|
||||
H5ConnectionRequiredError,
|
||||
initializeDesktopServerUrl,
|
||||
@ -130,6 +131,7 @@ export function AppShell() {
|
||||
}, [])
|
||||
|
||||
useKeyboardShortcuts()
|
||||
useElectronWindowDragRegions()
|
||||
|
||||
useEffect(() => {
|
||||
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-scroll-region')).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 () => {
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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({
|
||||
invoke: 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 () => {
|
||||
|
||||
@ -69,7 +69,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
shell: true,
|
||||
terminal: true,
|
||||
updates: true,
|
||||
windowControls: false,
|
||||
windowControls: true,
|
||||
zoom: true,
|
||||
},
|
||||
runtime: {
|
||||
@ -115,7 +115,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
minimize: () => invoke(ELECTRON_IPC_CHANNELS.windowMinimize),
|
||||
toggleMaximize: () => invoke(ELECTRON_IPC_CHANNELS.windowToggleMaximize),
|
||||
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),
|
||||
focus: () => invoke(ELECTRON_IPC_CHANNELS.windowFocus),
|
||||
isMaximized: () => invoke(ELECTRON_IPC_CHANNELS.windowIsMaximized),
|
||||
|
||||
@ -130,6 +130,11 @@ export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage
|
||||
|
||||
export type AppModeConfig = SettingsAppModeConfig
|
||||
|
||||
export type WindowDragMoveInput = {
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
}
|
||||
|
||||
export type AppModeSetInput = {
|
||||
mode: SettingsAppMode
|
||||
portableDir: string | null
|
||||
@ -184,7 +189,7 @@ export type DesktopHost = {
|
||||
minimize(): Promise<void>
|
||||
toggleMaximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
startDragging(): Promise<void>
|
||||
startDragging(input?: WindowDragMoveInput): Promise<void>
|
||||
requestAttention(): Promise<void>
|
||||
focus(): Promise<void>
|
||||
isMaximized(): Promise<boolean>
|
||||
|
||||
@ -959,10 +959,14 @@ html, body, #root {
|
||||
}
|
||||
|
||||
/* Desktop drag region */
|
||||
[data-desktop-drag-region] {
|
||||
html:not([data-desktop-drag-mode="manual"]) [data-desktop-drag-region] {
|
||||
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"] {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user