[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
701
electron/bridges/localFsBridge.cjs
Normal file
701
electron/bridges/localFsBridge.cjs
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Local Filesystem Bridge - Handles local file operations
|
||||
* Extracted from main.cjs for single responsibility
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_LOCAL_TREE_DIRECTORIES = 50_000;
|
||||
const MAX_LOCAL_TREE_ENTRIES = 200_000;
|
||||
const WINDOWS_ATTRIB_TIMEOUT_MS = 15_000;
|
||||
|
||||
function normalizeLocalTreeLimit(value, fallback) {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
let cachedLocalUserInfo;
|
||||
function localOwnerFromStat(stat) {
|
||||
if (process.platform === "win32") return undefined;
|
||||
if (!stat || typeof stat.uid !== "number") return undefined;
|
||||
try {
|
||||
cachedLocalUserInfo ??= os.userInfo();
|
||||
if (cachedLocalUserInfo.uid === stat.uid && cachedLocalUserInfo.username) {
|
||||
return cachedLocalUserInfo.username;
|
||||
}
|
||||
} catch {
|
||||
// userInfo() can throw when the process has no passwd entry.
|
||||
}
|
||||
return String(stat.uid);
|
||||
}
|
||||
|
||||
function createLocalTreeTraversalBudget(limits = {}) {
|
||||
return {
|
||||
directories: 0,
|
||||
entries: 0,
|
||||
maxDirectories: normalizeLocalTreeLimit(limits.maxDirectories, MAX_LOCAL_TREE_DIRECTORIES),
|
||||
maxEntries: normalizeLocalTreeLimit(limits.maxEntries, MAX_LOCAL_TREE_ENTRIES),
|
||||
};
|
||||
}
|
||||
|
||||
function claimLocalTreeDirectory(budget) {
|
||||
if (budget.directories >= budget.maxDirectories) {
|
||||
throw new Error(
|
||||
`Local directory traversal directory limit exceeded (${budget.maxDirectories}). Select a smaller folder to upload.`,
|
||||
);
|
||||
}
|
||||
budget.directories += 1;
|
||||
}
|
||||
|
||||
function accountLocalTreeEntries(budget, count) {
|
||||
const next = budget.entries + Math.max(0, Number(count) || 0);
|
||||
if (next > budget.maxEntries) {
|
||||
throw new Error(
|
||||
`Local directory traversal entry limit exceeded (${budget.maxEntries}). Select a smaller folder to upload.`,
|
||||
);
|
||||
}
|
||||
budget.entries = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
|
||||
* `H` (hidden) flag is set. Exposed separately so the parser can be
|
||||
* unit-tested without spawning a real subprocess.
|
||||
*
|
||||
* Example attrib output (one entry per line):
|
||||
* A C:\path\file1.txt
|
||||
* H C:\path\file2.txt
|
||||
* A H R C:\path\file3.txt
|
||||
* H C:\path\hidden_dir [DIR]
|
||||
*/
|
||||
function parseAttribOutput(stdout) {
|
||||
const hidden = new Set();
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
// Flags occupy the leading columns. Locate the path by the first
|
||||
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
|
||||
// alternative has no leading anchor because attrib output has the
|
||||
// path inside the line, not at column 0 (leading whitespace holds
|
||||
// the attribute flags).
|
||||
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
|
||||
if (pathStart < 0) continue;
|
||||
const attrPart = line.substring(0, pathStart).toUpperCase();
|
||||
if (!attrPart.includes("H")) continue;
|
||||
const fullPath = line.substring(pathStart).trim();
|
||||
// Some Windows versions append a trailing literal "[DIR]" marker
|
||||
// when attrib is invoked with /d. Strip only that exact marker —
|
||||
// not any arbitrary bracketed suffix — so legitimate filenames
|
||||
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
|
||||
// intact and still get matched by hiddenSet.has(entry.name).
|
||||
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
|
||||
// Always use the win32 basename here — attrib output uses backslash
|
||||
// separators, and the parser must work under CI on non-Windows hosts.
|
||||
const basename = path.win32.basename(cleaned);
|
||||
if (basename) hidden.add(basename);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-list hidden filenames in a Windows directory.
|
||||
*
|
||||
* Previously we called `attrib` once per entry inside the concurrency
|
||||
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
|
||||
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
|
||||
* the hidden attribute for every entry at once, so we replace the per-file
|
||||
* check with a single upfront pass and a Set lookup in the worker.
|
||||
*
|
||||
* Returns the set of hidden basenames (empty on non-Windows or on failure).
|
||||
*/
|
||||
async function listWindowsHiddenBasenames(dirPath) {
|
||||
if (process.platform !== "win32") return new Set();
|
||||
try {
|
||||
const pattern = path.join(dirPath, "*");
|
||||
// `/d` is required so attrib.exe also reports directory entries —
|
||||
// without it the wildcard is file-centric and hidden folders would
|
||||
// be silently omitted from the set, causing the SFTP browser to
|
||||
// show them as not-hidden (a regression from the per-file path
|
||||
// that passed each entry's full path directly).
|
||||
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
timeout: WINDOWS_ATTRIB_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseAttribOutput(stdout);
|
||||
} catch (err) {
|
||||
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
* On Windows, also detects hidden files using the hidden attribute
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Read directory entries and the Windows hidden-attribute set in
|
||||
// parallel. The hidden lookup is a single subprocess that covers every
|
||||
// entry in the directory; per-file attrib calls were the ~30 s hotspot
|
||||
// that #766 reported on an 800-file directory.
|
||||
const [entries, hiddenSet] = await Promise.all([
|
||||
fs.promises.readdir(dirPath, { withFileTypes: true }),
|
||||
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
|
||||
]);
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
const CONCURRENCY = 32;
|
||||
const result = new Array(entries.length);
|
||||
let cursor = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const i = cursor++;
|
||||
if (i >= entries.length) return;
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
|
||||
linkTarget = stat.isDirectory() ? "directory" : "file";
|
||||
} else if (entry.isDirectory()) {
|
||||
type = "directory";
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Windows hidden attribute: resolved from the batched lookup.
|
||||
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
|
||||
|
||||
// Follow the target for size/mtime/type; owner is the directory entry itself.
|
||||
const ownerStat = type === "symlink" ? await fs.promises.lstat(fullPath) : stat;
|
||||
const owner = localOwnerFromStat(ownerStat);
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
hidden,
|
||||
...(owner ? { owner } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
|
||||
const brokenEntry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, brokenEntry.name);
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
|
||||
const owner = localOwnerFromStat(lstat);
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
hidden,
|
||||
...(owner ? { owner } : {}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (lstatErr) {
|
||||
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
|
||||
}
|
||||
}
|
||||
console.warn(`Could not stat ${entries[i].name}:`, err.message);
|
||||
result[i] = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, entries.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
return result.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a local file.
|
||||
*
|
||||
* Optional `maxBytes` returns only the trailing bytes of the file (used by
|
||||
* Local Terminal histfile seeding so multi-MB shell histories do not stall
|
||||
* the renderer IPC path).
|
||||
*/
|
||||
async function readLocalFile(event, payload) {
|
||||
const maxBytes =
|
||||
Number.isFinite(payload?.maxBytes) && payload.maxBytes > 0
|
||||
? Math.floor(payload.maxBytes)
|
||||
: null;
|
||||
if (!maxBytes) {
|
||||
return fs.promises.readFile(payload.path);
|
||||
}
|
||||
|
||||
const handle = await fs.promises.open(payload.path, "r");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size <= maxBytes) {
|
||||
const buffer = Buffer.alloc(size);
|
||||
await handle.read(buffer, 0, size, 0);
|
||||
return buffer;
|
||||
}
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
await handle.read(buffer, 0, maxBytes, size - maxBytes);
|
||||
return buffer;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to a local file
|
||||
*/
|
||||
async function writeLocalFile(event, payload) {
|
||||
await fs.promises.writeFile(payload.path, Buffer.from(payload.content));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a local file or directory
|
||||
*/
|
||||
async function deleteLocalFile(event, payload) {
|
||||
const stat = await fs.promises.lstat(payload.path);
|
||||
const actualType = stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file";
|
||||
if (payload.expectedType && actualType !== payload.expectedType) {
|
||||
const error = new Error(
|
||||
`Local target changed before replace: expected ${payload.expectedType}, found ${actualType}`,
|
||||
);
|
||||
error.code = "ESTALE";
|
||||
throw error;
|
||||
}
|
||||
if (actualType === "directory") {
|
||||
await fs.promises.rm(payload.path, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.promises.unlink(payload.path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a local file or directory
|
||||
*/
|
||||
async function renameLocalFile(event, payload) {
|
||||
await fs.promises.rename(payload.oldPath, payload.newPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local directory
|
||||
*/
|
||||
async function mkdirLocal(event, payload) {
|
||||
try {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
} catch (err) {
|
||||
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
|
||||
// If the directory already exists, that's fine — ignore the error.
|
||||
try {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
if (stat.isDirectory()) return true;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local file statistics (follows symlinks — size/mtime of the target).
|
||||
* Resume and upload sizing rely on target bytes, not the link node.
|
||||
*/
|
||||
async function statLocal(event, payload) {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
type: stat.isDirectory() ? "directory" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local path metadata without following symlinks.
|
||||
* Conflict resolution needs this so Replace can unlink a link instead of
|
||||
* writing through it via writeLocalFile.
|
||||
*/
|
||||
async function lstatLocal(event, payload) {
|
||||
const stat = await fs.promises.lstat(payload.path);
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfLocalTreeCancelled(isCancelled) {
|
||||
if (typeof isCancelled === "function" && isCancelled()) {
|
||||
const error = new Error("Local directory traversal cancelled");
|
||||
error.code = "ERR_LOCAL_TREE_CANCELLED";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_TREE_ENTRY_BATCH_SIZE = 64;
|
||||
|
||||
async function collectLocalTreeEntries(rootPath, limits = {}, onProgress, isCancelled, onEntries) {
|
||||
const rootStat = await fs.promises.stat(rootPath);
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new Error("Selected path is not a directory");
|
||||
}
|
||||
throwIfLocalTreeCancelled(isCancelled);
|
||||
|
||||
const traversalBudget = createLocalTreeTraversalBudget(limits);
|
||||
claimLocalTreeDirectory(traversalBudget);
|
||||
const rootName = path.basename(rootPath);
|
||||
const rootRealPath = await fs.promises.realpath(rootPath);
|
||||
const rootEntry = {
|
||||
localPath: rootPath,
|
||||
relativePath: rootName,
|
||||
type: "directory",
|
||||
size: rootStat.size,
|
||||
lastModified: rootStat.mtime.getTime(),
|
||||
};
|
||||
// When streaming batches, avoid retaining the full tree in memory for 100k+
|
||||
// drops. Callers that only need the complete array still get it below.
|
||||
const retainAll = typeof onEntries !== "function";
|
||||
const entries = retainAll ? [rootEntry] : null;
|
||||
let pendingBatch = [rootEntry];
|
||||
const flushBatch = (force = false) => {
|
||||
if (typeof onEntries !== "function") return;
|
||||
if (!force && pendingBatch.length < LOCAL_TREE_ENTRY_BATCH_SIZE) return;
|
||||
if (pendingBatch.length === 0) return;
|
||||
const batch = pendingBatch;
|
||||
pendingBatch = [];
|
||||
onEntries(batch);
|
||||
};
|
||||
flushBatch(true);
|
||||
|
||||
const queue = [{
|
||||
localPath: rootPath,
|
||||
relativePath: rootName,
|
||||
realPath: rootRealPath,
|
||||
ancestorRealPaths: new Set([rootRealPath]),
|
||||
}];
|
||||
let queueIndex = 0;
|
||||
let fileCount = 0;
|
||||
let directoryCount = 1;
|
||||
let lastReportedTotal = 0;
|
||||
const reportProgress = (force = false) => {
|
||||
if (typeof onProgress !== "function") return;
|
||||
const entryCount = fileCount + directoryCount;
|
||||
if (!force && entryCount - lastReportedTotal < 32) return;
|
||||
lastReportedTotal = entryCount;
|
||||
onProgress({ fileCount, directoryCount, entryCount });
|
||||
};
|
||||
reportProgress(true);
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
throwIfLocalTreeCancelled(isCancelled);
|
||||
const current = queue[queueIndex++];
|
||||
const children = await fs.promises.readdir(current.localPath, { withFileTypes: true });
|
||||
accountLocalTreeEntries(traversalBudget, children.length);
|
||||
children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const metadataConcurrency = 32;
|
||||
for (let start = 0; start < children.length; start += metadataConcurrency) {
|
||||
throwIfLocalTreeCancelled(isCancelled);
|
||||
const inspected = (await Promise.all(
|
||||
children.slice(start, start + metadataConcurrency).map(async (child) => {
|
||||
const childPath = path.join(current.localPath, child.name);
|
||||
const childRelativePath = `${current.relativePath}/${child.name}`;
|
||||
try {
|
||||
// Use lstat to distinguish links, then stat the target. Directory
|
||||
// links retain the established folder-upload behavior, while the
|
||||
// real-path ancestor chain prevents junction/symlink cycles.
|
||||
// Ordinary files/dirs skip realpath: node_modules-sized trees pay
|
||||
// an extra syscall per directory otherwise, which shows up as
|
||||
// "scanning" stall before the first byte is uploaded.
|
||||
const linkStat = await fs.promises.lstat(childPath);
|
||||
const followLink = linkStat.isSymbolicLink();
|
||||
const stat = followLink
|
||||
? await fs.promises.stat(childPath).catch(() => linkStat)
|
||||
: linkStat;
|
||||
const isDirectory = stat.isDirectory();
|
||||
// Ordinary directories keep their ancestor identity by composing
|
||||
// from the already-resolved parent. Only directory links pay for
|
||||
// realpath, which is what cycle detection actually needs.
|
||||
const realPath = !isDirectory
|
||||
? null
|
||||
: followLink
|
||||
? await fs.promises.realpath(childPath)
|
||||
: path.join(current.realPath, child.name);
|
||||
const isCycle = !!realPath && current.ancestorRealPaths.has(realPath);
|
||||
const ancestorRealPaths = realPath
|
||||
? new Set([...current.ancestorRealPaths, realPath])
|
||||
: current.ancestorRealPaths;
|
||||
return {
|
||||
childPath,
|
||||
childRelativePath,
|
||||
stat,
|
||||
isDirectory,
|
||||
isCycle,
|
||||
realPath,
|
||||
ancestorRealPaths,
|
||||
};
|
||||
} catch (error) {
|
||||
// A folder can change while it is being scanned. Match the
|
||||
// tolerant browser traversal and skip entries that disappear or
|
||||
// become inaccessible instead of aborting the whole drop.
|
||||
console.warn(`Could not inspect ${childPath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)).filter(Boolean);
|
||||
|
||||
// Promise.all preserves the sorted child order, so restart manifests stay
|
||||
// deterministic while metadata I/O is parallelized.
|
||||
for (const child of inspected) {
|
||||
// Count every directory-shaped alias before cycle suppression. Otherwise
|
||||
// a non-cyclic symlink fan-out can expand one real tree thousands of
|
||||
// times without consuming the global directory budget.
|
||||
if (child.isDirectory) claimLocalTreeDirectory(traversalBudget);
|
||||
// Cyclic links cannot be represented by a finite copied tree. Skip the
|
||||
// loop itself instead of misreporting it as a file that later fails.
|
||||
if (child.isCycle) continue;
|
||||
const row = {
|
||||
localPath: child.childPath,
|
||||
relativePath: child.childRelativePath,
|
||||
type: child.isDirectory ? "directory" : "file",
|
||||
size: child.stat.size,
|
||||
lastModified: child.stat.mtime.getTime(),
|
||||
};
|
||||
if (retainAll) entries.push(row);
|
||||
pendingBatch.push(row);
|
||||
if (child.isDirectory) {
|
||||
directoryCount += 1;
|
||||
queue.push({
|
||||
localPath: child.childPath,
|
||||
relativePath: child.childRelativePath,
|
||||
realPath: child.realPath,
|
||||
ancestorRealPaths: child.ancestorRealPaths,
|
||||
});
|
||||
} else {
|
||||
fileCount += 1;
|
||||
}
|
||||
}
|
||||
flushBatch();
|
||||
reportProgress();
|
||||
}
|
||||
}
|
||||
|
||||
throwIfLocalTreeCancelled(isCancelled);
|
||||
flushBatch(true);
|
||||
reportProgress(true);
|
||||
return retainAll ? entries : [];
|
||||
}
|
||||
|
||||
async function listLocalTree(event, payload) {
|
||||
const progressChannel = typeof payload?.progressChannel === "string" && payload.progressChannel
|
||||
? payload.progressChannel
|
||||
: null;
|
||||
const cancelChannel = typeof payload?.cancelChannel === "string" && payload.cancelChannel
|
||||
? payload.cancelChannel
|
||||
: null;
|
||||
let cancelled = false;
|
||||
const onCancel = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Lazy-require so unit tests can import collectLocalTreeEntries without a
|
||||
// full Electron binary (top-level require("electron") breaks node --test).
|
||||
let electronIpcMain = null;
|
||||
if (cancelChannel) {
|
||||
try {
|
||||
electronIpcMain = require("electron").ipcMain;
|
||||
electronIpcMain.on(cancelChannel, onCancel);
|
||||
} catch {
|
||||
electronIpcMain = null;
|
||||
}
|
||||
}
|
||||
const onProgress = progressChannel
|
||||
? (stats) => {
|
||||
try {
|
||||
event.sender.send(progressChannel, stats);
|
||||
} catch {
|
||||
// Renderer may have gone away mid-scan.
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const entriesChannel = typeof payload?.entriesChannel === "string" && payload.entriesChannel
|
||||
? payload.entriesChannel
|
||||
: null;
|
||||
const onEntries = entriesChannel
|
||||
? (batch) => {
|
||||
try {
|
||||
// Always send plain arrays for entry batches. The stream end marker is
|
||||
// a separate object so the preload can keep its listener until every
|
||||
// nested batch has been delivered (invoke reply races with send).
|
||||
event.sender.send(entriesChannel, batch);
|
||||
} catch {
|
||||
// Renderer may have gone away mid-scan.
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
return await collectLocalTreeEntries(
|
||||
payload.path,
|
||||
payload.limits || {},
|
||||
onProgress,
|
||||
() => cancelled,
|
||||
onEntries,
|
||||
);
|
||||
} finally {
|
||||
// Must be sent after the last entry batch and before the invoke resolves
|
||||
// is not enough alone — preload must wait for this marker before removing
|
||||
// its listener, otherwise deep nested files (discovered late) are dropped.
|
||||
if (entriesChannel) {
|
||||
try {
|
||||
event.sender.send(entriesChannel, { type: "tree-end" });
|
||||
} catch {
|
||||
// Renderer may have gone away mid-scan.
|
||||
}
|
||||
}
|
||||
if (cancelChannel && electronIpcMain) {
|
||||
electronIpcMain.removeListener(cancelChannel, onCancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home directory
|
||||
*/
|
||||
async function getHomeDir() {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system info (username and hostname)
|
||||
*/
|
||||
async function getSystemInfo() {
|
||||
return {
|
||||
username: os.userInfo().username,
|
||||
hostname: os.hostname(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read system known_hosts file
|
||||
*/
|
||||
async function readKnownHosts() {
|
||||
const homeDir = os.homedir();
|
||||
const knownHostsPaths = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
|
||||
} else if (process.platform === "darwin") {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
|
||||
} else {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
|
||||
}
|
||||
|
||||
let combinedContent = "";
|
||||
|
||||
for (const knownHostsPath of knownHostsPaths) {
|
||||
try {
|
||||
if (fs.existsSync(knownHostsPath)) {
|
||||
const content = fs.readFileSync(knownHostsPath, "utf8");
|
||||
if (content.trim()) {
|
||||
combinedContent += content + "\n";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return combinedContent || null;
|
||||
}
|
||||
|
||||
async function listDrives() {
|
||||
if (process.platform !== "win32") return [];
|
||||
const letters = [];
|
||||
for (let i = 65; i <= 90; i++) {
|
||||
letters.push(String.fromCharCode(i));
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
letters.map((letter) => fs.promises.access(letter + ":\\"))
|
||||
);
|
||||
return letters.filter((_, idx) => results[idx].status === "fulfilled").map((letter) => letter + ":");
|
||||
}
|
||||
|
||||
async function extractLocalArchive(_event, payload) {
|
||||
const { extractLocalArchiveFile } = require("./sftpBridge/archiveExtract.cjs");
|
||||
return extractLocalArchiveFile(payload?.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for local filesystem operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:list", listLocalDir);
|
||||
ipcMain.handle("netcatty:local:read", readLocalFile);
|
||||
ipcMain.handle("netcatty:local:write", writeLocalFile);
|
||||
ipcMain.handle("netcatty:local:delete", deleteLocalFile);
|
||||
ipcMain.handle("netcatty:local:rename", renameLocalFile);
|
||||
ipcMain.handle("netcatty:local:extract", extractLocalArchive);
|
||||
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
|
||||
ipcMain.handle("netcatty:local:stat", statLocal);
|
||||
ipcMain.handle("netcatty:local:lstat", lstatLocal);
|
||||
ipcMain.handle("netcatty:local:tree", listLocalTree);
|
||||
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
||||
ipcMain.handle("netcatty:local:drives", listDrives);
|
||||
ipcMain.handle("netcatty:system:info", getSystemInfo);
|
||||
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WINDOWS_ATTRIB_TIMEOUT_MS,
|
||||
registerHandlers,
|
||||
listLocalDir,
|
||||
readLocalFile,
|
||||
writeLocalFile,
|
||||
deleteLocalFile,
|
||||
renameLocalFile,
|
||||
extractLocalArchive,
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
lstatLocal,
|
||||
collectLocalTreeEntries,
|
||||
createLocalTreeTraversalBudget,
|
||||
MAX_LOCAL_TREE_DIRECTORIES,
|
||||
MAX_LOCAL_TREE_ENTRIES,
|
||||
listLocalTree,
|
||||
getHomeDir,
|
||||
listDrives,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
parseAttribOutput,
|
||||
listWindowsHiddenBasenames,
|
||||
};
|
||||
Reference in New Issue
Block a user