diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8ef6e0e..468159e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,9 +34,11 @@ "node-pty": "^1.0.0", "serialport": "^12.0.0", "ssh2": "^1.17.0", + "yazl": "^3.3.1", "zod": "^3.24.1" }, "devDependencies": { + "@types/yazl": "^3.3.1", "electron": "^34.0.0", "electron-builder": "^26.0.12", "esbuild": "^0.25.12" diff --git a/apps/desktop/src/main/ipc/registerIpc.ts b/apps/desktop/src/main/ipc/registerIpc.ts index 8d26ff7..2c7945e 100644 --- a/apps/desktop/src/main/ipc/registerIpc.ts +++ b/apps/desktop/src/main/ipc/registerIpc.ts @@ -344,7 +344,7 @@ export interface RegisterIpcOptions { } export type IpcMainLike = Pick & - Partial>; + Partial>; const APP_SETTINGS_KEY = "app.settings"; const DEFAULT_CONNECTION_HISTORY_RETENTION_DAYS = 90; diff --git a/apps/desktop/src/main/ipc/sftpIpc.security.test.ts b/apps/desktop/src/main/ipc/sftpIpc.security.test.ts index 072894b..d00e160 100644 --- a/apps/desktop/src/main/ipc/sftpIpc.security.test.ts +++ b/apps/desktop/src/main/ipc/sftpIpc.security.test.ts @@ -1,7 +1,17 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { Readable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveSafeDragOutPath, verifyHostKey, type HostFingerprintLookup } from "./sftpIpc"; +import { + pruneDragOutCache, + resolveSafeDragOutPath, + shouldStartNativeDragOut, + stageSftpDragOutItem, + verifyHostKey, + type HostFingerprintLookup, +} from "./sftpIpc"; const { mockProbeHostKey } = vi.hoisted(() => { const mockProbeHostKey = vi.fn(); @@ -25,6 +35,341 @@ describe("resolveSafeDragOutPath", () => { }); }); +describe("pruneDragOutCache", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hypershell-drag-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("removes files older than the TTL and keeps fresher ones", async () => { + const now = Date.now(); + const stale = path.join(tempDir, "stale.bin"); + const fresh = path.join(tempDir, "fresh.bin"); + fs.writeFileSync(stale, "old"); + fs.writeFileSync(fresh, "new"); + const staleTime = new Date(now - 48 * 60 * 60 * 1000); + fs.utimesSync(stale, staleTime, staleTime); + + await pruneDragOutCache(tempDir, 24 * 60 * 60 * 1000, now); + + expect(fs.existsSync(stale)).toBe(false); + expect(fs.existsSync(fresh)).toBe(true); + }); + + it("recursively removes stale directories", async () => { + const now = Date.now(); + const nested = path.join(tempDir, "remote-home"); + fs.mkdirSync(path.join(nested, "sub"), { recursive: true }); + fs.writeFileSync(path.join(nested, "sub", "file.txt"), "payload"); + const staleTime = new Date(now - 48 * 60 * 60 * 1000); + fs.utimesSync(nested, staleTime, staleTime); + + await pruneDragOutCache(tempDir, 24 * 60 * 60 * 1000, now); + + expect(fs.existsSync(nested)).toBe(false); + }); + + it("is a no-op when the temp dir is missing", async () => { + const missing = path.join(tempDir, "does-not-exist"); + await expect(pruneDragOutCache(missing, 1_000)).resolves.toBeUndefined(); + }); +}); + +describe("stageSftpDragOutItem", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hypershell-drag-stage-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stages a remote file through the active SFTP transport", async () => { + const transport = { + createReadStream: vi.fn(() => Readable.from([Buffer.from("remote payload")])), + }; + + const stagedPath = await stageSftpDragOutItem({ + transport: transport as any, + tempDir, + cacheKey: "sftp-1:/var/log/app.log", + item: { + remotePath: "/var/log/app.log", + fileName: "app.log", + isDirectory: false, + }, + }); + + expect(transport.createReadStream).toHaveBeenCalledWith("/var/log/app.log"); + expect(path.dirname(stagedPath)).toBe(path.resolve(tempDir)); + expect(path.basename(stagedPath)).toMatch(/^app-[a-f0-9]{12}\.log$/); + expect(fs.readFileSync(stagedPath, "utf8")).toBe("remote payload"); + }); + + it("recursively stages a remote directory", async () => { + const transport = { + list: vi.fn(async (remotePath: string) => { + if (remotePath === "/home/user/project") { + return [ + { + name: "README.md", + path: "/home/user/project/README.md", + size: 7, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o644, + owner: 0, + group: 0, + }, + { + name: "src", + path: "/home/user/project/src", + size: 0, + modifiedAt: new Date(0).toISOString(), + isDirectory: true, + permissions: 0o755, + owner: 0, + group: 0, + }, + ]; + } + + if (remotePath === "/home/user/project/src") { + return [ + { + name: "main.ts", + path: "/home/user/project/src/main.ts", + size: 13, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o644, + owner: 0, + group: 0, + }, + ]; + } + + return []; + }), + createReadStream: vi.fn((remotePath: string) => + Readable.from([remotePath.endsWith("README.md") ? "# readme" : "console.log(1)"]) + ), + }; + + const stagedPath = await stageSftpDragOutItem({ + transport: transport as any, + tempDir, + cacheKey: "sftp-1:/home/user/project", + item: { + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }, + }); + + expect(path.dirname(stagedPath)).toBe(path.resolve(tempDir)); + expect(path.basename(stagedPath)).toMatch(/^project-[a-f0-9]{12}$/); + expect(fs.readFileSync(path.join(stagedPath, "README.md"), "utf8")).toBe("# readme"); + expect(fs.readFileSync(path.join(stagedPath, "src", "main.ts"), "utf8")).toBe("console.log(1)"); + }); + + it("archives remote directories when preparing them for native drag-out", async () => { + const transport = { + list: vi.fn(async (remotePath: string) => { + if (remotePath === "/home/user/project") { + return [ + { + name: "README.md", + path: "/home/user/project/README.md", + size: 7, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o644, + owner: 0, + group: 0, + }, + ]; + } + + return []; + }), + createReadStream: vi.fn(() => Readable.from(["# readme"])), + }; + + const stagedPath = await stageSftpDragOutItem({ + transport: transport as any, + tempDir, + cacheKey: "sftp-1:/home/user/project", + archiveDirectory: true, + item: { + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }, + }); + + expect(path.basename(stagedPath)).toMatch(/^project-[a-f0-9]{12}\.zip$/); + expect(fs.statSync(stagedPath).isFile()).toBe(true); + + const zipBytes = fs.readFileSync(stagedPath); + expect(zipBytes.subarray(0, 4).toString("hex")).toBe("504b0304"); + expect(zipBytes.toString("utf8")).toContain("project/README.md"); + expect(zipBytes.toString("utf8")).toContain("# readme"); + }); + + it("archives readable directory contents and records unreadable children", async () => { + const permissionDenied = Object.assign(new Error("Permission denied"), { code: 3 }); + const transport = { + list: vi.fn(async (remotePath: string) => { + if (remotePath === "/home/user/project") { + return [ + { + name: "public.txt", + path: "/home/user/project/public.txt", + size: 6, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o644, + owner: 0, + group: 0, + }, + { + name: "secret.txt", + path: "/home/user/project/secret.txt", + size: 6, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o600, + owner: 0, + group: 0, + }, + { + name: "private", + path: "/home/user/project/private", + size: 0, + modifiedAt: new Date(0).toISOString(), + isDirectory: true, + permissions: 0o700, + owner: 0, + group: 0, + }, + ]; + } + + if (remotePath === "/home/user/project/private") { + throw permissionDenied; + } + + return []; + }), + createReadStream: vi.fn((remotePath: string) => { + if (remotePath === "/home/user/project/secret.txt") { + return Readable.from((async function* () { + throw permissionDenied; + })()); + } + + return Readable.from(["public"]); + }), + }; + + const stagedPath = await stageSftpDragOutItem({ + transport: transport as any, + tempDir, + cacheKey: "sftp-1:/home/user/project", + archiveDirectory: true, + item: { + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }, + }); + + const zipText = fs.readFileSync(stagedPath).toString("utf8"); + expect(zipText).toContain("project/public.txt"); + expect(zipText).toContain("public"); + expect(zipText).toContain("HYPERSHELL_SKIPPED_FILES.txt"); + expect(zipText).toContain("/home/user/project/secret.txt"); + expect(zipText).toContain("/home/user/project/private"); + expect(zipText).toContain("Permission denied"); + }); + + it("rejects unsafe names returned from remote directory listings", async () => { + const transport = { + list: vi.fn(async () => [ + { + name: "../escape.txt", + path: "/home/user/project/../escape.txt", + size: 4, + modifiedAt: new Date(0).toISOString(), + isDirectory: false, + permissions: 0o644, + owner: 0, + group: 0, + }, + ]), + createReadStream: vi.fn(() => Readable.from(["nope"])), + }; + + await expect( + stageSftpDragOutItem({ + transport: transport as any, + tempDir, + cacheKey: "sftp-1:/home/user/project", + item: { + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }, + }) + ).rejects.toThrow(/invalid drag-out filename/i); + + expect(transport.createReadStream).not.toHaveBeenCalled(); + }); +}); + +describe("shouldStartNativeDragOut", () => { + it("does not start native drag for an uncached directory", () => { + expect( + shouldStartNativeDragOut( + { + isDirectory: true, + }, + false + ) + ).toBe(false); + }); + + it("starts native drag for a directory that was already staged", () => { + expect( + shouldStartNativeDragOut( + { + isDirectory: true, + }, + true + ) + ).toBe(true); + }); + + it("does not start native drag for prepare-only requests", () => { + expect( + shouldStartNativeDragOut( + { + prepareOnly: true, + }, + true + ) + ).toBe(false); + }); +}); + describe("verifyHostKey", () => { function makeMockRepo(overrides: Partial = {}): HostFingerprintLookup { return { diff --git a/apps/desktop/src/main/ipc/sftpIpc.ts b/apps/desktop/src/main/ipc/sftpIpc.ts index 2ae65a2..128f578 100644 --- a/apps/desktop/src/main/ipc/sftpIpc.ts +++ b/apps/desktop/src/main/ipc/sftpIpc.ts @@ -1,7 +1,9 @@ import { createHostsRepositoryFromDatabase, openDatabase, createSftpBookmarksRepository, createHostFingerprintRepositoryFromDatabase } from "@hypershell/db"; -import { app } from "electron"; +import { app, nativeImage } from "electron"; import path from "node:path"; import fs from "node:fs"; +import { pipeline } from "node:stream/promises"; +import { ZipFile } from "yazl"; import { ipcChannels, sftpBookmarkListRequestSchema, @@ -49,13 +51,13 @@ import { import type { SessionManager, SftpConnectionOptions, + SftpTransportHandle, KeyboardInteractiveCallback, Ssh2ConnectionPool, } from "@hypershell/session-core"; -import { createSyncEngine, probeHostKey, buildScpCommand } from "@hypershell/session-core"; -import { execFile } from "node:child_process"; +import { createSyncEngine, probeHostKey } from "@hypershell/session-core"; import { createHash, timingSafeEqual } from "node:crypto"; -import type { IpcMainInvokeEvent } from "electron"; +import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"; import type { IpcMainLike } from "./registerIpc"; import { editorWindowManager } from "../windows/editorWindowManager"; @@ -265,6 +267,228 @@ function createDragOutTempFileName(fileName: string, cacheKey: string): string { return `${stem}-${suffix}${extension}`; } +export interface SftpDragOutItem { + remotePath: string; + fileName: string; + isDirectory?: boolean; +} + +export interface SftpDragOutSkippedEntry { + path: string; + reason: string; +} + +export interface StageSftpDragOutItemOptions { + transport: Pick; + tempDir: string; + cacheKey: string; + archiveDirectory?: boolean; + item: SftpDragOutItem; +} + +function isPermissionDeniedError(error: unknown): boolean { + const maybeError = error as { code?: unknown; message?: unknown }; + return maybeError.code === 3 || /permission denied/i.test(String(maybeError.message ?? error)); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function stageRemoteFile( + transport: Pick, + remotePath: string, + localPath: string +): Promise { + await fs.promises.mkdir(path.dirname(localPath), { recursive: true }); + await pipeline(transport.createReadStream(remotePath), fs.createWriteStream(localPath)); +} + +async function stageRemoteDirectory( + transport: Pick, + remotePath: string, + localPath: string, + options: { + skipPermissionDenied?: boolean; + isRoot?: boolean; + skippedEntries?: SftpDragOutSkippedEntry[]; + } = {} +): Promise { + const skippedEntries = options.skippedEntries ?? []; + await fs.promises.mkdir(localPath, { recursive: true }); + let entries: Awaited["list"]>>; + try { + entries = await transport.list(remotePath); + } catch (error) { + if (options.skipPermissionDenied && !options.isRoot && isPermissionDeniedError(error)) { + skippedEntries.push({ path: remotePath, reason: getErrorMessage(error) }); + return skippedEntries; + } + throw error; + } + + for (const entry of entries) { + const childLocalPath = resolveSafeDragOutPath(localPath, entry.name); + if (entry.isDirectory) { + await stageRemoteDirectory(transport, entry.path, childLocalPath, { + skipPermissionDenied: options.skipPermissionDenied, + isRoot: false, + skippedEntries, + }); + continue; + } + + try { + await stageRemoteFile(transport, entry.path, childLocalPath); + } catch (error) { + if (options.skipPermissionDenied && isPermissionDeniedError(error)) { + skippedEntries.push({ path: entry.path, reason: getErrorMessage(error) }); + continue; + } + throw error; + } + } + + return skippedEntries; +} + +async function writeSkippedEntriesManifest( + localDir: string, + skippedEntries: SftpDragOutSkippedEntry[] +): Promise { + if (skippedEntries.length === 0) return; + + let manifestPath = resolveSafeDragOutPath(localDir, "HYPERSHELL_SKIPPED_FILES.txt"); + for (let index = 2; fs.existsSync(manifestPath); index += 1) { + manifestPath = resolveSafeDragOutPath(localDir, `HYPERSHELL_SKIPPED_FILES_${index}.txt`); + } + + const content = [ + "Some remote items could not be included in this drag-out archive.", + "", + ...skippedEntries.map((entry) => `${entry.path}\t${entry.reason}`), + "", + ].join("\n"); + await fs.promises.writeFile(manifestPath, content, "utf8"); +} + +async function addDirectoryToZip( + zipFile: ZipFile, + localDir: string, + metadataDir: string +): Promise { + const stats = await fs.promises.stat(localDir); + zipFile.addEmptyDirectory(`${metadataDir}/`, { mtime: stats.mtime }); + + const entries = await fs.promises.readdir(localDir, { withFileTypes: true }); + for (const entry of entries) { + const localPath = path.join(localDir, entry.name); + const metadataPath = path.posix.join(metadataDir, entry.name); + + if (entry.isDirectory()) { + await addDirectoryToZip(zipFile, localPath, metadataPath); + continue; + } + + zipFile.addFile(localPath, metadataPath, { compress: false }); + } +} + +export async function createZipFromDirectory( + sourceDir: string, + zipPath: string, + rootName: string +): Promise { + await fs.promises.rm(zipPath, { recursive: true, force: true }); + await fs.promises.mkdir(path.dirname(zipPath), { recursive: true }); + + const zipFile = new ZipFile(); + const output = fs.createWriteStream(zipPath); + const writeComplete = pipeline(zipFile.outputStream, output); + + await addDirectoryToZip(zipFile, sourceDir, rootName); + zipFile.end(); + await writeComplete; +} + +export async function stageSftpDragOutItem({ + transport, + tempDir, + cacheKey, + archiveDirectory, + item, +}: StageSftpDragOutItemOptions): Promise { + await fs.promises.mkdir(tempDir, { recursive: true }); + + const safeOriginalPath = resolveSafeDragOutPath(tempDir, item.fileName); + const safeOriginalName = path.basename(safeOriginalPath); + const uniqueFileName = createDragOutTempFileName(path.basename(safeOriginalPath), cacheKey); + const tempPath = resolveSafeDragOutPath(tempDir, uniqueFileName); + + await fs.promises.rm(tempPath, { recursive: true, force: true }); + + if (item.isDirectory) { + const skippedEntries = await stageRemoteDirectory(transport, item.remotePath, tempPath, { + skipPermissionDenied: archiveDirectory, + isRoot: true, + }); + if (archiveDirectory) { + await writeSkippedEntriesManifest(tempPath, skippedEntries); + const zipPath = `${tempPath}.zip`; + await createZipFromDirectory(tempPath, zipPath, safeOriginalName); + return zipPath; + } + + return tempPath; + } + + await stageRemoteFile(transport, item.remotePath, tempPath); + return tempPath; +} + +export function shouldStartNativeDragOut( + request: { isDirectory?: boolean; prepareOnly?: boolean }, + hadCachedTempPath: boolean +): boolean { + if (request.prepareOnly) return false; + if (request.isDirectory && !hadCachedTempPath) return false; + return true; +} + +const DRAG_OUT_TEMP_DIR_NAME = "hypershell-drag"; +const DRAG_OUT_STARTUP_TTL_MS = 60 * 60 * 1000; // 1h — drag cache has no long-term value +const DRAG_OUT_POST_DRAG_DELAY_MS = 5 * 60 * 1000; // 5m +const DRAG_OUT_ICON_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAADklEQVQ4jWNgGAWDEwAAAhAAATHKfqoAAAAASUVORK5CYII="; + +function createDragOutIcon() { + return nativeImage.createFromBuffer(Buffer.from(DRAG_OUT_ICON_PNG_BASE64, "base64")); +} + +export async function pruneDragOutCache( + tempDir: string, + maxAgeMs: number, + now = Date.now() +): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(tempDir, { withFileTypes: true }); + } catch { + return; // dir missing / unreadable — nothing to prune + } + + for (const entry of entries) { + const target = path.join(tempDir, entry.name); + try { + const stats = await fs.promises.stat(target); + if (now - stats.mtimeMs < maxAgeMs) continue; + await fs.promises.rm(target, { recursive: true, force: true }); + } catch { + // best-effort — skip locked/in-use entries, next startup will retry + } + } +} + export function registerSftpIpc( ipcMain: IpcMainLike, options: RegisterSftpIpcOptions @@ -281,6 +505,35 @@ export function registerSftpIpc( // Drag-out cache: pre-downloaded files keyed by "sessionId:remotePath" const dragCache = new Map(); + const dragStageTasks = new Map>(); + const dragTempDir = path.join(app.getPath("temp"), DRAG_OUT_TEMP_DIR_NAME); + // Fire-and-forget — never block app startup on (potentially multi-GB) rm. + void pruneDragOutCache(dragTempDir, DRAG_OUT_STARTUP_TTL_MS); + + const stageDragOutItemOnce = ( + cacheKey: string, + request: { sftpSessionId: string } & SftpDragOutItem + ): Promise => { + let stageTask = dragStageTasks.get(cacheKey); + if (!stageTask) { + const transport = sftpSessionManager.getTransport(request.sftpSessionId); + stageTask = stageSftpDragOutItem({ + transport, + tempDir: dragTempDir, + cacheKey, + archiveDirectory: request.isDirectory, + item: request, + }).then((stagedPath) => { + dragCache.set(cacheKey, stagedPath); + return stagedPath; + }).finally(() => { + dragStageTasks.delete(cacheKey); + }); + dragStageTasks.set(cacheKey, stageTask); + } + + return stageTask; + }; // Keyboard-interactive auth relay: pending requests keyed by requestId const pendingKbdInteractive = new Map { + const pathsToCleanup = [tempPath]; + if (isDirectory && tempPath.endsWith(".zip")) { + pathsToCleanup.push(tempPath.slice(0, -".zip".length)); + } + + setTimeout(() => { + for (const pathToCleanup of pathsToCleanup) { + try { + fs.rmSync(pathToCleanup, { recursive: true, force: true }); + } catch { + // best-effort; leave for startup prune + } + } + if (dragCache.get(cacheKey) === tempPath) { + dragCache.delete(cacheKey); + } + }, DRAG_OUT_POST_DRAG_DELAY_MS).unref(); + }; + + const startNativeDragOutFromCache = async ( + sender: IpcMainEvent["sender"], + request: { isDirectory?: boolean; prepareOnly?: boolean }, + cacheKey: string + ): Promise => { + const tempPath = dragCache.get(cacheKey); + const hadCachedTempPath = Boolean(tempPath && fs.existsSync(tempPath)); + if (!tempPath || !shouldStartNativeDragOut(request, hadCachedTempPath)) { + return ""; + } + + // Windows rejects startDrag with an empty/placeholder NativeImage. Ask + // the shell for the real icon of the staged file (e.g. the .zip icon) and + // fall back to the stub only if that fails. + let icon: Electron.NativeImage; + try { + icon = await app.getFileIcon(tempPath, { size: "small" }); + if (icon.isEmpty()) throw new Error("empty icon"); + } catch { + icon = createDragOutIcon(); + } + + sender.startDrag({ file: tempPath, icon }); + scheduleDragOutCleanup(cacheKey, tempPath, request.isDirectory); + return tempPath; + }; + const handleDragOut = async (event: IpcMainInvokeEvent, rawRequest: unknown) => { const request = sftpDragOutRequestSchema.parse(rawRequest); + const cacheKey = `${request.sftpSessionId}:${request.remotePath}`; // Check cache first — file may have been pre-downloaded on selection let tempPath = dragCache.get(cacheKey); + const hadCachedTempPath = Boolean(tempPath && fs.existsSync(tempPath)); - if (!tempPath || !fs.existsSync(tempPath)) { - const session = sftpSessionManager.getSession(request.sftpSessionId); - if (!session) throw new Error(`SFTP session ${request.sftpSessionId} not found`); - - const tempDir = path.join(app.getPath("temp"), "hypershell-drag"); - fs.mkdirSync(tempDir, { recursive: true }); - const safeOriginalPath = resolveSafeDragOutPath(tempDir, request.fileName); - const uniqueFileName = createDragOutTempFileName(path.basename(safeOriginalPath), cacheKey); - tempPath = resolveSafeDragOutPath(tempDir, uniqueFileName); - - const connOpts = session.connectionOptions; - const keyPath = connOpts.privateKeyPath ?? connOpts.fallbackKeyPaths?.[0]; - const scpCmd = buildScpCommand({ - hostname: connOpts.hostname, - port: connOpts.port, - username: connOpts.username, - privateKeyPath: keyPath, - proxyJump: connOpts.proxyJump, - direction: "download", - remotePath: request.remotePath, - localPath: tempPath, - }); - if (request.isDirectory) { - scpCmd.args.unshift("-r"); + if (!hadCachedTempPath) { + const stageTask = stageDragOutItemOnce(cacheKey, request); + + if (request.isDirectory && !request.prepareOnly) { + void stageTask.catch((error) => { + console.warn("[sftp] Failed to stage drag-out item", error); + }); + return { tempPath: "" }; } try { - await new Promise((resolve, reject) => { - execFile(scpCmd.command, scpCmd.args, { windowsHide: true }, (error) => { - if (error) reject(error); - else resolve(); - }); - }); - dragCache.set(cacheKey, tempPath); - } catch { - // SCP failed (directory, permission denied, etc.) — skip caching + tempPath = await stageTask; + } catch (error) { + console.warn("[sftp] Failed to stage drag-out item", error); return { tempPath: "" }; } } @@ -579,21 +859,23 @@ export function registerSftpIpc( return { tempPath }; } - // Initiate native OS drag from the cached temp file - let icon: Electron.NativeImage; - try { - icon = await app.getFileIcon(tempPath, { size: "small" }); - } catch { - const { nativeImage } = await import("electron"); - icon = nativeImage.createFromBuffer( - Buffer.from("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAADklEQVQ4jWNgGAWDEwAAAhAAATHKfqoAAAAASUVORK5CYII=", "base64") - ); + if (!tempPath || !shouldStartNativeDragOut(request, hadCachedTempPath)) { + return { tempPath: tempPath ?? "" }; } - event.sender.startDrag({ file: tempPath, icon }); + + await startNativeDragOutFromCache(event.sender, request, cacheKey); return { tempPath }; }; + const handleStartNativeDragOut = (event: IpcMainEvent, rawRequest: unknown) => { + const request = sftpDragOutRequestSchema.parse(rawRequest); + const cacheKey = `${request.sftpSessionId}:${request.remotePath}`; + void startNativeDragOutFromCache(event.sender, request, cacheKey).catch((error) => { + console.warn("[sftp] startNativeDragOut failed", error); + }); + }; + const handleKeyboardInteractiveResponse = async ( _event: IpcMainInvokeEvent, rawRequest: unknown @@ -636,6 +918,7 @@ export function registerSftpIpc( ipcMain.handle(ipcChannels.sftp.syncStop, handleSyncStop); ipcMain.handle(ipcChannels.sftp.syncList, handleSyncList); ipcMain.handle(ipcChannels.sftp.dragOut, handleDragOut); + ipcMain.on?.(ipcChannels.sftp.startNativeDragOut, handleStartNativeDragOut); const unsubscribeSyncEvents = syncEngine.onEvent((event) => { options.emitSyncEvent?.(event); @@ -735,5 +1018,6 @@ export function registerSftpIpc( for (const channel of Object.values(ipcChannels.sftp)) { ipcMain.removeHandler?.(channel); } + ipcMain.removeListener?.(ipcChannels.sftp.startNativeDragOut, handleStartNativeDragOut); }; } diff --git a/apps/desktop/src/preload/desktopApi.test.ts b/apps/desktop/src/preload/desktopApi.test.ts index 3987f21..1ec0d07 100644 --- a/apps/desktop/src/preload/desktopApi.test.ts +++ b/apps/desktop/src/preload/desktopApi.test.ts @@ -12,9 +12,11 @@ function createFakeIpcRenderer() { const invoke = vi.fn< (channel: string, ...args: unknown[]) => Promise >(async (_channel, _request) => undefined); + const send = vi.fn<(channel: string, ...args: unknown[]) => void>(); const ipcRenderer: PreloadIpcRenderer = { invoke, + send, on(channel, listener) { const current = listeners.get(channel) ?? new Set(); current.add(listener); @@ -28,6 +30,7 @@ function createFakeIpcRenderer() { return { ipcRenderer, invoke, + send, emit(channel: string, payload?: unknown) { for (const listener of listeners.get(channel) ?? []) { listener({}, payload); @@ -125,6 +128,26 @@ describe("createDesktopApi", () => { ).rejects.toBeTruthy(); }); + it("starts native SFTP drag-out over fire-and-forget IPC", () => { + const fake = createFakeIpcRenderer(); + const api = createDesktopApi(fake.ipcRenderer); + + api.sftpStartNativeDragOut({ + sftpSessionId: "sftp-1", + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }); + + expect(fake.send).toHaveBeenCalledWith(ipcChannels.sftp.startNativeDragOut, { + sftpSessionId: "sftp-1", + remotePath: "/home/user/project", + fileName: "project", + isDirectory: true, + }); + expect(fake.invoke).not.toHaveBeenCalled(); + }); + it("guards session event listener payloads and catches listener exceptions", () => { const fake = createFakeIpcRenderer(); const logger = { diff --git a/apps/desktop/src/preload/desktopApi.ts b/apps/desktop/src/preload/desktopApi.ts index 35e6861..b384841 100644 --- a/apps/desktop/src/preload/desktopApi.ts +++ b/apps/desktop/src/preload/desktopApi.ts @@ -296,6 +296,7 @@ import { z } from "zod"; export interface PreloadIpcRenderer { invoke(channel: string, ...args: unknown[]): Promise; + send(channel: string, ...args: unknown[]): void; on( channel: string, listener: (event: unknown, ...args: unknown[]) => void @@ -405,6 +406,7 @@ export interface DesktopApi { sftpSyncList(): Promise<{ syncs: SftpSyncStatus[] }>; onSftpSyncEvent(listener: (event: SftpSyncEvent) => void): () => void; sftpDragOut(request: SftpDragOutRequest): Promise; + sftpStartNativeDragOut(request: SftpDragOutRequest): void; // Host port forwards hostPortForwardList(request: ListHostPortForwardsRequest): Promise; hostPortForwardUpsert(request: UpsertHostPortForwardRequest): Promise; @@ -1399,6 +1401,10 @@ export function createDesktopApi( const result = await ipcRenderer.invoke(ipcChannels.sftp.dragOut, parsed); return sftpDragOutResponseSchema.parse(result); }, + sftpStartNativeDragOut(request: SftpDragOutRequest): void { + const parsed = sftpDragOutRequestSchema.parse(request); + ipcRenderer.send(ipcChannels.sftp.startNativeDragOut, parsed); + }, // Tmux detection async tmuxProbe(request: TmuxProbeRequest): Promise { const parsed = tmuxProbeRequestSchema.parse(request); diff --git a/apps/ui/src/features/sftp/components/FileList.tsx b/apps/ui/src/features/sftp/components/FileList.tsx index 294835f..7bcf393 100644 --- a/apps/ui/src/features/sftp/components/FileList.tsx +++ b/apps/ui/src/features/sftp/components/FileList.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react"; +import { toast } from "sonner"; import { formatFileSize, @@ -120,6 +121,49 @@ export function FileList({ // --- Mouse-drag range selection --- const dragSelectRef = useRef<{ startIndex: number } | null>(null); const didDragSelectRef = useRef(false); + const preparedNativeDragOutRef = useRef>(new Set()); + const pendingNativeDragOutRef = useRef>(new Set()); + + const createNativeDragOutKey = useCallback( + (remotePath: string) => `${sftpSessionId ?? ""}:${remotePath}`, + [sftpSessionId] + ); + + const prepareNativeDirectoryDragOut = useCallback( + (entry: FileListEntry) => { + const dragOut = window.hypershell?.sftpDragOut; + if (!sftpSessionId || !dragOut || !entry.isDirectory) return; + + const key = createNativeDragOutKey(entry.path); + if (preparedNativeDragOutRef.current.has(key) || pendingNativeDragOutRef.current.has(key)) { + return; + } + + pendingNativeDragOutRef.current.add(key); + toast(`Preparing ${entry.name} for drag-out...`); + + void dragOut({ + sftpSessionId, + remotePath: entry.path, + fileName: entry.name, + isDirectory: true, + prepareOnly: true, + }).then((result) => { + if (!result.tempPath) { + toast.error(`Failed to prepare ${entry.name} for drag-out.`); + return; + } + + preparedNativeDragOutRef.current.add(key); + toast.success(`${entry.name} is ready to drag out.`); + }).catch(() => { + toast.error(`Failed to prepare ${entry.name} for drag-out.`); + }).finally(() => { + pendingNativeDragOutRef.current.delete(key); + }); + }, + [createNativeDragOutKey, sftpSessionId] + ); const handleRowMouseDown = useCallback( (index: number, entry: FileListEntry, event: MouseEvent) => { @@ -188,17 +232,6 @@ export function FileList({ } else { onSelect(new Set([entry.path])); } - - // Pre-cache single file for drag-out (background download to temp) - if (sftpSessionId) { - void window.hypershell?.sftpDragOut?.({ - sftpSessionId, - remotePath: entry.path, - fileName: entry.name, - isDirectory: entry.isDirectory, - prepareOnly: true, - }).catch(() => {}); - } }; const handleRowDoubleClick = (entry: FileListEntry) => { @@ -213,6 +246,23 @@ export function FileList({ const handleDragStart = (event: DragEvent, entry: FileListEntry) => { const paths = selection.has(entry.path) ? Array.from(selection) : [entry.path]; + if (sftpSessionId && paths.length === 1 && entry.isDirectory) { + event.preventDefault(); + const key = createNativeDragOutKey(paths[0]); + if (!preparedNativeDragOutRef.current.has(key)) { + prepareNativeDirectoryDragOut(entry); + return; + } + + window.hypershell?.sftpStartNativeDragOut?.({ + sftpSessionId, + remotePath: paths[0], + fileName: entry.name, + isDirectory: true, + }); + return; + } + // Always set internal data synchronously (for cross-pane drops) event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData("application/x-sftp-paths", JSON.stringify(paths)); diff --git a/apps/ui/src/types/global.d.ts b/apps/ui/src/types/global.d.ts index f9c5ad0..673d9db 100644 --- a/apps/ui/src/types/global.d.ts +++ b/apps/ui/src/types/global.d.ts @@ -278,6 +278,7 @@ declare global { backupList?: () => Promise; backupShowOpenDialog?: () => Promise; sftpDragOut?: (request: SftpDragOutRequest) => Promise; + sftpStartNativeDragOut?: (request: SftpDragOutRequest) => void; tmuxProbe?: (request: { hostId: string }) => Promise<{ sessions: Array<{ name: string; windowCount: number; createdAt: string; attached: boolean }> }>; setAppTheme?: (theme: "light" | "dark") => Promise; }; diff --git a/packages/shared/src/ipc/channels.ts b/packages/shared/src/ipc/channels.ts index 339fada..3968d7d 100644 --- a/packages/shared/src/ipc/channels.ts +++ b/packages/shared/src/ipc/channels.ts @@ -102,10 +102,11 @@ export const sftpChannels = { bookmarksReorder: "sftp:bookmarks:reorder", syncStart: "sftp:sync:start", syncStop: "sftp:sync:stop", - syncList: "sftp:sync:list", - syncEvent: "sftp:sync:event", - dragOut: "sftp:drag-out", -} as const; + syncList: "sftp:sync:list", + syncEvent: "sftp:sync:event", + dragOut: "sftp:drag-out", + startNativeDragOut: "sftp:drag-out:start-native", +} as const; export const workspaceChannels = { save: "workspace:save", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f10486..7b87d2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,10 +41,16 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + yazl: + specifier: ^3.3.1 + version: 3.3.1 zod: specifier: ^3.24.1 version: 3.25.76 devDependencies: + '@types/yazl': + specifier: ^3.3.1 + version: 3.3.1 electron: specifier: ^34.0.0 version: 34.5.8 @@ -1063,6 +1069,9 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@types/yazl@3.3.1': + resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1101,6 +1110,7 @@ packages: '@xmldom/xmldom@0.8.12': resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -1234,6 +1244,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2722,6 +2736,9 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yazl@3.3.1: + resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3697,6 +3714,10 @@ snapshots: '@types/node': 24.12.2 optional: true + '@types/yazl@3.3.1': + dependencies: + '@types/node': 24.12.2 + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 @@ -3904,6 +3925,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -5503,6 +5526,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yazl@3.3.1: + dependencies: + buffer-crc32: 1.0.0 + yocto-queue@0.1.0: {} zod@3.25.76: {}