mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-27 21:55:24 +00:00
* fix: keep concurrent ssh pool connects from overwriting each other (#1619) * fix: stop auto-accepting changed jump host keys (#1620) A jump hop whose key no longer matched the pinned fingerprint was silently accepted and the pin overwritten. Background status probes reconnect to jump hosts every cycle, so a MITM on that hop got the credentials with no user interaction. Changed jump host keys now go through the same path as any other host: prompt when there is a terminal socket, otherwise refuse. * fix(electron): validate external login URL scheme and callback port (#1621) * fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622) The consent check read the first manifest.json with a hand-written tar reader, while the unpack keeps the last one and also follows pax and long-name headers. An archive with two manifests could pass the check with one capability list and get granted another. Read the manifest with node-tar, count only plain files, and refuse archives that hold more than one manifest.json. * fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. * fix: check trusted proxy login against nginx's peer, not nginx (#1625) The backend only sees the bundled nginx on loopback, so the allowlist could only work by trusting 127.0.0.1, which let any client that reached nginx log in as anyone by sending the identity headers. nginx now passes its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and loopback connections are judged by that address instead. * fix: keep foreign keys on during user data import (#1626) Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path. * fix: enforce unique usernames (#1628) * fix: enforce unique usernames Registration checked for a taken name, hashed the password, then inserted, with nothing in the schema enforcing uniqueness. Concurrent sign-ups could create two accounts with one name, after which login, password reset, proxy login and admin lookups by username picked between them arbitrarily. Add a unique index on users.username for every dialect. Existing duplicates are renamed (later rows get a -<id> suffix, the oldest keeps the name) rather than deleted. Registration and admin user creation now return the same 409 when the insert loses the race. * fix: avoid username collisions during migration * fix: don't auto-enable synced plugins signed only by a server-sent key (#1623) * fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys * fix(desktop): harden external editor and session cookie IPC (#1627) * fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie open-external-editor now launches only the editor saved from a file dialog and refuses to hand executable file types to the OS opener. The unused get-session-cookie and wait-session-cookie channels, which returned the httpOnly jwt, are removed. * fix: validate setting keys before editor authorization * fix(sidebar): sort hosts by IP address numerically (#1629) * fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. * fix(sync): send shared hosts with text columns in their stored form (#1631) resolveHostById turns forceKeyboardInteractive into a boolean, and the shared-host export passed it on as is. A desktop could not bind the boolean into the text column, so every shared host failed to apply. * fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632) * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint * fix(i18n): keep Indonesian technical terms consistent (#1636) Use the English terms Indonesian users know for tunnel, snippet, deploy, file manager, remote desktop, jump host, passphrase, sidebar, toolbar, endpoint, browser and SSH agent, instead of mixing literal translations (terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the English words. Match the split shortcuts to the split screen menu. Also fix the user data export description, which said secrets are encrypted while the English says they are decrypted. * fix(settings): leave user manage view when switching admin sections (#1649) The admin panel stays mounted across Settings pages, so an open user manage view kept replacing every other admin section until Back to users was clicked. Reset it whenever the selected section changes. * fix(sync): don't delete a local row when the server can't send its copy (#1650) * fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records * fix: smooth host editor jumps to lower sections * chore: add changelog entries for 26.10.2 fixes * fix: stop building ssh2 native crypto in the docker image (#1644) * chore: test that an update keeps a changed auth type (#1601) * feat: option to hide the sidebar after opening a host (#1652) * feat: sort the dashboard host status card (Plugin-Host-Metrics#6) * fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) * fix: say the server is down and keep retrying instead of a database error (#1637) * fix: show why a client tunnel failed to start (Plugin-Tunnels#2) * fix(sdk): skip comment-only chunks when splitting migrations * feat: setting to let the hover side menu push content again * fix: use the vite proxy for plugin sockets on any dev port * feat(sdk): let a plugin raise its public route rate limit * chore: update translations * chore: add the overview video to the readme * chore: update the overview video and add a play button to its thumbnail * feat: plugin builds bundle worker entries and import wasm and fonts as bytes * feat: plugin packs include LICENSE and NOTICE * chore: drop guacd from compose files and dev runner * chore: update remote desktop origin comment for the built-in engine * fix: show plugin names and descriptions in the UI language (#1658) * fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com> * fix(hosts): reject or infer a missing authType on host create (#1664) Creating an SSH host through the API without authType hit the NOT NULL auth_type column and came back as a 500. The create route now infers the type from the credential, key or password in the body, falls back to the host defaults, and otherwise answers 400. A non-string authType is a 400 on create and update. * chore: changelog for merged plugin name and host auth type PRs * fix: move macOS split pane navigation to Cmd+Option+Arrow * fix: pick a free port for the desktop backend when 30001 is taken (#1663) * feat: build the Windows desktop app for ARM64 (#1615) * fix(sync): keep the changes cursor within the 32-bit seq column (#1665) The desktop learned the server's entity types by asking for changes after Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres, which rejects that bound value, so every pass needing the probe failed with 22003 out of range. The server now clamps the cursor to the largest seq the column holds and answers 400 for a cursor or limit that is not a non-negative integer. The desktop asks for a one-row page from its saved cursor instead. * fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667) Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time. * feat(plugins): publish host.created when a host is added (#1668) Plugins that keep an inventory of hosts had no way to hear about a new one short of polling. Core now publishes host.created with the same { hostId, userId } payload as host.updated wherever a host row is created: the host create route, ctx.hosts.create, JSON and SSH config bulk import, database file import, and sync writing a host it did not have yet. * fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. * fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. * feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666) ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background work a session for calling Termix's own API as an existing user. It needs users:impersonate, defaults to 300s and is capped at 900s, is stored as a normal session row marked as plugin-issued, and every issue is audited. Such sessions are refused on routes that change the user's password, second factors, API keys or linked logins. ctx.users.getLocale(userId) reads the user's UI language under users:read. SDK 1.0.11, plugin API 1.3.0. * chore: changelog for merged sync, audit, event and desktop PRs * feat: add shell.rightEdge slot so plugin panels can push the page over * chore: pin bundled plugins to the latest releases * chore: lint, format, and bump version to 26.10.2 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com> Co-authored-by: ZacharyZcR <zkl205305@gmail.com> Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
1065 lines
33 KiB
JavaScript
1065 lines
33 KiB
JavaScript
// 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/<id> 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-]*$/;
|
|
let localBackendPort = 30001;
|
|
|
|
function setLocalBackendPort(port) {
|
|
localBackendPort = port;
|
|
}
|
|
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 ??
|
|
`http://localhost:${localBackendPort}${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,
|
|
setLocalBackendPort,
|
|
publishDownload,
|
|
defaultPublishFs,
|
|
assertWithinRoot,
|
|
walkPaths,
|
|
listDirectory,
|
|
};
|