diff --git a/electron/main.ts b/electron/main.ts index 3820786..4c0ca35 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,168 +1,267 @@ -import { app, BrowserWindow, ipcMain, safeStorage, Notification, shell, nativeImage } from "electron"; -import { ConvexClient } from "convex/browser"; -import { api } from "../convex/_generated/api"; -import path from "path"; -import fs from "fs"; - -const CONVEX_URL = process.env.CONVEX_URL ?? "https://next-pika-124.convex.cloud"; -const SESSION_FILE = path.join(app.getPath("userData"), "session.enc"); - -let mainWindow: BrowserWindow | null = null; - -// ── Session token storage (encrypted at rest) ── - -function readStoredToken(): string | null { - try { - if (!fs.existsSync(SESSION_FILE)) return null; - const encrypted = fs.readFileSync(SESSION_FILE); - if (!safeStorage.isEncryptionAvailable()) return null; - return safeStorage.decryptString(encrypted); - } catch { - return null; - } -} - -function storeToken(token: string): void { - if (!safeStorage.isEncryptionAvailable()) { - throw new Error("Encryption not available"); - } - const encrypted = safeStorage.encryptString(token); - fs.writeFileSync(SESSION_FILE, encrypted); -} - -function clearToken(): void { - try { - fs.unlinkSync(SESSION_FILE); - } catch { - // file may not exist - } -} - -// ── IPC handlers ── - -ipcMain.handle("store-session", (_event, token: string) => { - storeToken(token); - startNotificationSubscription(token); -}); - -ipcMain.handle("get-session", () => { - return readStoredToken(); -}); - -ipcMain.handle("clear-session", () => { - clearToken(); - stopNotificationSubscription(); -}); - -ipcMain.handle("open-external", (_event, url: string) => { - shell.openExternal(url); -}); - -// ── Native notification bridge ── - -let notifClient: ConvexClient | null = null; -let lastSeenTimestamp = Date.now(); - -function startNotificationSubscription(sessionToken: string) { - stopNotificationSubscription(); - - // Set baseline to now so we only fire for truly new notifications - lastSeenTimestamp = Date.now(); - let isFirstUpdate = true; - - notifClient = new ConvexClient(CONVEX_URL); - notifClient.onUpdate( - api.queries.getUnreadNotifications, - { sessionToken }, - (notifications: Array<{ title: string; body: string; timestamp: number }>) => { - if (!notifications) return; - - // Skip the initial snapshot — only fire for subsequent updates - if (isFirstUpdate) { - isFirstUpdate = false; - if (notifications.length > 0) { - lastSeenTimestamp = Math.max(...notifications.map((n) => n.timestamp)); - } - return; - } - - for (const n of notifications) { - if (n.timestamp > lastSeenTimestamp) { - const notif = new Notification({ title: n.title, body: n.body ?? "" }); - notif.show(); - } - } - if (notifications.length > 0) { - const maxTs = Math.max(...notifications.map((n) => n.timestamp)); - if (maxTs > lastSeenTimestamp) { - lastSeenTimestamp = maxTs; - } - } - }, - ); -} - -function stopNotificationSubscription() { - if (notifClient) { - notifClient.close(); - notifClient = null; - } -} - -// ── Window creation ── - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, - backgroundColor: "#09090b", - titleBarStyle: "hiddenInset", - icon: path.join(__dirname, "../../resources/glass-icon.png"), - webPreferences: { - preload: path.join(__dirname, "../preload/preload.cjs"), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - if (process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); - } else { - mainWindow.loadFile(path.join(__dirname, "../renderer/index.html")); - } - - - mainWindow.on("closed", () => { - mainWindow = null; - }); -} - -app.whenReady().then(() => { - if (process.platform === "darwin") { - app.dock.setIcon(path.join(__dirname, "../../resources/glass-icon.png")); - } - createWindow(); - - // Start notification subscription if we have a stored session - const token = readStoredToken(); - if (token) { - startNotificationSubscription(token); - } - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); - -app.on("before-quit", () => { - stopNotificationSubscription(); -}); +import { app, BrowserWindow, ipcMain, safeStorage, Notification, shell } from "electron"; +import { ConvexClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import path from "path"; +import fs from "fs"; + +const CONVEX_URL = process.env.CONVEX_URL ?? "https://next-pika-124.convex.cloud"; +const SESSION_FILE = path.join(app.getPath("userData"), "session.enc"); + +// Only these two schemes are ever handed to the OS URL handler. +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["https:", "http:"]); + +// Minimal set of web permissions required for user onboarding clipboard flows +const ALLOWED_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write"]); + +let mainWindow: BrowserWindow | null = null; + +/** + * Decides whether a renderer-supplied string may be passed to + * `shell.openExternal`. + */ +function isSafeExternalUrl(url: unknown): url is string { + if (typeof url !== "string" || url.length === 0) return false; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol); +} + +/** + * Opens a URL in the user's browser if it is an allowed scheme, + * logging rejections rather than throwing. + */ +function openExternalIfSafe(url: unknown): void { + if (!isSafeExternalUrl(url)) { + console.warn("Refusing to open external URL with a disallowed scheme:", url); + return; + } + void shell.openExternal(url); +} + +/** + * Validates whether a navigation target belongs strictly to the application. + */ +function isInternalUrl(url: string): boolean { + const rendererUrl = process.env.ELECTRON_RENDERER_URL; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (rendererUrl) { + try { + return parsed.origin === new URL(rendererUrl).origin; + } catch { + return false; + } + } + + return parsed.protocol === "file:"; +} + +// ── Session token storage (encrypted at rest) ── + +function readStoredToken(): string | null { + try { + if (!fs.existsSync(SESSION_FILE)) return null; + const encrypted = fs.readFileSync(SESSION_FILE); + if (!safeStorage.isEncryptionAvailable()) return null; + return safeStorage.decryptString(encrypted); + } catch (error) { + console.error("Failed to read the stored session token:", error); + return null; + } +} + +function storeToken(token: string): void { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("Encryption not available"); + } + const encrypted = safeStorage.encryptString(token); + fs.writeFileSync(SESSION_FILE, encrypted); +} + +function clearToken(): void { + try { + fs.unlinkSync(SESSION_FILE); + } catch { + // file may not exist + } +} + +// ── IPC handlers ── + +ipcMain.handle("store-session", (_event, token: unknown) => { + if (typeof token !== "string" || token.length === 0) { + throw new Error("store-session expects a non-empty string token"); + } + storeToken(token); + startNotificationSubscription(token); +}); + +ipcMain.handle("get-session", () => { + return readStoredToken(); +}); + +ipcMain.handle("clear-session", () => { + clearToken(); + stopNotificationSubscription(); +}); + +ipcMain.handle("open-external", (_event, url: unknown) => { + openExternalIfSafe(url); +}); + +// ── Native notification bridge ── + +let notifClient: ConvexClient | null = null; +let lastSeenTimestamp = Date.now(); + +function startNotificationSubscription(sessionToken: string) { + stopNotificationSubscription(); + + lastSeenTimestamp = Date.now(); + let isFirstUpdate = true; + + notifClient = new ConvexClient(CONVEX_URL); + notifClient.onUpdate( + api.queries.getUnreadNotifications, + { sessionToken }, + (notifications: Array<{ title: string; body: string; timestamp: number }>) => { + if (!notifications) return; + + if (isFirstUpdate) { + isFirstUpdate = false; + if (notifications.length > 0) { + lastSeenTimestamp = Math.max(...notifications.map((n) => n.timestamp)); + } + return; + } + + for (const n of notifications) { + if (n.timestamp > lastSeenTimestamp) { + const notif = new Notification({ title: n.title, body: n.body ?? "" }); + notif.show(); + } + } + if (notifications.length > 0) { + const maxTs = Math.max(...notifications.map((n) => n.timestamp)); + if (maxTs > lastSeenTimestamp) { + lastSeenTimestamp = maxTs; + } + } + }, + ); +} + +function stopNotificationSubscription() { + if (notifClient) { + notifClient.close(); + notifClient = null; + } +} + +// ── Window creation ── + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + backgroundColor: "#09090b", + titleBarStyle: "hiddenInset", + icon: path.join(__dirname, "../../resources/glass-icon.png"), + webPreferences: { + preload: path.join(__dirname, "../preload/preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + openExternalIfSafe(url); + return { action: "deny" }; + }); + + mainWindow.webContents.on("will-navigate", (event, url) => { + if (isInternalUrl(url)) return; + event.preventDefault(); + openExternalIfSafe(url); + }); + + mainWindow.webContents.on("will-redirect", (event, url) => { + if (isInternalUrl(url)) return; + event.preventDefault(); + openExternalIfSafe(url); + }); + + mainWindow.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => { + if (ALLOWED_PERMISSIONS.has(permission)) { + callback(true); + return; + } + console.warn(`Denied renderer permission request: ${permission}`); + callback(false); + }); + + if (process.env.ELECTRON_RENDERER_URL) { + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); + } else { + mainWindow.loadFile(path.join(__dirname, "../renderer/index.html")); + } + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +// Global protection across all WebContents instances +app.on("web-contents-created", (_event, contents) => { + contents.on("will-attach-webview", (event) => { + console.warn("Refused to attach a tag"); + event.preventDefault(); + }); + + // Guard window creation on all web contents to prevent unhandled popups + contents.setWindowOpenHandler(({ url }) => { + openExternalIfSafe(url); + return { action: "deny" }; + }); +}); + +app.whenReady().then(() => { + if (process.platform === "darwin") { + app.dock.setIcon(path.join(__dirname, "../../resources/glass-icon.png")); + } + createWindow(); + + const token = readStoredToken(); + if (token) { + startNotificationSubscription(token); + } + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + stopNotificationSubscription(); +}); diff --git a/renderer/index.html b/renderer/index.html index 4d5949c..797ea06 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -1,18 +1,54 @@ - - - - - - Vigil - - - - - -
- - - + + + + + + + + Vigil + + + + + +
+ + +