diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 25ec1bc0e..362d3362d 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -6,4 +6,14 @@ export const IPC_CHANNELS = { SET_QUICK_ASK_MODE: 'set-quick-ask-mode', GET_QUICK_ASK_MODE: 'get-quick-ask-mode', REPLACE_TEXT: 'replace-text', + // Folder sync channels + FOLDER_SYNC_SELECT_FOLDER: 'folder-sync:select-folder', + FOLDER_SYNC_ADD_FOLDER: 'folder-sync:add-folder', + FOLDER_SYNC_REMOVE_FOLDER: 'folder-sync:remove-folder', + FOLDER_SYNC_GET_FOLDERS: 'folder-sync:get-folders', + FOLDER_SYNC_GET_STATUS: 'folder-sync:get-status', + FOLDER_SYNC_FILE_CHANGED: 'folder-sync:file-changed', + FOLDER_SYNC_WATCHER_READY: 'folder-sync:watcher-ready', + FOLDER_SYNC_PAUSE: 'folder-sync:pause', + FOLDER_SYNC_RESUME: 'folder-sync:resume', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 18e343719..2baf957b0 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -1,5 +1,14 @@ import { app, ipcMain, shell } from 'electron'; import { IPC_CHANNELS } from './channels'; +import { + selectFolder, + addWatchedFolder, + removeWatchedFolder, + getWatchedFolders, + getWatcherStatus, + pauseWatcher, + resumeWatcher, +} from '../modules/folder-watcher'; export function registerIpcHandlers(): void { ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { @@ -16,4 +25,23 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_APP_VERSION, () => { return app.getVersion(); }); + + // Folder sync handlers + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_SELECT_FOLDER, () => selectFolder()); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_ADD_FOLDER, (_event, config) => + addWatchedFolder(config) + ); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_REMOVE_FOLDER, (_event, folderPath: string) => + removeWatchedFolder(folderPath) + ); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_GET_FOLDERS, () => getWatchedFolders()); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_GET_STATUS, () => getWatcherStatus()); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_PAUSE, () => pauseWatcher()); + + ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RESUME, () => resumeWatcher()); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 3ab41073b..f745d9b5e 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -6,6 +6,7 @@ import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; +import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher'; import { registerIpcHandlers } from './ipc/handlers'; registerGlobalErrorHandlers(); @@ -28,6 +29,7 @@ app.whenReady().then(async () => { } createMainWindow(); registerQuickAsk(); + registerFolderWatcher(); setupAutoUpdater(); handlePendingDeepLink(); @@ -47,4 +49,5 @@ app.on('window-all-closed', () => { app.on('will-quit', () => { unregisterQuickAsk(); + unregisterFolderWatcher(); }); diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts new file mode 100644 index 000000000..bfd2136c9 --- /dev/null +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -0,0 +1,216 @@ +import { BrowserWindow, dialog } from 'electron'; +import chokidar from 'chokidar'; +import * as path from 'path'; +import * as fs from 'fs'; +import { IPC_CHANNELS } from '../ipc/channels'; + +export interface WatchedFolderConfig { + path: string; + name: string; + excludePatterns: string[]; + fileExtensions: string[] | null; + connectorId: number; + searchSpaceId: number; + active: boolean; +} + +interface WatcherEntry { + config: WatchedFolderConfig; + watcher: chokidar.FSWatcher | null; +} + +const STORE_KEY = 'watchedFolders'; +let store: any = null; +let watchers: Map = new Map(); + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'folder-watcher', + defaults: { + [STORE_KEY]: [] as WatchedFolderConfig[], + }, + }); + } + return store; +} + +function getMainWindow(): BrowserWindow | null { + const windows = BrowserWindow.getAllWindows(); + return windows.length > 0 ? windows[0] : null; +} + +function sendToRenderer(channel: string, data: any) { + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send(channel, data); + } +} + +function startWatcher(config: WatchedFolderConfig) { + if (watchers.has(config.path)) { + return; + } + + const ignored = [ + /(^|[/\\])\../, // dotfiles by default + ...config.excludePatterns.map((p) => `**/${p}/**`), + ]; + + const watcher = chokidar.watch(config.path, { + persistent: true, + ignoreInitial: false, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + ignored, + }); + + let ready = false; + + watcher.on('ready', () => { + ready = true; + sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, { + connectorId: config.connectorId, + folderPath: config.path, + }); + }); + + const handleFileEvent = (filePath: string, action: string) => { + if (!ready) return; + + const relativePath = path.relative(config.path, filePath); + + if ( + config.fileExtensions && + config.fileExtensions.length > 0 + ) { + const ext = path.extname(filePath).toLowerCase(); + if (!config.fileExtensions.includes(ext)) return; + } + + sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, { + connectorId: config.connectorId, + searchSpaceId: config.searchSpaceId, + folderPath: config.path, + relativePath, + fullPath: filePath, + action, + timestamp: Date.now(), + }); + }; + + watcher.on('add', (fp) => handleFileEvent(fp, 'add')); + watcher.on('change', (fp) => handleFileEvent(fp, 'change')); + watcher.on('unlink', (fp) => handleFileEvent(fp, 'unlink')); + + watchers.set(config.path, { config, watcher }); +} + +function stopWatcher(folderPath: string) { + const entry = watchers.get(folderPath); + if (entry?.watcher) { + entry.watcher.close(); + } + watchers.delete(folderPath); +} + +export async function selectFolder(): Promise { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select a folder to watch', + }); + if (result.canceled || result.filePaths.length === 0) { + return null; + } + return result.filePaths[0]; +} + +export async function addWatchedFolder( + config: WatchedFolderConfig +): Promise { + const s = await getStore(); + const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + + const existing = folders.findIndex((f: WatchedFolderConfig) => f.path === config.path); + if (existing >= 0) { + folders[existing] = config; + } else { + folders.push(config); + } + + s.set(STORE_KEY, folders); + + if (config.active) { + startWatcher(config); + } + + return folders; +} + +export async function removeWatchedFolder( + folderPath: string +): Promise { + const s = await getStore(); + const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); + s.set(STORE_KEY, updated); + + stopWatcher(folderPath); + + return updated; +} + +export async function getWatchedFolders(): Promise { + const s = await getStore(); + return s.get(STORE_KEY, []); +} + +export async function getWatcherStatus(): Promise< + { path: string; active: boolean; watching: boolean }[] +> { + const s = await getStore(); + const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + return folders.map((f: WatchedFolderConfig) => ({ + path: f.path, + active: f.active, + watching: watchers.has(f.path), + })); +} + +export async function pauseWatcher(): Promise { + for (const [, entry] of watchers) { + if (entry.watcher) { + await entry.watcher.close(); + entry.watcher = null; + } + } +} + +export async function resumeWatcher(): Promise { + for (const [folderPath, entry] of watchers) { + if (!entry.watcher && entry.config.active) { + startWatcher(entry.config); + } + } +} + +export async function registerFolderWatcher(): Promise { + const s = await getStore(); + const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + + for (const config of folders) { + if (config.active && fs.existsSync(config.path)) { + startWatcher(config); + } + } +} + +export async function unregisterFolderWatcher(): Promise { + for (const [folderPath] of watchers) { + stopWatcher(folderPath); + } + watchers.clear(); +} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 264ec25b3..8f65aa633 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -21,4 +21,27 @@ contextBridge.exposeInMainWorld('electronAPI', { setQuickAskMode: (mode: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_QUICK_ASK_MODE, mode), getQuickAskMode: () => ipcRenderer.invoke(IPC_CHANNELS.GET_QUICK_ASK_MODE), replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text), + + // Folder sync + selectFolder: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_SELECT_FOLDER), + addWatchedFolder: (config: any) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_ADD_FOLDER, config), + removeWatchedFolder: (folderPath: string) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_REMOVE_FOLDER, folderPath), + getWatchedFolders: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_FOLDERS), + getWatcherStatus: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_STATUS), + onFileChanged: (callback: (data: any) => void) => { + const listener = (_event: unknown, data: any) => callback(data); + ipcRenderer.on(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, listener); + }; + }, + onWatcherReady: (callback: (data: any) => void) => { + const listener = (_event: unknown, data: any) => callback(data); + ipcRenderer.on(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, listener); + }; + }, + pauseWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_PAUSE), + resumeWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RESUME), });