fix(drop): route local terminal and composer drops through the resolver (#24009)

* fix(drop): copy macOS drag-temp files so the PTY daemon can read them

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 and Claude Code silently drops
the paste.

fs:resolveDroppedPathsForAgent now copies those files, and only those, into a
private per-user orca-drops-<uid>/orca-drop-XXXXXX/ directory, keeping the
original name. It streams from an O_NOFOLLOW handle capped at the inspected
size, so no xattrs (com.apple.macl) come along. The copy is 0600 in a 0700
directory, bounded by the remote-import per-file and per-drop limits, and
rechecked for changes. Other paths pass through. The local branch returns
per-item results, authorizes what it returns, and accepts no worktreePath.
Expired copies are swept 7 days later.

No renderer calls the local branch outside WSL yet, so this ships dark
until the renderer routes local drops through it.

* fix(drop): route local terminal and composer drops through the resolver

Local terminal drops pasted the dropped path directly, and composer drops
attached it after a per-path authorize call. So a macOS screenshot thumbnail
reached Claude Code as a path in a folder the PTY daemon can't open, and the
paste was silently dropped.

Every local terminal drop now calls fs:resolveDroppedPathsForAgent, pastes
what it returns, and reports skips and failures with local wording ("Could
not prepare N dropped files"). The WSL-only branch and the direct-paste
branch are gone; the local-WSL path mapping stays as a step after
resolution. The composer resolves the whole drop in one call, without a
project path so its attachments never get the WSL rewrite, stats only the
resolved paths, and folds resolver skips and failures into its existing
toast. The pane, transport, mounted and owner checks still run after the
await.

* test: verify resolver and write errors surface independently on drop

Add a test case ensuring that when path resolution and PTY write both fail during a file drop, the UI reports both failures separately rather than letting the write error mask the resolution issues. Refactor error handling in pasteLocalDropPaths to catch IPC resolution errors immediately, then handle paste errors separately, so skipped/failed files are always reported via the finally block regardless of outcome.

* test: extract transport variable in drop resolution test

Improves readability by extracting the terminal transport creation from the Map initialization.

* refactor(drop): extract native file drop relay and temp staging utilitie

- Move the native file drop relay queue from attach-main-window-services into
  a dedicated native-file-drop-relay module so it owns the async copy and
  forward pipeline for drag-temp files, separate from main window setup.
- Extract shared temp-directory management (ownership checks, sweeps,
  permissions) into owned-temp-staging-root, used by both drag-temp copies
  and remote clipboard staging.
- Simplify dropped-path-resolution to handle only the WSL path rewrite on
  local worktrees; the relay handles macOS drag-temp copying before it
  reaches terminal/composer drop handlers.

* fix(drop): pass drag-temp files through uncopied with timeout and budget

Large files exceeding the copy budget are now passed through uncopied instead
of rejecting the drop, so copy failures don't lose the entire interaction.
Copy timeout prevents hung copies from blocking subsequent drops, and budget
tracking accounts for retained copies to prevent disk fill.
Extract darwin-user-temp-dir to resolve the correct macOS per-user temp dir
rather than relying on $TMPDIR.

* fix(drop): discard queued drops on renderer reload

Capture the renderer's lifetime when a drop is enqueued. When the
renderer reloads before a copy completes, the operation cancels and
queued drops are discarded, preventing stale content from reaching
the reloaded document.

* fix(drop): serialize drag-temp copies and localize failure reasons

Main no longer sends user-facing failure messages; instead it sends reason tokens
that the renderer localizes. Drag-temp copies run serially under one byte budget
with a pending-copy limit, so non-temp drops can overtake. When a copy stage
aborts, remove any partial copies made so far. Distinguish 'uncopied' (original
handed over) from 'failed' (couldn't get it at all), and add specific reasons for
storage, permission, timeout, and budget exhaustion.

* fix(drop): serialize drops and extend TTL to 7 days

Ensure drops reach the renderer in arrival order by queuing all path drops,
not just copies. Extend TTL from 24h to 7d to support lazy readers like
drafts and startup prompts. Withhold uncopied files from agents that can't
open originals (terminal, composer), keeping editor-only access working.
This commit is contained in:
Jinjing
2026-10-01 10:00:31 -07:00
committed by GitHub
parent 4e8edc8872
commit 449b8ca17d
21 changed files with 2065 additions and 146 deletions
+1 -27
View File
@@ -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)
})
}
+24 -109
View File
@@ -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<string> {
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<Promise<void>>()
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<CleanupResult> {
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'
)
}
@@ -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<typeof DarwinUserTempDir> {
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()
})
})
+40
View File
@@ -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<string> | 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<string> {
if (platform !== 'darwin') {
return Promise.resolve(tmpdir())
}
resolved ??= readDarwinUserTempDir()
return resolved
}
async function readDarwinUserTempDir(): Promise<string> {
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()
}
@@ -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<typeof NodeFs>()
return {
...actual,
createWriteStream: (...args: Parameters<typeof actual.createWriteStream>) => {
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<typeof RuntimeImportLimits>()),
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<string> {
const filePath = join(providerDir, name)
await writeFile(filePath, content, { mode: 0o644 })
return filePath
}
async function copyDirs(): Promise<string[]> {
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)
})
})
+360
View File
@@ -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<DragTempCopyItemResult[]> {
const results: DragTempCopyItemResult[] = []
const completed = new Map<string, DragTempCopyItemResult>()
// 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<DragTempCopyItemResult>): Promise<void> {
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<void> {
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<number> {
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_*/<entry>`: 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<string> {
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
}
@@ -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<string, IpcListener>()
}))
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<typeof DragTempFileCopy>()),
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<typeof env>
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}
async function settle(): Promise<void> {
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<DragTempCopyItemResult[]>()
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<DragTempCopyItemResult[]>()
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<DragTempCopyItemResult[]>()
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<typeof env>(() => 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<string, () => 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<DragTempCopyItemResult[]>()
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()
}
)
})
+251
View File
@@ -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<NativeFileDropPayload, NativeFileDropRejectedPayload>
type RendererLifetime = { signal: AbortSignal; dispose: () => void }
type NativeFileDropQueueDeps = {
forward: (payload: NativeFileDropPayload) => void
platform: NodeJS.Platform
getCopyEnvironment: () => Promise<DragTempCopyEnvironment>
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> | 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<NativeFileDropPayload[]> {
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<void> {
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<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((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 } : {})
}
}
+128
View File
@@ -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<boolean> {
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<OwnedDirectorySweepResult> {
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<Promise<void>>()
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<void> {
await rm(directory, REMOVE_OPTIONS)
}
async function cleanupDirectory(
directory: string,
nowMs: number,
ttlMs: number
): Promise<CleanupResult> {
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'
}
}
@@ -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({
@@ -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(
+4
View File
@@ -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"
+15 -1
View File
@@ -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",
+23 -1
View File
@@ -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",
+15 -1
View File
@@ -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",
+15 -1
View File
@@ -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}} が見つかりません",
+15 -1
View File
@@ -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}}을(를) 찾을 수 없습니다",
+15 -1
View File
@@ -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}}",
@@ -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.')
}
+21
View File
@@ -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', () => {
+39 -4
View File
@@ -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))
)
}