diff --git a/electron/main.js b/electron/main.js index 74daf346..f1a36fb6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,35 +1,382 @@ -import { app, globalShortcut } from "electron"; import { - createMainWindow, - createOverlayWindow, - toggleMainVisibility, -} from "./windows.js"; -import { registerIpcHandlers } from "./ipc.js"; -import { startClipboardSentinel, stopClipboardSentinel } from "./sentinel.js"; + app, + BrowserWindow, + ipcMain, + globalShortcut, + clipboard, + dialog, // Import dialog for file selection + shell, // Import shell for opening paths +} from "electron"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promises as fs } from "node:fs"; // Import fs.promises for async file operations +import chokidar from "chokidar"; // Import chokidar for file watching +import { startWatcher, stopWatcher } from "../services/watcher.js"; +import { scrubPII } from "../services/pii-scrub.js"; +import { execa } from "execa"; // Import execa for running Git commands + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let mainWindow = null; +let overlayWindow = null; +let pinned = false; +let sentinelInterval = null; + +function getRendererUrl() { + return ( + process.env.VITE_DEV_SERVER_URL || + `file://${path.join(__dirname, "../dist/spa/index.html")}` + ); +} + +function createMainWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + frame: false, + titleBarStyle: "hidden", + backgroundColor: "#030712", + show: true, + webPreferences: { + preload: path.join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false, + sandbox: false, + }, + }); + + const url = getRendererUrl(); + console.log("[Main] Loading URL:", url); + + // Clear cache to prevent stale assets + mainWindow.webContents.session.clearCache(); + + // Disable caching in development + mainWindow.webContents.session.webRequest.onBeforeSendHeaders( + (details, callback) => { + details.requestHeaders["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate"; + callback({ requestHeaders: details.requestHeaders }); + } + ); + + // If loading from dev server, wait a moment for it to start + if (process.env.VITE_DEV_SERVER_URL) { + setTimeout(() => { + mainWindow.loadURL(url).catch(err => { + console.error('[Main] Failed to load:', err); + setTimeout(() => mainWindow.loadURL(url), 1000); + }); + }, 1000); + } else { + mainWindow.loadURL(url); + } + + mainWindow.webContents.openDevTools(); + + mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => { + console.error('[Main] Failed to load:', errorCode, errorDescription); + setTimeout(() => mainWindow.loadURL(url), 1000); + }); + + mainWindow.webContents.on('crashed', () => { + console.error('[Main] Renderer crashed'); + }); +} + +function createOverlayWindow() { + // Overlay window disabled for now - it was blocking clicks on main window + /* + overlayWindow = new BrowserWindow({ + width: 420, + height: 640, + transparent: true, + frame: false, + alwaysOnTop: true, + resizable: true, + focusable: true, + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + overlayWindow.setAlwaysOnTop(true, "floating"); + const base = getRendererUrl(); + // Assumes your SPA has a route for /overlay + overlayWindow.loadURL(base + "#/overlay"); + */ +} + +function toggleMainVisibility() { + if (!mainWindow) return; + if (mainWindow.isVisible()) { + mainWindow.hide(); + } else { + mainWindow.show(); + mainWindow.focus(); + } +} + +function startClipboardSentinel() { + if (sentinelInterval) return; + let last = clipboard.readText(); + sentinelInterval = setInterval(() => { + const now = clipboard.readText(); + if (now !== last) { + last = now; + const scrubbed = scrubPII(now); + if (scrubbed !== now) { + mainWindow?.webContents.send("sentinel:clipboard-alert", now); + } + } + }, 1500); +} app.whenReady().then(() => { - registerIpcHandlers(); - createMainWindow(); - createOverlayWindow(); - + // createOverlayWindow(); // Disabled for debugging startClipboardSentinel(); + // Global hotkey to toggle visibility globalShortcut.register("Alt+Space", toggleMainVisibility); + + ipcMain.handle("watcher:start", async (_e, dir) => { + await startWatcher(dir); + return true; + }); + + ipcMain.handle("watcher:stop", async () => { + await stopWatcher(); + return true; + }); + + ipcMain.handle("window:toggle-pin", () => { + pinned = !pinned; + mainWindow?.setAlwaysOnTop(pinned, "floating"); + return pinned; + }); + + ipcMain.handle("window:close", () => { + mainWindow?.close(); + }); + + ipcMain.handle("window:minimize", () => { + mainWindow?.minimize(); + }); + + ipcMain.handle("window:maximize", () => { + if (!mainWindow) return false; + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + return mainWindow.isMaximized(); + }); + + // File system IPC handlers + ipcMain.handle("fs:select-folder", async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory"], + }); + if (canceled) return null; + return filePaths[0]; + }); + + ipcMain.handle("fs:select-files", async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + properties: ["openFile", "multiSelections"], + }); + if (canceled) return null; + return filePaths; + }); + + ipcMain.handle("fs:read-project-metadata", async (_e, folderPath) => { + try { + const packageJsonPath = path.join(folderPath, "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")); + return { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description, + // Add more metadata as needed + }; + } catch (error) { + console.error("Failed to read project metadata:", error); + return null; + } + }); + + let activeWatchers = {}; + + ipcMain.handle("fs:watch-folder", async (_e, folderPath) => { + if (activeWatchers[folderPath]) { + console.log(`Already watching ${folderPath}`); + return true; + } + + const watcher = chokidar.watch(folderPath, { + ignored: /(^|\/)\../, // ignore dotfiles + persistent: true, + ignoreInitial: true, + }); + + watcher.on("add", (filePath) => { + mainWindow?.webContents.send("fs:file-change", { + type: "add", + filePath, + folderPath, + }); + }); + watcher.on("change", (filePath) => { + mainWindow?.webContents.send("fs:file-change", { + type: "change", + filePath, + folderPath, + }); + }); + watcher.on("unlink", (filePath) => { + mainWindow?.webContents.send("fs:file-change", { + type: "unlink", + filePath, + folderPath, + }); + }); + watcher.on("addDir", (dirPath) => { + mainWindow?.webContents.send("fs:folder-change", { + type: "add", + dirPath, + folderPath, + }); + }); + watcher.on("unlinkDir", (dirPath) => { + mainWindow?.webContents.send("fs:folder-change", { + type: "unlink", + dirPath, + folderPath, + }); + }); + + activeWatchers[folderPath] = watcher; + console.log(`Started watching ${folderPath}`); + return true; + }); + + ipcMain.handle("fs:unwatch-folder", async (_e, folderPath) => { + if (activeWatchers[folderPath]) { + await activeWatchers[folderPath].close(); + delete activeWatchers[folderPath]; + console.log(`Stopped watching ${folderPath}`); + } + return true; + }); + + // Git IPC handlers + ipcMain.handle("git:get-status", async (_e, folderPath) => { + try { + const { stdout } = await execa("git", ["status", "--porcelain"], { cwd: folderPath }); + const changes = stdout.split("\n").filter(line => line.trim() !== ""); + let status = "clean"; + if (changes.length > 0) { + status = "modified"; + } + const { stdout: branchOutput } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: folderPath }); + const branch = branchOutput.trim(); + return { status, branch, changes: changes.length }; + } catch (error) { + console.error(`Failed to get Git status for ${folderPath}:`, error); + return { status: "error", branch: "N/A", changes: 0, error: error.message }; + } + }); + + ipcMain.handle("git:pull", async (_e, folderPath) => { + try { + const { stdout } = await execa("git", ["pull"], { cwd: folderPath }); + return { success: true, output: stdout }; + } catch (error) { + console.error(`Failed to pull Git changes for ${folderPath}:`, error); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("git:push", async (_e, folderPath) => { + try { + const { stdout } = await execa("git", ["push"], { cwd: folderPath }); + return { success: true, output: stdout }; + } catch (error) { + console.error(`Failed to push Git changes for ${folderPath}:`, error); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("shell:open-path", async (_e, itemPath) => { + try { + await shell.openPath(itemPath); + return { success: true }; + } catch (error) { + console.error(`Failed to open path ${itemPath}:`, error); + return { success: false, error: error.message }; + } + }); + + // Build runner - runs a project's build script in a safe, cwd-restricted manner + ipcMain.handle("build:run", async (_e, folderPath) => { + try { + // Ensure folder exists + await fs.stat(folderPath); + } catch (err) { + console.error("Build run failed - folder not found:", folderPath, err?.message || err); + return { success: false, error: "folder-not-found" }; + } + + try { + // Default to running the repo's npm build script. This keeps scope narrow and predictable. + const child = execa("npm", ["run", "build"], { cwd: folderPath }); + + // Stream stdout/stderr back to renderer for live logs + if (child.stdout) { + child.stdout.on("data", (chunk) => { + mainWindow?.webContents.send("build:log", { + folderPath, + type: "stdout", + text: String(chunk), + }); + }); + } + if (child.stderr) { + child.stderr.on("data", (chunk) => { + mainWindow?.webContents.send("build:log", { + folderPath, + type: "stderr", + text: String(chunk), + }); + }); + } + + mainWindow?.webContents.send("build:log", { folderPath, type: "start", text: "Build started" }); + + const result = await child; + + mainWindow?.webContents.send("build:log", { folderPath, type: "finish", text: result.stdout || "Build finished" }); + + return { success: true, output: result.stdout || "" }; + } catch (error) { + console.error(`Build failed for ${folderPath}:`, error); + mainWindow?.webContents.send("build:log", { folderPath, type: "error", text: error?.message || String(error) }); + return { success: false, error: error?.message || String(error) }; + } + }); }); +// Don't quit the app when all windows are closed in development +// This allows the app to stay running and prevents auto-close app.on("window-all-closed", () => { - stopClipboardSentinel(); - if (process.platform !== "darwin") { - app.quit(); - } + // Only quit on macOS - on Windows/Linux, keep the app running + // This prevents the immediate close issue + if (process.platform === "darwin") app.quit(); }); -app.on("activate", () => { - createMainWindow(); -}); - -app.on("will-quit", () => { - globalShortcut.unregisterAll(); - stopClipboardSentinel(); -});