// Local filesystem access + streamed local<->remote transfers for the // desktop app's dual-pane file manager. // // The renderer has no Node access (contextIsolation), so browsing the user's // own disk and moving bytes between it and the backend has to happen here. // Uploads/downloads are streamed through Electron's `net` stack against the // same file-manager HTTP routes the renderer already uses, so nothing is ever // buffered whole in memory. // // Trust boundary: the renderer never supplies a URL or headers. It names an // origin ("local" | "remote") and a route from a fixed allowlist; the main // process resolves the actual Termix backend URL and attaches credentials // itself (session cookies for the embedded backend, the linked server's // session). Nothing here can be pointed at another host. const fs = require("fs"); const fsp = require("fs/promises"); const os = require("os"); const path = require("path"); const { URL } = require("url"); const IPC = { HOME: "local-fs:home", LIST: "local-fs:list", MKDIR: "local-fs:mkdir", CREATE_FILE: "local-fs:create-file", RENAME: "local-fs:rename", TRASH: "local-fs:trash", ENSURE_DIR: "local-fs:ensure-dir", EXISTS: "local-fs:exists", WALK: "local-fs:walk", REVEAL: "local-fs:reveal", OPEN: "local-fs:open", UPLOAD: "local-transfer:upload", DOWNLOAD: "local-transfer:download", CANCEL: "local-transfer:cancel", PROGRESS: "local-transfer:progress", SET_API: "local-transfer:set-api", }; const READ_CHUNK_BYTES = 1024 * 1024; const PROGRESS_INTERVAL_MS = 150; const activeTransfers = new Map(); function isAbsoluteLocalPath(candidate) { return typeof candidate === "string" && path.isAbsolute(candidate); } function normalizeLocalPath(candidate) { if (!isAbsoluteLocalPath(candidate)) { throw new Error("A local absolute path is required"); } return path.normalize(candidate); } function isHiddenEntry(name) { return name.startsWith("."); } // A single path segment: no separators, not "." / "..", no NULs. function requireEntryName(name, what) { const safeName = String(name || "").trim(); if ( !safeName || safeName === "." || safeName === ".." || /[\/\\\0]/.test(safeName) ) { throw new Error(`Invalid ${what}`); } return safeName; } // Asserts that `candidate` (already absolute) lies strictly inside `root` // after normalisation on the *current* platform. `pathImpl` is injectable so // the Windows rules can be exercised in tests on any OS. Traversal that // survives normalisation ("..\\x" is a plain file name on POSIX but a parent // reference on Windows) is caught here, as are absolute / drive-qualified // names that path.join or path.resolve would let take over. function assertWithinRoot(rootPath, candidate, pathImpl = path) { if (typeof rootPath !== "string" || !rootPath.trim()) { throw new LocalFileError("EINVAL", "A download folder is required"); } const root = pathImpl.normalize(pathImpl.resolve(rootPath)); const target = pathImpl.normalize(pathImpl.resolve(candidate)); const rel = pathImpl.relative(root, target); const escapes = rel === "" || rel === ".." || rel.startsWith(`..${pathImpl.sep}`) || pathImpl.isAbsolute(rel) || // A different drive on Windows yields an absolute relative path; a UNC // or drive-qualified segment must never survive either. rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); if (escapes) { throw new LocalFileError( "EINVAL", `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, ); } return target; } // Resolve a deliberately selected linked root once, but never follow links // supplied as descendants of a downloaded tree. async function prepareDownloadPath(rootPath, candidate, directory = false) { const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); const selected = path.resolve(rootPath); const realRoot = await fsp.realpath(selected); if (!(await fsp.stat(realRoot)).isDirectory()) { throw new LocalFileError("EINVAL", "The download root is not a directory"); } const parts = path.relative(selected, target).split(path.sep); let current = realRoot; for (let i = 0; i < parts.length; i++) { current = path.join(current, parts[i]); const needsDirectory = directory || i < parts.length - 1; if (needsDirectory) { await fsp.mkdir(current).catch((error) => { if (error.code !== "EEXIST") throw error; }); } const stat = await fsp.lstat(current).catch((error) => { if (error.code === "ENOENT" && !needsDirectory) return null; throw error; }); if (stat?.isSymbolicLink()) { throw new LocalFileError( "EINVAL", "Downloads cannot follow links inside the selected folder", ); } if (needsDirectory && !stat.isDirectory()) { throw new LocalFileError( "ENOTDIR", "A download parent is not a directory", ); } } return { root: realRoot, path: current }; } async function pathExists(target) { try { await fsp.lstat(target); return true; } catch { return false; } } async function describeEntry(dirPath, dirent) { const entryPath = path.join(dirPath, dirent.name); let type = "file"; let size = 0; let modifiedTimestamp; let linkTarget; try { if (dirent.isSymbolicLink()) { type = "link"; try { linkTarget = await fsp.readlink(entryPath); } catch { // dangling or unreadable link } // Follow the link so a symlinked directory still navigates like one. try { const target = await fsp.stat(entryPath); if (target.isDirectory()) type = "directory"; size = target.size; modifiedTimestamp = target.mtimeMs; } catch { const own = await fsp.lstat(entryPath); modifiedTimestamp = own.mtimeMs; } } else if (dirent.isDirectory()) { type = "directory"; const stat = await fsp.stat(entryPath); modifiedTimestamp = stat.mtimeMs; } else { const stat = await fsp.stat(entryPath); size = stat.size; modifiedTimestamp = stat.mtimeMs; } } catch { // Unreadable entry: still list it so the user sees it exists. } return { name: dirent.name, path: entryPath, type, size, modifiedTimestamp, linkTarget, hidden: isHiddenEntry(dirent.name), }; } async function listDirectory(dirPath) { const resolved = normalizeLocalPath(dirPath); const dirents = await fsp.readdir(resolved, { withFileTypes: true }); const entries = await Promise.all( dirents.map((dirent) => describeEntry(resolved, dirent)), ); const parent = path.dirname(resolved); return { path: resolved, parent: parent === resolved ? null : parent, entries, }; } // Expands a set of dropped local paths into the flat list of files (with // paths relative to the drop root) plus any empty directories, mirroring what // the browser's FileSystemEntry walker produces for OS drops. async function walkPaths(rootPaths) { const files = []; const emptyDirs = []; let totalBytes = 0; async function walkDir(absDir, relDir) { const dirents = await fsp.readdir(absDir, { withFileTypes: true }); if (dirents.length === 0) { emptyDirs.push(relDir); return; } for (const dirent of dirents) { const abs = path.join(absDir, dirent.name); const rel = `${relDir}/${dirent.name}`; if (dirent.isDirectory()) { await walkDir(abs, rel); } else if (dirent.isFile()) { const stat = await fsp.stat(abs); files.push({ localPath: abs, relativePath: rel, size: stat.size }); totalBytes += stat.size; } else if (dirent.isSymbolicLink()) { // Upload what the link points at, if it is a regular file. try { const stat = await fsp.stat(abs); if (stat.isFile()) { files.push({ localPath: abs, relativePath: rel, size: stat.size }); totalBytes += stat.size; } } catch { // dangling link: skip } } } } for (const rootPath of rootPaths) { const abs = normalizeLocalPath(rootPath); const stat = await fsp.stat(abs); const name = path.basename(abs); if (stat.isDirectory()) { await walkDir(abs, name); } else if (stat.isFile()) { files.push({ localPath: abs, relativePath: name, size: stat.size }); totalBytes += stat.size; } } return { files, emptyDirs, totalBytes }; } function toHeaderMap(headers) { const out = {}; if (!headers || typeof headers !== "object") return out; for (const [key, value] of Object.entries(headers)) { if (value === undefined || value === null) continue; out[key] = String(value); } return out; } function makeProgressReporter(sender, transferId) { let lastSentAt = 0; return (transferred, total, force = false) => { const now = Date.now(); if (!force && now - lastSentAt < PROGRESS_INTERVAL_MS) return; lastSentAt = now; if (sender.isDestroyed()) return; sender.send(IPC.PROGRESS, { transferId, transferred, total }); }; } function collectBody(response) { return new Promise((resolve) => { const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); response.on("error", () => resolve("")); }); } function describeHttpError(statusCode, bodyText) { try { const parsed = JSON.parse(bodyText); if (parsed && typeof parsed.error === "string") return parsed.error; if (parsed && typeof parsed.message === "string") return parsed.message; } catch { // not JSON } return bodyText?.trim() || `Request failed with status ${statusCode}`; } function writeToRequest(request, chunk) { return new Promise((resolve, reject) => { try { request.write(chunk, () => resolve()); } catch (error) { reject(error); } }); } const TRANSFER_ROUTES = Object.freeze({ uploadFileStream: "/uploadFileStream", downloadFileStream: "/downloadFileStream", }); const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; // A compact JWT (base64url segments joined by dots); anything else is refused. const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; // The plugin that serves the two streaming routes tells us its /plugin-api/ // path, so nothing here names it. Only a /plugin-api/ path is accepted, // which keeps every transfer on the backend's plugin routes. const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; const LOCAL_BACKEND = "http://localhost:30001"; let registeredApiPath = null; function setTransferApiPath(apiPath) { if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) { throw new Error("Invalid transfer API path"); } registeredApiPath = apiPath; } function normalizeHttpBase(candidate, what) { let parsed; try { parsed = new URL(String(candidate || "")); } catch { throw new Error(`${what} is not a valid URL`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`${what} must use http or https`); } return parsed.toString().replace(/\/$/, ""); } // Resolves where a transfer may go. Only the two file-manager streaming // routes are reachable, and only on the embedded backend or the linked // server; credentials come from the main process, not the caller. function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { const resolveApiPath = () => { const resolved = apiPath ?? registeredApiPath; if (!resolved) throw new Error("Local file transfers are not available yet"); return resolved; }; return function resolveTransferTarget({ origin, route, deviceId, authToken, } = {}) { const routePath = TRANSFER_ROUTES[route]; if (!routePath) { throw new Error(`Unknown transfer route: ${String(route)}`); } const headers = { "X-Electron-App": "true" }; if (deviceId !== undefined && deviceId !== null && deviceId !== "") { if (typeof deviceId !== "string" || !DEVICE_ID_PATTERN.test(deviceId)) { throw new Error("Invalid device id"); } headers["X-Termix-Device-ID"] = deviceId; } if (origin === "local") { // The desktop renderer authenticates against the embedded backend with // the token it keeps in localStorage (see the Electron branch of the // axios request interceptor), and the `jwt` cookie only exists for a // while after an interactive login. Send that same token here, so a // transfer does not depend on a cookie the rest of the app no longer // needs; the session cookie still rides along as a fallback. The // renderer already uses this token on every request it makes itself, // so forwarding it grants nothing new, and the URL stays ours. if (authToken !== undefined && authToken !== null && authToken !== "") { if ( typeof authToken !== "string" || authToken.length > 8192 || !AUTH_TOKEN_PATTERN.test(authToken) ) { throw new Error("Invalid auth token"); } headers.Authorization = `Bearer ${authToken}`; } return { url: `${normalizeHttpBase( localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, "Local backend URL", )}${routePath}`, headers, }; } if (origin === "remote") { const linked = typeof getLinkedServer === "function" ? getLinkedServer() : null; if (!linked || !linked.serverUrl) { throw new Error("This device is not linked to a server"); } const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); if (linked.token) headers.Authorization = `Bearer ${linked.token}`; return { url: `${base}${resolveApiPath()}${routePath}`, headers }; } throw new Error(`Unknown transfer origin: ${String(origin)}`); }; } function createNetRequest(net, event, method, url) { return net.request({ method, url, session: event.sender.session, useSessionCookies: true, }); } // Streams one local file to the backend's multipart `uploadFileStream` route. async function uploadLocalFile({ net, resolveTransferTarget }, event, options) { const { transferId, origin, deviceId, authToken, fields, localPath, fileName, } = options || {}; if (!transferId || !localPath) { throw new Error("Missing upload parameters"); } const { url, headers } = resolveTransferTarget({ origin, route: "uploadFileStream", deviceId, authToken, }); const absPath = normalizeLocalPath(localPath); const stat = await fsp.stat(absPath); if (!stat.isFile()) { throw new Error("Only regular files can be uploaded"); } const boundary = `----TermixLocalUpload${Date.now()}${Math.random() .toString(36) .slice(2)}`; const safeName = String(fileName || path.basename(absPath)) .replace(/[\r\n]/g, " ") .replace(/"/g, "%22"); let preamble = ""; for (const [key, value] of Object.entries(fields || {})) { if (value === undefined || value === null) continue; preamble += `--${boundary}\r\n` + `Content-Disposition: form-data; name="${key}"\r\n\r\n` + `${String(value)}\r\n`; } preamble += `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="${safeName}"\r\n` + `Content-Type: application/octet-stream\r\n\r\n`; const epilogue = `\r\n--${boundary}--\r\n`; const preambleBuffer = Buffer.from(preamble, "utf8"); const epilogueBuffer = Buffer.from(epilogue, "utf8"); const request = createNetRequest(net, event, "POST", url); for (const [key, value] of Object.entries(toHeaderMap(headers))) { request.setHeader(key, value); } request.setHeader( "Content-Type", `multipart/form-data; boundary=${boundary}`, ); // Without chunked encoding Electron buffers the whole body in the main // process before sending; chunked keeps memory flat for multi-GB files. // Busboy on the backend parses the multipart stream incrementally either way. request.chunkedEncoding = true; const report = makeProgressReporter(event.sender, transferId); const readStream = fs.createReadStream(absPath, { highWaterMark: READ_CHUNK_BYTES, }); const state = { cancelled: false, abort: () => { state.cancelled = true; readStream.destroy(); request.abort(); }, }; activeTransfers.set(transferId, state); // The server may answer early (auth failure, missing session) while the // body is still streaming; stop pushing bytes as soon as it does. let responded = false; const responsePromise = new Promise((resolve, reject) => { request.on("response", async (response) => { responded = true; const bodyText = await collectBody(response); resolve({ statusCode: response.statusCode, bodyText }); }); request.on("error", (error) => reject(error)); request.on("abort", () => reject(new Error("Transfer cancelled"))); }); // Avoid an unhandled rejection if we bail out before awaiting below. responsePromise.catch(() => {}); // A write callback never fires once the request has errored or been // aborted, so every write races against the response promise's rejection. const write = (chunk) => Promise.race([writeToRequest(request, chunk), responsePromise]); try { await write(preambleBuffer); let sent = 0; for await (const chunk of readStream) { if (state.cancelled) throw new Error("Transfer cancelled"); if (responded) break; await write(chunk); sent += chunk.length; report(sent, stat.size); } if (!responded) { await write(epilogueBuffer); request.end(); } const { statusCode, bodyText } = await responsePromise; if (statusCode < 200 || statusCode >= 300) { throw new Error(describeHttpError(statusCode, bodyText)); } report(stat.size, stat.size, true); return { success: true, bytes: stat.size }; } catch (error) { readStream.destroy(); throw error; } finally { activeTransfers.delete(transferId); } } class LocalFileError extends Error { constructor(code, message) { super(message); this.name = "LocalFileError"; this.code = code; } } // Destinations with a download in flight, so two transfers can never race // each other onto the same file. const activeDestinations = new Set(); function partialPathFor(absDest, transferId) { const token = String(transferId) .replace(/[^A-Za-z0-9_-]/g, "") .slice(0, 48); return `${absDest}.${token || "transfer"}.termix-part`; } // File operations used to publish a download, injectable so the replace // strategy can be tested against Windows-like semantics (where rename() onto // an existing name fails) without running on Windows. const defaultPublishFs = Object.freeze({ rename: (from, to) => fsp.rename(from, to), link: (from, to) => fsp.link(from, to), copyFileExcl: (from, to) => fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL), rm: (target) => fsp.rm(target, { force: true }), lstat: (target) => fsp.lstat(target), }); function replacedPathFor(absDest, transferId) { const token = String(transferId) .replace(/[^A-Za-z0-9_-]/g, "") .slice(0, 48); return `${absDest}.${token || "transfer"}.termix-replaced`; } // Puts `sourcePath` under `absDest` without ever replacing an existing file: // link() is atomic and fails with EEXIST if the name is taken; filesystems // without hard links fall back to an exclusive copy. The source is removed // once the destination exists. async function publishExclusive(io, sourcePath, absDest) { const exists = () => new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`); try { await io.link(sourcePath, absDest); } catch (error) { if (error && error.code === "EEXIST") throw exists(); try { await io.copyFileExcl(sourcePath, absDest); } catch (copyError) { if (copyError && copyError.code === "EEXIST") throw exists(); throw copyError; } } await io.rm(sourcePath); } // Replaces `absDest` with the finished partial. rename() over an existing // file is not portable: POSIX replaces it, but on Windows the call commonly // fails (EEXIST / EPERM, always when the file is open), so the swap never // renames onto an occupied name. Instead: // 1. move the current file aside to a transfer-unique sibling // (renaming to a fresh name is safe everywhere); // 2. publish the partial exclusively under the now-free name; // 3. delete the aside copy. // If step 1 fails nothing has changed and the caller gets EBUSY. If step 2 // fails the aside copy is moved back, so the original survives. async function replaceExisting(io, partialPath, absDest, transferId) { let current; try { current = await io.lstat(absDest); } catch (error) { if (error && error.code === "ENOENT") { // Nothing to replace after all (the file went away meanwhile). await publishExclusive(io, partialPath, absDest); return; } throw error; } if (current.isDirectory()) { throw new LocalFileError( "EISDIR", `"${path.basename(absDest)}" is a folder and cannot be replaced by a file`, ); } const asidePath = replacedPathFor(absDest, transferId); try { await io.rename(absDest, asidePath); } catch (error) { if ( error && (error.code === "EPERM" || error.code === "EBUSY" || error.code === "EACCES") ) { throw new LocalFileError( "EBUSY", `"${path.basename(absDest)}" is in use and could not be replaced`, ); } throw error; } try { await publishExclusive(io, partialPath, absDest); } catch (error) { // Put the original back under its name; the partial is cleaned up by // the caller. await io.rm(absDest).catch(() => {}); await io.rename(asidePath, absDest).catch(() => {}); throw error; } // The old contents are no longer reachable under the real name; removing // the aside copy can still fail on Windows if another process holds it // open, so retry once before giving up and leaving it for the user. try { await io.rm(asidePath); } catch { await new Promise((resolve) => setTimeout(resolve, 100)); await io.rm(asidePath).catch((error) => { console.warn( `[local-files] replaced "${absDest}" but could not remove the previous copy at "${asidePath}": ${error && error.message}`, ); }); } } // Moves a finished partial file onto its final name. Without `overwrite` the // publish is exclusive: an existing file is never replaced, even if it // appeared while the download was running. With `overwrite` the existing file // is swapped out in a way that also works on Windows. async function publishDownload( partialPath, absDest, overwrite, transferId, io = defaultPublishFs, ) { if (overwrite) { await replaceExisting(io, partialPath, absDest, transferId); return; } await publishExclusive(io, partialPath, absDest); } // Streams one remote file (via the backend's `downloadFileStream` route) into // a local destination. Bytes go to a transfer-unique temp sibling first and // are published under the real name only on success, so a failed transfer // never leaves a truncated file behind. Existing files are refused unless // the caller explicitly asked to overwrite. async function downloadToLocal( { net, resolveTransferTarget, publishFs }, event, options, ) { const { transferId, origin, deviceId, authToken, body, destPath, rootPath, expectedSize, } = options || {}; const overwrite = options?.overwrite === true; if (!transferId || !destPath) { throw new Error("Missing download parameters"); } const { url, headers } = resolveTransferTarget({ origin, route: "downloadFileStream", deviceId, authToken, }); // The renderer builds destPath from remote names; never trust that it // stayed inside the folder the user picked. const destination = await prepareDownloadPath(rootPath, destPath); const absDest = destination.path; if (activeDestinations.has(absDest)) { throw new LocalFileError( "EBUSY", `"${path.basename(absDest)}" is already being downloaded`, ); } activeDestinations.add(absDest); const partialPath = partialPathFor( path.join(destination.root, path.basename(absDest)), transferId, ); const report = makeProgressReporter(event.sender, transferId); const state = { cancelled: false, abort: () => { state.cancelled = true; request.abort(); }, }; let request = null; try { if (!overwrite && (await pathExists(absDest))) { throw new LocalFileError( "EEXIST", `"${path.basename(absDest)}" already exists`, ); } request = createNetRequest(net, event, "POST", url); for (const [key, value] of Object.entries(toHeaderMap(headers))) { request.setHeader(key, value); } const payload = Buffer.from(JSON.stringify(body || {}), "utf8"); request.setHeader("Content-Type", "application/json"); // No explicit Content-Length: Electron's net module forbids apps from // setting it (the request fails with net::ERR_INVALID_ARGUMENT) and // computes it itself from the buffered body when chunked encoding is off. activeTransfers.set(transferId, state); await new Promise((resolve, reject) => { let settled = false; const fail = (error) => { if (settled) return; settled = true; reject(error); }; request.on("error", fail); request.on("abort", () => fail(new Error("Transfer cancelled"))); request.on("response", (response) => { const statusCode = response.statusCode; if (statusCode < 200 || statusCode >= 300) { collectBody(response).then((bodyText) => fail(new Error(describeHttpError(statusCode, bodyText))), ); return; } const lengthHeader = response.headers["content-length"]; const total = Number( Array.isArray(lengthHeader) ? lengthHeader[0] : lengthHeader, ) || Number(expectedSize) || undefined; // "wx": the temp name is ours alone; refuse to reuse a stale one. const writeStream = fs.createWriteStream(partialPath, { flags: "wx" }); let received = 0; response.on("data", (chunk) => { received += chunk.length; const ok = writeStream.write(chunk); if (!ok) { response.pause(); writeStream.once("drain", () => response.resume()); } report(received, total); }); response.on("end", () => { writeStream.end(() => { report(received, total ?? received, true); if (settled) return; settled = true; resolve(); }); }); response.on("error", (error) => { writeStream.destroy(); fail(error); }); writeStream.on("error", (error) => { request.abort(); fail(error); }); }); request.end(payload); }); await prepareDownloadPath(destination.root, absDest); await publishDownload( partialPath, absDest, overwrite, transferId, publishFs || defaultPublishFs, ); return { success: true, path: absDest }; } catch (error) { await fsp.rm(partialPath, { force: true }).catch(() => {}); throw error; } finally { activeTransfers.delete(transferId); activeDestinations.delete(absDest); } } function wrap(handler) { return async (event, ...args) => { try { const result = await handler(event, ...args); return { success: true, ...(result || {}) }; } catch (error) { return { success: false, error: error && error.message ? error.message : String(error), code: error && error.code ? error.code : undefined, }; } }; } // Builds the IPC handler map from injected dependencies so the whole boundary // can be exercised in tests with a Node http shim instead of Electron. function createLocalFileHandlers({ net, shell, getLinkedServer, localBaseUrl, publishFs, }) { if (!net || typeof net.request !== "function") { throw new Error("createLocalFileHandlers requires a net implementation"); } const resolveTransferTarget = createTargetResolver({ localBaseUrl, getLinkedServer, }); const deps = { net, resolveTransferTarget, publishFs: publishFs || defaultPublishFs, }; return { [IPC.SET_API]: wrap(async (_event, apiPath) => { setTransferApiPath(apiPath); return {}; }), [IPC.HOME]: wrap(async () => ({ home: os.homedir(), separator: path.sep, platform: process.platform, })), [IPC.LIST]: wrap(async (_event, dirPath) => listDirectory(dirPath)), [IPC.MKDIR]: wrap(async (_event, parentPath, name) => { const parent = normalizeLocalPath(parentPath); const safeName = requireEntryName(name, "folder name"); const target = path.join(parent, safeName); await fsp.mkdir(target); return { path: target }; }), [IPC.CREATE_FILE]: wrap(async (_event, parentPath, name) => { const parent = normalizeLocalPath(parentPath); const safeName = requireEntryName(name, "file name"); const target = path.join(parent, safeName); // "wx" fails if the file already exists rather than truncating it. await fsp.writeFile(target, "", { flag: "wx" }); return { path: target }; }), [IPC.RENAME]: wrap(async (_event, oldPath, newName) => { const source = normalizeLocalPath(oldPath); const safeName = requireEntryName(newName, "name"); const target = path.join(path.dirname(source), safeName); if (target === source) return { path: source }; if (await pathExists(target)) { throw new LocalFileError("EEXIST", `"${safeName}" already exists`); } await fsp.rename(source, target); return { path: target }; }), // Moves entries to the OS trash (Finder Trash / Recycle Bin) rather than // deleting outright, so a mis-click in the file manager is recoverable. [IPC.TRASH]: wrap(async (_event, targetPaths) => { if (!Array.isArray(targetPaths) || targetPaths.length === 0) { throw new Error("No local paths provided"); } const failed = []; for (const candidate of targetPaths) { const target = normalizeLocalPath(candidate); try { await shell.trashItem(target); } catch (error) { failed.push({ path: target, error: error && error.message ? error.message : String(error), }); } } return { trashed: targetPaths.length - failed.length, failed }; }), [IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => { let target = normalizeLocalPath(dirPath); // Transfers pass the folder the user picked; the directory skeleton of // a downloaded tree must stay inside it. if (rootPath !== undefined) { target = (await prepareDownloadPath(rootPath, target, true)).path; } else { await fsp.mkdir(target, { recursive: true }); } return { path: target }; }), // Which of the given paths already exist; lets the renderer ask about // collisions before a batch download starts. [IPC.EXISTS]: wrap(async (_event, targetPaths) => { if (!Array.isArray(targetPaths)) { throw new Error("Expected a list of paths"); } const existing = []; for (const candidate of targetPaths) { const target = normalizeLocalPath(candidate); if (await pathExists(target)) existing.push(target); } return { existing }; }), [IPC.WALK]: wrap(async (_event, rootPaths) => { if (!Array.isArray(rootPaths) || rootPaths.length === 0) { throw new Error("No local paths provided"); } return walkPaths(rootPaths); }), [IPC.REVEAL]: wrap(async (_event, targetPath) => { shell.showItemInFolder(normalizeLocalPath(targetPath)); }), [IPC.OPEN]: wrap(async (_event, targetPath) => { const error = await shell.openPath(normalizeLocalPath(targetPath)); if (error) throw new Error(error); }), [IPC.UPLOAD]: wrap((event, options) => uploadLocalFile(deps, event, options), ), [IPC.DOWNLOAD]: wrap((event, options) => downloadToLocal(deps, event, options), ), [IPC.CANCEL]: wrap(async (_event, transferId) => { const state = activeTransfers.get(transferId); if (!state) return { cancelled: false }; state.abort(); return { cancelled: true }; }), }; } function registerLocalFileHandlers({ ipcMain, shell }) { // Real Electron wiring; tests build the handlers directly instead. const { net } = require("electron"); const { getLinkedServer } = require("./linked-server.cjs"); const handlers = createLocalFileHandlers({ net, shell, getLinkedServer, }); for (const [channel, handler] of Object.entries(handlers)) { ipcMain.handle(channel, handler); } } module.exports = { IPC, TRANSFER_ROUTES, registerLocalFileHandlers, // exported for tests / reuse createLocalFileHandlers, createTargetResolver, setTransferApiPath, publishDownload, defaultPublishFs, assertWithinRoot, walkPaths, listDirectory, };