fix: guard native drag drop payloads
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
82
src/hooks/useTauriDragDropEvent.test.ts
Normal file
82
src/hooks/useTauriDragDropEvent.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user