Files
NetMesh/electron/bridges/fileWatcherBridge.cjs

662 lines
22 KiB
JavaScript
Raw Permalink Normal View History

/**
* File Watcher Bridge - Watches local temp files for changes to sync back to remote
*
* This bridge enables auto-sync functionality for files opened with external applications.
* When a file is downloaded to temp and opened with an external app, we watch for changes
* and automatically upload them back to the remote server.
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
const activeWatchers = new Map();
const activeWatchIdsByKey = new Map();
const observedOwnerSenders = new WeakSet();
const pendingWatchStarts = new Set();
const MAX_ACTIVE_FILE_WATCHERS = 256;
const TEMP_FILE_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500, 1000, 2000];
// Debounce map to prevent multiple rapid syncs
const debounceTimers = new Map();
// Map of sftpId -> Map<localPath, Set<webContentsId>> to track temp files even without watching
// This allows cleanup when SFTP session closes, regardless of auto-sync setting
const tempFilesMap = new Map();
let sftpClients = null;
let electronModule = null;
let transferBridge = null;
/**
* Initialize the file watcher bridge with dependencies
*/
function init(deps) {
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
transferBridge = deps.transferBridge || null;
}
/**
* Register a temp file for cleanup when SFTP session closes
* Called regardless of whether auto-sync is enabled
*/
function getOwnerId(sender, fallback) {
if (Number.isSafeInteger(sender?.id)) return sender.id;
return Number.isSafeInteger(fallback) ? fallback : null;
}
function buildWatchKey(sftpId, localPath, remotePath) {
return `${sftpId}\0${path.resolve(localPath)}\0${remotePath}`;
}
function registerTempFile(sftpId, localPath, ownerId = null) {
if (!tempFilesMap.has(sftpId)) {
tempFilesMap.set(sftpId, new Map());
}
const files = tempFilesMap.get(sftpId);
const owners = files.get(localPath) ?? new Set();
if (Number.isSafeInteger(ownerId)) owners.add(ownerId);
files.set(localPath, owners);
console.log(`[FileWatcher] Registered temp file for cleanup: ${localPath} (session: ${sftpId})`);
}
function releaseTempFileReference(
sftpId,
localPath,
ownerId,
{ force = false, cleanup = false } = {},
) {
const files = tempFilesMap.get(sftpId);
const owners = files?.get(localPath);
if (!files || !owners) return false;
if (!force && ownerId != null) owners.delete(ownerId);
if (!force && ownerId != null && owners.size > 0) return false;
files.delete(localPath);
if (files.size === 0) tempFilesMap.delete(sftpId);
if (cleanup) cleanupTempFileAsync(localPath);
return true;
}
async function unregisterTempFile(event, payload = {}) {
const { sftpId, localPath } = payload;
if (!sftpId || !localPath) return { success: false };
const ownerId = getOwnerId(event?.sender, payload.webContentsId);
const wasTracked = tempFilesMap.get(sftpId)?.has(localPath) === true;
releaseTempFileReference(sftpId, localPath, ownerId, {
force: ownerId == null,
cleanup: false,
});
const retained = tempFilesMap.get(sftpId)?.has(localPath) === true;
if (!retained) await cleanupTempFileAsync(localPath);
return { success: true, retained, wasTracked };
}
function hasOtherOwnedWatch(sftpId, localPath, ownerId, excludedWatchId) {
if (ownerId == null) return false;
for (const [watchId, watchInfo] of activeWatchers) {
if (watchId === excludedWatchId) continue;
if (watchInfo.sftpId !== sftpId || watchInfo.localPath !== localPath) continue;
if (watchInfo.owners.has(ownerId)) return true;
}
return false;
}
function observeOwnerSender(sender) {
if (!sender || typeof sender !== "object" || observedOwnerSenders.has(sender)) return;
observedOwnerSenders.add(sender);
sender.once?.("destroyed", () => {
const ownerId = getOwnerId(sender);
if (ownerId != null) releaseOwnerResources(ownerId, true);
});
}
/**
* Show a system notification for file sync events
* Works on macOS, Windows, and Linux
*/
function showSystemNotification(title, body) {
try {
if (!electronModule?.Notification) {
console.warn("[FileWatcher] Electron Notification API not available");
return;
}
const { Notification } = electronModule;
// Check if notifications are supported
if (!Notification.isSupported()) {
console.warn("[FileWatcher] System notifications not supported on this platform");
return;
}
const notification = new Notification({
title,
body,
silent: false, // Allow notification sound
});
notification.show();
} catch (err) {
console.warn("[FileWatcher] Failed to show system notification:", err.message);
}
}
/**
* Start watching a local file for changes
* Returns a watchId that can be used to stop watching
*/
async function startWatching(event, { localPath, remotePath, sftpId, encoding }) {
const ownerId = getOwnerId(event?.sender);
observeOwnerSender(event?.sender);
const watchKey = buildWatchKey(sftpId, localPath, remotePath);
const existingWatchId = activeWatchIdsByKey.get(watchKey);
const existingWatch = existingWatchId ? activeWatchers.get(existingWatchId) : null;
if (existingWatch) {
if (ownerId != null) existingWatch.owners.set(ownerId, event.sender);
existingWatch.encoding = encoding;
return { watchId: existingWatchId, reused: true };
}
if (activeWatchers.size + pendingWatchStarts.size >= MAX_ACTIVE_FILE_WATCHERS) {
throw new Error(`Too many active external file watches (max ${MAX_ACTIVE_FILE_WATCHERS})`);
}
const watchId = `watch-${crypto.randomUUID()}`;
const pendingStart = { watchId, watchKey, sftpId, ownerId, cancelled: false };
pendingWatchStarts.add(pendingStart);
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
// Get initial file stats
let lastModified;
let lastSize;
try {
const stat = await fs.promises.stat(localPath);
lastModified = stat.mtimeMs;
lastSize = stat.size;
console.log(`[FileWatcher] Initial file stats: mtime=${lastModified}, size=${lastSize}`);
} catch (err) {
pendingWatchStarts.delete(pendingStart);
console.error(`[FileWatcher] Failed to stat file ${localPath}:`, err.message);
throw new Error(`Cannot watch file: ${err.message}`);
}
if (pendingStart.cancelled) {
pendingWatchStarts.delete(pendingStart);
throw new Error("File watch start cancelled because its owner or SFTP session closed");
}
// Two identical starts can overlap during the async stat. Re-check after the
// await so only the first continuation installs a polling listener.
const racedWatchId = activeWatchIdsByKey.get(watchKey);
const racedWatch = racedWatchId ? activeWatchers.get(racedWatchId) : null;
if (racedWatch) {
if (ownerId != null) racedWatch.owners.set(ownerId, event.sender);
racedWatch.encoding = encoding;
pendingWatchStarts.delete(pendingStart);
return { watchId: racedWatchId, reused: true };
}
// Use fs.watchFile (polling) instead of fs.watch for better reliability on Windows
// fs.watch can miss events when editors use atomic writes (save to temp, then rename)
// fs.watchFile polls the file system at regular intervals
const pollInterval = 1000; // Check every 1 second
const listener = async (curr, prev) => {
console.log(`[FileWatcher] File stat change detected for ${localPath}`);
console.log(`[FileWatcher] Previous: mtime=${prev.mtimeMs}, size=${prev.size}`);
console.log(`[FileWatcher] Current: mtime=${curr.mtimeMs}, size=${curr.size}`);
// Check if file was deleted
if (curr.nlink === 0) {
console.log(`[FileWatcher] File ${localPath} was deleted, stopping watch`);
stopWatching(null, { watchId, force: true, cleanupTempFile: true });
return;
}
// Check if file was actually modified
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
console.log(`[FileWatcher] File unchanged, skipping`);
return;
}
// Debounce rapid changes (e.g., multiple saves in quick succession)
const existingTimer = debounceTimers.get(watchId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
debounceTimers.delete(watchId);
await handleFileChange(watchId);
}, 500); // 500ms debounce
debounceTimers.set(watchId, timer);
};
try {
fs.watchFile(localPath, { persistent: true, interval: pollInterval }, listener);
activeWatchers.set(watchId, {
watcher: null, // fs.watchFile doesn't return a watcher object
localPath,
remotePath,
sftpId,
encoding,
lastModified,
lastSize,
listener,
watchKey,
owners: new Map(ownerId == null ? [] : [[ownerId, event.sender]]),
activeTransferId: null,
syncPromise: null,
syncRequested: false,
syncSequence: 0,
stopped: false,
useWatchFile: true, // Flag to indicate we're using fs.watchFile
});
activeWatchIdsByKey.set(watchKey, watchId);
} finally {
pendingWatchStarts.delete(pendingStart);
}
console.log(`[FileWatcher] Watch started with ID: ${watchId} (using fs.watchFile polling every ${pollInterval}ms)`);
return { watchId };
}
/**
* Handle file change event - sync to remote
*/
function notifyWatchOwners(watchInfo, channel, payload) {
for (const [ownerId, webContents] of [...watchInfo.owners]) {
if (webContents?.isDestroyed?.()) {
watchInfo.owners.delete(ownerId);
continue;
}
webContents?.send?.(channel, payload);
}
}
async function syncFileChangeOnce(watchId, watchInfo) {
if (watchInfo.stopped || activeWatchers.get(watchId) !== watchInfo) return;
const { localPath, remotePath, sftpId, encoding, lastModified: previousModified, lastSize: previousSize } = watchInfo;
// Extract file name once for notifications and logging
const fileName = path.basename(remotePath);
console.log(`[FileWatcher] File change detected: ${localPath}`);
// Check if file was actually modified (compare mtime and size).
const stat = await fs.promises.stat(localPath);
if (stat.mtimeMs <= previousModified && stat.size === previousSize) {
console.log(`[FileWatcher] File unchanged (mtime and size same), skipping sync`);
return;
}
// Record the observed version before the upload. A save that lands while the
// stream is running triggers another watcher tick and is serialized below.
watchInfo.lastModified = stat.mtimeMs;
watchInfo.lastSize = stat.size;
if (!sftpClients?.has(sftpId)) throw new Error("SFTP session not found or expired");
if (!transferBridge?.startInternalTransfer) throw new Error("Transfer runtime not initialized");
if (watchInfo.stopped || activeWatchers.get(watchId) !== watchInfo) return;
const transferId = `filewatch-${watchId}-${++watchInfo.syncSequence}`;
watchInfo.activeTransferId = transferId;
console.log(`[FileWatcher] Streaming ${stat.size} bytes to ${remotePath}`);
try {
const result = await transferBridge.startInternalTransfer(null, {
transferId,
sourcePath: localPath,
targetPath: remotePath,
sourceType: "local",
targetType: "sftp",
targetSftpId: sftpId,
targetEncoding: encoding,
totalBytes: stat.size,
resumable: false,
sourceIsOwnedTemp: false,
});
if (result?.cancelled || /cancel/i.test(result?.error || "")) {
throw new Error("Transfer cancelled");
}
if (result?.error) throw new Error(result.error);
} finally {
if (watchInfo.activeTransferId === transferId) watchInfo.activeTransferId = null;
}
if (watchInfo.stopped || activeWatchers.get(watchId) !== watchInfo) return;
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
showSystemNotification("Netcatty", `File synced to remote: ${fileName}`);
notifyWatchOwners(watchInfo, "netcatty:filewatch:synced", {
watchId,
localPath,
remotePath,
bytesWritten: stat.size,
});
}
async function handleFileChange(watchId) {
const watchInfo = activeWatchers.get(watchId);
if (!watchInfo || watchInfo.stopped) return;
watchInfo.syncRequested = true;
if (watchInfo.syncPromise) return watchInfo.syncPromise;
const syncPromise = (async () => {
while (
watchInfo.syncRequested
&& !watchInfo.stopped
&& activeWatchers.get(watchId) === watchInfo
) {
watchInfo.syncRequested = false;
try {
await syncFileChangeOnce(watchId, watchInfo);
} catch (err) {
// A newer save already queued behind this attempt will be retried; avoid
// flashing a stale failure for the superseded version.
if (watchInfo.stopped || activeWatchers.get(watchId) !== watchInfo) break;
if (watchInfo.syncRequested) continue;
const message = err?.message || String(err);
console.error(`[FileWatcher] Sync failed for ${watchInfo.localPath}:`, message);
showSystemNotification(
"Netcatty",
`Failed to sync ${path.basename(watchInfo.remotePath)}: ${message}`,
);
notifyWatchOwners(watchInfo, "netcatty:filewatch:error", {
watchId,
localPath: watchInfo.localPath,
remotePath: watchInfo.remotePath,
error: message,
});
}
}
})().finally(() => {
if (watchInfo.syncPromise === syncPromise) watchInfo.syncPromise = null;
});
watchInfo.syncPromise = syncPromise;
return syncPromise;
}
/**
* Stop watching a file and optionally clean up the temp file
*/
function stopWatching(event, {
watchId,
cleanupTempFile = false,
force = false,
webContentsId,
}) {
const watchInfo = activeWatchers.get(watchId);
if (!watchInfo) {
console.log(`[FileWatcher] Watch ID not found: ${watchId}`);
return { success: false };
}
const ownerId = getOwnerId(event?.sender, webContentsId);
let tempFileCleanupQueued = false;
if (force) {
notifyWatchOwners(watchInfo, "netcatty:filewatch:stopped", {
watchId,
localPath: watchInfo.localPath,
remotePath: watchInfo.remotePath,
sftpId: watchInfo.sftpId,
});
}
if (!force && ownerId != null) {
if (!watchInfo.owners.delete(ownerId)) return { success: false };
if (cleanupTempFile && !hasOtherOwnedWatch(
watchInfo.sftpId,
watchInfo.localPath,
ownerId,
watchId,
)) {
tempFileCleanupQueued = releaseTempFileReference(
watchInfo.sftpId,
watchInfo.localPath,
ownerId,
{
cleanup: true,
},
);
}
if (watchInfo.owners.size > 0) return { success: true, retained: true };
}
console.log(`[FileWatcher] Stopping watch: ${watchInfo.localPath}`);
watchInfo.stopped = true;
watchInfo.syncRequested = false;
const activeTransferId = watchInfo.activeTransferId;
watchInfo.activeTransferId = null;
if (activeTransferId && transferBridge?.cancelTransfer) {
void Promise.resolve(transferBridge.cancelTransfer(null, { transferId: activeTransferId })).catch(() => {});
}
// Clear debounce timer if any
const timer = debounceTimers.get(watchId);
if (timer) {
clearTimeout(timer);
debounceTimers.delete(watchId);
}
// Stop the watcher
try {
if (watchInfo.useWatchFile) {
// Using fs.watchFile - need to use fs.unwatchFile
fs.unwatchFile(watchInfo.localPath, watchInfo.listener);
} else if (watchInfo.watcher) {
// Using fs.watch - close the watcher
watchInfo.watcher.close();
}
} catch (err) {
console.warn(`[FileWatcher] Error stopping watcher:`, err.message);
}
// Clean up temp file if requested
if (
cleanupTempFile
&& !tempFileCleanupQueued
&& watchInfo.localPath
&& !hasOtherOwnedWatch(watchInfo.sftpId, watchInfo.localPath, ownerId, watchId)
) {
const releasedRegisteredFile = releaseTempFileReference(
watchInfo.sftpId,
watchInfo.localPath,
ownerId,
{ force: force || ownerId == null, cleanup: true },
);
if (!releasedRegisteredFile) cleanupTempFileAsync(watchInfo.localPath);
}
activeWatchers.delete(watchId);
if (activeWatchIdsByKey.get(watchInfo.watchKey) === watchId) {
activeWatchIdsByKey.delete(watchInfo.watchKey);
}
return { success: true };
}
/**
* Asynchronously delete a temp file, logging success and silently handling failures
*/
async function cleanupTempFileAsync(filePath) {
for (let attempt = 0; ; attempt += 1) {
try {
await fs.promises.unlink(filePath);
console.log(`[FileWatcher] Temp file cleaned up: ${filePath}`);
return true;
} catch (err) {
if (err?.code === "ENOENT") return true;
const retryable = err?.code === "EBUSY"
|| err?.code === "EPERM"
|| err?.code === "EACCES";
const delayMs = TEMP_FILE_CLEANUP_RETRY_DELAYS_MS[attempt];
if (!retryable || delayMs == null) {
console.log(`[FileWatcher] Could not delete temp file (may be in use): ${filePath}`);
return false;
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, delayMs);
// Cleanup is best-effort and must not keep Electron alive during quit.
timer.unref?.();
});
}
}
}
/**
* Stop all watchers for a specific SFTP session and clean up temp files
* Called when SFTP connection is closed
*/
function stopWatchersForSession(sftpId, cleanupTempFiles = true) {
let watcherCount = 0;
for (const pendingStart of pendingWatchStarts) {
if (pendingStart.sftpId === sftpId) pendingStart.cancelled = true;
}
// Stop active watchers
for (const [watchId, watchInfo] of activeWatchers.entries()) {
if (watchInfo.sftpId === sftpId) {
stopWatching(null, { watchId, cleanupTempFile: cleanupTempFiles, force: true });
watcherCount++;
}
}
if (watcherCount > 0) {
console.log(`[FileWatcher] Stopped ${watcherCount} watcher(s) for SFTP session: ${sftpId}`);
}
// Clean up any registered temp files that weren't being watched
if (cleanupTempFiles && tempFilesMap.has(sftpId)) {
const tempFiles = tempFilesMap.get(sftpId);
let cleanedCount = 0;
for (const filePath of tempFiles.keys()) {
cleanupTempFileAsync(filePath);
cleanedCount++;
}
tempFilesMap.delete(sftpId);
if (cleanedCount > 0) {
console.log(`[FileWatcher] Queued cleanup for ${cleanedCount} temp file(s) for SFTP session: ${sftpId}`);
}
}
}
function releaseOwnerResources(webContentsId, cleanupTempFiles = true) {
if (!Number.isSafeInteger(webContentsId)) return { removed: 0 };
for (const pendingStart of pendingWatchStarts) {
if (pendingStart.ownerId === webContentsId) pendingStart.cancelled = true;
}
let removed = 0;
for (const [watchId, watchInfo] of [...activeWatchers]) {
if (!watchInfo.owners.has(webContentsId)) continue;
const result = stopWatching(null, {
watchId,
cleanupTempFile: false,
webContentsId,
});
if (result.success) removed += 1;
}
for (const [sftpId, files] of [...tempFilesMap]) {
for (const [filePath, owners] of [...files]) {
if (!owners.has(webContentsId)) continue;
releaseTempFileReference(sftpId, filePath, webContentsId, {
cleanup: cleanupTempFiles,
});
}
if (files.size === 0) tempFilesMap.delete(sftpId);
}
return { removed };
}
/**
* Get list of active watchers
*/
function listWatchers() {
const watchers = [];
for (const [watchId, info] of activeWatchers.entries()) {
watchers.push({
watchId,
localPath: info.localPath,
remotePath: info.remotePath,
sftpId: info.sftpId,
});
}
return watchers;
}
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
ipcMain.handle(channel, (event, payload) => terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
}));
}
/**
* Register IPC handlers for file watching operations
*/
function registerHandlers(ipcMain, options = {}) {
console.log("[FileWatcher] Registering IPC handlers");
const terminalWorkerManager = options.terminalWorkerManager || null;
if (terminalWorkerManager) {
const observedSenders = new WeakSet();
const observeSender = (sender) => {
if (!sender || typeof sender !== "object" || observedSenders.has(sender)) return;
observedSenders.add(sender);
sender.once?.("destroyed", () => {
void terminalWorkerManager.request(
"netcatty:filewatch:releaseOwner",
{ webContentsId: sender.id, cleanupTempFiles: true },
{ webContentsId: sender.id },
).catch(() => {});
});
};
for (const channel of [
"netcatty:filewatch:start",
"netcatty:filewatch:registerTempFile",
"netcatty:filewatch:unregisterTempFile",
]) {
ipcMain.handle(channel, (event, payload) => {
observeSender(event?.sender);
return terminalWorkerManager.request(channel, payload, { webContentsId: event?.sender?.id });
});
}
for (const channel of ["netcatty:filewatch:stop", "netcatty:filewatch:list"]) {
registerWorkerHandle(ipcMain, terminalWorkerManager, channel);
}
return;
}
ipcMain.handle("netcatty:filewatch:start", (event, args) => {
console.log("[FileWatcher] IPC netcatty:filewatch:start received", args);
return startWatching(event, args);
});
ipcMain.handle("netcatty:filewatch:stop", stopWatching);
ipcMain.handle("netcatty:filewatch:list", listWatchers);
ipcMain.handle("netcatty:filewatch:registerTempFile", (event, { sftpId, localPath }) => {
observeOwnerSender(event?.sender);
registerTempFile(sftpId, localPath, getOwnerId(event?.sender));
return { success: true };
});
ipcMain.handle("netcatty:filewatch:unregisterTempFile", unregisterTempFile);
ipcMain.handle("netcatty:filewatch:releaseOwner", (_event, payload = {}) => (
releaseOwnerResources(payload.webContentsId, payload.cleanupTempFiles !== false)
));
}
/**
* Cleanup all watchers on shutdown
*/
function cleanup() {
console.log(`[FileWatcher] Cleaning up ${activeWatchers.size} watcher(s)`);
for (const pendingStart of pendingWatchStarts) pendingStart.cancelled = true;
for (const [watchId] of activeWatchers.entries()) {
stopWatching(null, { watchId, force: true, cleanupTempFile: true });
}
for (const [sftpId] of tempFilesMap) stopWatchersForSession(sftpId, true);
}
module.exports = {
init,
registerHandlers,
stopWatchersForSession,
};