[codex] Support remote image clipboard paste (#1408)

* Support remote image clipboard paste

* Address remote image paste review findings
This commit is contained in:
陈大猫
2026-06-11 17:01:36 +08:00
committed by GitHub
parent 3408bba303
commit 74d41b43b6
13 changed files with 580 additions and 7 deletions

View File

@@ -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,
});

View 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");
});

View 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);
}
}
}

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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 () => {

View File

@@ -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"),

View File

@@ -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>;

View File

@@ -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>;