fix(desktop): restore native window dragging (#770 #796)

Remove the Windows renderer-side drag delta fallback and keep frameless window movement on Electron app-region handling. Reject drag movement payloads on the legacy IPC channel so window drags cannot mutate bounds through repeated setPosition calls.

Tested: cd desktop && bun run test --run src/hooks/useElectronWindowDragRegions.test.tsx electron/ipc/capabilities.test.ts src/lib/desktopHost/electronHost.test.ts
Tested: bun run check:desktop
Tested: bun run check:native
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:39:06 +08:00
parent 978dfb2efb
commit c97bd55a57
9 changed files with 20 additions and 137 deletions

View File

@ -26,9 +26,7 @@ describe('Electron IPC capabilities', () => {
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.windowStartDragging, { deltaX: 4, deltaY: -2 })).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)

View File

@ -56,17 +56,6 @@ 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'
@ -102,7 +91,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]: windowDragMovePayload,
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,

View File

@ -297,19 +297,7 @@ function registerIpcHandlers() {
else window.maximize()
})
registerHandler(ELECTRON_IPC_CHANNELS.windowClose, event => currentWindow(event).close())
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.windowStartDragging, () => 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())

View File

@ -56,15 +56,15 @@ describe('useElectronWindowDragRegions', () => {
delete document.documentElement.dataset.desktopDragMode
})
it('uses the manual drag fallback on Windows only', () => {
expect(shouldUseManualWindowDrag('Win32')).toBe(true)
it('uses native app-region dragging on every platform', () => {
expect(shouldUseManualWindowDrag('Win32')).toBe(false)
expect(shouldUseManualWindowDrag('MacIntel')).toBe(false)
expect(shouldUseManualWindowDrag('Linux x86_64')).toBe(false)
})
it('moves the native window while dragging a desktop drag region', async () => {
it('leaves Windows drag regions on the native app-region path', async () => {
render(<Harness />)
expect(document.documentElement.dataset.desktopDragMode).toBe('manual')
expect(document.documentElement.dataset.desktopDragMode).toBeUndefined()
fireEvent.mouseDown(screen.getByTestId('blank-space'), {
button: 0,
@ -77,10 +77,7 @@ describe('useElectronWindowDragRegions', () => {
})
await waitFor(() => {
expect(desktopHostMock.host.window.startDragging).toHaveBeenCalledWith({
deltaX: 30,
deltaY: 30,
})
expect(desktopHostMock.host.window.startDragging).not.toHaveBeenCalled()
})
fireEvent.mouseUp(window)
@ -89,10 +86,10 @@ describe('useElectronWindowDragRegions', () => {
screenY: 210,
})
expect(desktopHostMock.host.window.startDragging).toHaveBeenCalledTimes(1)
expect(desktopHostMock.host.window.startDragging).not.toHaveBeenCalled()
})
it('keeps buttons and tab reorder targets out of the window drag fallback', () => {
it('does not route buttons or tab reorder targets through legacy drag IPC', () => {
render(<Harness />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'Button' }), {

View File

@ -1,86 +1,9 @@
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))
void platform
return false
}
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)
}
}, [])
// Electron's native app-region dragging is the supported frameless-window path.
// The previous Windows JS delta fallback could resize frameless windows while moving them.
}

View File

@ -45,19 +45,16 @@ describe('electron desktop host', () => {
expect(host.capabilities.windowControls).toBe(true)
})
it('forwards drag-region fallback movement through the window dragging IPC channel', async () => {
it('keeps the legacy window dragging IPC channel payload-free', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
invoke,
subscribe: vi.fn(),
})
await host.window.startDragging({ deltaX: 12, deltaY: -8 })
await host.window.startDragging()
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.windowStartDragging, {
deltaX: 12,
deltaY: -8,
})
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)
})
it('opens dedicated trace windows through a narrow IPC channel', async () => {

View File

@ -118,7 +118,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: input => invoke(ELECTRON_IPC_CHANNELS.windowStartDragging, input),
startDragging: () => invoke(ELECTRON_IPC_CHANNELS.windowStartDragging),
requestAttention: () => invoke(ELECTRON_IPC_CHANNELS.windowRequestAttention),
focus: () => invoke(ELECTRON_IPC_CHANNELS.windowFocus),
isMaximized: () => invoke(ELECTRON_IPC_CHANNELS.windowIsMaximized),

View File

@ -130,11 +130,6 @@ export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage
export type AppModeConfig = SettingsAppModeConfig
export type WindowDragMoveInput = {
deltaX: number
deltaY: number
}
export type AppModeSetInput = {
mode: SettingsAppMode
portableDir: string | null
@ -192,7 +187,7 @@ export type DesktopHost = {
minimize(): Promise<void>
toggleMaximize(): Promise<void>
close(): Promise<void>
startDragging(input?: WindowDragMoveInput): Promise<void>
startDragging(): Promise<void>
requestAttention(): Promise<void>
focus(): Promise<void>
isMaximized(): Promise<boolean>

View File

@ -959,14 +959,10 @@ html, body, #root {
}
/* Desktop drag region */
html:not([data-desktop-drag-mode="manual"]) [data-desktop-drag-region] {
[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;