Files
NetMesh/electron/bridges/clipboardFiles.cjs

248 lines
7.3 KiB
JavaScript
Raw Normal View History

"use strict";
const path = require("node:path");
const fs = require("node:fs");
const { fileURLToPath } = require("node:url");
const HDROP_FORMATS = ["CF_HDROP", "FileDrop"];
const URI_LIST_FORMATS = ["text/uri-list", "text/x-moz-url"];
function decodeWindowsFileNameW(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return [];
return buffer
.toString("utf16le")
.split("\0")
.map((entry) => entry.trim())
.filter(Boolean);
}
function decodeWindowsFileName(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return [];
return buffer
.toString("utf8")
.split("\0")
.map((entry) => entry.trim())
.filter(Boolean);
}
function decodeWindowsHDrop(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 20) return [];
const filesOffset = buffer.readUInt32LE(0);
if (filesOffset <= 0 || filesOffset >= buffer.length) return [];
const isWide = buffer.readUInt32LE(16) !== 0;
const payload = buffer.subarray(filesOffset);
const text = payload.toString(isWide ? "utf16le" : "utf8");
return text
.split("\0")
.map((entry) => entry.trim())
.filter(Boolean);
}
function decodeFileUri(value, { windows = process.platform === "win32" } = {}) {
if (!value.startsWith("file://")) return value;
try {
return fileURLToPath(value, { windows });
} catch {
return value;
}
}
function toClipboardFile(filePath, { fsImpl = fs, pathImpl = path } = {}) {
if (!filePath || !fsImpl.existsSync(filePath)) return null;
try {
const stat = fsImpl.statSync(filePath);
const isDirectory = stat.isDirectory();
if (!isDirectory && !stat.isFile()) return null;
const name = filePath.includes("\\")
? path.win32.basename(filePath)
: pathImpl.basename(filePath);
return {
path: filePath,
name,
isDirectory,
size: isDirectory ? 0 : stat.size,
};
} catch {
return null;
}
}
function collectExistingFiles(paths, options = {}) {
const seen = new Set();
const files = [];
for (const candidate of paths) {
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
const file = toClipboardFile(candidate, options);
if (file) files.push(file);
}
return files;
}
function parseClipboardTextFilePaths(text, options = {}) {
if (!text) return [];
const candidates = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && line.startsWith("file://"))
.map((line) => decodeFileUri(line, options));
return collectExistingFiles(candidates, options);
}
function readClipboardFiles({
clipboard,
fsImpl = fs,
pathImpl = path,
} = {}) {
if (!clipboard) return [];
const options = { fsImpl, pathImpl };
try {
const formats = typeof clipboard.availableFormats === "function"
? clipboard.availableFormats()
: [];
if (typeof clipboard.readBuffer === "function") {
for (const format of HDROP_FORMATS) {
if (!formats.includes(format)) continue;
const files = collectExistingFiles(decodeWindowsHDrop(clipboard.readBuffer(format)), options);
if (files.length > 0) return files;
}
}
if (formats.includes("FileNameW") && typeof clipboard.readBuffer === "function") {
const files = collectExistingFiles(decodeWindowsFileNameW(clipboard.readBuffer("FileNameW")), options);
if (files.length > 0) return files;
}
if (formats.includes("FileName") && typeof clipboard.readBuffer === "function") {
const files = collectExistingFiles(decodeWindowsFileName(clipboard.readBuffer("FileName")), options);
if (files.length > 0) return files;
}
if (
typeof clipboard.readText === "function" &&
formats.some((format) => URI_LIST_FORMATS.includes(format))
) {
return parseClipboardTextFilePaths(clipboard.readText(), options);
}
if (typeof clipboard.read === "function") {
for (const format of ["NSFilenamesPboardType", "public.file-url"]) {
if (!formats.includes(format)) continue;
const raw = clipboard.read(format);
if (typeof raw !== "string" || raw.trim().length === 0) continue;
if (raw.includes("file://")) {
const files = parseClipboardTextFilePaths(raw, options);
if (files.length > 0) return files;
}
const files = collectExistingFiles(
raw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
options,
);
if (files.length > 0) return files;
}
}
} catch {
return [];
}
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;
}
}
/**
* Peek whether the OS clipboard currently holds an image without writing a
* temp file. Used by local terminal paste to decide whether to forward Ctrl+V
* to nested TUIs (e.g. Claude Code image paste) after Electron's Edit>Paste
* turns the chord into a clipboard paste event.
*/
function hasClipboardImage({ clipboard } = {}) {
if (!clipboard || typeof clipboard.readImage !== "function") return false;
try {
if (typeof clipboard.availableFormats === "function") {
try {
const formats = clipboard.availableFormats();
if (Array.isArray(formats) && formats.some((format) => typeof format === "string" && format.startsWith("image/"))) {
return true;
}
} catch {
// Fall through to readImage when format probing fails.
}
}
const image = clipboard.readImage();
if (!image) return false;
if (typeof image.isEmpty === "function" && image.isEmpty()) return false;
if (typeof image.getSize === "function") {
const size = image.getSize();
return Boolean(size && size.width > 0 && size.height > 0);
}
if (typeof image.toPNG !== "function") return false;
const content = image.toPNG();
return Buffer.isBuffer(content) && content.length > 0;
} catch {
return false;
}
}
module.exports = {
createClipboardImageFileName,
decodeWindowsHDrop,
decodeWindowsFileNameW,
hasClipboardImage,
parseClipboardTextFilePaths,
readClipboardFiles,
readClipboardImage,
};