fix: guard native drag drop payloads

This commit is contained in:
lucaronin
2026-06-05 22:11:49 +02:00
parent 4716003ca1
commit 762ac4ff48
4 changed files with 181 additions and 14 deletions

View File

@@ -14,7 +14,8 @@ vi.mock('../mock-tauri', () => ({
isTauri: () => tauriMode,
}))
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
type NativeDropPayload = { type: string; paths: string[]; position: { x: number; y: number } }
type DragDropEvent = { payload: unknown }
type DragDropCallback = (event: DragDropEvent) => void
let capturedDragDropHandler: DragDropCallback | undefined
let nativeDropUnlisten = () => {
@@ -208,7 +209,7 @@ describe('useImageDrop — Tauri native drag-drop', () => {
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
}
function emitNativeDropEvent(payload: DragDropEvent['payload']) {
function emitNativeDropEvent(payload: unknown) {
if (!capturedDragDropHandler) throw new Error('No native drop handler registered')
capturedDragDropHandler({ payload })
}
@@ -245,6 +246,24 @@ describe('useImageDrop — Tauri native drag-drop', () => {
expect(result.current.isDragOver).toBe(false)
})
it('ignores malformed native drag-drop payloads without throwing', async () => {
const { result } = renderImageDropTauri()
await waitForNativeDropListeners()
const file = new File(['data'], 'photo.png', { type: 'image/png' })
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
expect(result.current.isDragOver).toBe(true)
expect(() => {
act(() => {
emitNativeDropEvent(null)
})
}).not.toThrow()
expect(result.current.isDragOver).toBe(false)
})
it('copies native image drops into the vault and emits attachment asset URLs', async () => {
const onImageUrl = vi.fn()
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
@@ -261,7 +280,7 @@ describe('useImageDrop — Tauri native drag-drop', () => {
type: 'drop',
paths: ['/tmp/photo.png', '/tmp/readme.txt'],
position: { x: 100, y: 100 },
})
} satisfies NativeDropPayload)
})
await waitFor(() => {

View File

@@ -22,6 +22,28 @@ type DroppedImagesRequest = {
vaultPath: string | undefined
onImageUrl: ImageUrlHandler | undefined
}
type NativeDropEventRequest = {
event: TauriDropEvent
onImageUrl: ImageUrlHandler | undefined
setIsDragOver: (isDragOver: boolean) => void
vaultPath: string | undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
}
function isNativeDropPayload(payload: unknown): payload is TauriDragDropPayload {
if (!isRecord(payload)) return false
const type = Reflect.get(payload, 'type')
if (typeof type !== 'string') return false
if (type !== 'drop') return true
return isStringArray(Reflect.get(payload, 'paths'))
}
function hasImageFiles(dt: DataTransfer): boolean {
for (let i = 0; i < dt.items.length; i++) {
@@ -81,6 +103,29 @@ function insertDroppedImages({
}
}
function handleNativeDropEvent({
event,
onImageUrl,
setIsDragOver,
vaultPath,
}: NativeDropEventRequest): void {
if (!isNativeDropPayload(event.payload)) {
setIsDragOver(false)
return
}
const { payload } = event
if (payload.type === 'drop') {
setIsDragOver(false)
insertDroppedImages({
imagePaths: payload.paths.filter(isImagePath),
vaultPath,
onImageUrl,
})
return
}
setIsDragOver(false)
}
async function registerNativeDropListeners(
handler: (event: TauriDropEvent) => void,
): Promise<UnlistenFn[]> {
@@ -155,16 +200,12 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
void (async () => {
try {
const nextUnlisteners = await registerNativeDropListeners((event) => {
if (event.payload.type === 'drop') {
setIsDragOver(false)
insertDroppedImages({
imagePaths: event.payload.paths.filter(isImagePath),
vaultPath: vaultPathRef.current,
onImageUrl: onImageUrlRef.current,
})
return
}
setIsDragOver(false)
handleNativeDropEvent({
event,
onImageUrl: onImageUrlRef.current,
setIsDragOver,
vaultPath: vaultPathRef.current,
})
})
if (mounted) unlisteners = nextUnlisteners
else cleanupTauriEventListeners(nextUnlisteners)

View File

@@ -0,0 +1,82 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useTauriDragDropEvent } from './useTauriDragDropEvent'
let tauriMode = true
type NativeDragDropPayload = {
type: string
paths: string[]
position: { x: number; y: number }
}
type CapturedDragDropHandler = (event: { payload: unknown }) => void
let capturedDragDropHandler: CapturedDragDropHandler | undefined
const unlisten = vi.fn()
const onDragDropEvent = vi.fn((handler: CapturedDragDropHandler) => {
capturedDragDropHandler = handler
return Promise.resolve(unlisten)
})
vi.mock('../mock-tauri', () => ({
isTauri: () => tauriMode,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onDragDropEvent,
}),
}))
function emitNativeDragDropPayload(payload: unknown): void {
if (!capturedDragDropHandler) throw new Error('No native drag-drop handler registered')
capturedDragDropHandler({ payload })
}
async function waitForNativeDragDropListener(): Promise<void> {
await waitFor(() => {
expect(capturedDragDropHandler).toBeDefined()
})
}
describe('useTauriDragDropEvent', () => {
beforeEach(() => {
tauriMode = true
capturedDragDropHandler = undefined
onDragDropEvent.mockClear()
unlisten.mockClear()
})
afterEach(() => {
tauriMode = false
capturedDragDropHandler = undefined
})
it('does not forward null native drag-drop payloads to consumers', async () => {
const handler = vi.fn()
renderHook(() => useTauriDragDropEvent(handler))
await waitForNativeDragDropListener()
expect(() => {
emitNativeDragDropPayload(null)
}).not.toThrow()
expect(handler).not.toHaveBeenCalled()
})
it('forwards valid native drag-drop payloads to consumers', async () => {
const handler = vi.fn()
renderHook(() => useTauriDragDropEvent(handler))
await waitForNativeDragDropListener()
const payload = {
type: 'drop',
paths: ['/tmp/photo.png'],
position: { x: 10, y: 20 },
} satisfies NativeDragDropPayload
emitNativeDragDropPayload(payload)
expect(handler).toHaveBeenCalledWith({ payload })
})
})

View File

@@ -7,9 +7,34 @@ import { cleanupTauriEventListeners } from '../utils/tauriEventCleanup'
export type TauriDragDropEvent = TauriEvent<TauriDragDropPayload>
type TauriDragDropHandler = (event: TauriDragDropEvent) => void
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
}
function hasDropPosition(value: unknown): boolean {
if (!isRecord(value)) return false
return typeof Reflect.get(value, 'x') === 'number'
&& typeof Reflect.get(value, 'y') === 'number'
}
function isNativeDropPayload(payload: unknown): payload is TauriDragDropPayload {
if (!isRecord(payload)) return false
const type = Reflect.get(payload, 'type')
if (typeof type !== 'string') return false
if (type !== 'drop') return true
return isStringArray(Reflect.get(payload, 'paths'))
&& hasDropPosition(Reflect.get(payload, 'position'))
}
async function registerNativeDropListener(handler: TauriDragDropHandler): Promise<UnlistenFn> {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow().onDragDropEvent(handler)
return getCurrentWindow().onDragDropEvent((event) => {
if (isNativeDropPayload(event.payload)) handler(event as TauriDragDropEvent)
})
}
export function useTauriDragDropEvent(handler: TauriDragDropHandler) {