fix(desktop): guard manual window drag coordinates

This commit is contained in:
Mohamed Boudra
2026-03-21 14:58:57 +07:00
parent b15159caeb
commit 6731879931
6 changed files with 360 additions and 142 deletions
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
describe('readFiniteScreenPoint', () => {
it('returns finite screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
screenX: 1280,
screenY: 720,
})
})
it('rejects NaN screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull()
})
it('rejects infinite screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull()
})
it('rejects missing screen coordinates', () => {
expect(readFiniteScreenPoint(undefined)).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull()
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull()
})
})
@@ -0,0 +1,27 @@
export type DesktopWindowScreenPoint = {
screenX: number
screenY: number
}
type ScreenPointInput =
| {
screenX?: unknown
screenY?: unknown
}
| null
| undefined
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null
}
return {
screenX: input.screenX,
screenY: input.screenY,
}
}
+89 -83
View File
@@ -1,20 +1,21 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from 'react'
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native"; import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from 'react-native'
import { import {
getIsDesktopMac, getIsDesktopMac,
DESKTOP_TRAFFIC_LIGHT_WIDTH, DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT, DESKTOP_TRAFFIC_LIGHT_HEIGHT,
} from "@/constants/layout"; } from '@/constants/layout'
import { getDesktopWindow } from "@/desktop/electron/window"; import { getDesktopWindow } from '@/desktop/electron/window'
import { isDesktop } from "@/desktop/host"; import { isDesktop } from '@/desktop/host'
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
export async function toggleMaximize() { export async function toggleMaximize() {
const win = getDesktopWindow(); const win = getDesktopWindow()
if (win && typeof win.toggleMaximize === "function") { if (win && typeof win.toggleMaximize === 'function') {
try { try {
await win.toggleMaximize(); await win.toggleMaximize()
} catch (error) { } catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error); console.warn('[DesktopWindow] toggleMaximize failed', error)
} }
} }
} }
@@ -27,147 +28,152 @@ export async function toggleMaximize() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const INTERACTIVE_SELECTOR = const INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, " + 'button, a, input, textarea, select, ' +
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " + "[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " + "[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
"[role='menuitem'], [contenteditable='true']"; "[role='menuitem'], [contenteditable='true']"
const DOUBLE_CLICK_MS = 300; const DOUBLE_CLICK_MS = 300
type DesktopDragViewProps = Pick< type DesktopDragViewProps = Pick<
ViewProps, ViewProps,
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel" 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'
>; >
export function useDesktopDragHandlers(): DesktopDragViewProps { export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false); const isDragging = useRef(false)
const lastPointerDownAt = useRef(0); const lastPointerDownAt = useRef(0)
const isActive = Platform.OS === "web" && isDesktop(); const isActive = Platform.OS === 'web' && isDesktop()
useEffect(() => { useEffect(() => {
if (!isActive) return; if (!isActive) return
function handleBlur() { function handleBlur() {
if (!isDragging.current) return; if (!isDragging.current) return
isDragging.current = false; isDragging.current = false
getDesktopWindow()?.endMove?.(); getDesktopWindow()?.endMove?.()
} }
window.addEventListener("blur", handleBlur); window.addEventListener('blur', handleBlur)
return () => window.removeEventListener("blur", handleBlur); return () => window.removeEventListener('blur', handleBlur)
}, [isActive]); }, [isActive])
return useMemo((): DesktopDragViewProps => { return useMemo((): DesktopDragViewProps => {
if (!isActive) return {}; if (!isActive) return {}
function stopDrag(e: RNPointerEvent) { function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return; if (!isDragging.current) return
isDragging.current = false; isDragging.current = false
// On web, currentTarget is a DOM Element (typed as HostInstance in RN) // On web, currentTarget is a DOM Element (typed as HostInstance in RN)
const el = e.currentTarget as unknown as Element | null; const el = e.currentTarget as unknown as Element | null
if (el && "releasePointerCapture" in el) { if (el && 'releasePointerCapture' in el) {
el.releasePointerCapture(e.nativeEvent.pointerId); el.releasePointerCapture(e.nativeEvent.pointerId)
} }
getDesktopWindow()?.endMove?.(); getDesktopWindow()?.endMove?.()
} }
return { return {
onPointerDown: (e: RNPointerEvent) => { onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return; if (e.nativeEvent.button !== 0) return
// On web, e.target is a DOM Element (typed as HostInstance in RN) // On web, e.target is a DOM Element (typed as HostInstance in RN)
const target = e.target as unknown as Element; const target = e.target as unknown as Element
if (target.closest?.(INTERACTIVE_SELECTOR)) return; if (target.closest?.(INTERACTIVE_SELECTOR)) return
e.preventDefault(); e.preventDefault()
const now = Date.now(); const now = Date.now()
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) { if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0; lastPointerDownAt.current = 0
void toggleMaximize(); void toggleMaximize()
return; return
} }
lastPointerDownAt.current = now; lastPointerDownAt.current = now
const win = getDesktopWindow(); const win = getDesktopWindow()
if (!win?.startMove) return; if (!win?.startMove) return
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!screenPoint) return
isDragging.current = true; isDragging.current = true
const el = e.currentTarget as unknown as Element; const el = e.currentTarget as unknown as Element
el.setPointerCapture(e.nativeEvent.pointerId); el.setPointerCapture(e.nativeEvent.pointerId)
win.startMove(e.nativeEvent.screenX, e.nativeEvent.screenY); win.startMove(screenPoint.screenX, screenPoint.screenY)
}, },
onPointerMove: (e: RNPointerEvent) => { onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return; if (!isDragging.current) return
getDesktopWindow()?.moving?.(e.nativeEvent.screenX, e.nativeEvent.screenY); const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!screenPoint) {
stopDrag(e)
return
}
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY)
}, },
onPointerUp: stopDrag, onPointerUp: stopDrag,
onPointerCancel: stopDrag, onPointerCancel: stopDrag,
}; }
}, [isActive]); }, [isActive])
} }
export function useTrafficLightPadding(): { left: number; top: number } { export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false)
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return; if (Platform.OS !== 'web' || !getIsDesktopMac()) return
let disposed = false; let disposed = false
let cleanup: (() => void) | undefined; let cleanup: (() => void) | undefined
let didCleanup = false; let didCleanup = false
function runCleanup() { function runCleanup() {
if (!cleanup || didCleanup) return; if (!cleanup || didCleanup) return
didCleanup = true; didCleanup = true
try { try {
void Promise.resolve(cleanup()).catch((error) => { void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error); console.warn('[DesktopWindow] Failed to remove resize listener', error)
}); })
} catch (error) { } catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error); console.warn('[DesktopWindow] Failed to remove resize listener', error)
} }
} }
async function setup() { async function setup() {
const win = getDesktopWindow(); const win = getDesktopWindow()
if (!win) return; if (!win) return
const fullscreen = const fullscreen = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; if (disposed) return
if (disposed) return; setIsFullscreen(fullscreen)
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") { if (typeof win.onResized !== 'function') {
return; return
} }
const unlisten = await win.onResized(async () => { const unlisten = await win.onResized(async () => {
if (disposed) return; if (disposed) return
const fs = const fs = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; if (disposed) return
if (disposed) return; setIsFullscreen(fs)
setIsFullscreen(fs); })
});
cleanup = unlisten; cleanup = unlisten
if (disposed) { if (disposed) {
runCleanup(); runCleanup()
} }
} }
void setup(); void setup()
return () => { return () => {
disposed = true; disposed = true
runCleanup(); runCleanup()
}; }
}, []); }, [])
if (!getIsDesktopMac() || isFullscreen) { if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 }; return { left: 0, top: 0 }
} }
return { return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH, left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
}; }
} }
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
} from './window-drag-coordinates'
describe('window-drag-coordinates', () => {
describe('readWindowMovePayload', () => {
it('returns finite screen coordinates', () => {
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
screenX: 1500,
screenY: 900,
})
})
it('rejects non-finite screen coordinates', () => {
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull()
expect(readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY })).toBeNull()
})
})
describe('createWindowMoveState', () => {
it('derives finite offsets from the window position', () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: 1200,
windowY: 700,
})
).toEqual({
offsetX: 300,
offsetY: 200,
})
})
it('rejects non-finite offsets', () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: Number.NaN,
windowY: 700,
})
).toBeNull()
})
})
describe('resolveWindowMovePosition', () => {
it('rounds the next window position', () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1501.4, screenY: 902.6 },
state: { offsetX: 300, offsetY: 200 },
})
).toEqual({
x: 1201,
y: 703,
})
})
it('rejects non-finite next positions', () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1500, screenY: 900 },
state: { offsetX: Number.NaN, offsetY: 200 },
})
).toBeNull()
})
})
})
@@ -0,0 +1,64 @@
export type WindowMovePayload = {
screenX: number
screenY: number
}
export type WindowMoveState = {
offsetX: number
offsetY: number
}
export type WindowPosition = {
x: number
y: number
}
type WindowMovePayloadInput =
| {
screenX?: unknown
screenY?: unknown
}
| null
| undefined
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null
}
return {
screenX: input.screenX,
screenY: input.screenY,
}
}
export function createWindowMoveState(input: {
payload: WindowMovePayload
windowX: number
windowY: number
}): WindowMoveState | null {
const offsetX = input.payload.screenX - input.windowX
const offsetY = input.payload.screenY - input.windowY
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
return null
}
return { offsetX, offsetY }
}
export function resolveWindowMovePosition(input: {
payload: WindowMovePayload
state: WindowMoveState
}): WindowPosition | null {
const x = Math.round(input.payload.screenX - input.state.offsetX)
const y = Math.round(input.payload.screenY - input.state.offsetY)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null
}
return { x, y }
}
+81 -59
View File
@@ -1,81 +1,103 @@
import { app, BrowserWindow, ipcMain } from "electron"; import { app, BrowserWindow, ipcMain } from 'electron'
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
type WindowMoveState,
} from './window-drag-coordinates.js'
export function registerWindowManager(): void { export function registerWindowManager(): void {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag). // Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
// State is keyed per window id to support multiple windows. // State is keyed per window id to support multiple windows.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const moveStates = new Map<number, { offsetX: number; offsetY: number }>(); const moveStates = new Map<number, WindowMoveState>()
ipcMain.on( ipcMain.on('paseo:window:startMove', (event, payload: { screenX: number; screenY: number }) => {
"paseo:window:startMove", const win = BrowserWindow.fromWebContents(event.sender)
(event, payload: { screenX: number; screenY: number }) => { if (!win) return
if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; const movePayload = readWindowMovePayload(payload)
const win = BrowserWindow.fromWebContents(event.sender); if (!movePayload) {
if (!win) return; moveStates.delete(win.id)
const [winX, winY] = win.getPosition(); return
moveStates.set(win.id, { offsetX: payload.screenX - winX, offsetY: payload.screenY - winY });
} }
); const [winX, winY] = win.getPosition()
const moveState = createWindowMoveState({
ipcMain.on( payload: movePayload,
"paseo:window:moving", windowX: winX,
(event, payload: { screenX: number; screenY: number }) => { windowY: winY,
if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; })
const win = BrowserWindow.fromWebContents(event.sender); if (!moveState) {
if (!win) return; moveStates.delete(win.id)
const state = moveStates.get(win.id); return
if (!state) return;
win.setPosition(
Math.round(payload.screenX - state.offsetX),
Math.round(payload.screenY - state.offsetY)
);
} }
); moveStates.set(win.id, moveState)
})
ipcMain.on("paseo:window:endMove", (event) => { ipcMain.on('paseo:window:moving', (event, payload: { screenX: number; screenY: number }) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender)
if (win) moveStates.delete(win.id); if (!win) return
}); const state = moveStates.get(win.id)
if (!state) return
const movePayload = readWindowMovePayload(payload)
if (!movePayload) {
moveStates.delete(win.id)
return
}
const nextPosition = resolveWindowMovePosition({
payload: movePayload,
state,
})
if (!nextPosition) {
moveStates.delete(win.id)
return
}
win.setPosition(nextPosition.x, nextPosition.y)
})
app.on("browser-window-created", (_event, win) => { ipcMain.on('paseo:window:endMove', (event) => {
win.on("closed", () => moveStates.delete(win.id)); const win = BrowserWindow.fromWebContents(event.sender)
}); if (win) moveStates.delete(win.id)
})
ipcMain.handle("paseo:window:toggleMaximize", (event) => { app.on('browser-window-created', (_event, win) => {
const win = BrowserWindow.fromWebContents(event.sender); win.on('closed', () => moveStates.delete(win.id))
if (!win) return; })
ipcMain.handle('paseo:window:toggleMaximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) { if (win.isMaximized()) {
win.unmaximize(); win.unmaximize()
} else { } else {
win.maximize(); win.maximize()
} }
}); })
ipcMain.handle("paseo:window:isFullscreen", (event) => { ipcMain.handle('paseo:window:isFullscreen', (event) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false; return win?.isFullScreen() ?? false
}); })
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => { ipcMain.handle('paseo:window:setBadgeCount', (_event, count?: number) => {
if (process.platform === "darwin" || process.platform === "linux") { if (process.platform === 'darwin' || process.platform === 'linux') {
app.setBadgeCount(count ?? 0); app.setBadgeCount(count ?? 0)
} }
}); })
} }
export function setupWindowResizeEvents(win: BrowserWindow): void { export function setupWindowResizeEvents(win: BrowserWindow): void {
win.on("resize", () => { win.on('resize', () => {
win.webContents.send("paseo:window:resized", {}); win.webContents.send('paseo:window:resized', {})
}); })
win.on("enter-full-screen", () => { win.on('enter-full-screen', () => {
win.webContents.send("paseo:window:resized", {}); win.webContents.send('paseo:window:resized', {})
}); })
win.on("leave-full-screen", () => { win.on('leave-full-screen', () => {
win.webContents.send("paseo:window:resized", {}); win.webContents.send('paseo:window:resized', {})
}); })
} }
/** /**
@@ -83,11 +105,11 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
* The renderer handles drag-drop via standard HTML5 APIs instead. * The renderer handles drag-drop via standard HTML5 APIs instead.
*/ */
export function setupDragDropPrevention(win: BrowserWindow): void { export function setupDragDropPrevention(win: BrowserWindow): void {
win.webContents.on("will-navigate", (event, url) => { win.webContents.on('will-navigate', (event, url) => {
// Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs // Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs
// that result from dropping files onto the window. // that result from dropping files onto the window.
if (url.startsWith("file://")) { if (url.startsWith('file://')) {
event.preventDefault(); event.preventDefault()
} }
}); })
} }