[codex] Support remote image clipboard paste (#1408)
* Support remote image clipboard paste * Address remote image paste review findings
This commit is contained in:
@@ -461,6 +461,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
const supportsRemoteImagePaste =
|
||||
!isLocalConnection &&
|
||||
!isSerialConnection &&
|
||||
host.protocol !== "telnet" &&
|
||||
host.protocol !== "mosh" &&
|
||||
!host.moshEnabled &&
|
||||
host.protocol !== "et" &&
|
||||
!host.etEnabled;
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
@@ -816,6 +824,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [sessionId]);
|
||||
|
||||
const executeSnippetCommand = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
@@ -870,7 +886,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
// handleContextMenuCapture (which is bound once per sessionId) can
|
||||
@@ -1106,10 +1125,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useTerminalFilePaste({
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
status,
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
resolveSftpInitialPath,
|
||||
scrollOnPasteRef,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
189
components/terminal/clipboardImagePaste.test.ts
Normal file
189
components/terminal/clipboardImagePaste.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildRemoteClipboardImagePath,
|
||||
handleRemoteClipboardImagePaste,
|
||||
quoteRemotePathForShell,
|
||||
} from "./clipboardImagePaste";
|
||||
|
||||
test("remote clipboard image path is placed under the current directory", () => {
|
||||
assert.equal(
|
||||
buildRemoteClipboardImagePath("/srv/app", "netcatty paste:1.png"),
|
||||
"/srv/app/.netcatty-paste-images/netcatty_paste_1.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote clipboard image path is empty when cwd is unavailable", () => {
|
||||
assert.equal(
|
||||
buildRemoteClipboardImagePath(undefined, "shot.png"),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote paths are quoted for shell-safe insertion", () => {
|
||||
assert.equal(
|
||||
quoteRemotePathForShell("/srv/app/.netcatty-paste-images/a b's.png"),
|
||||
"'/srv/app/.netcatty-paste-images/a b'\\''s.png'",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote clipboard image paste uploads and inserts the remote image path", async () => {
|
||||
const writes: Array<{ sessionId: string; data: string }> = [];
|
||||
const scrolled: string[] = [];
|
||||
let focused = false;
|
||||
let closedSftpId: string | undefined;
|
||||
let deletedTempFile: string | undefined;
|
||||
const transferPayloads: unknown[] = [];
|
||||
const broadcastData: string[] = [];
|
||||
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot 1.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async (sessionId) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
return "sftp-1";
|
||||
},
|
||||
startStreamTransfer: async (options) => {
|
||||
transferPayloads.push(options);
|
||||
return { transferId: options.transferId, totalBytes: 12 };
|
||||
},
|
||||
closeSftp: async (sftpId) => {
|
||||
closedSftpId = sftpId;
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
createTransferId: () => "transfer-1",
|
||||
getRemoteCwd: async () => "/home/alice/project",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId, data) => writes.push({ sessionId, data }),
|
||||
},
|
||||
onPasteData: (data) => broadcastData.push(data),
|
||||
term: {
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(transferPayloads, [
|
||||
{
|
||||
transferId: "transfer-1",
|
||||
sourcePath: "/tmp/netcatty/shot.png",
|
||||
targetPath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "sftp-1",
|
||||
totalBytes: 12,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
data: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(scrolled, ["/home/alice/project/.netcatty-paste-images/shot_1.png"]);
|
||||
assert.deepEqual(broadcastData, ["/home/alice/project/.netcatty-paste-images/shot_1.png"]);
|
||||
assert.equal(focused, true);
|
||||
assert.equal(closedSftpId, "sftp-1");
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
|
||||
test("remote clipboard image paste reports unhandled when no image exists", async () => {
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test("remote clipboard image paste skips upload without a reliable cwd", async () => {
|
||||
const transferPayloads: unknown[] = [];
|
||||
let deletedTempFile: string | undefined;
|
||||
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without cwd");
|
||||
},
|
||||
startStreamTransfer: async (options) => {
|
||||
transferPayloads.push(options);
|
||||
return { transferId: options.transferId };
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
getRemoteCwd: async () => undefined,
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without upload"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(transferPayloads, []);
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
|
||||
test("remote clipboard image paste does not insert a path when upload returns an error", async () => {
|
||||
let closedSftpId: string | undefined;
|
||||
let deletedTempFile: string | undefined;
|
||||
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
closeSftp: async (sftpId) => {
|
||||
closedSftpId = sftpId;
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste failed upload path"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(closedSftpId, "sftp-1");
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
120
components/terminal/clipboardImagePaste.ts
Normal file
120
components/terminal/clipboardImagePaste.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
const REMOTE_CLIPBOARD_IMAGE_DIR = ".netcatty-paste-images";
|
||||
|
||||
type ClipboardImageFile = {
|
||||
path: string;
|
||||
name: string;
|
||||
mediaType: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type RemoteClipboardImageBridge = Pick<
|
||||
NetcattyBridge,
|
||||
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
|
||||
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
|
||||
|
||||
type TerminalLike = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
type HandleRemoteClipboardImagePasteOptions = {
|
||||
bridge?: RemoteClipboardImageBridge;
|
||||
createTransferId?: () => string;
|
||||
getRemoteCwd: () => Promise<string | null | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
sessionId: string | null | undefined;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
};
|
||||
term?: TerminalLike | null;
|
||||
};
|
||||
|
||||
const shellSafePathPattern = /^[A-Za-z0-9_./~:@%+=,-]+$/;
|
||||
|
||||
export function sanitizeRemoteClipboardImageName(name: string): string {
|
||||
const fallback = "netcatty-paste.png";
|
||||
const trimmed = name.trim() || fallback;
|
||||
const sanitized = trimmed
|
||||
.replace(/[\0/\\]/g, "_")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
export function buildRemoteClipboardImagePath(cwd: string | null | undefined, fileName: string): string {
|
||||
const safeFileName = sanitizeRemoteClipboardImageName(fileName);
|
||||
const normalizedCwd = typeof cwd === "string" ? cwd.trim() : "";
|
||||
if (!normalizedCwd) return "";
|
||||
const base = normalizedCwd.replace(/\/+$/g, "") || "/";
|
||||
|
||||
if (base === "/") {
|
||||
return `/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
||||
}
|
||||
|
||||
return `${base}/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
||||
}
|
||||
|
||||
export function quoteRemotePathForShell(remotePath: string): string {
|
||||
if (shellSafePathPattern.test(remotePath)) return remotePath;
|
||||
return `'${remotePath.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function defaultTransferId(): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
return uuid ? `clipboard-image-${uuid}` : `clipboard-image-${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function handleRemoteClipboardImagePaste({
|
||||
bridge,
|
||||
createTransferId = defaultTransferId,
|
||||
getRemoteCwd,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
onPasteData,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
}: HandleRemoteClipboardImagePasteOptions): Promise<boolean> {
|
||||
if (!sessionId) return false;
|
||||
if (!bridge?.readClipboardImage || !bridge.openSftpForSession || !bridge.startStreamTransfer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const image: ClipboardImageFile | null = await bridge.readClipboardImage();
|
||||
if (!image?.path || !image.name) return false;
|
||||
|
||||
let sftpId: string | undefined;
|
||||
try {
|
||||
const remoteCwd = await getRemoteCwd();
|
||||
const targetPath = buildRemoteClipboardImagePath(remoteCwd, image.name);
|
||||
if (!targetPath) return false;
|
||||
const transferId = createTransferId();
|
||||
|
||||
sftpId = await bridge.openSftpForSession(sessionId);
|
||||
const transferResult = await bridge.startStreamTransfer({
|
||||
transferId,
|
||||
sourcePath: image.path,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: sftpId,
|
||||
totalBytes: image.size,
|
||||
});
|
||||
if (!transferResult || transferResult.error) return false;
|
||||
|
||||
const pastedPath = quoteRemotePathForShell(targetPath);
|
||||
terminalBackend.writeToSession(sessionId, pastedPath);
|
||||
onPasteData?.(pastedPath);
|
||||
scrollToBottomAfterProgrammaticInput?.(pastedPath);
|
||||
term?.focus?.();
|
||||
return true;
|
||||
} finally {
|
||||
if (sftpId && bridge.closeSftp) {
|
||||
await bridge.closeSftp(sftpId).catch(() => undefined);
|
||||
}
|
||||
if (bridge.deleteTempFile) {
|
||||
await bridge.deleteTempFile(image.path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { logger } from "../../../lib/logger";
|
||||
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
|
||||
import { clearTerminalViewport } from "../clearTerminalViewport";
|
||||
import { extractRootPathsFromClipboardFiles } from "../terminalHelpers";
|
||||
import { handleRemoteClipboardImagePaste } from "../clipboardImagePaste";
|
||||
|
||||
type BroadcastPasteRefs = {
|
||||
sourceSessionId: string;
|
||||
@@ -34,7 +35,10 @@ export const useTerminalContextActions = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
terminalBackend,
|
||||
getRemoteCwd,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
}: {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sourceSessionId: string;
|
||||
@@ -44,9 +48,12 @@ export const useTerminalContextActions = ({
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
};
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
}) => {
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
return broadcastTerminalPasteData(data, {
|
||||
@@ -70,7 +77,21 @@ export const useTerminalContextActions = ({
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
try {
|
||||
const readClipboardFiles = netcattyBridge.get()?.readClipboardFiles;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (supportsRemoteImagePaste && bridge?.readClipboardImage && getRemoteCwd) {
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge,
|
||||
getRemoteCwd,
|
||||
sessionId: sessionRef.current,
|
||||
terminalBackend,
|
||||
term,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const readClipboardFiles = bridge?.readClipboardFiles;
|
||||
if (readClipboardFiles) {
|
||||
const files = await readClipboardFiles();
|
||||
if (files.length > 0 && isLocalConnection && sessionRef.current) {
|
||||
@@ -96,10 +117,13 @@ export const useTerminalContextActions = ({
|
||||
}, [
|
||||
broadcastUserPasteData,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
sessionRef,
|
||||
termRef,
|
||||
scrollOnPasteRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
|
||||
@@ -7,25 +7,34 @@ import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSession } from "../../../types";
|
||||
import { extractRootPathsFromClipboardFiles } from "../terminalHelpers";
|
||||
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
|
||||
import { handleRemoteClipboardImagePaste } from "../clipboardImagePaste";
|
||||
|
||||
interface UseTerminalFilePasteOptions {
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
status: TerminalSession["status"];
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
sessionRef: React.MutableRefObject<string | null>;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
};
|
||||
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
|
||||
scrollOnPasteRef?: React.RefObject<boolean>;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useTerminalFilePaste({
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
status,
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
resolveSftpInitialPath,
|
||||
scrollOnPasteRef,
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
}: UseTerminalFilePasteOptions) {
|
||||
@@ -38,7 +47,10 @@ export function useTerminalFilePaste({
|
||||
if (!term || !sessionRef.current) return;
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
pasteTextIntoTerminal(term, text, { scrollOnPaste: false });
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData,
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
// clipboard access denied — silently ignore
|
||||
@@ -46,10 +58,35 @@ export function useTerminalFilePaste({
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (!isLocalConnection || status !== "connected") return;
|
||||
if (status !== "connected") return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readClipboardFiles) return;
|
||||
|
||||
if (supportsRemoteImagePaste && bridge?.readClipboardImage) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const handled = await handleRemoteClipboardImagePaste({
|
||||
bridge,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
sessionId: sessionRef.current,
|
||||
terminalBackend,
|
||||
term: termRef.current,
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
if (!handled) fallbackToTextPaste();
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle remote image paste", error);
|
||||
fallbackToTextPaste();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalConnection || !bridge?.readClipboardFiles) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
@@ -89,6 +126,10 @@ export function useTerminalFilePaste({
|
||||
}, [
|
||||
containerRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
onPasteData,
|
||||
resolveSftpInitialPath,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionRef,
|
||||
status,
|
||||
|
||||
@@ -151,9 +151,62 @@ function readClipboardFiles({
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatClipboardImageTimestamp(date = new Date()) {
|
||||
const pad = (value, width = 2) => String(value).padStart(width, "0");
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
"-",
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
"-",
|
||||
pad(date.getMilliseconds(), 3),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function createClipboardImageFileName(date = new Date()) {
|
||||
return `netcatty-paste-${formatClipboardImageTimestamp(date)}.png`;
|
||||
}
|
||||
|
||||
async function readClipboardImage({
|
||||
clipboard,
|
||||
fsImpl = fs,
|
||||
tempDirBridge,
|
||||
now = () => new Date(),
|
||||
} = {}) {
|
||||
if (!clipboard || typeof clipboard.readImage !== "function" || !tempDirBridge?.getTempFilePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = clipboard.readImage();
|
||||
if (!image || (typeof image.isEmpty === "function" && image.isEmpty())) return null;
|
||||
if (typeof image.toPNG !== "function") return null;
|
||||
|
||||
const content = image.toPNG();
|
||||
if (!Buffer.isBuffer(content) || content.length === 0) return null;
|
||||
|
||||
const name = createClipboardImageFileName(now());
|
||||
const imagePath = tempDirBridge.getTempFilePath(name);
|
||||
await fsImpl.promises.writeFile(imagePath, content);
|
||||
return {
|
||||
path: imagePath,
|
||||
name,
|
||||
mediaType: "image/png",
|
||||
size: content.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createClipboardImageFileName,
|
||||
decodeWindowsHDrop,
|
||||
decodeWindowsFileNameW,
|
||||
parseClipboardTextFilePaths,
|
||||
readClipboardFiles,
|
||||
readClipboardImage,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,12 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
createClipboardImageFileName,
|
||||
decodeWindowsHDrop,
|
||||
decodeWindowsFileNameW,
|
||||
parseClipboardTextFilePaths,
|
||||
readClipboardFiles,
|
||||
readClipboardImage,
|
||||
} = require("./clipboardFiles.cjs");
|
||||
|
||||
const createFs = (entries) => ({
|
||||
@@ -147,3 +149,58 @@ test("reads CF_HDROP before falling back to FileNameW", () => {
|
||||
{ path: "D:\\b.txt", name: "b.txt", isDirectory: false, size: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates stable clipboard image file names", () => {
|
||||
assert.equal(
|
||||
createClipboardImageFileName(new Date(2026, 5, 11, 3, 4, 5, 6)),
|
||||
"netcatty-paste-20260611-030405-006.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("writes clipboard image to Netcatty temp directory", async () => {
|
||||
const writes = [];
|
||||
const content = Buffer.from([1, 2, 3]);
|
||||
const result = await readClipboardImage({
|
||||
clipboard: {
|
||||
readImage: () => ({
|
||||
isEmpty: () => false,
|
||||
toPNG: () => content,
|
||||
}),
|
||||
},
|
||||
fsImpl: {
|
||||
promises: {
|
||||
writeFile: async (filePath, data) => writes.push({ filePath, data }),
|
||||
},
|
||||
},
|
||||
tempDirBridge: {
|
||||
getTempFilePath: (name) => `/netcatty-temp/${name}`,
|
||||
},
|
||||
now: () => new Date(2026, 5, 11, 3, 4, 5, 6),
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: "/netcatty-temp/netcatty-paste-20260611-030405-006.png",
|
||||
name: "netcatty-paste-20260611-030405-006.png",
|
||||
mediaType: "image/png",
|
||||
size: 3,
|
||||
});
|
||||
assert.deepEqual(writes, [
|
||||
{ filePath: "/netcatty-temp/netcatty-paste-20260611-030405-006.png", data: content },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when clipboard image is empty", async () => {
|
||||
const result = await readClipboardImage({
|
||||
clipboard: {
|
||||
readImage: () => ({
|
||||
isEmpty: () => true,
|
||||
toPNG: () => Buffer.from([1]),
|
||||
}),
|
||||
},
|
||||
tempDirBridge: {
|
||||
getTempFilePath: () => "/netcatty-temp/unused.png",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
@@ -771,9 +771,16 @@ async function openSftpForSession(_event, payload) {
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
|
||||
throwIfAborted(payload?.abortSignal);
|
||||
const { sshClient } = ensureRemoteSftpSupport(sessionId);
|
||||
const { session, sshClient } = ensureRemoteSftpSupport(sessionId);
|
||||
const sftpId = `${sessionId}-sftp-${randomUUID()}`;
|
||||
const client = createSessionBackedSftpClient(sessionId, sshClient);
|
||||
const refHolder = {};
|
||||
if (session.connRef && typeof acquireConnectionRef === "function") {
|
||||
acquireConnectionRef(refHolder, session.connRef);
|
||||
}
|
||||
const client = createSessionBackedSftpClient(sessionId, sshClient, {
|
||||
refHolder,
|
||||
sourceSessionId: sessionId,
|
||||
});
|
||||
try {
|
||||
await requireSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
@@ -928,6 +935,7 @@ const {
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:open", openSftp);
|
||||
ipcMain.handle("netcatty:sftp:openForSession", openSftpForSession);
|
||||
ipcMain.handle("netcatty:sftp:list", listSftp);
|
||||
ipcMain.handle("netcatty:sftp:read", readSftp);
|
||||
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
|
||||
|
||||
@@ -4,6 +4,7 @@ const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
|
||||
|
||||
function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
|
||||
const bridgePath = require.resolve("./sftpBridge.cjs");
|
||||
@@ -154,3 +155,47 @@ test("openSftp cleans a jump proxy socket when the first jump connection fails",
|
||||
assert.equal(proxySocket.destroyed, true);
|
||||
assert.equal(FailingSshClient.instances[0]?.ended, true);
|
||||
});
|
||||
|
||||
test("openSftpForSession holds a shared SSH connection until the SFTP handle closes", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
const fakeSftp = {
|
||||
ended: false,
|
||||
readdir: () => {},
|
||||
stat: () => {},
|
||||
mkdir: () => {},
|
||||
unlink: () => {},
|
||||
end() {
|
||||
this.ended = true;
|
||||
},
|
||||
};
|
||||
const conn = {
|
||||
ended: false,
|
||||
sftp(cb) {
|
||||
cb(null, fakeSftp);
|
||||
},
|
||||
end() {
|
||||
this.ended = true;
|
||||
},
|
||||
};
|
||||
const connRef = { count: 1, conn, chainConnections: [] };
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
connRef,
|
||||
};
|
||||
const sessions = new Map([["session-1", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "session-1" });
|
||||
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(connRef.count, 2);
|
||||
assert.equal(releaseConnectionRef(session), false);
|
||||
assert.equal(conn.ended, false);
|
||||
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
|
||||
assert.equal(fakeSftp.ended, true);
|
||||
assert.equal(conn.ended, true);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
let bridgesRegistered = false;
|
||||
let cloudSyncSessionPassword = null;
|
||||
const { readClipboardFiles } = require("../bridges/clipboardFiles.cjs");
|
||||
const { readClipboardFiles, readClipboardImage } = require("../bridges/clipboardFiles.cjs");
|
||||
|
||||
function createBridgeRegistrar(context) {
|
||||
const {
|
||||
@@ -511,6 +511,10 @@ function createBridgeRegistrar(context) {
|
||||
ipcMain.handle("netcatty:clipboard:readFiles", async () => {
|
||||
return readClipboardFiles({ clipboard, fsImpl: fs, pathImpl: path });
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:clipboard:readImage", async () => {
|
||||
return readClipboardImage({ clipboard, fsImpl: fs, tempDirBridge });
|
||||
});
|
||||
|
||||
// Select an application from system file picker
|
||||
ipcMain.handle("netcatty:selectApplication", async () => {
|
||||
|
||||
@@ -278,6 +278,10 @@ function createPreloadApi(ctx) {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
},
|
||||
openSftpForSession: async (sessionId) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:openForSession", { sessionId });
|
||||
return result.sftpId;
|
||||
},
|
||||
listSftp: async (sftpId, path, encoding) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:list", { sftpId, path, encoding });
|
||||
},
|
||||
@@ -822,6 +826,9 @@ function createPreloadApi(ctx) {
|
||||
readClipboardFiles: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readFiles");
|
||||
},
|
||||
readClipboardImage: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readImage");
|
||||
},
|
||||
|
||||
// Credential encryption (field-level safeStorage)
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
|
||||
1
types/global/netcatty-bridge-files.d.ts
vendored
1
types/global/netcatty-bridge-files.d.ts
vendored
@@ -88,6 +88,7 @@ declare global {
|
||||
readClipboardText?(): Promise<string>;
|
||||
writeClipboardText?(text: string): Promise<boolean>;
|
||||
readClipboardFiles?(): Promise<Array<{ path: string; name: string; isDirectory: boolean; size?: number }>>;
|
||||
readClipboardImage?(): Promise<{ path: string; name: string; mediaType: string; size?: number } | null>;
|
||||
|
||||
// Credential encryption (field-level safeStorage for sensitive data at rest)
|
||||
credentialsAvailable?(): Promise<boolean>;
|
||||
|
||||
1
types/global/netcatty-bridge-sftp.d.ts
vendored
1
types/global/netcatty-bridge-sftp.d.ts
vendored
@@ -4,6 +4,7 @@ declare global {
|
||||
interface NetcattyBridge {
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
openSftpForSession?(sessionId: string): Promise<string>;
|
||||
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<ArrayBuffer>;
|
||||
|
||||
Reference in New Issue
Block a user