/** * Compress Upload Bridge - Handles folder compression and upload * * Compresses folders locally using tar, uploads the archive, then extracts on remote server */ const fs = require("node:fs"); const path = require("node:path"); const { createHash } = require("node:crypto"); const { spawn } = require("node:child_process"); const { StringDecoder } = require("node:string_decoder"); const { getTempFilePath } = require("./tempDirBridge.cjs"); const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs"); /** * Escape shell arguments to prevent injection attacks * Wraps arguments in single quotes and escapes any existing single quotes */ function escapeShellArg(arg) { // Replace single quotes with '\'' (end quote, escaped quote, start quote) return "'" + arg.replace(/'/g, "'\\''") + "'"; } function buildAtomicRemoteExtractionCommand({ compressionId, archivePath, targetDir, folderName, }) { const normalizedFolderName = path.posix.basename(String(folderName || "").replace(/\\/g, "/")); if (!normalizedFolderName || normalizedFolderName === "." || normalizedFolderName === "..") { throw new Error("Invalid compressed upload folder name"); } // Recovery artifacts are stable for one destination, not one attempt. A // failed post-commit cleanup is therefore reconciled by the next upload // instead of leaving one new backup per compression id. const suffix = createHash("sha256") .update(`${targetDir}\0${normalizedFolderName}`) .digest("hex") .slice(0, 16); const stageDir = path.posix.join(targetDir, `.netcatty-compress-${suffix}.stage`); const backupDir = path.posix.join(targetDir, `.netcatty-compress-${suffix}.backup`); const finalDir = path.posix.join(targetDir, normalizedFolderName); const stagedFinalDir = path.posix.join(stageDir, normalizedFolderName); return [ "set -e", `archive=${escapeShellArg(archivePath)}`, `stage=${escapeShellArg(stageDir)}`, `backup=${escapeShellArg(backupDir)}`, `final=${escapeShellArg(finalDir)}`, `staged_final=${escapeShellArg(stagedFinalDir)}`, "rollback_compressed_upload() {", " status=$1", " trap - EXIT HUP INT TERM", " if [ ! -e \"$final\" ] && [ ! -L \"$final\" ] && { [ -e \"$backup\" ] || [ -L \"$backup\" ]; }; then", " mv -- \"$backup\" \"$final\" 2>/dev/null || true", " fi", " make_tree_writable \"$stage\"", " rm -rf -- \"$stage\"", " rm -f -- \"$archive\"", " exit \"$status\"", "}", "make_tree_writable() {", " candidate=$1", " if [ -d \"$candidate\" ] && [ ! -L \"$candidate\" ]; then chmod -R u+w -- \"$candidate\" 2>/dev/null || true; fi", "}", "trap 'rollback_compressed_upload $?' EXIT", "trap 'rollback_compressed_upload 129' HUP", "trap 'rollback_compressed_upload 130' INT", "trap 'rollback_compressed_upload 143' TERM", // Recover a prior interrupted promotion before touching either artifact. "if [ ! -e \"$final\" ] && [ ! -L \"$final\" ] && { [ -e \"$backup\" ] || [ -L \"$backup\" ]; }; then mv -- \"$backup\" \"$final\"; fi", "if [ -L \"$final\" ]; then echo 'Refusing to replace a symlink target' >&2; exit 1; fi", "if [ -e \"$backup\" ] || [ -L \"$backup\" ]; then make_tree_writable \"$backup\"; rm -rf -- \"$backup\"; fi", "make_tree_writable \"$stage\"", "rm -rf -- \"$stage\"", "mkdir -p -- \"$stage\"", // Preserve merge semantics without exposing the live destination to tar. "if [ -e \"$final\" ]; then", " if ! cp -a -- \"$final\" \"$staged_final\" 2>/dev/null; then", " rm -rf -- \"$staged_final\"", " cp -Rp -- \"$final\" \"$staged_final\"", " fi", "fi", "tar -xzf \"$archive\" -C \"$stage\" --exclude='._*' --exclude='.DS_Store'", "if [ ! -d \"$staged_final\" ]; then echo 'Compressed archive did not contain the expected folder' >&2; exit 1; fi", "if [ -e \"$final\" ]; then mv -- \"$final\" \"$backup\"; fi", "mv -- \"$staged_final\" \"$final\"", "make_tree_writable \"$backup\"", "make_tree_writable \"$stage\"", "rm -rf -- \"$backup\" \"$stage\"", "rm -f -- \"$archive\"", "trap - EXIT HUP INT TERM", ].join("\n"); } // Shared references let sftpClients = null; let transferBridge = null; // Active compress operations const activeCompressions = new Map(); const workerCompressionLifecycleEpochs = new Map(); const compressionSupportCache = new Map(); const compressionTargetLocks = new Map(); const COMPRESSION_SUPPORT_CACHE_TTL_MS = 10_000; const MAX_COMPRESSION_SUPPORT_CACHE_ENTRIES = 64; const REMOTE_TAR_PROBE_TIMEOUT_MS = 15_000; const REMOTE_CLEANUP_TIMEOUT_MS = 15_000; const LOCAL_TAR_PROBE_TIMEOUT_MS = 10_000; const LOCAL_TAR_KILL_GRACE_MS = 750; const MAX_REMOTE_EXEC_STDERR_BYTES = 64 * 1024; const MAX_LOCAL_TAR_STDERR_BYTES = 64 * 1024; async function runWithCompressionTargetLock(targetKey, signal, operation) { const predecessor = compressionTargetLocks.get(targetKey) || Promise.resolve(); let release; const ownGate = new Promise((resolve) => { release = resolve; }); const ownTail = predecessor.catch(() => {}).then(() => ownGate); compressionTargetLocks.set(targetKey, ownTail); let removeAbortListener = () => {}; try { if (signal) { const aborted = new Promise((_, reject) => { const onAbort = () => reject( signal.reason instanceof Error ? signal.reason : new Error("Upload cancelled"), ); if (signal.aborted) onAbort(); else { signal.addEventListener("abort", onAbort, { once: true }); removeAbortListener = () => signal.removeEventListener("abort", onAbort); } }); await Promise.race([predecessor.catch(() => {}), aborted]); } else { await predecessor.catch(() => {}); } if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : new Error("Upload cancelled"); } return await operation(); } finally { removeAbortListener(); release(); void ownTail.finally(() => { if (compressionTargetLocks.get(targetKey) === ownTail) { compressionTargetLocks.delete(targetKey); } }); } } function buildCompressionTargetKey(connectionIdentity, targetPath, folderName) { if (!connectionIdentity) { throw new Error("SFTP connection identity unavailable for compressed upload"); } const normalizedFolderName = path.posix.basename(String(folderName || "").replace(/\\/g, "/")); if (!normalizedFolderName || normalizedFolderName === "." || normalizedFolderName === "..") { throw new Error("Invalid compressed upload folder name"); } const normalizedTarget = path.posix.normalize(path.posix.join( String(targetPath || ".").replace(/\\/g, "/"), normalizedFolderName, )); return `${connectionIdentity}\0${normalizedTarget}`; } function resolveCompressionTargetKey(sftpId, targetPath, folderName) { const client = sftpClients?.get?.(sftpId); const connectionIdentity = client?.__netcattyEndpointKey || client?.__netcattyRefHolder?.connRef?.endpointKey; return buildCompressionTargetKey(connectionIdentity, targetPath, folderName); } function createBoundedUtf8Collector(maxBytes) { const limit = Math.max(1, Number(maxBytes) || 1); const decoder = new StringDecoder("utf8"); let text = ""; let bytes = 0; let truncated = false; let ended = false; return { append(chunk) { if (ended) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); const remaining = Math.max(0, limit - bytes); const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining); if (accepted.length > 0) text += decoder.write(accepted); bytes += accepted.length; if (accepted.length < buffer.length) truncated = true; }, end() { if (ended) return text; ended = true; // If the byte cap cut through a code point, discard the held prefix // instead of turning it into a replacement character in diagnostics. if (!truncated || decoder.lastNeed === 0) text += decoder.end(); return text; }, value() { return text; }, }; } function terminateRemoteExecStream(stream) { if (!stream) return; try { stream.once?.('error', () => {}); } catch { /* ignore */ } try { stream.stderr?.once?.('error', () => {}); } catch { /* ignore */ } try { stream.close?.(); } catch { /* ignore */ } try { stream.end?.(); } catch { /* ignore */ } try { stream.destroy?.(); } catch { /* ignore */ } } function runRemoteExec(sshClient, command, options = {}) { const timeoutMs = Math.max(1, Number(options.timeoutMs) || REMOTE_CLEANUP_TIMEOUT_MS); const signal = options.signal; return new Promise((resolve, reject) => { let stream = null; let settled = false; const stderr = createBoundedUtf8Collector(MAX_REMOTE_EXEC_STDERR_BYTES); let hasOutput = false; const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener?.('abort', onAbort); }; const finish = (error, result) => { if (settled) return; settled = true; cleanup(); if (error) { terminateRemoteExecStream(stream); reject(error); } else { resolve(result); } }; const onAbort = () => { finish(signal?.reason instanceof Error ? signal.reason : new Error('Remote command cancelled')); if (!stream) invalidateSshTransport(sshClient); }; const timeout = setTimeout(() => { finish(new Error(`Remote command timed out after ${timeoutMs / 1000} seconds`)); if (!stream) invalidateSshTransport(sshClient); }, timeoutMs); if (signal?.aborted) { onAbort(); return; } signal?.addEventListener?.('abort', onAbort, { once: true }); try { sshClient.exec(command, (error, nextStream) => { if (settled) { terminateRemoteExecStream(nextStream); return; } if (error) { finish(error); return; } stream = nextStream; stream.on('data', () => { hasOutput = true; }); stream.stderr?.on?.('data', (data) => { stderr.append(data); }); stream.stderr?.once?.('error', (streamError) => finish(streamError)); stream.once('close', (code) => finish(null, { code, hasOutput, stderr: stderr.end() })); stream.once('error', (streamError) => finish(streamError)); }); } catch (error) { finish(error); } }); } function broadcastCompressionEvent(payload) { if (!payload?.transferId) return; try { transferBridge?.broadcastGlobalTransferEvent?.(payload); } catch { // Global UI fanout is best-effort; the job itself must keep running. } } function settleCompressionTerminal(compression, type, payload = {}) { if (!compression || compression.terminalState) return false; compression.terminalState = type; broadcastCompressionEvent({ type, transferId: compression.compressionId, endedAt: Date.now(), ...payload, }); return true; } function getCachedCompressionSupport(sftpId) { const entry = compressionSupportCache.get(sftpId); if (!entry) return null; if (entry.expiresAt <= Date.now()) { compressionSupportCache.delete(sftpId); return null; } compressionSupportCache.delete(sftpId); compressionSupportCache.set(sftpId, entry); return entry.value; } function cacheCompressionSupport(sftpId, value) { compressionSupportCache.delete(sftpId); compressionSupportCache.set(sftpId, { value, expiresAt: Date.now() + COMPRESSION_SUPPORT_CACHE_TTL_MS, }); while (compressionSupportCache.size > MAX_COMPRESSION_SUPPORT_CACHE_ENTRIES) { compressionSupportCache.delete(compressionSupportCache.keys().next().value); } return value; } async function resolveCompressedUploadSupport(sftpId, signal) { const cached = getCachedCompressionSupport(sftpId); if (cached) return cached; const localTar = await checkTarAvailable(signal); if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('Upload cancelled'); const remoteTar = localTar ? await checkRemoteTarAvailable(sftpId, signal) : false; if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('Upload cancelled'); return cacheCompressionSupport(sftpId, { supported: localTar && remoteTar, localTar, remoteTar, }); } function buildRemoteArchivePath(targetPath, folderName, compressionId) { const base = path.posix.basename(String(folderName || 'folder').replace(/\\/g, '/')) .replace(/[^A-Za-z0-9._-]/g, '_') || 'folder'; const suffix = createHash('sha256') .update(String(compressionId || 'compression')) .digest('hex') .slice(0, 16); return path.posix.join(targetPath, `.${base}.netcatty-${suffix}.tar.gz`); } function waitWhilePaused(compression) { if (!compression.paused || compression.cancelled) return Promise.resolve(); broadcastCompressionEvent({ type: 'paused', transferId: compression.compressionId, phase: compression.phase, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: 'paused', }); return new Promise((resolve) => compression.resumeWaiters.push(resolve)); } function releasePausedCompression(compression) { compression.paused = false; const waiters = compression.resumeWaiters.splice(0); for (const resolve of waiters) resolve(); } function clearCompressionProcess(compression, processHandle) { if (compression.process !== processHandle) return; compression.process = null; if (compression.processKillTimer) { clearTimeout(compression.processKillTimer); compression.processKillTimer = null; } } function terminateCompressionProcess(compression) { const processHandle = compression?.process; if (!processHandle) return; try { processHandle.kill('SIGTERM'); } catch { /* already exited */ } if (compression.processKillTimer) clearTimeout(compression.processKillTimer); compression.processKillTimer = setTimeout(() => { compression.processKillTimer = null; if (compression.process !== processHandle) return; try { processHandle.kill('SIGKILL'); } catch { /* already exited */ } }, LOCAL_TAR_KILL_GRACE_MS); compression.processKillTimer.unref?.(); } /** * Initialize the compress upload bridge with dependencies */ function init(deps) { sftpClients = deps.sftpClients; transferBridge = deps.transferBridge; } /** * Check if tar command is available on the system */ async function checkTarAvailable(signal) { return new Promise((resolve) => { const tar = spawn('tar', ['--version'], { stdio: 'ignore' }); let settled = false; let childClosed = false; let forceKillTimer = null; const finish = (available) => { if (settled) return; settled = true; clearTimeout(timeout); signal?.removeEventListener?.('abort', onAbort); resolve(available); }; const stop = () => { try { tar.kill('SIGTERM'); } catch { /* ignore */ } if (!childClosed) { forceKillTimer = setTimeout(() => { forceKillTimer = null; if (!childClosed) { try { tar.kill('SIGKILL'); } catch { /* ignore */ } } }, LOCAL_TAR_KILL_GRACE_MS); forceKillTimer.unref?.(); } finish(false); }; const onAbort = () => stop(); const timeout = setTimeout(stop, LOCAL_TAR_PROBE_TIMEOUT_MS); if (signal?.aborted) { stop(); return; } signal?.addEventListener?.('abort', onAbort, { once: true }); tar.on('close', (code) => { childClosed = true; if (forceKillTimer) clearTimeout(forceKillTimer); finish(code === 0); }); tar.on('error', () => { childClosed = true; if (forceKillTimer) clearTimeout(forceKillTimer); finish(false); }); }); } /** * Check if tar command is available on remote server */ async function checkRemoteTarAvailable(sftpId, signal) { try { const client = sftpClients.get(sftpId); if (!client) throw new Error("SFTP session not found"); // Try to execute tar --version via SSH const sshClient = client.client; // Get underlying SSH2 client if (!sshClient) throw new Error("SSH client not available"); const result = await runRemoteExec(sshClient, 'tar --version', { timeoutMs: REMOTE_TAR_PROBE_TIMEOUT_MS, signal, }); return result.code === 0 && result.hasOutput; } catch { return false; } } /** * Compress a folder using tar */ async function compressFolder(folderPath, outputPath, compressionId, sendProgress) { return new Promise((resolve, reject) => { const compression = activeCompressions.get(compressionId); if (!compression) { reject(new Error('Compression cancelled')); return; } // Use tar with gzip compression, excluding macOS resource fork files // -czf: create, gzip, file // -C: change to directory (so we don't include the full path in archive) // --exclude='._*': exclude macOS resource fork files // --exclude='.DS_Store': exclude macOS folder metadata files const folderName = path.basename(folderPath); const parentDir = path.dirname(folderPath); const tar = spawn('tar', [ '-czf', outputPath, '-C', parentDir, '--exclude=._*', '--exclude=.DS_Store', '--exclude=.Spotlight-V100', '--exclude=.Trashes', folderName ], { stdio: ['ignore', 'pipe', 'pipe'] }); compression.process = tar; const stderr = createBoundedUtf8Collector(MAX_LOCAL_TAR_STDERR_BYTES); // Monitor progress by checking output file size periodically const progressInterval = setInterval(async () => { if (compression.cancelled) { clearInterval(progressInterval); return; } try { const stat = await fs.promises.stat(outputPath); // We don't know the final size, so we'll show indeterminate progress sendProgress(stat.size, 0); // 0 means indeterminate } catch { // File doesn't exist yet, ignore } }, 500); tar.stderr.on('data', (data) => { stderr.append(data); }); tar.on('close', (code) => { clearInterval(progressInterval); clearCompressionProcess(compression, tar); if (compression.cancelled) { // Clean up output file if cancelled fs.promises.unlink(outputPath).catch(() => {}); reject(new Error('Compression cancelled')); return; } if (code === 0) { resolve(); } else { reject(new Error(`Tar compression failed: ${stderr.end()}`)); } }); tar.on('error', (err) => { clearInterval(progressInterval); clearCompressionProcess(compression, tar); reject(new Error(`Failed to start tar: ${err.message}`)); }); }); } /** * Extract archive on remote server * @param {string} sftpId - SFTP session ID * @param {string} archivePath - Path to the archive on remote server * @param {string} targetDir - Target directory for extraction * @param {number} [archiveSize] - Size of the archive in bytes (optional, for timeout calculation) */ async function extractRemoteArchive( sftpId, archivePath, targetDir, folderName, compressionId, archiveSize, signal, ) { const client = sftpClients.get(sftpId); if (!client) throw new Error("SFTP session not found"); const sshClient = client.client; if (!sshClient) throw new Error("SSH client not available"); // Calculate timeout based on archive size // Base: 60 seconds minimum // Add 30 seconds per 10MB of archive size // Maximum: 10 minutes to prevent excessively long waits const baseTimeout = 60000; // 60 seconds minimum const maxTimeout = 600000; // 10 minutes maximum const sizeBasedTimeout = archiveSize ? Math.ceil(archiveSize / (10 * 1024 * 1024)) * 30000 : 0; const extractionTimeout = Math.min(maxTimeout, Math.max(baseTimeout, baseTimeout + sizeBasedTimeout)); // Extract into a sibling staging directory, then atomically swap the complete // folder into place. Existing directory contents are copied into the stage so // compressed upload keeps its historical merge semantics. const command = buildAtomicRemoteExtractionCommand({ compressionId, archivePath, targetDir, folderName, }); let result; try { result = await runRemoteExec(sshClient, command, { timeoutMs: extractionTimeout, signal, }); } catch (error) { if (/timed out/i.test(error?.message || '')) { throw new Error(`Remote extraction timed out after ${extractionTimeout / 1000} seconds`); } throw new Error(`Remote extraction failed: ${error?.message || String(error)}`); } if (result.code === 0) return; throw new Error(`Remote extraction failed: ${result.stderr || `exit code ${result.code}`}`); } /** * Start compressed folder upload */ async function startCompressedUpload(event, payload) { const { compressionId, folderPath, targetPath, sftpId, folderName, totalBytes = 0, } = payload; if (activeCompressions.has(compressionId)) { throw new Error(`Compressed upload is already active: ${compressionId}`); } const sender = event.sender; // Register compression for cancellation const compression = { compressionId, cancelled: false, process: null, processKillTimer: null, phase: 'preparing', paused: false, lifecycleEpoch: Math.max(0, Number(payload.lifecycleEpoch) || 0), lifecycleState: 'transferring', terminalState: null, resumeWaiters: [], remoteExecAbortController: new AbortController(), }; activeCompressions.set(compressionId, compression); broadcastCompressionEvent({ type: 'started', transferId: compressionId, direction: 'upload', fileName: `${folderName} (compressed)`, sourcePath: folderPath, targetPath: `${targetPath}/${folderName}`, totalBytes, isDirectory: true, controlKind: 'compressed-upload', phase: 'compressing', startedAt: Date.now(), lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); const sendProgress = (phase, transferred, total) => { if (compression.cancelled) return; const ratio = total > 0 ? Math.max(0, Math.min(1, transferred / total)) : 0; const transferredBytes = Math.floor(ratio * totalBytes); sender.send("netcatty:compress:progress", { compressionId, phase, transferred, total, transferredBytes, totalBytes, fileName: `${folderName} (compressed)`, sourcePath: folderPath, targetPath: `${targetPath}/${folderName}`, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); broadcastCompressionEvent({ type: 'progress', transferId: compressionId, transferred: transferredBytes, totalBytes, speed: 0, phase, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); }; const sendComplete = () => { if (compression.terminalState) return; // Send final 100% progress before completion if (!compression.cancelled) { sendProgress('extracting', 100, 100); } if (!settleCompressionTerminal(compression, 'completed', { transferred: totalBytes, totalBytes, })) return; sender.send("netcatty:compress:complete", { compressionId }); }; const sendError = (error) => { if (!settleCompressionTerminal(compression, 'failed', { error: error.message || String(error), })) return; sender.send("netcatty:compress:error", { compressionId, error: error.message || String(error), }); }; const sendCancelled = () => { if (!settleCompressionTerminal(compression, 'cancelled')) return; sender.send("netcatty:compress:cancelled", { compressionId }); }; // Declare tempArchivePath in outer scope for cleanup access let tempArchivePath = null; const lifecycleLeaseId = `compress-lifecycle:${compressionId}`; let lifecycleLeasedSftpIds = []; try { if ( !transferBridge?.acquireTransferSessionLeases || !transferBridge?.releaseTransferSessionLeases ) { throw new Error("Transfer runtime session leasing is unavailable"); } // The archive upload has its own short-lived transfer lease, but probe, // compression, extraction and cleanup also use this SFTP/SSH handle. Hold // one outer lease so closing the browse tab soft-closes instead of tearing // down the connection halfway through the operation. lifecycleLeasedSftpIds = transferBridge.acquireTransferSessionLeases( lifecycleLeaseId, { targetSftpId: sftpId }, ); // Reuse the short-lived result produced by the renderer's support check. // A cache miss still performs one bounded, cancellable probe here. const support = await resolveCompressedUploadSupport( sftpId, compression.remoteExecAbortController.signal, ); if (!support.localTar) { throw new Error("tar command not available on local system. Please install tar."); } if (!support.remoteTar) { throw new Error("tar command not available on remote server. Please install tar on the remote system."); } // Phase 1: Compression (0-30%) compression.phase = 'compressing'; await waitWhilePaused(compression); sendProgress('compressing', 0, 100); tempArchivePath = getTempFilePath(`${folderName}.tar.gz`); await compressFolder(folderPath, tempArchivePath, compressionId, (transferred) => { // Show compression progress (0-30%) sendProgress('compressing', Math.min(30, transferred / 1024 / 1024), 100); }); if (compression.cancelled) { try { await fs.promises.unlink(tempArchivePath); } catch { // Ignore cleanup errors } throw new Error('Upload cancelled'); } // Get compressed file size const stat = await fs.promises.stat(tempArchivePath); const compressedSize = stat.size; sendProgress('compressing', 30, 100); // Phase 2: Upload (30-90%) compression.phase = 'uploading'; await waitWhilePaused(compression); sendProgress('uploading', 30, 100); const remoteArchivePath = buildRemoteArchivePath(targetPath, folderName, compressionId); // Use existing transfer bridge for upload with progress const transferId = `compress-${compressionId}`; // Progress callback to map upload progress to 30-90% const onUploadProgress = (transferred, total, _speed) => { if (compression.cancelled) return; compression.checkpointBytes = transferred; const uploadProgress = Math.min(60, (transferred / total) * 60); sendProgress('uploading', 30 + uploadProgress, 100); }; // Start the transfer with progress callback const uploadResult = await transferBridge.startInternalTransfer(event, { transferId, sourcePath: tempArchivePath, targetPath: remoteArchivePath, sourceType: 'local', targetType: 'sftp', targetSftpId: sftpId, totalBytes: compressedSize, resumable: true, checkpointBytes: compression.checkpointBytes || 0, }, onUploadProgress); if (uploadResult?.error) { throw new Error(uploadResult.error); } if (compression.cancelled) { await fs.promises.unlink(tempArchivePath).catch(() => {}); throw new Error('Upload cancelled'); } // Upload completed, update to 90% sendProgress('uploading', 90, 100); // Phase 3: Extraction (90-100%) compression.phase = 'extracting'; await waitWhilePaused(compression); sendProgress('extracting', 90, 100); await runWithCompressionTargetLock( resolveCompressionTargetKey(sftpId, targetPath, folderName), compression.remoteExecAbortController.signal, () => extractRemoteArchive( sftpId, remoteArchivePath, targetPath, folderName, compressionId, compressedSize, compression.remoteExecAbortController.signal, ), ); // Extraction is an atomic remote step. A pause requested during extraction // takes effect here, before completion is published. await waitWhilePaused(compression); // Update progress to 95% after extraction sendProgress('extracting', 95, 100); // Keep the outer SFTP lease until best-effort archive cleanup settles. // The extraction command already excludes macOS AppleDouble files. Never // run a parent-directory-wide find/delete here: that can remove unrelated, // pre-existing files whose names happen to begin with "._". try { const client = sftpClients.get(sftpId); if (client && client.client && client.client.writable !== false) { await runRemoteExec(client.client, `rm -f ${escapeShellArg(remoteArchivePath)}`, { timeoutMs: REMOTE_CLEANUP_TIMEOUT_MS, signal: compression.remoteExecAbortController.signal, }); } } catch { // Cleanup is best-effort; staging/promotion already committed atomically. } // Clean up local temp file try { await fs.promises.unlink(tempArchivePath); } catch { // Ignore cleanup errors } // Check if cancelled during extraction before reporting completion if (compression.cancelled) { sendCancelled(); return { compressionId, cancelled: true }; } sendComplete(); return { compressionId, success: true }; } catch (err) { // Clean up local temp file if it exists if (tempArchivePath) { try { await fs.promises.unlink(tempArchivePath); } catch { // Ignore cleanup errors } } if (err.message === 'Upload cancelled' || err.message === 'Compression cancelled' || err.message === 'Transfer cancelled') { sendCancelled(); } else { sendError(err.message || 'Unknown error occurred'); } return { compressionId, error: err.message }; } finally { // Always clean up the active compression entry try { compression.remoteExecAbortController.abort(new Error('Compression finished')); } catch { /* ignore */ } if (lifecycleLeasedSftpIds.length > 0) { transferBridge.releaseTransferSessionLeases( lifecycleLeaseId, lifecycleLeasedSftpIds, ); lifecycleLeasedSftpIds = []; } if (activeCompressions.get(compressionId) === compression) { activeCompressions.delete(compressionId); } } } /** * Cancel a compression operation */ async function cancelCompression(event, payload) { const { compressionId } = payload; const compression = activeCompressions.get(compressionId); if (compression && !compression.terminalState) { compression.cancelled = true; settleCompressionTerminal(compression, 'cancelled'); event?.sender?.send?.("netcatty:compress:cancelled", { compressionId }); releasePausedCompression(compression); try { compression.remoteExecAbortController?.abort(new Error('Upload cancelled')); } catch { /* ignore */ } terminateCompressionProcess(compression); // Cancel the associated transfer if it's running const transferId = `compress-${compressionId}`; if (transferBridge && transferBridge.cancelTransfer) { try { await transferBridge.cancelTransfer(event, { transferId }); } catch { // Ignore errors when cancelling transfer } } } return compression ? { success: true } : { success: false, reason: 'Compression is not active' }; } async function pauseCompression(event, payload) { const { compressionId } = payload; const compression = activeCompressions.get(compressionId); if (!compression || compression.cancelled) { return { success: false, reason: 'Compression is not active' }; } if (compression.paused) return { success: true, deferred: compression.phase === 'extracting' }; compression.paused = true; compression.lifecycleEpoch += 1; compression.lifecycleState = 'pausing'; if (compression.phase === 'compressing' && compression.process && process.platform !== 'win32') { try { compression.process.kill('SIGSTOP'); } catch { // The process may have completed between the phase check and signal. } } if (compression.phase === 'uploading' && transferBridge?.pauseTransfer) { const result = await transferBridge.pauseTransfer(event, { transferId: `compress-${compressionId}` }); if (!result?.success) { compression.paused = false; compression.lifecycleEpoch += 1; compression.lifecycleState = 'transferring'; broadcastCompressionEvent({ type: 'resumed', transferId: compressionId, phase: compression.phase, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); return result || { success: false, reason: 'Upload pause is unavailable' }; } } const deferred = compression.phase === 'extracting' || (compression.phase === 'compressing' && process.platform === 'win32'); compression.lifecycleState = deferred ? 'pausing' : 'paused'; broadcastCompressionEvent({ type: deferred ? 'pausing' : 'paused', transferId: compressionId, phase: compression.phase, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); return { success: true, deferred, lifecycleEpoch: compression.lifecycleEpoch }; } async function resumeCompression(event, payload) { const { compressionId } = payload; const compression = activeCompressions.get(compressionId); if (!compression || compression.cancelled) { return { success: false, reason: 'Compression is not active' }; } if (compression.phase === 'compressing' && compression.process && process.platform !== 'win32') { try { compression.process.kill('SIGCONT'); } catch { // The process may have completed while the request was in flight. } } if (compression.phase === 'uploading' && transferBridge?.resumeTransfer) { const result = await transferBridge.resumeTransfer(event, { transferId: `compress-${compressionId}` }); if (!result?.success) return result || { success: false, reason: 'Upload resume is unavailable' }; } compression.lifecycleEpoch += 1; compression.lifecycleState = 'transferring'; releasePausedCompression(compression); broadcastCompressionEvent({ type: 'resumed', transferId: compressionId, phase: compression.phase, lifecycleEpoch: compression.lifecycleEpoch, lifecycleState: compression.lifecycleState, }); return { success: true, lifecycleEpoch: compression.lifecycleEpoch }; } /** * Check if compressed upload is supported (tar available on both local and remote) */ async function checkCompressedUploadSupport(event, payload) { const { sftpId } = payload; try { return await resolveCompressedUploadSupport(sftpId); } catch (err) { return { supported: false, localTar: false, remoteTar: false, error: err.message }; } } function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) { ipcMain.handle(channel, (event, payload) => terminalWorkerManager.request(channel, payload, { webContentsId: event?.sender?.id, })); } /** * Register IPC handlers */ function registerHandlers(ipcMain, options = {}) { const terminalWorkerManager = options.terminalWorkerManager || null; if (terminalWorkerManager) { const workerRequest = (event, channel, payload) => terminalWorkerManager.request(channel, payload, { webContentsId: event?.sender?.id, }); const nextEpoch = (compressionId, suggestedEpoch) => { const entry = workerCompressionLifecycleEpochs.get(compressionId); const current = Math.max(0, Number(entry?.epoch) || 0); const suggested = Number(suggestedEpoch); const next = Number.isFinite(suggested) && suggested > current ? suggested : current + 1; if (entry) entry.epoch = next; return next; }; ipcMain.handle("netcatty:compress:start", (event, payload) => { let lifecycleEntry = null; if (payload?.compressionId) { lifecycleEntry = { epoch: Math.max(0, Number(payload.lifecycleEpoch) || 0), }; workerCompressionLifecycleEpochs.set(payload.compressionId, lifecycleEntry); } const releaseLifecycleEntry = () => { if ( payload?.compressionId && workerCompressionLifecycleEpochs.get(payload.compressionId) === lifecycleEntry ) { workerCompressionLifecycleEpochs.delete(payload.compressionId); } }; try { return Promise.resolve(workerRequest(event, "netcatty:compress:start", payload)) .finally(releaseLifecycleEntry); } catch (error) { releaseLifecycleEntry(); throw error; } }); ipcMain.handle("netcatty:compress:pause", async (event, payload) => { const lifecycleEpoch = nextEpoch(payload?.compressionId); broadcastCompressionEvent({ type: 'pausing', transferId: payload?.compressionId, lifecycleEpoch, lifecycleState: 'pausing', }); try { const result = await workerRequest(event, "netcatty:compress:pause", payload); if (!result?.success) { const rollbackEpoch = nextEpoch(payload?.compressionId); broadcastCompressionEvent({ type: 'resumed', transferId: payload?.compressionId, lifecycleEpoch: rollbackEpoch, lifecycleState: 'transferring', }); return result; } const lifecycleState = result.deferred ? 'pausing' : 'paused'; broadcastCompressionEvent({ type: lifecycleState, transferId: payload?.compressionId, lifecycleEpoch, lifecycleState, }); return result; } catch (error) { const rollbackEpoch = nextEpoch(payload?.compressionId); broadcastCompressionEvent({ type: 'resumed', transferId: payload?.compressionId, lifecycleEpoch: rollbackEpoch, lifecycleState: 'transferring', }); throw error; } }); ipcMain.handle("netcatty:compress:resume", async (event, payload) => { const result = await workerRequest(event, "netcatty:compress:resume", payload); if (result?.success) { const lifecycleEpoch = nextEpoch(payload?.compressionId, result.lifecycleEpoch); broadcastCompressionEvent({ type: 'resumed', transferId: payload?.compressionId, lifecycleEpoch, lifecycleState: 'transferring', }); } return result; }); [ "netcatty:compress:cancel", "netcatty:compress:checkSupport", ].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel)); return; } ipcMain.handle("netcatty:compress:start", startCompressedUpload); ipcMain.handle("netcatty:compress:cancel", cancelCompression); ipcMain.handle("netcatty:compress:pause", pauseCompression); ipcMain.handle("netcatty:compress:resume", resumeCompression); ipcMain.handle("netcatty:compress:checkSupport", checkCompressedUploadSupport); } module.exports = { init, registerHandlers, pauseCompression, resumeCompression, _runRemoteExecForTests: runRemoteExec, _buildAtomicRemoteExtractionCommandForTests: buildAtomicRemoteExtractionCommand, _buildRemoteArchivePathForTests: buildRemoteArchivePath, _createBoundedUtf8CollectorForTests: createBoundedUtf8Collector, _getActiveCompressionCountForTests: () => activeCompressions.size, _getWorkerCompressionLifecycleEpochCountForTests: () => workerCompressionLifecycleEpochs.size, _resetCompressionSupportCacheForTests: () => compressionSupportCache.clear(), _runWithCompressionTargetLockForTests: runWithCompressionTargetLock, _buildCompressionTargetKeyForTests: buildCompressionTargetKey, _resolveCompressionTargetKeyForTests: resolveCompressionTargetKey, _getCompressionTargetLockCountForTests: () => compressionTargetLocks.size, };