diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 04caf8e894b..7bd3e41b378 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -29,7 +29,6 @@ import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' -import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { scheduleWorktreeBaseDirectoryWatcherSync, @@ -38,6 +37,7 @@ import { import { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' import { scheduleMainWindowAutoUpdaterSetup } from './main-window-updater' import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle' +import { registerFileDropRelay } from './native-file-drop-relay' export { ensureAutoUpdaterConfigured, registerUpdaterHandlers } from './main-window-updater' @@ -241,29 +241,3 @@ function registerAppReloadHandler( activeAppReloadHandlerToken = null }) } - -function registerFileDropRelay(mainWindow: BrowserWindow): void { - const channel = 'terminal:file-dropped-from-preload' - const mainWebContents = mainWindow.webContents - ipcMain.removeAllListeners(channel) - const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { - if ( - mainWindow.isDestroyed() || - mainWebContents.isDestroyed() || - event.sender !== mainWebContents - ) { - return - } - if (!isNativeFileDropPayload(args)) { - return - } - - // Why: one IPC event per drop gesture so the renderer gets the full path batch without timer-based reconstruction. - mainWindow.webContents.send('terminal:file-drop', args) - } - ipcMain.on(channel, relayFileDrop) - mainWindow.on('closed', () => { - // Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained. - ipcMain.removeListener(channel, relayFileDrop) - }) -} diff --git a/src/main/window/clipboard-remote-file-staging.ts b/src/main/window/clipboard-remote-file-staging.ts index 58c1e0f2518..d7043d22869 100644 --- a/src/main/window/clipboard-remote-file-staging.ts +++ b/src/main/window/clipboard-remote-file-staging.ts @@ -1,12 +1,19 @@ -import type { Dir, Stats } from 'node:fs' -import { access, lstat, mkdir, opendir, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, resolve } from 'node:path' +import { access, lstat, mkdir, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { + ensureOwnedTempStagingRoot, + getOwnedTempStagingRoot, + isDirectChild, + isMissingPathError, + isSafeOwnedDirectory, + removeOwnedDirectory, + sweepExpiredOwnedDirectories +} from './owned-temp-staging-root' const REMOTE_CLIPBOARD_STAGING_ROOT_NAME = 'orca-clipboard-files' const REMOTE_CLIPBOARD_LEGACY_PREFIX = 'orca-clipboard-file-' const REMOTE_CLIPBOARD_MIGRATION_MARKER = '.legacy-cleanup-complete' const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 -const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000 const REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT = 3 // Why: compatibility cleanup must never restore O(shared temp root) work. @@ -17,15 +24,6 @@ const LEGACY_DIRECTORY_PATTERN = new RegExp( `^${REMOTE_CLIPBOARD_LEGACY_PREFIX}\\d{1,16}-${UUID_PATTERN}$`, 'i' ) -const REMOVE_OPTIONS = { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 -} as const - -type CleanupResult = 'failed' | 'fresh' | 'ignored' | 'removed' - export class RemoteClipboardStagingRootUnsafeError extends Error { constructor() { super('Remote clipboard staging root is unsafe') @@ -34,8 +32,7 @@ export class RemoteClipboardStagingRootUnsafeError extends Error { } export function getRemoteClipboardStagingRoot(tempRoot: string): string { - const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' - return join(tempRoot, `${REMOTE_CLIPBOARD_STAGING_ROOT_NAME}${uidSuffix}`) + return getOwnedTempStagingRoot(tempRoot, REMOTE_CLIPBOARD_STAGING_ROOT_NAME) } export async function createRemoteClipboardTransferDirectory( @@ -69,7 +66,11 @@ export async function cleanupExpiredRemoteClipboardStaging( } catch { return } - await sweepDirectories(stagingRoot, nowMs, isTransferDirectoryName) + await sweepExpiredOwnedDirectories(stagingRoot, { + nowMs, + ttlMs: REMOTE_CLIPBOARD_FILE_TTL_MS, + ownsEntry: isTransferDirectoryName + }) } export async function cleanupLegacyRemoteClipboardStaging( @@ -93,12 +94,12 @@ export async function cleanupLegacyRemoteClipboardStaging( } } - const result = await sweepDirectories( - tempRoot, + const result = await sweepExpiredOwnedDirectories(tempRoot, { nowMs, - isLegacyTransferDirectoryName, - REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT - ) + ttlMs: REMOTE_CLIPBOARD_FILE_TTL_MS, + ownsEntry: isLegacyTransferDirectoryName, + entryLimit: REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT + }) if (result.complete && !result.hasFreshDirectories && !result.hasFailures) { await writeFile(markerPath, '', { flag: 'wx', mode: 0o600 }).catch(() => undefined) } @@ -121,7 +122,7 @@ export async function removeRemoteClipboardTransferDirectory( if (!isSafeOwnedDirectory(transferStats)) { return false } - await rm(transferDirectory, REMOVE_OPTIONS) + await removeOwnedDirectory(transferDirectory) return true } catch (error) { return isMissingPathError(error) @@ -165,92 +166,12 @@ function scheduleCleanupAttempt( async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise { const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) - await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) - const rootStats = await lstat(stagingRoot) - if (!isSafeOwnedDirectory(rootStats)) { + if (!(await ensureOwnedTempStagingRoot(stagingRoot))) { throw new RemoteClipboardStagingRootUnsafeError() } return stagingRoot } -async function sweepDirectories( - root: string, - nowMs: number, - ownsEntry: (name: string) => boolean, - entryLimit = Number.POSITIVE_INFINITY -): Promise<{ complete: boolean; hasFailures: boolean; hasFreshDirectories: boolean }> { - let rootDir: Dir - try { - rootDir = await opendir(root) - } catch { - return { complete: false, hasFailures: false, hasFreshDirectories: false } - } - - let complete = true - let entriesVisited = 0 - let hasFailures = false - let hasFreshDirectories = false - const pending = new Set>() - try { - for await (const entry of rootDir) { - entriesVisited += 1 - if (entry.isDirectory() && ownsEntry(entry.name)) { - const candidate = join(root, entry.name) - if (isDirectChild(root, candidate)) { - const cleanup = cleanupDirectory(candidate, nowMs).then((result) => { - hasFailures ||= result === 'failed' - hasFreshDirectories ||= result === 'fresh' - }) - pending.add(cleanup) - void cleanup.finally(() => pending.delete(cleanup)) - if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { - await Promise.race(pending) - } - } - } - if (entriesVisited >= entryLimit) { - break - } - } - } catch { - complete = false - } finally { - await rootDir.close().catch(() => undefined) - } - await Promise.all(pending) - return { complete, hasFailures, hasFreshDirectories } -} - -async function cleanupDirectory(directory: string, nowMs: number): Promise { - try { - const directoryStats = await lstat(directory) - if (!isSafeOwnedDirectory(directoryStats)) { - return 'ignored' - } - if (nowMs - directoryStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { - return 'fresh' - } - await rm(directory, REMOVE_OPTIONS) - return 'removed' - } catch (error) { - return isMissingPathError(error) ? 'ignored' : 'failed' - } -} - -function isSafeOwnedDirectory(stats: Stats): boolean { - if (!stats.isDirectory() || stats.isSymbolicLink()) { - return false - } - if (typeof process.getuid !== 'function') { - return true - } - return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700 -} - -function isDirectChild(parent: string, candidate: string): boolean { - return dirname(resolve(candidate)) === resolve(parent) -} - function isTransferDirectoryName(name: string): boolean { return TRANSFER_DIRECTORY_PATTERN.test(name) } @@ -258,9 +179,3 @@ function isTransferDirectoryName(name: string): boolean { function isLegacyTransferDirectoryName(name: string): boolean { return LEGACY_DIRECTORY_PATTERN.test(name) } - -function isMissingPathError(error: unknown): boolean { - return ( - error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' - ) -} diff --git a/src/main/window/darwin-user-temp-dir.test.ts b/src/main/window/darwin-user-temp-dir.test.ts new file mode 100644 index 00000000000..3d97591d351 --- /dev/null +++ b/src/main/window/darwin-user-temp-dir.test.ts @@ -0,0 +1,54 @@ +import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DarwinUserTempDir from './darwin-user-temp-dir' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +async function loadResolver(): Promise { + vi.resetModules() + return import('./darwin-user-temp-dir') +} + +function getconfResult(stdout: string, code = 0) { + return { code, signal: null, stdout, stderr: '', timedOut: false } +} + +beforeEach(() => { + runProcessMock.mockReset() +}) + +describe('getDarwinUserTempDir', () => { + it('asks getconf once and ignores a custom $TMPDIR', async () => { + runProcessMock.mockResolvedValue(getconfResult('/var/folders/ab/xyz/T/\n')) + const { getDarwinUserTempDir } = await loadResolver() + + expect(await getDarwinUserTempDir('darwin')).toBe('/var/folders/ab/xyz/T/') + expect(await getDarwinUserTempDir('darwin')).toBe('/var/folders/ab/xyz/T/') + expect(runProcessMock).toHaveBeenCalledTimes(1) + expect(runProcessMock.mock.calls[0][0]).toMatchObject({ + program: '/usr/bin/getconf', + args: ['DARWIN_USER_TEMP_DIR'] + }) + }) + + it('falls back to os.tmpdir() on failure and asks again next time', async () => { + runProcessMock + .mockResolvedValueOnce(getconfResult('', 1)) + .mockRejectedValueOnce(new Error('spawn failed')) + .mockResolvedValueOnce(getconfResult('/var/folders/ab/xyz/T/\n')) + const { getDarwinUserTempDir } = await loadResolver() + + expect(await getDarwinUserTempDir('darwin')).toBe(tmpdir()) + expect(await getDarwinUserTempDir('darwin')).toBe(tmpdir()) + expect(await getDarwinUserTempDir('darwin')).toBe('/var/folders/ab/xyz/T/') + }) + + it('uses os.tmpdir() without spawning off macOS', async () => { + const { getDarwinUserTempDir } = await loadResolver() + + expect(await getDarwinUserTempDir('linux')).toBe(tmpdir()) + expect(runProcessMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/darwin-user-temp-dir.ts b/src/main/window/darwin-user-temp-dir.ts new file mode 100644 index 00000000000..30408e66dac --- /dev/null +++ b/src/main/window/darwin-user-temp-dir.ts @@ -0,0 +1,40 @@ +import { tmpdir } from 'node:os' +import { isAbsolute } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' + +const GETCONF_TIMEOUT_MS = 5_000 + +let resolved: Promise | null = null + +/** + * The per-user temp dir macOS drag providers write to. `os.tmpdir()` follows + * `$TMPDIR`, so a custom one would hide every `TemporaryItems/NSIRD_*` drop. + */ +export function getDarwinUserTempDir( + platform: NodeJS.Platform = process.platform +): Promise { + if (platform !== 'darwin') { + return Promise.resolve(tmpdir()) + } + resolved ??= readDarwinUserTempDir() + return resolved +} + +async function readDarwinUserTempDir(): Promise { + try { + const result = await runProcess({ + program: '/usr/bin/getconf', + args: ['DARWIN_USER_TEMP_DIR'], + timeoutMs: GETCONF_TIMEOUT_MS + }) + const dir = result.stdout.trim() + if (result.code === 0 && isAbsolute(dir)) { + return dir + } + } catch { + // Fall through: `os.tmpdir()` is right whenever `$TMPDIR` is not customised. + } + // Why: don't pin a transient failure for the app's lifetime. + resolved = null + return tmpdir() +} diff --git a/src/main/window/dragged-temp-file-copy.test.ts b/src/main/window/dragged-temp-file-copy.test.ts new file mode 100644 index 00000000000..3b3252b7ee6 --- /dev/null +++ b/src/main/window/dragged-temp-file-copy.test.ts @@ -0,0 +1,476 @@ +import type * as NodeFs from 'node:fs' +import { appendFileSync } from 'node:fs' +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + utimes, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import type * as RuntimeImportLimits from '../ipc/runtime-import-limits' + +const fsFaults: { + beforeCopyWrite: (() => void) | null + writeError: NodeJS.ErrnoException | null +} = vi.hoisted(() => ({ beforeCopyWrite: null, writeError: null })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createWriteStream: (...args: Parameters) => { + fsFaults.beforeCopyWrite?.() + const stream = actual.createWriteStream(...args) + const writeError = fsFaults.writeError + if (writeError) { + stream.once('open', () => stream.destroy(writeError)) + } + return stream + } + } +}) + +vi.mock('../ipc/runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 10, + REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 +})) + +import { + DRAG_TEMP_COPY_TTL_MS, + materializeDragTempPaths, + mayNeedDragTempCopy, + scheduleDragTempCopySweep, + sweepExpiredDragTempCopies, + type DragTempCopyEnvironment +} from './dragged-temp-file-copy' + +const SCREENSHOT_NAME = 'Screenshot 2026-09-28 at 4.03.11 PM.png' +const canChangePermissions = process.platform !== 'win32' && process.getuid?.() !== 0 + +let root: string +let env: DragTempCopyEnvironment +let providerDir: string + +async function dragTempFile(name: string, content: string | Buffer): Promise { + const filePath = join(providerDir, name) + await writeFile(filePath, content, { mode: 0o644 }) + return filePath +} + +async function copyDirs(): Promise { + try { + return await readdir(env.copyRoot) + } catch { + return [] + } +} + +function importedPath(result: { status: string; destPath?: string }): string { + expect(result.status).toBe('imported') + return result.destPath! +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drag-temp-test-')) + const sourceTempRoot = join(root, 'T') + providerDir = join(sourceTempRoot, 'TemporaryItems', 'NSIRD_screencaptureui_abc123') + await mkdir(providerDir, { recursive: true }) + env = { platform: 'darwin', sourceTempRoot, copyRoot: join(root, 'app-temp', 'orca-drops') } +}) + +afterEach(async () => { + fsFaults.beforeCopyWrite = null + fsFaults.writeError = null + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('materializeDragTempPaths', () => { + it('copies a drag-temp file, keeping its basename and bytes', async () => { + const source = await dragTempFile(SCREENSHOT_NAME, 'png-bytes') + + const [result] = await materializeDragTempPaths([source], env) + + const dest = importedPath(result) + expect(basename(dest)).toBe(SCREENSHOT_NAME) + expect(basename(dirname(dest))).toMatch(/^orca-drop-/) + expect(dirname(dirname(dest))).toBe(env.copyRoot) + expect(await readFile(dest, 'utf8')).toBe('png-bytes') + }) + + it.skipIf(process.platform === 'win32')( + 'creates a 0600 copy in a 0700 directory for a 0644 source', + async () => { + const source = await dragTempFile('shot.png', 'x') + + const dest = importedPath((await materializeDragTempPaths([source], env))[0]) + + expect((await stat(dest)).mode & 0o777).toBe(0o600) + expect((await stat(dirname(dest))).mode & 0o777).toBe(0o700) + } + ) + + it.skipIf(process.platform !== 'darwin')( + 'carries none of the source or provider directory xattrs', + async () => { + const source = await dragTempFile('shot.png', 'x') + for (const target of [source, providerDir]) { + const set = await runProcess({ + program: '/usr/bin/xattr', + args: ['-w', 'com.orca.test-marker', '1', target] + }) + expect(set.code).toBe(0) + } + + const dest = importedPath((await materializeDragTempPaths([source], env))[0]) + + for (const target of [dest, dirname(dest)]) { + const listed = await runProcess({ program: '/usr/bin/xattr', args: [target] }) + expect(listed.stdout).not.toContain('com.orca.test-marker') + } + } + ) + + it('produces an empty copy for a zero-byte source', async () => { + const source = await dragTempFile('empty.png', '') + + const dest = importedPath((await materializeDragTempPaths([source], env))[0]) + + expect((await stat(dest)).size).toBe(0) + }) + + it('passes everything through off macOS', async () => { + const source = await dragTempFile('shot.png', 'x') + + const results = await materializeDragTempPaths([source], { ...env, platform: 'linux' }) + + expect(results).toEqual([{ sourcePath: source, status: 'imported', destPath: source }]) + expect(await copyDirs()).toEqual([]) + }) + + it('passes through Finder paths and temp paths outside TemporaryItems/NSIRD_*', async () => { + const finder = join(root, 'Desktop', 'shot.png') + const otherTemp = join(env.sourceTempRoot, 'TemporaryItems', 'other', 'shot.png') + const providerDirItself = providerDir + await mkdir(dirname(finder), { recursive: true }) + await writeFile(finder, 'x') + await mkdir(dirname(otherTemp), { recursive: true }) + await writeFile(otherTemp, 'x') + + const paths = [finder, otherTemp, providerDirItself, '/not/there/shot.png'] + const results = await materializeDragTempPaths(paths, env) + + expect(results.map((result) => importedPath(result))).toEqual(paths) + expect(await copyDirs()).toEqual([]) + }) + + it('passes missing TemporaryItems lookalikes outside the configured temp root through', async () => { + const missing = join(root, 'other', 'TemporaryItems', 'NSIRD_provider', 'gone.png') + + expect(await materializeDragTempPaths([missing], env)).toEqual([ + { sourcePath: missing, status: 'imported', destPath: missing } + ]) + }) + + it.skipIf(process.platform === 'win32')( + 'passes symlinks and directories through unchanged', + async () => { + const target = join(root, 'outside.png') + await writeFile(target, 'x') + const link = join(providerDir, 'link.png') + await symlink(target, link) + const nested = join(providerDir, 'folder') + await mkdir(nested) + + const results = await materializeDragTempPaths([link, nested], env) + + expect(results.map((result) => importedPath(result))).toEqual([link, nested]) + expect(await copyDirs()).toEqual([]) + } + ) + + it.skipIf(process.platform === 'win32')( + 'treats a symlinked temp root and its real path as the same root', + async () => { + const source = await dragTempFile('shot.png', 'x') + const linkedRoot = join(root, 'var-link') + await symlink(env.sourceTempRoot, linkedRoot) + const viaLink = join(linkedRoot, 'TemporaryItems', basename(providerDir), 'shot.png') + + const [throughLinkedSource] = await materializeDragTempPaths([viaLink], env) + const [throughLinkedRoot] = await materializeDragTempPaths([source], { + ...env, + sourceTempRoot: linkedRoot + }) + + expect(importedPath(throughLinkedSource)).not.toBe(viaLink) + expect(importedPath(throughLinkedRoot)).not.toBe(source) + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not copy prefix siblings, nested lookalikes, or symlink escapes', + async () => { + const sibling = join(`${env.sourceTempRoot}-sibling`, 'TemporaryItems', 'NSIRD_x', 'a.png') + const nested = join(env.sourceTempRoot, 'deep', 'TemporaryItems', 'NSIRD_x', 'a.png') + const outsideDir = join(root, 'outside-provider') + const escaped = join(env.sourceTempRoot, 'TemporaryItems', 'NSIRD_escape', 'a.png') + for (const filePath of [sibling, nested, join(outsideDir, 'a.png')]) { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, 'x') + } + await symlink(outsideDir, dirname(escaped)) + + const paths = [sibling, nested, escaped] + const results = await materializeDragTempPaths(paths, env) + + expect(results.map((result) => importedPath(result))).toEqual(paths) + expect(await copyDirs()).toEqual([]) + } + ) + + it('reports a missing drag-temp file instead of passing the original through', async () => { + const missing = join(providerDir, 'gone.png') + + expect(await materializeDragTempPaths([missing], env)).toEqual([ + { sourcePath: missing, status: 'failed', reason: 'missing' } + ]) + }) + + it.skipIf(!canChangePermissions)( + 'reports an unreadable drag-temp file as permission denied and leaves no copy', + async () => { + const source = await dragTempFile('locked.png', 'x') + await chmod(source, 0o000) + + const results = await materializeDragTempPaths([source], env) + + expect(results).toEqual([ + { sourcePath: source, status: 'failed', reason: 'permission-denied' } + ]) + expect(await copyDirs()).toEqual([]) + } + ) + + it('copies at the per-file limit and hands one byte more over uncopied', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const atLimit = await dragTempFile('ten.png', '0123456789') + const overLimit = await dragTempFile('eleven.png', '0123456789a') + + const [accepted, uncopied] = await materializeDragTempPaths([atLimit, overLimit], env) + + expect(importedPath(accepted)).not.toBe(atLimit) + expect(uncopied).toEqual({ sourcePath: overLimit, status: 'uncopied', reason: 'too-large' }) + expect(await copyDirs()).toHaveLength(1) + }) + + it('shares one budget within a drop, and an uncopied item does not block a smaller later one', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const finder = join(root, 'big-finder-file.png') + await writeFile(finder, 'x'.repeat(100)) + const first = await dragTempFile('a.png', '0123456789') + const second = await dragTempFile('b.png', '0123456789') + const third = await dragTempFile('c.png', '01234') + + const results = await materializeDragTempPaths([finder, first, second, third], env) + + expect(importedPath(results[0])).toBe(finder) + expect(importedPath(results[1])).not.toBe(first) + expect(results[2]).toEqual({ sourcePath: second, status: 'uncopied', reason: 'storage-full' }) + expect(importedPath(results[3])).not.toBe(third) + expect(await copyDirs()).toHaveLength(2) + }) + + it('counts copies retained from earlier drops against the budget', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const first = await dragTempFile('a.png', '0123456789') + const second = await dragTempFile('b.png', '0123456789') + const third = await dragTempFile('c.png', '012345') + + const [firstDrop] = await materializeDragTempPaths([first], env) + const [secondDrop] = await materializeDragTempPaths([second], env) + const [thirdDrop] = await materializeDragTempPaths([third], env) + + expect(importedPath(firstDrop)).not.toBe(first) + expect(secondDrop).toMatchObject({ status: 'uncopied', reason: 'storage-full' }) + expect(importedPath(thirdDrop)).not.toBe(third) + expect(await copyDirs()).toHaveLength(2) + }) + + it('rejects a source that changes mid-copy and leaves no copy behind', async () => { + const source = await dragTempFile('shot.png', 'png') + fsFaults.beforeCopyWrite = () => appendFileSync(source, '-grown') + + expect(await materializeDragTempPaths([source], env)).toEqual([ + { sourcePath: source, status: 'failed', reason: 'changed' } + ]) + expect(await readdir(env.copyRoot)).toEqual([]) + }) + + it('reports a full disk as a reason token, logging the errno, and leaves no copy behind', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const source = await dragTempFile('shot.png', 'png') + fsFaults.writeError = Object.assign(new Error('ENOSPC: no space left on device, write'), { + code: 'ENOSPC' + }) + + expect(await materializeDragTempPaths([source], env)).toEqual([ + { sourcePath: source, status: 'failed', reason: 'out-of-space' } + ]) + expect(await readdir(env.copyRoot)).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.any(String), { code: 'ENOSPC' }) + }) + + it('reports an unexpected errno as a generic copy failure', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const source = await dragTempFile('shot.png', 'png') + fsFaults.writeError = Object.assign(new Error('EIO: i/o error, write'), { code: 'EIO' }) + + expect(await materializeDragTempPaths([source], env)).toEqual([ + { sourcePath: source, status: 'failed', reason: 'copy-failed' } + ]) + }) + + it('reuses one copy for a duplicate source path within a batch', async () => { + const source = await dragTempFile('shot.png', 'x') + + const [first, second] = await materializeDragTempPaths([source, source], env) + + expect(importedPath(second)).toBe(importedPath(first)) + expect(await copyDirs()).toHaveLength(1) + }) + + it('gives concurrent batches distinct copy directories', async () => { + const source = await dragTempFile('shot.png', 'x') + + const [[left], [right]] = await Promise.all([ + materializeDragTempPaths([source], env), + materializeDragTempPaths([source], env) + ]) + + expect(dirname(importedPath(left))).not.toBe(dirname(importedPath(right))) + expect(await readFile(importedPath(left), 'utf8')).toBe('x') + expect(await readFile(importedPath(right), 'utf8')).toBe('x') + }) + + it('reports a broken copy root as a storage failure, not a problem with the dropped file', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const source = await dragTempFile('shot.png', 'x') + await mkdir(dirname(env.copyRoot), { recursive: true }) + await writeFile(env.copyRoot, 'not a directory') + + const [result] = await materializeDragTempPaths([source], env) + + expect(result).toEqual({ sourcePath: source, status: 'failed', reason: 'storage-unavailable' }) + }) + + it.skipIf(!canChangePermissions)('refuses a copy root other users can read', async () => { + const source = await dragTempFile('shot.png', 'x') + await mkdir(env.copyRoot, { recursive: true }) + await chmod(env.copyRoot, 0o755) + + expect(await materializeDragTempPaths([source], env)).toEqual([ + { sourcePath: source, status: 'failed', reason: 'storage-not-private' } + ]) + }) + + it('stops on abort without leaving a copy behind', async () => { + const source = await dragTempFile('shot.png', 'x') + const controller = new AbortController() + controller.abort(new Error('renderer gone')) + + await expect(materializeDragTempPaths([source], env, controller.signal)).rejects.toThrow( + 'renderer gone' + ) + expect(await copyDirs()).toEqual([]) + }) + + it('removes copies it already made when aborted partway through a drop', async () => { + const first = await dragTempFile('a.png', 'a') + const second = await dragTempFile('b.png', 'b') + const controller = new AbortController() + let writes = 0 + fsFaults.beforeCopyWrite = () => { + writes += 1 + if (writes === 2) { + controller.abort(new Error('timed out')) + } + } + + await expect( + materializeDragTempPaths([first, second], env, controller.signal) + ).rejects.toThrow() + expect(await copyDirs()).toEqual([]) + }) +}) + +describe('mayNeedDragTempCopy', () => { + it('matches only files below a TemporaryItems/NSIRD_* directory on macOS', () => { + const drag = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'a.png') + + expect(mayNeedDragTempCopy(drag, 'darwin')).toBe(true) + expect(mayNeedDragTempCopy(drag, 'linux')).toBe(false) + expect(mayNeedDragTempCopy(dirname(drag), 'darwin')).toBe(false) + expect(mayNeedDragTempCopy(join('/', 'Users', 'me', 'Desktop', 'a.png'), 'darwin')).toBe(false) + }) +}) + +describe('sweepExpiredDragTempCopies', () => { + it('removes only expired orca-drop directories', async () => { + const source = await dragTempFile('shot.png', 'x') + const [oldCopy] = await materializeDragTempPaths([source], env) + const [freshCopy] = await materializeDragTempPaths([source], env) + const oldDir = dirname(importedPath(oldCopy)) + const freshDir = dirname(importedPath(freshCopy)) + const foreign = join(env.copyRoot, 'not-ours') + await mkdir(foreign) + const nowMs = Date.now() + const expired = new Date(nowMs - DRAG_TEMP_COPY_TTL_MS - 1000) + await utimes(oldDir, expired, expired) + await utimes(foreign, expired, expired) + + await sweepExpiredDragTempCopies(env.copyRoot, nowMs) + + await expect(lstat(oldDir)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await lstat(freshDir)).isDirectory()).toBe(true) + expect((await lstat(foreign)).isDirectory()).toBe(true) + }) + + it('does nothing when no copy root exists', async () => { + await expect(sweepExpiredDragTempCopies(join(root, 'nope'))).resolves.toBeUndefined() + }) +}) + +describe('scheduleDragTempCopySweep', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('sweeps on macOS shortly after startup and then hourly, scheduling only once', async () => { + vi.useFakeTimers() + const getCopyRoot = vi.fn(() => join(root, 'nope')) + + scheduleDragTempCopySweep(getCopyRoot, 'linux') + expect(vi.getTimerCount()).toBe(0) + + scheduleDragTempCopySweep(getCopyRoot, 'darwin') + scheduleDragTempCopySweep(getCopyRoot, 'darwin') + expect(vi.getTimerCount()).toBe(2) + + await vi.advanceTimersByTimeAsync(30_000) + expect(getCopyRoot).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + expect(getCopyRoot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/window/dragged-temp-file-copy.ts b/src/main/window/dragged-temp-file-copy.ts new file mode 100644 index 00000000000..cacd4f4c9c5 --- /dev/null +++ b/src/main/window/dragged-temp-file-copy.ts @@ -0,0 +1,360 @@ +import { constants, createWriteStream, type Dir, type Stats } from 'node:fs' +import { lstat, mkdtemp, open, opendir, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { pipeline } from 'node:stream/promises' +import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop' +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from '../ipc/runtime-import-limits' +import { + ensureOwnedTempStagingRoot, + getOwnedTempStagingRoot, + isSafeOwnedDirectory, + sweepExpiredOwnedDirectories +} from './owned-temp-staging-root' + +// Why: macOS screenshot thumbnails live in `$TMPDIR/TemporaryItems/NSIRD_*`, +// which only processes attributed to Orca main may open. The detached PTY +// daemon is not, so agents in local terminals get EPERM on the original path. + +const TEMPORARY_ITEMS_SEGMENT = 'TemporaryItems' +const DRAG_PROVIDER_DIR_PREFIX = 'NSIRD_' +const COPY_ROOT_NAME = 'orca-drops' +const COPY_DIR_PREFIX = 'orca-drop-' +const COPY_DIR_PATTERN = /^orca-drop-[A-Za-z0-9]{6}$/ +// Why: open drafts and startup prompts read the copy lazily, often days later, so +// keep it well past the drop; the TTL still bounds what the copy budget holds. +export const DRAG_TEMP_COPY_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const SWEEP_FIRST_DELAY_MS = 30 * 1000 +const SWEEP_INTERVAL_MS = 60 * 60 * 1000 + +export type DragTempCopyEnvironment = { + platform: NodeJS.Platform + /** macOS per-user temp dir: where drag providers put their files. */ + sourceTempRoot: string + /** Orca-owned directory that holds one `orca-drop-*` directory per copy. */ + copyRoot: string +} + +export type DragTempCopyItemResult = + | { sourcePath: string; status: 'imported'; destPath: string } + /** Left as the original path, which only main's children can open. */ + | { sourcePath: string; status: 'uncopied'; reason: 'too-large' | 'storage-full' } + | { sourcePath: string; status: 'failed'; reason: NativeFileDropCopyFailureReason } + +export function getDragTempCopyRoot(appTempRoot: string): string { + return getOwnedTempStagingRoot(appTempRoot, COPY_ROOT_NAME) +} + +/** Lexical check only: whether a path could be a drag-temp file worth inspecting. */ +export function mayNeedDragTempCopy(path: string, platform: NodeJS.Platform): boolean { + return platform === 'darwin' && hasDragTempMarker(resolve(path).split(sep)) +} + +/** + * Copy every drag-temp path in a drop into Orca-owned storage, sequentially, + * under the byte budget storage has left. Other paths pass through unchanged. + */ +export async function materializeDragTempPaths( + paths: readonly string[], + env: DragTempCopyEnvironment, + signal?: AbortSignal +): Promise { + const results: DragTempCopyItemResult[] = [] + const completed = new Map() + // Why: the budget spans every retained copy, so repeated drops cannot fill the disk. + let remainingBytes = + env.platform === 'darwin' + ? REMOTE_IMPORT_MAX_TOTAL_BYTES - (await measureRetainedCopyBytes(env.copyRoot)) + : 0 + try { + for (const sourcePath of paths) { + signal?.throwIfAborted() + // Why: reuse one copy so composer de-duplication still sees equal paths. + const previous = completed.get(sourcePath) + if (previous) { + results.push(previous) + continue + } + const { result, copiedBytes } = await materializeDragTempPath( + sourcePath, + remainingBytes, + env, + signal + ) + remainingBytes -= copiedBytes + completed.set(sourcePath, result) + results.push(result) + } + signal?.throwIfAborted() + return results + } catch (error) { + // Why: a caller that gave up never hands these out; don't hold budget until the sweep. + await removeCopies(completed.values()) + throw error + } +} + +async function removeCopies(results: Iterable): Promise { + for (const result of results) { + if (result.status === 'imported' && result.destPath !== result.sourcePath) { + await rm(dirname(result.destPath), { recursive: true, force: true }).catch(() => undefined) + } + } +} + +export async function materializeDragTempPath( + sourcePath: string, + remainingBytes: number, + env: DragTempCopyEnvironment, + signal?: AbortSignal +): Promise<{ result: DragTempCopyItemResult; copiedBytes: number }> { + const passThrough = { result: imported(sourcePath, sourcePath), copiedBytes: 0 } + if (!mayNeedDragTempCopy(sourcePath, env.platform)) { + return passThrough + } + let copyDir: string | undefined + try { + let inspected: Stats + try { + inspected = await lstat(sourcePath) + } catch (error) { + // A missing lookalike outside `$TMPDIR` is still an ordinary path. + if ( + errorCode(error) === 'ENOENT' && + !isPathWithin(resolve(env.sourceTempRoot), resolve(sourcePath)) + ) { + return passThrough + } + throw error + } + if (!inspected.isFile()) { + // Why: directories and symlinks keep today's reference-in-place behaviour. + return passThrough + } + const canonicalSource = await realpath(sourcePath) + const canonicalTempRoot = await realpath(env.sourceTempRoot) + if (!hasDragTempMarker(relativeSegments(canonicalTempRoot, canonicalSource))) { + return passThrough + } + + const handle = await open(canonicalSource, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const opened = await handle.stat() + if (!opened.isFile() || !isSameSnapshot(opened, inspected)) { + throw new DropCopyError('changed') + } + const size = opened.size + if (size > REMOTE_IMPORT_MAX_FILE_BYTES || size > remainingBytes) { + // Why: targets read by main can still use the original; the relay decides. + console.warn('[drop] leaving a drag-temp file uncopied: over the copy budget', { + bytes: formatByteCeiling(size), + remaining: formatByteCeiling(Math.max(remainingBytes, 0)) + }) + const reason = size > REMOTE_IMPORT_MAX_FILE_BYTES ? 'too-large' : 'storage-full' + return { result: { sourcePath, status: 'uncopied', reason }, copiedBytes: 0 } + } + signal?.throwIfAborted() + + copyDir = await createCopyDirectory(env.copyRoot) + const destPath = join(copyDir, basename(canonicalSource)) + // Why: stream from the checked handle, capped at the inspected size, so a + // swapped or growing source cannot slip through; unlike cp, ditto or + // clonefile it copies no xattrs. A read stream with `end: -1` throws, so + // an empty source gets its own branch. + await (size === 0 + ? writeFile(destPath, '', { flag: 'wx', mode: 0o600 }) + : pipeline( + handle.createReadStream({ start: 0, end: size - 1, autoClose: false }), + createWriteStream(destPath, { flags: 'wx', mode: 0o600 }), + { signal } + )) + const written = await lstat(destPath) + const afterRead = await handle.stat() + if (written.size !== size || !isSameSnapshot(afterRead, opened)) { + throw new DropCopyError('changed') + } + // Why: a caller that gave up mid-copy never hands this path out; don't retain it. + signal?.throwIfAborted() + console.debug('[drop] copied a drag-temp file into Orca storage', { bytes: size }) + return { result: imported(sourcePath, destPath), copiedBytes: size } + } finally { + await handle.close() + } + } catch (error) { + if (copyDir) { + // Why: cleanup must not hide the original failure or stop later items. + await rm(copyDir, { recursive: true, force: true }).catch(() => undefined) + } + if (signal?.aborted) { + throw error + } + return { result: classifyFailure(sourcePath, error), copiedBytes: 0 } + } +} + +/** Remove `orca-drop-*` copies older than the TTL; younger ones may still be read lazily. */ +export async function sweepExpiredDragTempCopies( + copyRoot: string, + nowMs = Date.now() +): Promise { + try { + if (!isSafeOwnedDirectory(await lstat(copyRoot))) { + return + } + } catch { + // Missing or unreadable root: nothing of ours to sweep. + return + } + await sweepExpiredOwnedDirectories(copyRoot, { + nowMs, + ttlMs: DRAG_TEMP_COPY_TTL_MS, + ownsEntry: (name) => COPY_DIR_PATTERN.test(name) + }) +} + +/** Bytes held by `orca-drop-*` copies still on disk; unreadable entries count as zero. */ +async function measureRetainedCopyBytes(copyRoot: string): Promise { + let total = 0 + let rootDir: Dir + try { + rootDir = await opendir(copyRoot) + } catch { + return 0 + } + try { + for await (const entry of rootDir) { + if (!entry.isDirectory() || !COPY_DIR_PATTERN.test(entry.name)) { + continue + } + const copyDir = join(copyRoot, entry.name) + const names = await readdir(copyDir).catch(() => []) + for (const name of names) { + total += await lstat(join(copyDir, name)).then( + (stats) => (stats.isFile() ? stats.size : 0), + () => 0 + ) + } + } + } catch { + // A partial count still bounds growth; the next drop measures again. + } + return total +} + +let sweepScheduled = false + +/** Sweep shortly after startup, then hourly, so a long-running app still expires copies. */ +export function scheduleDragTempCopySweep( + getCopyRoot: () => string, + platform: NodeJS.Platform = process.platform +): void { + if (sweepScheduled || platform !== 'darwin') { + return + } + sweepScheduled = true + const sweep = (): void => { + void Promise.resolve() + .then(() => sweepExpiredDragTempCopies(getCopyRoot())) + .catch(() => undefined) + } + setTimeout(sweep, SWEEP_FIRST_DELAY_MS).unref() + setInterval(sweep, SWEEP_INTERVAL_MS).unref() +} + +// True when the segments hold `TemporaryItems/NSIRD_*/`: something below the provider dir. +function hasDragTempMarker(segments: readonly string[]): boolean { + for (let i = 0; i + 2 < segments.length; i += 1) { + if ( + segments[i] === TEMPORARY_ITEMS_SEGMENT && + segments[i + 1].startsWith(DRAG_PROVIDER_DIR_PREFIX) + ) { + return true + } + } + return false +} + +/** Path segments of `candidate` below `root`, or [] when it is not inside it. */ +function relativeSegments(root: string, candidate: string): string[] { + const rel = relative(root, candidate) + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return [] + } + const segments = rel.split(sep) + // Why: anchor at the temp root so a nested lookalike elsewhere is not copied. + return segments[0] === TEMPORARY_ITEMS_SEGMENT ? segments : [] +} + +function isPathWithin(root: string, candidate: string): boolean { + const rel = relative(root, candidate) + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) +} + +async function createCopyDirectory(copyRoot: string): Promise { + try { + if (!(await ensureOwnedTempStagingRoot(copyRoot))) { + throw new DropCopyError('storage-not-private') + } + return await mkdtemp(join(copyRoot, COPY_DIR_PREFIX)) + } catch (error) { + if (error instanceof DropCopyError) { + throw error + } + // Why: a storage fault must not read as a missing or unreadable dropped file. + console.warn('[drop] could not create drop storage', { code: errorCode(error) }) + throw new DropCopyError(isOutOfSpace(errorCode(error)) ? 'out-of-space' : 'storage-unavailable') + } +} + +/** Same inode, size and mtime, where the filesystem reports an inode. */ +function isSameSnapshot(a: Stats, b: Stats): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + (a.ino === 0 || b.ino === 0 || a.ino === b.ino) && + (a.dev === 0 || b.dev === 0 || a.dev === b.dev) + ) +} + +function imported(sourcePath: string, destPath: string): DragTempCopyItemResult { + return { sourcePath, status: 'imported', destPath } +} + +class DropCopyError extends Error { + constructor(readonly reason: NativeFileDropCopyFailureReason) { + super(reason) + } +} + +function classifyFailure(sourcePath: string, error: unknown): DragTempCopyItemResult { + return { sourcePath, status: 'failed', reason: failureReason(error) } +} + +function isOutOfSpace(code: string | undefined): boolean { + return code === 'ENOSPC' || code === 'EDQUOT' +} + +function failureReason(error: unknown): NativeFileDropCopyFailureReason { + if (error instanceof DropCopyError) { + return error.reason + } + const code = errorCode(error) + if (code === 'ENOENT') { + return 'missing' + } + if (code === 'EPERM' || code === 'EACCES') { + return 'permission-denied' + } + console.warn('[drop] could not copy a drag-temp file', { code }) + return isOutOfSpace(code) ? 'out-of-space' : 'copy-failed' +} + +function errorCode(error: unknown): string | undefined { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + return error.code + } + return undefined +} diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts new file mode 100644 index 00000000000..276387bc6a4 --- /dev/null +++ b/src/main/window/native-file-drop-relay.test.ts @@ -0,0 +1,476 @@ +import type { BrowserWindow } from 'electron' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type * as DragTempFileCopy from './dragged-temp-file-copy' +import type { DragTempCopyItemResult } from './dragged-temp-file-copy' + +type IpcListener = (event: { sender: unknown }, payload: unknown) => void + +const { materializeMock, sweepMock, ipcListeners } = vi.hoisted(() => ({ + materializeMock: vi.fn(), + sweepMock: vi.fn(), + ipcListeners: new Map() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/app-temp' }, + ipcMain: { + on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener), + removeListener: (channel: string, listener: IpcListener) => { + if (ipcListeners.get(channel) === listener) { + ipcListeners.delete(channel) + } + }, + removeAllListeners: (channel: string) => ipcListeners.delete(channel) + } +})) + +vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({ + ...(await importOriginal()), + materializeDragTempPaths: materializeMock, + scheduleDragTempCopySweep: sweepMock +})) + +vi.mock('./darwin-user-temp-dir', () => ({ + getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T' +})) + +import { + createNativeFileDropQueue, + MAX_PENDING_DRAG_TEMP_COPIES, + registerFileDropRelay +} from './native-file-drop-relay' + +const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png') +const OTHER_DRAG_TEMP = join( + '/', + 'var', + 'T', + 'TemporaryItems', + 'NSIRD_screencaptureui_1', + 'Other.png' +) +const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png') +const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt') +const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' } + +function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult { + return { sourcePath, status: 'imported', destPath } +} + +function createQueue( + overrides: { + getCopyEnvironment?: () => Promise + forward?: (payload: NativeFileDropPayload) => void + copyTimeoutMs?: number + } = {} +) { + const forwarded: NativeFileDropPayload[] = [] + const controller = new AbortController() + const enqueue = createNativeFileDropQueue({ + forward: overrides.forward ?? ((payload) => forwarded.push(payload)), + platform: 'darwin', + getCopyEnvironment: overrides.getCopyEnvironment ?? (async () => env), + watchRenderer: () => ({ signal: controller.signal, dispose: () => undefined }), + copyTimeoutMs: overrides.copyTimeoutMs + }) + return { enqueue, forwarded, controller } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +beforeEach(() => { + materializeMock.mockReset() +}) + +describe('createNativeFileDropQueue', () => { + it('forwards a drop with no drag-temp path synchronously without copying', () => { + const { enqueue, forwarded } = createQueue() + const payload: NativeFileDropPayload = { paths: [FINDER], target: 'editor' } + + enqueue(payload) + + expect(forwarded).toEqual([payload]) + expect(materializeMock).not.toHaveBeenCalled() + }) + + it('forwards drag-temp-looking paths untouched off macOS', () => { + const forwarded: NativeFileDropPayload[] = [] + const enqueue = createNativeFileDropQueue({ + forward: (payload) => forwarded.push(payload), + platform: 'linux', + getCopyEnvironment: async () => env, + watchRenderer: () => ({ signal: new AbortController().signal, dispose: () => undefined }) + }) + + enqueue({ paths: [DRAG_TEMP], target: 'composer' }) + + expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'composer' }]) + expect(materializeMock).not.toHaveBeenCalled() + }) + + it('swaps in the copy and keeps the drop target fields', async () => { + materializeMock.mockResolvedValue([copied(FINDER), copied(DRAG_TEMP, COPY)]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal', tabId: 't1', paneLeafId: 'p1' }) + await settle() + + expect(materializeMock).toHaveBeenCalledWith([FINDER, DRAG_TEMP], env, expect.any(AbortSignal)) + expect(forwarded).toEqual([ + { paths: [FINDER, COPY], target: 'terminal', tabId: 't1', paneLeafId: 'p1' } + ]) + }) + + it('holds a later plain drop until an earlier copy is forwarded', async () => { + const copy = deferred() + materializeMock.mockReturnValueOnce(copy.promise) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [DRAG_TEMP], target: 'composer' }) + enqueue({ paths: [FINDER], target: 'composer' }) + await settle() + expect(forwarded).toEqual([]) + + copy.resolve([copied(DRAG_TEMP, COPY)]) + await settle() + + expect(forwarded).toEqual([ + { paths: [COPY], target: 'composer' }, + { paths: [FINDER], target: 'composer' } + ]) + enqueue({ paths: [FINDER], target: 'editor' }) + expect(forwarded).toHaveLength(3) + }) + + it('copies drag-temp drops one at a time, in arrival order', async () => { + const copy = deferred() + materializeMock + .mockReturnValueOnce(copy.promise) + .mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [DRAG_TEMP], target: 'composer' }) + enqueue({ paths: [OTHER_DRAG_TEMP], target: 'terminal' }) + await settle() + expect(materializeMock).toHaveBeenCalledTimes(1) + + copy.resolve([copied(DRAG_TEMP, COPY)]) + await settle() + + expect(forwarded.map((payload) => payload.target)).toEqual(['composer', 'terminal']) + }) + + it('hands an uncopied file to a target main reads, without reporting it', async () => { + materializeMock.mockResolvedValue([ + { sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'too-large' } + ]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [DRAG_TEMP], target: 'editor' }) + await settle() + + expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'editor' }]) + }) + + it('withholds an uncopied file from terminals and composers and reports it', async () => { + materializeMock.mockResolvedValue([ + copied(FINDER), + { sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'storage-full' } + ]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' }) + await settle() + + expect(forwarded).toEqual([ + { paths: [FINDER], target: 'terminal' }, + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'storage-full' + } + ]) + }) + + it('forwards what it could copy and reports the rest with their shared reason', async () => { + materializeMock.mockResolvedValue([ + copied(DRAG_TEMP, COPY), + { sourcePath: OTHER_DRAG_TEMP, status: 'failed', reason: 'permission-denied' } + ]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [DRAG_TEMP, OTHER_DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' }) + await settle() + + expect(forwarded).toEqual([ + { paths: [COPY], target: 'composer', scopeKey: 'pane-1' }, + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'permission-denied' + } + ]) + }) + + it('reports a drop that lost every file, with no shared reason when they differ', async () => { + materializeMock.mockResolvedValue([ + { sourcePath: DRAG_TEMP, status: 'failed', reason: 'missing' }, + { sourcePath: DRAG_TEMP, status: 'failed', reason: 'changed' } + ]) + const { enqueue, forwarded } = createQueue() + + enqueue({ paths: [DRAG_TEMP, DRAG_TEMP], target: 'terminal' }) + await settle() + + expect(forwarded).toEqual([ + { byteLength: 0, pathCount: 2, reason: 'temp-copy-failed', target: 'rejected' } + ]) + }) + + it('drops a copy whose renderer went away, and keeps serving later drops', async () => { + const { enqueue, forwarded, controller } = createQueue() + materializeMock.mockImplementationOnce(async () => { + controller.abort(new Error('renderer gone')) + throw new Error('renderer gone') + }) + + enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) + await settle() + enqueue({ paths: [FINDER], target: 'editor' }) + + expect(forwarded).toEqual([{ paths: [FINDER], target: 'editor' }]) + }) + + it('drops queued drops from a document that reloaded, and serves the new document', async () => { + const copy = deferred() + materializeMock + .mockReturnValueOnce(copy.promise) + .mockImplementation(async (paths: string[]) => paths.map((path) => copied(path, COPY))) + let document = new AbortController() + const forwarded: NativeFileDropPayload[] = [] + const enqueue = createNativeFileDropQueue({ + forward: (payload) => forwarded.push(payload), + platform: 'darwin', + getCopyEnvironment: async () => env, + watchRenderer: () => ({ signal: document.signal, dispose: () => undefined }) + }) + + enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) + await settle() + enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' }) + document.abort(new Error('reloaded')) + document = new AbortController() + enqueue({ paths: [OTHER_DRAG_TEMP], target: 'composer' }) + copy.resolve([copied(DRAG_TEMP, COPY)]) + await settle() + + expect(forwarded).toEqual([{ paths: [COPY], target: 'composer' }]) + }) + + it('still delivers the ordinary paths of a drop whose copy stage failed outright', async () => { + const { enqueue, forwarded } = createQueue({ + getCopyEnvironment: async () => { + throw new Error('no temp path') + } + }) + + enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' }) + await settle() + + expect(forwarded).toEqual([ + { paths: [FINDER], target: 'composer', scopeKey: 'pane-1' }, + { byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' } + ]) + }) + + it('forwards a rejected drop at once, without waiting on the copy environment', () => { + const getCopyEnvironment = vi.fn(() => new Promise(() => undefined)) + const { enqueue, forwarded } = createQueue({ getCopyEnvironment }) + enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) + const rejected: NativeFileDropPayload = { + byteLength: 0, + pathCount: 300, + reason: 'too-many-paths', + target: 'rejected' + } + + enqueue(rejected) + + expect(forwarded).toEqual([rejected]) + }) + + it('gives up on a hung copy, says why, and serves the copies behind it', async () => { + let copySignal: AbortSignal | undefined + materializeMock.mockImplementationOnce((_paths, _env, signal: AbortSignal) => { + copySignal = signal + return new Promise(() => undefined) + }) + materializeMock.mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)]) + const { enqueue, forwarded } = createQueue({ copyTimeoutMs: 5 }) + + enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' }) + enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(copySignal?.aborted).toBe(true) + expect(forwarded).toEqual([ + { paths: [FINDER], target: 'terminal' }, + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'timed-out' + }, + { paths: [COPY], target: 'editor' } + ]) + }) + + it('refuses drag-temp files past the pending-copy limit but keeps their ordinary paths', async () => { + materializeMock.mockReturnValue(new Promise(() => undefined)) + const { enqueue, forwarded, controller } = createQueue() + + for (let i = 0; i < MAX_PENDING_DRAG_TEMP_COPIES; i += 1) { + enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) + } + enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer' }) + + // The ordinary path waits behind the earlier drops; the refusal does not. + expect(forwarded).toEqual([ + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'busy' + } + ]) + controller.abort(new Error('test done')) + }) + + it('does not report a copied drop as a failed copy when forwarding it throws', async () => { + materializeMock.mockResolvedValueOnce([copied(DRAG_TEMP, COPY)]) + const forward = vi.fn((_payload: NativeFileDropPayload) => { + throw new Error('send failed') + }) + const { enqueue } = createQueue({ forward }) + + enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) + await settle() + + expect(forward.mock.calls).toEqual([[{ paths: [COPY], target: 'terminal' }]]) + }) +}) + +const CHANNEL = 'terminal:file-dropped-from-preload' + +function createWindow() { + let destroyed = false + const windowListeners = new Map void>() + const webContents = { + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() + } + const fake = { + isDestroyed: () => destroyed, + on: (event: string, listener: () => void) => windowListeners.set(event, listener), + webContents + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay only touches the members stubbed above. + const window = fake as unknown as BrowserWindow + return { + window, + webContents, + close: () => { + destroyed = true + windowListeners.get('closed')?.() + }, + destroy: () => { + destroyed = true + } + } +} + +function relay(): IpcListener { + const listener = ipcListeners.get(CHANNEL) + expect(listener).toBeDefined() + return listener! +} + +describe('registerFileDropRelay', () => { + beforeEach(() => { + ipcListeners.clear() + sweepMock.mockReset() + }) + + it('relays only well-formed drops from its own renderer, until the window closes', () => { + const { window, webContents, close } = createWindow() + registerFileDropRelay(window) + const drop: NativeFileDropPayload = { paths: [FINDER], target: 'editor' } + + relay()({ sender: { id: 'other-window' } }, drop) + relay()({ sender: webContents }, { paths: 'not-a-list', target: 'editor' }) + // Only main may report a failed copy; a renderer claiming one is ignored. + relay()( + { sender: webContents }, + { byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' } + ) + relay()({ sender: webContents }, drop) + + expect(webContents.send.mock.calls).toEqual([['terminal:file-drop', drop]]) + expect(sweepMock).toHaveBeenCalledTimes(1) + close() + expect(ipcListeners.has(CHANNEL)).toBe(false) + }) + + it('replaces a listener left by an earlier window', () => { + const first = createWindow() + const second = createWindow() + registerFileDropRelay(first.window) + registerFileDropRelay(second.window) + + relay()({ sender: first.webContents }, { paths: [FINDER], target: 'editor' }) + + expect(first.webContents.send).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform !== 'darwin')( + 'copies from the macOS user temp dir and sends nothing once the window is gone', + async () => { + const copy = deferred() + materializeMock.mockReturnValueOnce(copy.promise) + const { window, webContents, destroy } = createWindow() + registerFileDropRelay(window) + + relay()({ sender: webContents }, { paths: [DRAG_TEMP], target: 'terminal' }) + await settle() + destroy() + copy.resolve([copied(DRAG_TEMP, COPY)]) + await settle() + + expect(materializeMock.mock.calls[0][1]).toMatchObject({ + platform: 'darwin', + sourceTempRoot: '/private/var/folders/ab/xyz/T' + }) + expect(webContents.send).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts new file mode 100644 index 00000000000..b8af442e895 --- /dev/null +++ b/src/main/window/native-file-drop-relay.ts @@ -0,0 +1,251 @@ +import { app, ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import { + isNativeFileDropPayload, + NATIVE_FILE_DROP_TARGET, + type NativeFileDropCopyFailureReason, + type NativeFileDropPayload, + type NativeFileDropRejectedPayload +} from '../../shared/native-file-drop' +import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' +import { + getDragTempCopyRoot, + materializeDragTempPaths, + mayNeedDragTempCopy, + scheduleDragTempCopySweep, + type DragTempCopyEnvironment +} from './dragged-temp-file-copy' +import { getDarwinUserTempDir } from './darwin-user-temp-dir' + +// Why: copies run one at a time, so a hung copy must not hold every later copy forever. +const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 +// Why: a drop is one user gesture; more than this waiting means copies are stuck, not busy. +export const MAX_PENDING_DRAG_TEMP_COPIES = 8 + +type AcceptedNativeFileDropPayload = Exclude + +type RendererLifetime = { signal: AbortSignal; dispose: () => void } + +type NativeFileDropQueueDeps = { + forward: (payload: NativeFileDropPayload) => void + platform: NodeJS.Platform + getCopyEnvironment: () => Promise + watchRenderer: () => RendererLifetime + copyTimeoutMs?: number +} + +export function registerFileDropRelay(mainWindow: BrowserWindow): void { + const channel = 'terminal:file-dropped-from-preload' + const mainWebContents = mainWindow.webContents + const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() + ipcMain.removeAllListeners(channel) + const enqueue = createNativeFileDropQueue({ + // Why: one IPC event per drop gesture so the renderer gets the full path batch without timer-based reconstruction. + forward: (payload) => { + if (!isWindowGone()) { + mainWebContents.send('terminal:file-drop', payload) + } + }, + platform: process.platform, + getCopyEnvironment: async () => ({ + platform: process.platform, + sourceTempRoot: await getDarwinUserTempDir(), + copyRoot: getDragTempCopyRoot(app.getPath('temp')) + }), + watchRenderer: () => abortWhenRendererGone(mainWebContents) + }) + const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { + if (isWindowGone() || event.sender !== mainWebContents) { + return + } + // Why: only main reports a failed copy; a renderer claiming one is ignored. + if (!isNativeFileDropPayload(args) || isTempCopyFailure(args)) { + return + } + enqueue(args) + } + ipcMain.on(channel, relayFileDrop) + mainWindow.on('closed', () => { + // Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained. + ipcMain.removeListener(channel, relayFileDrop) + }) + scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) +} + +/** + * A drop holding a macOS drag-temp path waits for main to copy it, because the + * PTY daemon cannot open the original. Those copies run one at a time, in order, + * under one byte budget. Other drops are forwarded at once unless an earlier drop + * is still queued, so drops reach the renderer in the order they were made. + */ +export function createNativeFileDropQueue( + deps: NativeFileDropQueueDeps +): (payload: NativeFileDropPayload) => void { + let tail = Promise.resolve() + let queued = 0 + let pendingCopies = 0 + const enqueue = ( + deliver: (lifetime: RendererLifetime) => Promise | void, + isCopy: boolean + ): void => { + queued += 1 + pendingCopies += isCopy ? 1 : 0 + // Why: bind to the document that dropped now, so a reload while this waits discards it. + const lifetime = deps.watchRenderer() + // Why: never reject, or one failed drop would stall every drop queued behind it. + tail = tail + .then(() => (lifetime.signal.aborted ? undefined : deliver(lifetime))) + .catch(() => undefined) + .finally(() => { + queued -= 1 + pendingCopies -= isCopy ? 1 : 0 + lifetime.dispose() + }) + } + const forwardInOrder = (payload: NativeFileDropPayload): void => { + // Why: a rejection carries no paths, so only path drops wait their turn. + if (payload.target === 'rejected' || queued === 0) { + deps.forward(payload) + } else { + enqueue(() => deps.forward(payload), false) + } + } + return (payload) => { + if (payload.target === 'rejected' || !needsDragTempCopy(payload, deps.platform)) { + forwardInOrder(payload) + return + } + if (pendingCopies >= MAX_PENDING_DRAG_TEMP_COPIES) { + for (const item of failWholeDrop(payload, deps.platform, 'busy')) { + forwardInOrder(item) + } + return + } + enqueue((lifetime) => copyAndForward(payload, lifetime, deps), true) + } +} + +/** The payloads to forward for one drop: its prepared paths, then a rejection for any it lost. */ +export async function prepareNativeFileDrop( + payload: AcceptedNativeFileDropPayload, + env: DragTempCopyEnvironment, + signal?: AbortSignal +): Promise { + const results = await materializeDragTempPaths(payload.paths, env, signal) + // Why: agents run under the PTY daemon, which cannot open an uncopied original; + // other targets are read by main, so the original still works there. + const acceptsOriginal = + payload.target !== NATIVE_FILE_DROP_TARGET.terminal && + payload.target !== NATIVE_FILE_DROP_TARGET.composer + const paths = results.flatMap((result) => + result.status === 'imported' + ? [result.destPath] + : result.status === 'uncopied' && acceptsOriginal + ? [result.sourcePath] + : [] + ) + const unprepared = results.flatMap((result) => + result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) + ? [] + : [result] + ) + const prepared: NativeFileDropPayload[] = paths.length > 0 ? [{ ...payload, paths }] : [] + if (unprepared.length > 0) { + const commonReason = unprepared.every((item) => item.reason === unprepared[0].reason) + ? unprepared[0].reason + : undefined + prepared.push(copyFailure(unprepared.length, commonReason)) + } + return prepared +} + +/** When the copy stage fails as a whole, still deliver the paths that never needed a copy. */ +function failWholeDrop( + payload: AcceptedNativeFileDropPayload, + platform: NodeJS.Platform, + reason: NativeFileDropCopyFailureReason | undefined +): NativeFileDropPayload[] { + const ordinary = payload.paths.filter((path) => !mayNeedDragTempCopy(path, platform)) + const lost = payload.paths.length - ordinary.length + return [ + ...(ordinary.length > 0 ? [{ ...payload, paths: ordinary }] : []), + ...(lost > 0 ? [copyFailure(lost, reason)] : []) + ] +} + +function forwardAll(deps: NativeFileDropQueueDeps, payloads: NativeFileDropPayload[]): void { + for (const payload of payloads) { + deps.forward(payload) + } +} + +function isTempCopyFailure(payload: NativeFileDropPayload): boolean { + return payload.target === 'rejected' && payload.reason === 'temp-copy-failed' +} + +function needsDragTempCopy( + payload: AcceptedNativeFileDropPayload, + platform: NodeJS.Platform +): boolean { + return payload.paths.some((path) => mayNeedDragTempCopy(path, platform)) +} + +async function copyAndForward( + payload: AcceptedNativeFileDropPayload, + lifetime: RendererLifetime, + deps: NativeFileDropQueueDeps +): Promise { + const timeout = new AbortController() + const timer = setTimeout( + () => timeout.abort(new Error('Copying the dropped files took too long')), + deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS + ) + const signal = AbortSignal.any([lifetime.signal, timeout.signal]) + let prepared: NativeFileDropPayload[] + try { + // Why: race the signal too, since a hung fs call never reaches the copy's abort checks. + prepared = await rejectOnAbort( + deps.getCopyEnvironment().then((env) => prepareNativeFileDrop(payload, env, signal)), + signal + ) + } catch { + // Why: an aborted drop has no renderer to report to; anything else must not vanish silently. + if (lifetime.signal.aborted) { + return + } + prepared = failWholeDrop( + payload, + deps.platform, + timeout.signal.aborted ? 'timed-out' : undefined + ) + } finally { + clearTimeout(timer) + } + // Why: outside the try, so a failed forward is not reported a second time as a failed copy. + forwardAll(deps, prepared) +} + +function rejectOnAbort(work: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason) + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + } + work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)) + }) +} + +function copyFailure( + pathCount: number, + commonReason?: NativeFileDropCopyFailureReason +): NativeFileDropRejectedPayload { + return { + byteLength: 0, + pathCount, + reason: 'temp-copy-failed', + target: 'rejected', + ...(commonReason ? { commonReason } : {}) + } +} diff --git a/src/main/window/owned-temp-staging-root.ts b/src/main/window/owned-temp-staging-root.ts new file mode 100644 index 00000000000..695950fb2c0 --- /dev/null +++ b/src/main/window/owned-temp-staging-root.ts @@ -0,0 +1,128 @@ +import type { Dir, Stats } from 'node:fs' +import { lstat, mkdir, opendir, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +const SWEEP_CONCURRENCY = 8 +const REMOVE_OPTIONS = { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 +} as const + +type CleanupResult = 'failed' | 'fresh' | 'ignored' | 'removed' + +export type OwnedDirectorySweepResult = { + complete: boolean + hasFailures: boolean + hasFreshDirectories: boolean +} + +/** Per-user, so one account cannot hand another a root it controls in a shared temp dir. */ +export function getOwnedTempStagingRoot(tempRoot: string, rootName: string): string { + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' + return join(tempRoot, `${rootName}${uidSuffix}`) +} + +/** Create the root if needed; false when what is there is not a private directory we own. */ +export async function ensureOwnedTempStagingRoot(stagingRoot: string): Promise { + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) + return isSafeOwnedDirectory(await lstat(stagingRoot)) +} + +export function isSafeOwnedDirectory(stats: Stats): boolean { + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return false + } + if (typeof process.getuid !== 'function') { + return true + } + return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700 +} + +export function isDirectChild(parent: string, candidate: string): boolean { + return dirname(resolve(candidate)) === resolve(parent) +} + +export function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +/** Remove owned child directories of `root` whose mtime is at least `ttlMs` old. */ +export async function sweepExpiredOwnedDirectories( + root: string, + options: { + nowMs: number + ttlMs: number + ownsEntry: (name: string) => boolean + entryLimit?: number + } +): Promise { + const entryLimit = options.entryLimit ?? Number.POSITIVE_INFINITY + let rootDir: Dir + try { + rootDir = await opendir(root) + } catch { + return { complete: false, hasFailures: false, hasFreshDirectories: false } + } + + let complete = true + let entriesVisited = 0 + let hasFailures = false + let hasFreshDirectories = false + const pending = new Set>() + try { + for await (const entry of rootDir) { + entriesVisited += 1 + if (entry.isDirectory() && options.ownsEntry(entry.name)) { + const candidate = join(root, entry.name) + if (isDirectChild(root, candidate)) { + const cleanup = cleanupDirectory(candidate, options.nowMs, options.ttlMs).then( + (result) => { + hasFailures ||= result === 'failed' + hasFreshDirectories ||= result === 'fresh' + } + ) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= SWEEP_CONCURRENCY) { + await Promise.race(pending) + } + } + } + if (entriesVisited >= entryLimit) { + break + } + } + } catch { + complete = false + } finally { + await rootDir.close().catch(() => undefined) + } + await Promise.all(pending) + return { complete, hasFailures, hasFreshDirectories } +} + +export async function removeOwnedDirectory(directory: string): Promise { + await rm(directory, REMOVE_OPTIONS) +} + +async function cleanupDirectory( + directory: string, + nowMs: number, + ttlMs: number +): Promise { + try { + const directoryStats = await lstat(directory) + if (!isSafeOwnedDirectory(directoryStats)) { + return 'ignored' + } + if (nowMs - directoryStats.mtimeMs < ttlMs) { + return 'fresh' + } + await removeOwnedDirectory(directory) + return 'removed' + } catch (error) { + return isMissingPathError(error) ? 'ignored' : 'failed' + } +} diff --git a/src/renderer/src/hooks/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts index 15b22854d9a..975401dc0e3 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.test.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts @@ -115,6 +115,26 @@ describe('shouldUploadRemoteEditorFileDrop', () => { expect(JSON.stringify(message)).not.toContain('secret') }) + it('explains drag-temp files main could not copy, using their shared reason', () => { + const rejection = { byteLength: 0, reason: 'temp-copy-failed', target: 'rejected' } as const + expect( + getNativeFileDropRejectionMessage({ + ...rejection, + pathCount: 1, + commonReason: 'permission-denied' + }) + ).toEqual({ description: 'Permission denied.', title: "Orca couldn't copy 1 dropped file." }) + expect( + getNativeFileDropRejectionMessage({ ...rejection, pathCount: 2, commonReason: 'timed-out' }) + ).toEqual({ + description: 'Copying took too long. Try the drop again.', + title: "Orca couldn't copy 2 dropped files." + }) + expect(getNativeFileDropRejectionMessage({ ...rejection, pathCount: 2 }).description).toBe( + 'Try the drop again.' + ) + }) + it('names the drop whose file items carried no readable path (#15782)', () => { expect( getNativeFileDropRejectionMessage({ diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index 5aae80e7183..e201893088b 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -20,6 +20,7 @@ import { type NativeFileDropRejectedPayload } from '../../../shared/native-file-drop' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' +import { describeDropTempCopyFailure } from '@/lib/drop-temp-copy-failure-copy' export function getEditorFileDropSettingsForWorktree( store: WorktreeRuntimeOwnerState, @@ -207,6 +208,17 @@ export function getNativeFileDropRejectionMessage(data: NativeFileDropRejectedPa description: string title: string } { + if (data.reason === 'temp-copy-failed') { + return { + description: describeDropTempCopyFailure(data.commonReason), + title: translate( + 'auto.hooks.useGlobalFileDrop.nativeDropTempCopyFailed', + "Orca couldn't copy {{count}} dropped files.", + { count: data.pathCount } + ) + } + } + if (data.reason === 'unresolved-paths') { return { description: translate( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e5ad0c295f3..5dae9e98fb1 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2615,6 +2615,10 @@ "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached." }, + "useGlobalFileDrop": { + "nativeDropTempCopyFailed_one": "Orca couldn't copy {{count}} dropped file.", + "nativeDropTempCopyFailed_other": "Orca couldn't copy {{count}} dropped files." + }, "useIpcEvents": { "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f6300deb8b": "New Browser Tab" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bf67a5a1fc3..3e930124282 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1026,6 +1026,17 @@ "symlink": "Symbolic links cannot be attached.", "permissionDenied": "Permission denied.", "unsupported": "Unsupported file type." + }, + "dropTempCopyFailure": { + "changed": "The file changed while Orca was copying it. Try the drop again.", + "outOfSpace": "Not enough disk space to copy the dropped files.", + "storageUnavailable": "Orca couldn't create storage for dropped files.", + "storageNotPrivate": "Orca's storage for dropped files can be read by other users, so nothing was copied.", + "timedOut": "Copying took too long. Try the drop again.", + "busy": "Too many drops are still being copied. Wait a moment, then try again.", + "tooLarge": "Too large to copy, so Orca couldn't hand it to the agent.", + "storageFull": "Orca's storage for dropped files is full, so Orca couldn't hand it to the agent.", + "generic": "Try the drop again." } }, "hooks": { @@ -1060,7 +1071,10 @@ "nativeDropPathsTooLarge": "Drop path list is too large.", "ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects.", "nativeDropUnresolvedPathsDescription": "Save them to disk first, then drop the saved files.", - "nativeDropUnresolvedPaths": "Orca couldn't read a path for the dropped files." + "nativeDropUnresolvedPaths": "Orca couldn't read a path for the dropped files.", + "nativeDropTempCopyFailed": "Orca couldn't copy {{count}} dropped files.", + "nativeDropTempCopyFailed_one": "Orca couldn't copy {{count}} dropped file.", + "nativeDropTempCopyFailed_other": "Orca couldn't copy {{count}} dropped files." }, "useIpcEvents": { "0e3cf53060": "Browser tab {{value0}} not found", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ab6bbaaa5d..03b3f528bad 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -733,6 +733,23 @@ "totalTooLarge": "{{fileName}} superaría el total de {{maxSize}} de archivos adjuntos." } } + }, + "dropSkipReason": { + "missing": "Ya no está en su ruta original.", + "symlink": "Los enlaces simbólicos no se pueden adjuntar.", + "permissionDenied": "Permiso denegado.", + "unsupported": "Tipo de archivo no compatible." + }, + "dropTempCopyFailure": { + "changed": "El archivo cambió mientras Orca lo copiaba. Vuelve a soltarlo.", + "outOfSpace": "No hay suficiente espacio en disco para copiar los archivos soltados.", + "storageUnavailable": "Orca no pudo crear el almacenamiento para los archivos soltados.", + "storageNotPrivate": "Otros usuarios pueden leer el almacenamiento de Orca para archivos soltados, así que no se copió nada.", + "timedOut": "La copia tardó demasiado. Vuelve a soltar los archivos.", + "busy": "Todavía se están copiando demasiados archivos soltados. Espera un momento y vuelve a intentarlo.", + "tooLarge": "Es demasiado grande para copiarlo, así que Orca no pudo pasárselo al agente.", + "storageFull": "El almacenamiento de Orca para archivos soltados está lleno, así que Orca no pudo pasárselo al agente.", + "generic": "Vuelve a soltar los archivos." } }, "hooks": { @@ -761,7 +778,12 @@ "nativeDropTooManyPaths": "Has soltado demasiados archivos.", "nativeDropPathsTooLargeDescription": "Suelta menos archivos o usa una lista de rutas más corta.", "nativeDropPathsTooLarge": "La lista de rutas soltadas es demasiado grande.", - "ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects." + "ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects.", + "nativeDropUnresolvedPathsDescription": "Guárdalos primero en el disco y luego suelta los archivos guardados.", + "nativeDropUnresolvedPaths": "Orca no pudo leer una ruta para los archivos soltados.", + "nativeDropTempCopyFailed": "Orca no pudo copiar {{count}} archivos soltados.", + "nativeDropTempCopyFailed_one": "Orca no pudo copiar {{count}} archivo soltado.", + "nativeDropTempCopyFailed_other": "Orca no pudo copiar {{count}} archivos soltados." }, "useIpcEvents": { "0e3cf53060": "Pestaña del navegador {{value0}} no encontrada", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6465f23c87e..8b01f3b4c25 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -999,6 +999,17 @@ "symlink": "Les liens symboliques ne peuvent pas être attachés.", "permissionDenied": "Autorisation refusée.", "unsupported": "Type de fichier non pris en charge." + }, + "dropTempCopyFailure": { + "changed": "Le fichier a changé pendant qu'Orca le copiait. Réessayez de le déposer.", + "outOfSpace": "Espace disque insuffisant pour copier les fichiers déposés.", + "storageUnavailable": "Orca n'a pas pu créer l'espace de stockage des fichiers déposés.", + "storageNotPrivate": "L'espace de stockage d'Orca pour les fichiers déposés est lisible par d'autres utilisateurs ; rien n'a été copié.", + "timedOut": "La copie a pris trop de temps. Réessayez de déposer les fichiers.", + "busy": "Trop de dépôts sont encore en cours de copie. Patientez un instant, puis réessayez.", + "tooLarge": "Trop volumineux pour être copié : Orca n'a pas pu le transmettre à l'agent.", + "storageFull": "L'espace de stockage d'Orca pour les fichiers déposés est plein : Orca n'a pas pu le transmettre à l'agent.", + "generic": "Réessayez de déposer les fichiers." } }, "hooks": { @@ -1033,7 +1044,10 @@ "nativeDropPathsTooLarge": "La liste de chemins du dépôt est trop grande.", "ownerChanged": "Impossible de vérifier quel hôte possède cet espace de travail. Réessayez après sa reconnexion.", "nativeDropUnresolvedPathsDescription": "Enregistrez-les d'abord sur le disque, puis déposez les fichiers enregistrés.", - "nativeDropUnresolvedPaths": "Orca n'a pas pu lire le chemin des fichiers supprimés." + "nativeDropUnresolvedPaths": "Orca n'a pas pu lire le chemin des fichiers supprimés.", + "nativeDropTempCopyFailed": "Orca n'a pas pu copier {{count}} fichiers déposés.", + "nativeDropTempCopyFailed_one": "Orca n'a pas pu copier {{count}} fichier déposé.", + "nativeDropTempCopyFailed_other": "Orca n'a pas pu copier {{count}} fichiers déposés." }, "useIpcEvents": { "0e3cf53060": "Onglet de navigateur {{value0}} introuvable", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4af40935b24..8ad615691e8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -900,6 +900,17 @@ "symlink": "シンボリックリンクは接続できません。", "permissionDenied": "アクセスが拒否されました。", "unsupported": "サポートされていないファイル形式です。" + }, + "dropTempCopyFailure": { + "changed": "コピー中にファイルが変更されました。もう一度ドロップしてください。", + "outOfSpace": "ドロップされたファイルをコピーするためのディスク容量が足りません。", + "storageUnavailable": "ドロップされたファイル用の保存先を Orca で作成できませんでした。", + "storageNotPrivate": "ドロップされたファイル用の Orca の保存先が他のユーザーから読み取れる状態のため、何もコピーしませんでした。", + "timedOut": "コピーに時間がかかりすぎました。もう一度ドロップしてください。", + "busy": "コピー中のドロップが多すぎます。しばらく待ってから、もう一度お試しください。", + "tooLarge": "サイズが大きすぎてコピーできないため、Orca は Agent に渡せませんでした。", + "storageFull": "ドロップされたファイル用の Orca の保存先がいっぱいのため、Orca は Agent に渡せませんでした。", + "generic": "もう一度ドロップしてください。" } }, "hooks": { @@ -934,7 +945,10 @@ "nativeDropPathsTooLarge": "ドロップされたパス一覧が大きすぎます。", "ownerChanged": "このワークスペースを所有するホストを確認できませんでした。再接続してから、もう一度お試しください。", "nativeDropUnresolvedPathsDescription": "まずそれらをディスクに保存してから、保存したファイルをドロップします。", - "nativeDropUnresolvedPaths": "Orca はドロップされたファイルのパスを読み取ることができませんでした。" + "nativeDropUnresolvedPaths": "Orca はドロップされたファイルのパスを読み取ることができませんでした。", + "nativeDropTempCopyFailed": "ドロップされた {{count}} 個のファイルを Orca でコピーできませんでした。", + "nativeDropTempCopyFailed_one": "ドロップされた {{count}} 個のファイルを Orca でコピーできませんでした。", + "nativeDropTempCopyFailed_other": "ドロップされた {{count}} 個のファイルを Orca でコピーできませんでした。" }, "useIpcEvents": { "0e3cf53060": "ブラウザタブ {{value0}} が見つかりません", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a72fbaa9eaa..336e323729d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -900,6 +900,17 @@ "symlink": "심볼릭 링크는 첨부할 수 없습니다.", "permissionDenied": "액세스가 거부되었습니다.", "unsupported": "지원되지 않는 파일 형식입니다." + }, + "dropTempCopyFailure": { + "changed": "Orca가 복사하는 동안 파일이 변경되었습니다. 다시 드롭해 보세요.", + "outOfSpace": "드롭한 파일을 복사할 디스크 공간이 부족합니다.", + "storageUnavailable": "Orca가 드롭한 파일을 위한 저장 공간을 만들지 못했습니다.", + "storageNotPrivate": "드롭한 파일을 위한 Orca 저장 공간을 다른 사용자가 읽을 수 있어 아무것도 복사하지 않았습니다.", + "timedOut": "복사하는 데 너무 오래 걸렸습니다. 다시 드롭해 보세요.", + "busy": "아직 복사 중인 드롭이 너무 많습니다. 잠시 후 다시 시도하세요.", + "tooLarge": "너무 커서 복사할 수 없어 Orca가 에이전트에 전달하지 못했습니다.", + "storageFull": "드롭한 파일을 위한 Orca 저장 공간이 가득 차서 Orca가 에이전트에 전달하지 못했습니다.", + "generic": "다시 드롭해 보세요." } }, "hooks": { @@ -934,7 +945,10 @@ "nativeDropPathsTooLarge": "드롭 경로 목록이 너무 큽니다.", "ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects.", "nativeDropUnresolvedPathsDescription": "먼저 디스크에 저장한 다음 저장된 파일을 삭제하세요.", - "nativeDropUnresolvedPaths": "Orca가 삭제된 파일의 경로를 읽을 수 없습니다." + "nativeDropUnresolvedPaths": "Orca가 삭제된 파일의 경로를 읽을 수 없습니다.", + "nativeDropTempCopyFailed": "Orca가 드롭한 파일 {{count}}개를 복사하지 못했습니다.", + "nativeDropTempCopyFailed_one": "Orca가 드롭한 파일 {{count}}개를 복사하지 못했습니다.", + "nativeDropTempCopyFailed_other": "Orca가 드롭한 파일 {{count}}개를 복사하지 못했습니다." }, "useIpcEvents": { "0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cf73cbb7d8f..f54fbab8102 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -900,6 +900,17 @@ "symlink": "无法附加符号链接。", "permissionDenied": "没有访问权限。", "unsupported": "不支持的文件类型。" + }, + "dropTempCopyFailure": { + "changed": "Orca 复制时文件发生了变化。请重新拖放。", + "outOfSpace": "磁盘空间不足,无法复制拖放的文件。", + "storageUnavailable": "Orca 无法为拖放的文件创建存储位置。", + "storageNotPrivate": "Orca 用于拖放文件的存储位置可被其他用户读取,因此未复制任何内容。", + "timedOut": "复制耗时过长。请重新拖放。", + "busy": "仍有太多拖放正在复制。请稍候再试。", + "tooLarge": "文件太大,无法复制,因此 Orca 无法将其交给代理。", + "storageFull": "Orca 用于拖放文件的存储空间已满,因此 Orca 无法将其交给代理。", + "generic": "请重新拖放。" } }, "hooks": { @@ -934,7 +945,10 @@ "nativeDropPathsTooLarge": "拖放的路径列表过大。", "ownerChanged": "无法确认哪个主机拥有此工作区。请在它重新连接后重试。", "nativeDropUnresolvedPathsDescription": "首先将它们保存到磁盘,然后删除保存的文件。", - "nativeDropUnresolvedPaths": "Orca 无法读取已删除文件的路径。" + "nativeDropUnresolvedPaths": "Orca 无法读取已删除文件的路径。", + "nativeDropTempCopyFailed": "Orca 无法复制 {{count}} 个拖放的文件。", + "nativeDropTempCopyFailed_one": "Orca 无法复制 {{count}} 个拖放的文件。", + "nativeDropTempCopyFailed_other": "Orca 无法复制 {{count}} 个拖放的文件。" }, "useIpcEvents": { "0e3cf53060": "未找到浏览器选项卡 {{value0}}", diff --git a/src/renderer/src/lib/drop-temp-copy-failure-copy.ts b/src/renderer/src/lib/drop-temp-copy-failure-copy.ts new file mode 100644 index 00000000000..cdc276dfe20 --- /dev/null +++ b/src/renderer/src/lib/drop-temp-copy-failure-copy.ts @@ -0,0 +1,61 @@ +import { translate } from '@/i18n/i18n' +import type { NativeFileDropCopyFailureReason } from '../../../shared/native-file-drop' +import { describeDropSkipReason } from './drop-skip-reason-copy' + +/** User-facing copy for why main could not copy dropped files; generic when they had no shared reason. */ +export function describeDropTempCopyFailure( + reason: NativeFileDropCopyFailureReason | undefined +): string { + switch (reason) { + case 'missing': + case 'permission-denied': + return describeDropSkipReason(reason) ?? genericFailure() + case 'changed': + return translate( + 'auto.lib.dropTempCopyFailure.changed', + 'The file changed while Orca was copying it. Try the drop again.' + ) + case 'out-of-space': + return translate( + 'auto.lib.dropTempCopyFailure.outOfSpace', + 'Not enough disk space to copy the dropped files.' + ) + case 'storage-unavailable': + return translate( + 'auto.lib.dropTempCopyFailure.storageUnavailable', + "Orca couldn't create storage for dropped files." + ) + case 'storage-not-private': + return translate( + 'auto.lib.dropTempCopyFailure.storageNotPrivate', + "Orca's storage for dropped files can be read by other users, so nothing was copied." + ) + case 'timed-out': + return translate( + 'auto.lib.dropTempCopyFailure.timedOut', + 'Copying took too long. Try the drop again.' + ) + case 'busy': + return translate( + 'auto.lib.dropTempCopyFailure.busy', + 'Too many drops are still being copied. Wait a moment, then try again.' + ) + case 'too-large': + return translate( + 'auto.lib.dropTempCopyFailure.tooLarge', + "Too large to copy, so Orca couldn't hand it to the agent." + ) + case 'storage-full': + return translate( + 'auto.lib.dropTempCopyFailure.storageFull', + "Orca's storage for dropped files is full, so Orca couldn't hand it to the agent." + ) + case 'copy-failed': + case undefined: + return genericFailure() + } +} + +function genericFailure(): string { + return translate('auto.lib.dropTempCopyFailure.generic', 'Try the drop again.') +} diff --git a/src/shared/native-file-drop.test.ts b/src/shared/native-file-drop.test.ts index 27c8adcd9c3..20adac92088 100644 --- a/src/shared/native-file-drop.test.ts +++ b/src/shared/native-file-drop.test.ts @@ -230,6 +230,15 @@ describe('isNativeFileDropPayload', () => { target: 'rejected' }) ).toBe(true) + expect( + isNativeFileDropPayload({ + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'permission-denied' + }) + ).toBe(true) expect( isNativeFileDropPayload({ @@ -283,6 +292,18 @@ describe('isNativeFileDropPayload', () => { target: 'rejected' }) ).toBe(false) + // commonReason is rendered in a toast, so only known tokens may cross. + for (const commonReason of [{ text: 'x' }, 'Raw English failure text']) { + expect( + isNativeFileDropPayload({ + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason + }) + ).toBe(false) + } }) it('enforces native file-drop count and byte limits at their boundaries', () => { diff --git a/src/shared/native-file-drop.ts b/src/shared/native-file-drop.ts index 2c81575bafa..8d7b44be08a 100644 --- a/src/shared/native-file-drop.ts +++ b/src/shared/native-file-drop.ts @@ -43,14 +43,38 @@ export type NativeFileDropRejectedPayload = { pathCount: number reason: NativeFileDropRejectionReason target: 'rejected' + /** Why every file in a `temp-copy-failed` drop went uncopied, when they share one reason. */ + commonReason?: NativeFileDropCopyFailureReason } +// Why tokens: the renderer owns the localized copy; main never sends display text. +export const NATIVE_FILE_DROP_COPY_FAILURE_REASONS = [ + 'missing', + 'permission-denied', + 'changed', + 'out-of-space', + 'storage-unavailable', + 'storage-not-private', + 'copy-failed', + 'timed-out', + 'busy', + // Too big to copy, so agents in terminals and composers can't be given it. + 'too-large', + 'storage-full' +] as const + +export type NativeFileDropCopyFailureReason = (typeof NATIVE_FILE_DROP_COPY_FAILURE_REASONS)[number] + /** What path validation alone can reject a drop for. */ export type NativeFileDropSizeRejectionReason = 'paths-too-large' | 'too-many-paths' /** `unresolved-paths`: the OS handed us file items no path could be read from - * (promised/virtual files), which used to be swallowed with no feedback. */ -export type NativeFileDropRejectionReason = NativeFileDropSizeRejectionReason | 'unresolved-paths' + * (promised/virtual files), which used to be swallowed with no feedback. + * `temp-copy-failed`: main could not copy a macOS drag-temp file; only main sends it. */ +export type NativeFileDropRejectionReason = + | NativeFileDropSizeRejectionReason + | 'unresolved-paths' + | 'temp-copy-failed' export type NativeFileDropPathEntry = { nativeFileDropTarget?: string @@ -73,10 +97,19 @@ function isNativeFileDropRejectedReason( reason: unknown ): reason is NativeFileDropRejectedPayload['reason'] { return ( - reason === 'paths-too-large' || reason === 'too-many-paths' || reason === 'unresolved-paths' + reason === 'paths-too-large' || + reason === 'too-many-paths' || + reason === 'unresolved-paths' || + reason === 'temp-copy-failed' ) } +function isNativeFileDropCopyFailureReason( + reason: unknown +): reason is NativeFileDropCopyFailureReason { + return NATIVE_FILE_DROP_COPY_FAILURE_REASONS.some((known) => known === reason) +} + function isNativeFileDropTarget(target: unknown): target is NativeFileDropPayload['target'] { return Object.values(NATIVE_FILE_DROP_TARGET).includes(target as never) || target === 'rejected' } @@ -257,7 +290,9 @@ export function isNativeFileDropPayload(value: unknown): value is NativeFileDrop return ( isNonNegativeFiniteNumber(payload.byteLength) && isNonNegativeFiniteNumber(payload.pathCount) && - isNativeFileDropRejectedReason(payload.reason) + isNativeFileDropRejectedReason(payload.reason) && + (payload.commonReason === undefined || + isNativeFileDropCopyFailureReason(payload.commonReason)) ) }