From dff3440f72a7cbfe2c788ca96bc3067c99b6c5ef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:44:48 +0200 Subject: [PATCH 01/32] refactor(desktop): extract error handling into modules/errors.ts --- surfsense_desktop/src/main.ts | 33 +++---------------------- surfsense_desktop/src/modules/errors.ts | 33 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 surfsense_desktop/src/modules/errors.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index e0a6c3be5..a7a12c485 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,37 +1,10 @@ -import { app, BrowserWindow, shell, ipcMain, session, dialog, clipboard, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; import path from 'path'; import { getPort } from 'get-port-please'; import { autoUpdater } from 'electron-updater'; +import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; -function showErrorDialog(title: string, error: unknown): void { - const err = error instanceof Error ? error : new Error(String(error)); - console.error(`${title}:`, err); - - if (app.isReady()) { - const detail = err.stack || err.message; - const buttonIndex = dialog.showMessageBoxSync({ - type: 'error', - buttons: ['OK', process.platform === 'darwin' ? 'Copy Error' : 'Copy error'], - defaultId: 0, - noLink: true, - message: title, - detail, - }); - if (buttonIndex === 1) { - clipboard.writeText(`${title}\n${detail}`); - } - } else { - dialog.showErrorBox(title, err.stack || err.message); - } -} - -process.on('uncaughtException', (error) => { - showErrorDialog('Unhandled Error', error); -}); - -process.on('unhandledRejection', (reason) => { - showErrorDialog('Unhandled Promise Rejection', reason); -}); +registerGlobalErrorHandlers(); const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; diff --git a/surfsense_desktop/src/modules/errors.ts b/surfsense_desktop/src/modules/errors.ts new file mode 100644 index 000000000..ab9f7088c --- /dev/null +++ b/surfsense_desktop/src/modules/errors.ts @@ -0,0 +1,33 @@ +import { app, clipboard, dialog } from 'electron'; + +export function showErrorDialog(title: string, error: unknown): void { + const err = error instanceof Error ? error : new Error(String(error)); + console.error(`${title}:`, err); + + if (app.isReady()) { + const detail = err.stack || err.message; + const buttonIndex = dialog.showMessageBoxSync({ + type: 'error', + buttons: ['OK', process.platform === 'darwin' ? 'Copy Error' : 'Copy error'], + defaultId: 0, + noLink: true, + message: title, + detail, + }); + if (buttonIndex === 1) { + clipboard.writeText(`${title}\n${detail}`); + } + } else { + dialog.showErrorBox(title, err.stack || err.message); + } +} + +export function registerGlobalErrorHandlers(): void { + process.on('uncaughtException', (error) => { + showErrorDialog('Unhandled Error', error); + }); + + process.on('unhandledRejection', (reason) => { + showErrorDialog('Unhandled Promise Rejection', reason); + }); +} From f08199ececd8df86c8695bc06dd6c6bed15b8302 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:48:35 +0200 Subject: [PATCH 02/32] refactor(desktop): extract server startup into modules/server.ts --- surfsense_desktop/src/main.ts | 57 ++----------------------- surfsense_desktop/src/modules/server.ts | 53 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 53 deletions(-) create mode 100644 surfsense_desktop/src/modules/server.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index a7a12c485..db0f8f937 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,67 +1,18 @@ import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; import path from 'path'; -import { getPort } from 'get-port-please'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; +import { startNextServer, getServerPort } from './modules/server'; registerGlobalErrorHandlers(); const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; let deepLinkUrl: string | null = null; -let serverPort: number = 3000; // overwritten at startup with a free port const PROTOCOL = 'surfsense'; -// Injected at compile time from .env via esbuild define const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; -function getStandalonePath(): string { - if (isDev) { - return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); - } - return path.join(process.resourcesPath, 'standalone'); -} - -async function waitForServer(url: string, maxRetries = 60): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - const res = await fetch(url); - if (res.ok || res.status === 404 || res.status === 500) return true; - } catch { - // not ready yet - } - await new Promise((r) => setTimeout(r, 500)); - } - return false; -} - -async function startNextServer(): Promise { - if (isDev) return; - - serverPort = await getPort({ port: 3000, portRange: [30_011, 50_000] }); - console.log(`Selected port ${serverPort}`); - - const standalonePath = getStandalonePath(); - const serverScript = path.join(standalonePath, 'server.js'); - - // The standalone server.js reads PORT / HOSTNAME from process.env and - // uses process.chdir(__dirname). Running it via require() in the same - // process is the proven approach (avoids spawning a second Electron - // instance whose ASAR-patched fs breaks Next.js static file serving). - process.env.PORT = String(serverPort); - process.env.HOSTNAME = 'localhost'; - process.env.NODE_ENV = 'production'; - process.chdir(standalonePath); - - require(serverScript); - - const ready = await waitForServer(`http://localhost:${serverPort}`); - if (!ready) { - throw new Error('Next.js server failed to start within 30 s'); - } - console.log(`Next.js server ready on port ${serverPort}`); -} - function createWindow() { mainWindow = new BrowserWindow({ width: 1280, @@ -83,7 +34,7 @@ function createWindow() { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${serverPort}/login`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); // External links open in system browser, not in the Electron window mainWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -98,7 +49,7 @@ function createWindow() { // and rewrite them to localhost so the user stays in the desktop app. const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { - const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${serverPort}`); + const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); callback({ redirectURL: rewritten }); }); @@ -145,7 +96,7 @@ function handleDeepLink(url: string) { const parsed = new URL(url); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - mainWindow.loadURL(`http://localhost:${serverPort}/auth/callback?${params}`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); } mainWindow.show(); diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts new file mode 100644 index 000000000..969478e4a --- /dev/null +++ b/surfsense_desktop/src/modules/server.ts @@ -0,0 +1,53 @@ +import path from 'path'; +import { app } from 'electron'; +import { getPort } from 'get-port-please'; + +const isDev = !app.isPackaged; +let serverPort = 3000; + +export function getServerPort(): number { + return serverPort; +} + +function getStandalonePath(): string { + if (isDev) { + return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); + } + return path.join(process.resourcesPath, 'standalone'); +} + +async function waitForServer(url: string, maxRetries = 60): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(url); + if (res.ok || res.status === 404 || res.status === 500) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +export async function startNextServer(): Promise { + if (isDev) return; + + serverPort = await getPort({ port: 3000, portRange: [30_011, 50_000] }); + console.log(`Selected port ${serverPort}`); + + const standalonePath = getStandalonePath(); + const serverScript = path.join(standalonePath, 'server.js'); + + process.env.PORT = String(serverPort); + process.env.HOSTNAME = 'localhost'; + process.env.NODE_ENV = 'production'; + process.chdir(standalonePath); + + require(serverScript); + + const ready = await waitForServer(`http://localhost:${serverPort}`); + if (!ready) { + throw new Error('Next.js server failed to start within 30 s'); + } + console.log(`Next.js server ready on port ${serverPort}`); +} From 95c4a674be26a76fce84b95edbef07be9cc61ae5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:50:50 +0200 Subject: [PATCH 03/32] refactor(desktop): extract window creation into modules/window.ts --- surfsense_desktop/src/main.ts | 81 ++++--------------------- surfsense_desktop/src/modules/window.ts | 67 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 68 deletions(-) create mode 100644 surfsense_desktop/src/modules/window.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index db0f8f937..efbee44bb 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,72 +1,16 @@ -import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; import path from 'path'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer, getServerPort } from './modules/server'; +import { createMainWindow, getMainWindow } from './modules/window'; registerGlobalErrorHandlers(); const isDev = !app.isPackaged; -let mainWindow: BrowserWindow | null = null; let deepLinkUrl: string | null = null; const PROTOCOL = 'surfsense'; -const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - minWidth: 800, - minHeight: 600, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webviewTag: false, - }, - show: false, - titleBarStyle: 'hiddenInset', - }); - - mainWindow.once('ready-to-show', () => { - mainWindow?.show(); - }); - - mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); - - // External links open in system browser, not in the Electron window - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://localhost')) { - return { action: 'allow' }; - } - shell.openExternal(url); - return { action: 'deny' }; - }); - - // Intercept backend OAuth redirects targeting the hosted web frontend - // and rewrite them to localhost so the user stays in the desktop app. - const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; - session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { - const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); - callback({ redirectURL: rewritten }); - }); - - mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`); - if (errorCode === -3) return; // ERR_ABORTED — normal during redirects - showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); - }); - - if (isDev) { - mainWindow.webContents.openDevTools(); - } - - mainWindow.on('closed', () => { - mainWindow = null; - }); -} // IPC handlers ipcMain.on('open-external', (_event, url: string) => { @@ -90,17 +34,17 @@ function handleDeepLink(url: string) { deepLinkUrl = url; - if (!mainWindow) return; + const win = getMainWindow(); + if (!win) return; - // Rewrite surfsense:// deep link to localhost so TokenHandler.tsx processes it const parsed = new URL(url); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - mainWindow.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); + win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); } - mainWindow.show(); - mainWindow.focus(); + win.show(); + win.focus(); } // Single instance lock — second instance passes deep link to first @@ -113,9 +57,10 @@ if (!gotTheLock) { const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); if (url) handleDeepLink(url); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); } }); } @@ -188,7 +133,7 @@ app.whenReady().then(async () => { setTimeout(() => app.quit(), 0); return; } - createWindow(); + createMainWindow(); setupAutoUpdater(); // If a deep link was received before the window was ready, handle it now @@ -199,7 +144,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + createMainWindow(); } }); }); diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts new file mode 100644 index 000000000..1b3f3baed --- /dev/null +++ b/surfsense_desktop/src/modules/window.ts @@ -0,0 +1,67 @@ +import { app, BrowserWindow, shell, session } from 'electron'; +import path from 'path'; +import { showErrorDialog } from './errors'; +import { getServerPort } from './server'; + +const isDev = !app.isPackaged; +const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; + +let mainWindow: BrowserWindow | null = null; + +export function getMainWindow(): BrowserWindow | null { + return mainWindow; +} + +export function createMainWindow(): BrowserWindow { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: false, + }, + show: false, + titleBarStyle: 'hiddenInset', + }); + + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + }); + + mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://localhost')) { + return { action: 'allow' }; + } + shell.openExternal(url); + return { action: 'deny' }; + }); + + const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; + session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { + const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); + callback({ redirectURL: rewritten }); + }); + + mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { + console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`); + if (errorCode === -3) return; + showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); + }); + + if (isDev) { + mainWindow.webContents.openDevTools(); + } + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + return mainWindow; +} From 35da1cf1b4e8f5476388fbce110fcc0689299b61 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:55:44 +0200 Subject: [PATCH 04/32] refactor(desktop): extract deep link handling into modules/deep-links.ts --- surfsense_desktop/src/main.ts | 71 +++------------------ surfsense_desktop/src/modules/deep-links.ts | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 surfsense_desktop/src/modules/deep-links.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index efbee44bb..b61e82008 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,16 +1,17 @@ import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; -import path from 'path'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; -import { startNextServer, getServerPort } from './modules/server'; -import { createMainWindow, getMainWindow } from './modules/window'; +import { startNextServer } from './modules/server'; +import { createMainWindow } from './modules/window'; +import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; registerGlobalErrorHandlers(); -const isDev = !app.isPackaged; -let deepLinkUrl: string | null = null; +if (!setupDeepLinks()) { + app.quit(); +} -const PROTOCOL = 'surfsense'; +const isDev = !app.isPackaged; // IPC handlers ipcMain.on('open-external', (_event, url: string) => { @@ -28,58 +29,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -// Deep link handling -function handleDeepLink(url: string) { - if (!url.startsWith(`${PROTOCOL}://`)) return; - - deepLinkUrl = url; - - const win = getMainWindow(); - if (!win) return; - - const parsed = new URL(url); - if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { - const params = parsed.searchParams.toString(); - win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); - } - - win.show(); - win.focus(); -} - -// Single instance lock — second instance passes deep link to first -const gotTheLock = app.requestSingleInstanceLock(); -if (!gotTheLock) { - app.quit(); -} else { - app.on('second-instance', (_event, argv) => { - // Windows/Linux: deep link URL is in argv - const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); - if (url) handleDeepLink(url); - - const win = getMainWindow(); - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); - } - }); -} - -// macOS: deep link arrives via open-url event -app.on('open-url', (event, url) => { - event.preventDefault(); - handleDeepLink(url); -}); - -// Register surfsense:// protocol -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); - } -} else { - app.setAsDefaultProtocolClient(PROTOCOL); -} - function setupAutoUpdater() { if (isDev) return; @@ -136,11 +85,7 @@ app.whenReady().then(async () => { createMainWindow(); setupAutoUpdater(); - // If a deep link was received before the window was ready, handle it now - if (deepLinkUrl) { - handleDeepLink(deepLinkUrl); - deepLinkUrl = null; - } + handlePendingDeepLink(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts new file mode 100644 index 000000000..1a2b08395 --- /dev/null +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -0,0 +1,66 @@ +import { app } from 'electron'; +import path from 'path'; +import { getMainWindow } from './window'; +import { getServerPort } from './server'; + +const PROTOCOL = 'surfsense'; + +let deepLinkUrl: string | null = null; + +function handleDeepLink(url: string) { + if (!url.startsWith(`${PROTOCOL}://`)) return; + + deepLinkUrl = url; + + const win = getMainWindow(); + if (!win) return; + + const parsed = new URL(url); + if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { + const params = parsed.searchParams.toString(); + win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); + } + + win.show(); + win.focus(); +} + +export function setupDeepLinks(): boolean { + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + return false; + } + + app.on('second-instance', (_event, argv) => { + const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); + if (url) handleDeepLink(url); + + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); + + app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); + + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); + } + } else { + app.setAsDefaultProtocolClient(PROTOCOL); + } + + return true; +} + +export function handlePendingDeepLink(): void { + if (deepLinkUrl) { + handleDeepLink(deepLinkUrl); + deepLinkUrl = null; + } +} From d868464de71ba9ee34e14297b472fd6e2e3c7e67 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:59:20 +0200 Subject: [PATCH 05/32] refactor(desktop): extract auto-updater into modules/auto-updater.ts --- surfsense_desktop/src/main.ts | 37 +------------------ surfsense_desktop/src/modules/auto-updater.ts | 33 +++++++++++++++++ 2 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 surfsense_desktop/src/modules/auto-updater.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index b61e82008..11b2f7ab6 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,9 +1,9 @@ -import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; -import { autoUpdater } from 'electron-updater'; +import { app, BrowserWindow, shell, ipcMain, Menu } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; +import { setupAutoUpdater } from './modules/auto-updater'; registerGlobalErrorHandlers(); @@ -11,8 +11,6 @@ if (!setupDeepLinks()) { app.quit(); } -const isDev = !app.isPackaged; - // IPC handlers ipcMain.on('open-external', (_event, url: string) => { try { @@ -29,37 +27,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -function setupAutoUpdater() { - if (isDev) return; - - autoUpdater.autoDownload = true; - - autoUpdater.on('update-available', (info) => { - console.log(`Update available: ${info.version}`); - }); - - autoUpdater.on('update-downloaded', (info) => { - console.log(`Update downloaded: ${info.version}`); - dialog.showMessageBox({ - type: 'info', - buttons: ['Restart', 'Later'], - defaultId: 0, - title: 'Update Ready', - message: `Version ${info.version} has been downloaded. Restart to apply the update.`, - }).then(({ response }) => { - if (response === 0) { - autoUpdater.quitAndInstall(); - } - }); - }); - - autoUpdater.on('error', (err) => { - console.error('Auto-updater error:', err); - }); - - autoUpdater.checkForUpdates(); -} - function setupMenu() { const isMac = process.platform === 'darwin'; const template: Electron.MenuItemConstructorOptions[] = [ diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts new file mode 100644 index 000000000..f895516c0 --- /dev/null +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -0,0 +1,33 @@ +import { app, dialog } from 'electron'; +import { autoUpdater } from 'electron-updater'; + +export function setupAutoUpdater(): void { + if (!app.isPackaged) return; + + autoUpdater.autoDownload = true; + + autoUpdater.on('update-available', (info) => { + console.log(`Update available: ${info.version}`); + }); + + autoUpdater.on('update-downloaded', (info) => { + console.log(`Update downloaded: ${info.version}`); + dialog.showMessageBox({ + type: 'info', + buttons: ['Restart', 'Later'], + defaultId: 0, + title: 'Update Ready', + message: `Version ${info.version} has been downloaded. Restart to apply the update.`, + }).then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall(); + } + }); + }); + + autoUpdater.on('error', (err) => { + console.error('Auto-updater error:', err); + }); + + autoUpdater.checkForUpdates(); +} From b6a7f0afa7bced3f83fdd90a4424b4c9c8038a04 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:01:13 +0200 Subject: [PATCH 06/32] refactor(desktop): extract menu setup into modules/menu.ts --- surfsense_desktop/src/main.ts | 15 ++------------- surfsense_desktop/src/modules/menu.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 surfsense_desktop/src/modules/menu.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 11b2f7ab6..6d55d478b 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,9 +1,10 @@ -import { app, BrowserWindow, shell, ipcMain, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; +import { setupMenu } from './modules/menu'; registerGlobalErrorHandlers(); @@ -27,18 +28,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -function setupMenu() { - const isMac = process.platform === 'darwin'; - const template: Electron.MenuItemConstructorOptions[] = [ - ...(isMac ? [{ role: 'appMenu' as const }] : []), - { role: 'fileMenu' as const }, - { role: 'editMenu' as const }, - { role: 'viewMenu' as const }, - { role: 'windowMenu' as const }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - // App lifecycle app.whenReady().then(async () => { setupMenu(); diff --git a/surfsense_desktop/src/modules/menu.ts b/surfsense_desktop/src/modules/menu.ts new file mode 100644 index 000000000..128a73a21 --- /dev/null +++ b/surfsense_desktop/src/modules/menu.ts @@ -0,0 +1,13 @@ +import { Menu } from 'electron'; + +export function setupMenu(): void { + const isMac = process.platform === 'darwin'; + const template: Electron.MenuItemConstructorOptions[] = [ + ...(isMac ? [{ role: 'appMenu' as const }] : []), + { role: 'fileMenu' as const }, + { role: 'editMenu' as const }, + { role: 'viewMenu' as const }, + { role: 'windowMenu' as const }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} From fb4dbf04ae805e6e8ce8e77d5e02210d2ac881f4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:06:21 +0200 Subject: [PATCH 07/32] refactor(desktop): extract IPC channels and handlers into src/ipc/ --- surfsense_desktop/src/ipc/channels.ts | 6 ++++++ surfsense_desktop/src/ipc/handlers.ts | 19 +++++++++++++++++++ surfsense_desktop/src/main.ts | 19 +++---------------- 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 surfsense_desktop/src/ipc/channels.ts create mode 100644 surfsense_desktop/src/ipc/handlers.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts new file mode 100644 index 000000000..4d0f3bf80 --- /dev/null +++ b/surfsense_desktop/src/ipc/channels.ts @@ -0,0 +1,6 @@ +export const IPC_CHANNELS = { + OPEN_EXTERNAL: 'open-external', + GET_APP_VERSION: 'get-app-version', + DEEP_LINK: 'deep-link', + GET_CLIPBOARD_CONTENT: 'get-clipboard-content', +} as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts new file mode 100644 index 000000000..18e343719 --- /dev/null +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -0,0 +1,19 @@ +import { app, ipcMain, shell } from 'electron'; +import { IPC_CHANNELS } from './channels'; + +export function registerIpcHandlers(): void { + ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url); + } + } catch { + // invalid URL — ignore + } + }); + + ipcMain.handle(IPC_CHANNELS.GET_APP_VERSION, () => { + return app.getVersion(); + }); +} diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 6d55d478b..aff64db22 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,10 +1,11 @@ -import { app, BrowserWindow, shell, ipcMain } from 'electron'; +import { app, BrowserWindow } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; +import { registerIpcHandlers } from './ipc/handlers'; registerGlobalErrorHandlers(); @@ -12,21 +13,7 @@ if (!setupDeepLinks()) { app.quit(); } -// IPC handlers -ipcMain.on('open-external', (_event, url: string) => { - try { - const parsed = new URL(url); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - shell.openExternal(url); - } - } catch { - // invalid URL — ignore - } -}); - -ipcMain.handle('get-app-version', () => { - return app.getVersion(); -}); +registerIpcHandlers(); // App lifecycle app.whenReady().then(async () => { From ecdd7354e930d71364f6c2735ca24201cf55c83b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:13:58 +0200 Subject: [PATCH 08/32] refactor(desktop): use IPC channel constants in preload, add getClipboardContent --- surfsense_desktop/src/preload.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index dd4b89cf8..3f0f4be1f 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -1,4 +1,5 @@ const { contextBridge, ipcRenderer } = require('electron'); +const { IPC_CHANNELS } = require('./ipc/channels'); contextBridge.exposeInMainWorld('electronAPI', { versions: { @@ -7,13 +8,14 @@ contextBridge.exposeInMainWorld('electronAPI', { chrome: process.versions.chrome, platform: process.platform, }, - openExternal: (url: string) => ipcRenderer.send('open-external', url), - getAppVersion: () => ipcRenderer.invoke('get-app-version'), + openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), + getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), + getClipboardContent: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CLIPBOARD_CONTENT), onDeepLink: (callback: (url: string) => void) => { const listener = (_event: unknown, url: string) => callback(url); - ipcRenderer.on('deep-link', listener); + ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener); return () => { - ipcRenderer.removeListener('deep-link', listener); + ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, }); From 275fa86ecddbf5c9b51cb89f74734af49f7549d2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:22:37 +0200 Subject: [PATCH 09/32] feat(desktop): add system tray with clipboard-to-chat support --- surfsense_desktop/src/main.ts | 4 ++ surfsense_desktop/src/modules/clipboard.ts | 14 +++++ surfsense_desktop/src/modules/tray.ts | 73 ++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 surfsense_desktop/src/modules/clipboard.ts create mode 100644 surfsense_desktop/src/modules/tray.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index aff64db22..10f442c08 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,7 +5,9 @@ import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; +import { setupTray } from './modules/tray'; import { registerIpcHandlers } from './ipc/handlers'; +import { registerClipboardHandlers } from './modules/clipboard'; registerGlobalErrorHandlers(); @@ -14,6 +16,7 @@ if (!setupDeepLinks()) { } registerIpcHandlers(); +registerClipboardHandlers(); // App lifecycle app.whenReady().then(async () => { @@ -26,6 +29,7 @@ app.whenReady().then(async () => { return; } createMainWindow(); + setupTray(); setupAutoUpdater(); handlePendingDeepLink(); diff --git a/surfsense_desktop/src/modules/clipboard.ts b/surfsense_desktop/src/modules/clipboard.ts new file mode 100644 index 000000000..4f9d7b802 --- /dev/null +++ b/surfsense_desktop/src/modules/clipboard.ts @@ -0,0 +1,14 @@ +import { ipcMain } from 'electron'; +import { IPC_CHANNELS } from '../ipc/channels'; + +let lastClipboardContent = ''; + +export function setClipboardContent(text: string): void { + lastClipboardContent = text; +} + +export function registerClipboardHandlers(): void { + ipcMain.handle(IPC_CHANNELS.GET_CLIPBOARD_CONTENT, () => { + return lastClipboardContent; + }); +} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts new file mode 100644 index 000000000..3527cf691 --- /dev/null +++ b/surfsense_desktop/src/modules/tray.ts @@ -0,0 +1,73 @@ +import { app, BrowserWindow, clipboard, Menu, Tray } from 'electron'; +import path from 'path'; +import { getServerPort } from './server'; +import { setClipboardContent } from './clipboard'; + +let tray: Tray | null = null; +let clipWindow: BrowserWindow | null = null; + +function getIconPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'icon.png'); + } + return path.join(__dirname, '..', 'assets', 'icon.png'); +} + +function createClipWindow(): BrowserWindow { + if (clipWindow && !clipWindow.isDestroyed()) { + clipWindow.focus(); + return clipWindow; + } + + clipWindow = new BrowserWindow({ + width: 420, + height: 620, + resizable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + show: false, + titleBarStyle: 'hiddenInset', + }); + + clipWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + + clipWindow.once('ready-to-show', () => { + clipWindow?.show(); + }); + + clipWindow.on('closed', () => { + clipWindow = null; + }); + + return clipWindow; +} + +export function setupTray(): void { + tray = new Tray(getIconPath()); + tray.setToolTip('SurfSense'); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Ask about clipboard', + click: () => { + const text = clipboard.readText(); + setClipboardContent(text); + createClipWindow(); + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => app.quit(), + }, + ]); + + tray.setContextMenu(contextMenu); +} From 5ab534511c8b6ff99d324d17579276ac4cea0801 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:26:33 +0200 Subject: [PATCH 10/32] feat(web): add initialText prop to InlineMentionEditor --- .../assistant-ui/inline-mention-editor.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index dacc845ec..656a3ca2d 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,7 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; + initialText?: string; } // Unique data attribute to identify chip elements @@ -96,6 +97,7 @@ export const InlineMentionEditor = forwardRef { @@ -115,6 +117,16 @@ export const InlineMentionEditor = forwardRef { + if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; + initialTextAppliedRef.current = true; + editorRef.current.textContent = initialText; + setIsEmpty(false); + onChange?.(initialText, Array.from(mentionedDocs.values())); + }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps + // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; From c78f0e78aae2e540eaf7f86cf8cb194e53ec6980 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:33:43 +0200 Subject: [PATCH 11/32] feat(web): wire Composer to pre-fill clipboard content from Electron tray --- surfsense_web/components/assistant-ui/thread.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index b7a5bcf0e..b6bbea2f4 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -314,6 +314,16 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); + // Clipboard content from Electron tray (pre-filled into composer) + const [clipboardText, setClipboardText] = useState(); + useEffect(() => { + const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; + if (!api?.getClipboardContent) return; + api.getClipboardContent().then((text) => { + if (text) setClipboardText(text); + }); + }, []); + const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -520,6 +530,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={clipboardText} className="min-h-[24px]" /> From 9e058e13290fdbdb258d7984afc611fecdcda190 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:35:29 +0200 Subject: [PATCH 12/32] chore: clean up comments in editor and composer --- surfsense_web/components/assistant-ui/inline-mention-editor.tsx | 2 +- surfsense_web/components/assistant-ui/thread.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 656a3ca2d..be48b60fa 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -117,7 +117,7 @@ export const InlineMentionEditor = forwardRef { if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index b6bbea2f4..023b0f7bc 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -314,7 +314,7 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); - // Clipboard content from Electron tray (pre-filled into composer) + // Clipboard content const [clipboardText, setClipboardText] = useState(); useEffect(() => { const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; From d6d4ebc75de6bdbff5ffb4732d8ad8e8e9693b80 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:39:18 +0200 Subject: [PATCH 13/32] feat(web): add ElectronAPI type declaration for window.electronAPI --- surfsense_web/components/assistant-ui/thread.tsx | 2 +- surfsense_web/types/window.d.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 023b0f7bc..389b9f204 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -317,7 +317,7 @@ const Composer: FC = () => { // Clipboard content const [clipboardText, setClipboardText] = useState(); useEffect(() => { - const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; + const api = window.electronAPI; if (!api?.getClipboardContent) return; api.getClipboardContent().then((text) => { if (text) setClipboardText(text); diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 4d4abc9c1..487e2058f 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -1,7 +1,21 @@ import type { PostHog } from "posthog-js"; +interface ElectronAPI { + versions: { + electron: string; + node: string; + chrome: string; + platform: string; + }; + openExternal: (url: string) => void; + getAppVersion: () => Promise; + getClipboardContent: () => Promise; + onDeepLink: (callback: (url: string) => void) => () => void; +} + declare global { interface Window { posthog?: PostHog; + electronAPI?: ElectronAPI; } } From dea0651a94921f99f4f5d7017f6579fb91d71c80 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 23 Mar 2026 15:49:50 +0200 Subject: [PATCH 14/32] fix(desktop): include tray icon in packaged app resources --- surfsense_desktop/electron-builder.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index eaca0f19b..715366e0c 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -13,6 +13,8 @@ files: - "!scripts" - "!release" extraResources: + - from: assets/icon.png + to: icon.png - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: From f783b00d2e287a488b5129e4fe2cbc652c175ab6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 18:40:07 +0200 Subject: [PATCH 15/32] fix(desktop): bind to 0.0.0.0 and silence auto-updater 404 --- surfsense_desktop/src/modules/auto-updater.ts | 4 ++-- surfsense_desktop/src/modules/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index f895516c0..2e7680953 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -26,8 +26,8 @@ export function setupAutoUpdater(): void { }); autoUpdater.on('error', (err) => { - console.error('Auto-updater error:', err); + console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); }); - autoUpdater.checkForUpdates(); + autoUpdater.checkForUpdates().catch(() => {}); } diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts index 969478e4a..e2f078a8c 100644 --- a/surfsense_desktop/src/modules/server.ts +++ b/surfsense_desktop/src/modules/server.ts @@ -39,7 +39,7 @@ export async function startNextServer(): Promise { const serverScript = path.join(standalonePath, 'server.js'); process.env.PORT = String(serverPort); - process.env.HOSTNAME = 'localhost'; + process.env.HOSTNAME = '0.0.0.0'; process.env.NODE_ENV = 'production'; process.chdir(standalonePath); From 59e7f8f06880bf7d7cd42f7a9c6cb8e17239a173 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:12:04 +0200 Subject: [PATCH 16/32] remove tray and clipboard-to-chat feature --- surfsense_desktop/electron-builder.yml | 2 - surfsense_desktop/src/ipc/channels.ts | 1 - surfsense_desktop/src/main.ts | 4 - surfsense_desktop/src/modules/clipboard.ts | 14 ---- surfsense_desktop/src/modules/tray.ts | 73 ------------------- surfsense_desktop/src/preload.ts | 1 - .../assistant-ui/inline-mention-editor.tsx | 12 --- .../components/assistant-ui/thread.tsx | 11 --- surfsense_web/types/window.d.ts | 1 - 9 files changed, 119 deletions(-) delete mode 100644 surfsense_desktop/src/modules/clipboard.ts delete mode 100644 surfsense_desktop/src/modules/tray.ts diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index 715366e0c..eaca0f19b 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -13,8 +13,6 @@ files: - "!scripts" - "!release" extraResources: - - from: assets/icon.png - to: icon.png - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 4d0f3bf80..8ae21cfcf 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -2,5 +2,4 @@ export const IPC_CHANNELS = { OPEN_EXTERNAL: 'open-external', GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', - GET_CLIPBOARD_CONTENT: 'get-clipboard-content', } as const; diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 10f442c08..aff64db22 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,9 +5,7 @@ import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; -import { setupTray } from './modules/tray'; import { registerIpcHandlers } from './ipc/handlers'; -import { registerClipboardHandlers } from './modules/clipboard'; registerGlobalErrorHandlers(); @@ -16,7 +14,6 @@ if (!setupDeepLinks()) { } registerIpcHandlers(); -registerClipboardHandlers(); // App lifecycle app.whenReady().then(async () => { @@ -29,7 +26,6 @@ app.whenReady().then(async () => { return; } createMainWindow(); - setupTray(); setupAutoUpdater(); handlePendingDeepLink(); diff --git a/surfsense_desktop/src/modules/clipboard.ts b/surfsense_desktop/src/modules/clipboard.ts deleted file mode 100644 index 4f9d7b802..000000000 --- a/surfsense_desktop/src/modules/clipboard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ipcMain } from 'electron'; -import { IPC_CHANNELS } from '../ipc/channels'; - -let lastClipboardContent = ''; - -export function setClipboardContent(text: string): void { - lastClipboardContent = text; -} - -export function registerClipboardHandlers(): void { - ipcMain.handle(IPC_CHANNELS.GET_CLIPBOARD_CONTENT, () => { - return lastClipboardContent; - }); -} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts deleted file mode 100644 index 3527cf691..000000000 --- a/surfsense_desktop/src/modules/tray.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { app, BrowserWindow, clipboard, Menu, Tray } from 'electron'; -import path from 'path'; -import { getServerPort } from './server'; -import { setClipboardContent } from './clipboard'; - -let tray: Tray | null = null; -let clipWindow: BrowserWindow | null = null; - -function getIconPath(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'icon.png'); - } - return path.join(__dirname, '..', 'assets', 'icon.png'); -} - -function createClipWindow(): BrowserWindow { - if (clipWindow && !clipWindow.isDestroyed()) { - clipWindow.focus(); - return clipWindow; - } - - clipWindow = new BrowserWindow({ - width: 420, - height: 620, - resizable: true, - minimizable: false, - maximizable: false, - fullscreenable: false, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - show: false, - titleBarStyle: 'hiddenInset', - }); - - clipWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); - - clipWindow.once('ready-to-show', () => { - clipWindow?.show(); - }); - - clipWindow.on('closed', () => { - clipWindow = null; - }); - - return clipWindow; -} - -export function setupTray(): void { - tray = new Tray(getIconPath()); - tray.setToolTip('SurfSense'); - - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Ask about clipboard', - click: () => { - const text = clipboard.readText(); - setClipboardContent(text); - createClipWindow(); - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => app.quit(), - }, - ]); - - tray.setContextMenu(contextMenu); -} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 3f0f4be1f..d36db8c22 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -10,7 +10,6 @@ contextBridge.exposeInMainWorld('electronAPI', { }, openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), - getClipboardContent: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CLIPBOARD_CONTENT), onDeepLink: (callback: (url: string) => void) => { const listener = (_event: unknown, url: string) => callback(url); ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index be48b60fa..dacc845ec 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,7 +47,6 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; - initialText?: string; } // Unique data attribute to identify chip elements @@ -97,7 +96,6 @@ export const InlineMentionEditor = forwardRef { @@ -117,16 +115,6 @@ export const InlineMentionEditor = forwardRef { - if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; - initialTextAppliedRef.current = true; - editorRef.current.textContent = initialText; - setIsEmpty(false); - onChange?.(initialText, Array.from(mentionedDocs.values())); - }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps - // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 0a38e19d5..081e234a8 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -330,16 +330,6 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); - // Clipboard content - const [clipboardText, setClipboardText] = useState(); - useEffect(() => { - const api = window.electronAPI; - if (!api?.getClipboardContent) return; - api.getClipboardContent().then((text) => { - if (text) setClipboardText(text); - }); - }, []); - const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -546,7 +536,6 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - initialText={clipboardText} className="min-h-[24px]" /> diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 487e2058f..8d2c38c8b 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -9,7 +9,6 @@ interface ElectronAPI { }; openExternal: (url: string) => void; getAppVersion: () => Promise; - getClipboardContent: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; } From 801c07291e4cd09f21bc27ecd657b430ef977711 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:22:43 +0200 Subject: [PATCH 17/32] add quick-ask IPC channel and shortcut module --- surfsense_desktop/src/ipc/channels.ts | 1 + surfsense_desktop/src/modules/quick-ask.ts | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 surfsense_desktop/src/modules/quick-ask.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 8ae21cfcf..18002b520 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -2,4 +2,5 @@ export const IPC_CHANNELS = { OPEN_EXTERNAL: 'open-external', GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', + QUICK_ASK_TEXT: 'quick-ask-text', } as const; diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts new file mode 100644 index 000000000..e38b0d693 --- /dev/null +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -0,0 +1,29 @@ +import { clipboard, globalShortcut } from 'electron'; +import { IPC_CHANNELS } from '../ipc/channels'; +import { getMainWindow } from './window'; + +const SHORTCUT = 'CommandOrControl+Option+S'; + +export function registerQuickAsk(): void { + const ok = globalShortcut.register(SHORTCUT, () => { + const win = getMainWindow(); + if (!win) return; + + const text = clipboard.readText().trim(); + if (!text) return; + + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + }); + + if (!ok) { + console.log(`Quick-ask: failed to register ${SHORTCUT}`); + } +} + +export function unregisterQuickAsk(): void { + globalShortcut.unregister(SHORTCUT); +} From 45e91135227d5faa2758595eca21f5c4ec199c39 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:23:24 +0200 Subject: [PATCH 18/32] expose onQuickAskText in preload --- surfsense_desktop/src/preload.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index d36db8c22..ca894d6b3 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -17,4 +17,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, + onQuickAskText: (callback: (text: string) => void) => { + const listener = (_event: unknown, text: string) => callback(text); + ipcRenderer.on(IPC_CHANNELS.QUICK_ASK_TEXT, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.QUICK_ASK_TEXT, listener); + }; + }, }); From 032ccd95415b4c060372f2e2d03a9050e21adf30 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:24:02 +0200 Subject: [PATCH 19/32] add onQuickAskText type to ElectronAPI --- surfsense_web/types/window.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 8d2c38c8b..6c7e192db 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -10,6 +10,7 @@ interface ElectronAPI { openExternal: (url: string) => void; getAppVersion: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; + onQuickAskText: (callback: (text: string) => void) => () => void; } declare global { From 71a262b2e797f92e9abaab5e2ee6f028f19825ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:24:41 +0200 Subject: [PATCH 20/32] wire quick-ask in main.ts --- surfsense_desktop/src/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index aff64db22..3ab41073b 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,6 +5,7 @@ import { createMainWindow } from './modules/window'; 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 { registerIpcHandlers } from './ipc/handlers'; registerGlobalErrorHandlers(); @@ -26,6 +27,7 @@ app.whenReady().then(async () => { return; } createMainWindow(); + registerQuickAsk(); setupAutoUpdater(); handlePendingDeepLink(); @@ -44,5 +46,5 @@ app.on('window-all-closed', () => { }); app.on('will-quit', () => { - // Server runs in-process — no child process to kill + unregisterQuickAsk(); }); From f9be80ab766af7cfd78d93a044d6724b68326ab6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:26:13 +0200 Subject: [PATCH 21/32] re-add initialText prop to InlineMentionEditor --- .../components/assistant-ui/inline-mention-editor.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index dacc845ec..ab1213a49 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,7 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; + initialText?: string; } // Unique data attribute to identify chip elements @@ -96,6 +97,7 @@ export const InlineMentionEditor = forwardRef { @@ -115,6 +117,13 @@ export const InlineMentionEditor = forwardRef { + if (!initialText || !editorRef.current) return; + editorRef.current.textContent = initialText; + setIsEmpty(false); + onChange?.(initialText, Array.from(mentionedDocs.values())); + }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps + // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; From 875046263738ddcbfc6b92ed7d08546eef24b8d1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:28:41 +0200 Subject: [PATCH 22/32] listen for quick-ask text in Composer --- surfsense_web/components/assistant-ui/thread.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 081e234a8..eb98fd025 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -330,6 +330,13 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); + const [quickAskText, setQuickAskText] = useState(); + useEffect(() => { + return window.electronAPI?.onQuickAskText((text) => { + if (text) setQuickAskText(text); + }); + }, []); + const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -536,6 +543,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={quickAskText} className="min-h-[24px]" /> From d033e1cb4847e0b08b2f4d7e82e6ac235e169c63 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:12:49 +0200 Subject: [PATCH 23/32] open quick-ask as mini window at cursor position --- surfsense_desktop/src/modules/quick-ask.ts | 53 ++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index e38b0d693..6785fb3ce 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,20 +1,57 @@ -import { clipboard, globalShortcut } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, screen } from 'electron'; +import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; -import { getMainWindow } from './window'; +import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; +let quickAskWindow: BrowserWindow | null = null; + +function createQuickAskWindow(x: number, y: number): BrowserWindow { + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + quickAskWindow.setPosition(x, y); + quickAskWindow.show(); + quickAskWindow.focus(); + return quickAskWindow; + } + + quickAskWindow = new BrowserWindow({ + width: 450, + height: 550, + x, + y, + alwaysOnTop: true, + resizable: true, + frame: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + show: false, + skipTaskbar: true, + }); + + quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + + quickAskWindow.once('ready-to-show', () => { + quickAskWindow?.show(); + }); + + quickAskWindow.on('closed', () => { + quickAskWindow = null; + }); + + return quickAskWindow; +} export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { - const win = getMainWindow(); - if (!win) return; - const text = clipboard.readText().trim(); if (!text) return; - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); + const cursor = screen.getCursorScreenPoint(); + const win = createQuickAskWindow(cursor.x, cursor.y); win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); }); From 985299b72da8759f363d187b26f4828929bdabca Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:32:27 +0200 Subject: [PATCH 24/32] toggle, blur-dismiss, and titlebar for quick-ask window --- surfsense_desktop/src/modules/quick-ask.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 6785fb3ce..acf41febf 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -6,6 +6,12 @@ import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; +function hideQuickAsk(): void { + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + quickAskWindow.hide(); + } +} + function createQuickAskWindow(x: number, y: number): BrowserWindow { if (quickAskWindow && !quickAskWindow.isDestroyed()) { quickAskWindow.setPosition(x, y); @@ -19,9 +25,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, - alwaysOnTop: true, resizable: true, - frame: false, + titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -38,6 +43,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); + quickAskWindow.on('blur', hideQuickAsk); + quickAskWindow.on('closed', () => { quickAskWindow = null; }); @@ -47,6 +54,11 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { + if (quickAskWindow && !quickAskWindow.isDestroyed() && quickAskWindow.isVisible()) { + hideQuickAsk(); + return; + } + const text = clipboard.readText().trim(); if (!text) return; From 296b95ba5b689bdadc0be18fc2d55d3ba36cd119 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:45:56 +0200 Subject: [PATCH 25/32] use type panel for floating non-focus-stealing window --- surfsense_desktop/src/modules/quick-ask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index acf41febf..0058a738e 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -25,8 +25,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, + type: 'panel', resizable: true, - titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, From 91ad36027dbe32f00dbf1a81a8908564ce3dfab4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 15:57:20 +0200 Subject: [PATCH 26/32] fix: send clipboard text after page load on first open --- surfsense_desktop/src/modules/quick-ask.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 0058a738e..8eb094812 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -62,10 +62,17 @@ export function registerQuickAsk(): void { const text = clipboard.readText().trim(); if (!text) return; + const isExisting = quickAskWindow && !quickAskWindow.isDestroyed(); const cursor = screen.getCursorScreenPoint(); const win = createQuickAskWindow(cursor.x, cursor.y); - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + if (isExisting) { + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + } else { + win.webContents.once('did-finish-load', () => { + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + }); + } }); if (!ok) { From 4a5a28805d6ce670d01056c67a47b47c46b3a882 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:08:39 +0200 Subject: [PATCH 27/32] start at /dashboard, focus cursor after clipboard text --- surfsense_desktop/src/modules/window.ts | 2 +- .../components/assistant-ui/inline-mention-editor.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 1b3f3baed..245814cad 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -33,7 +33,7 @@ export function createMainWindow(): BrowserWindow { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http://localhost')) { diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ab1213a49..ae490cdd0 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,9 +119,16 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; - editorRef.current.textContent = initialText; + editorRef.current.textContent = initialText + "\n"; setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); + editorRef.current.focus(); + const sel = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps // Focus at the end of the editor From f3d6ae95e1dc1c731e9f36de450a4a64053bcf37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:22:32 +0200 Subject: [PATCH 28/32] fix: pull-based clipboard text and cursor at end with br --- surfsense_desktop/src/modules/quick-ask.ts | 21 +++++++++---------- surfsense_desktop/src/preload.ts | 8 +------ .../assistant-ui/inline-mention-editor.tsx | 4 +++- .../components/assistant-ui/thread.tsx | 2 +- surfsense_web/types/window.d.ts | 2 +- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 8eb094812..45bfe7c04 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,10 +1,11 @@ -import { BrowserWindow, clipboard, globalShortcut, screen } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; +let pendingText = ''; function hideQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { @@ -62,22 +63,20 @@ export function registerQuickAsk(): void { const text = clipboard.readText().trim(); if (!text) return; - const isExisting = quickAskWindow && !quickAskWindow.isDestroyed(); + pendingText = text; const cursor = screen.getCursorScreenPoint(); - const win = createQuickAskWindow(cursor.x, cursor.y); - - if (isExisting) { - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); - } else { - win.webContents.once('did-finish-load', () => { - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); - }); - } + createQuickAskWindow(cursor.x, cursor.y); }); if (!ok) { console.log(`Quick-ask: failed to register ${SHORTCUT}`); } + + ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { + const text = pendingText; + pendingText = ''; + return text; + }); } export function unregisterQuickAsk(): void { diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index ca894d6b3..9c857de1b 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -17,11 +17,5 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, - onQuickAskText: (callback: (text: string) => void) => { - const listener = (_event: unknown, text: string) => callback(text); - ipcRenderer.on(IPC_CHANNELS.QUICK_ASK_TEXT, listener); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.QUICK_ASK_TEXT, listener); - }; - }, + getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT), }); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ae490cdd0..40bd16f8d 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,7 +119,9 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; - editorRef.current.textContent = initialText + "\n"; + editorRef.current.innerText = initialText; + editorRef.current.appendChild(document.createElement("br")); + editorRef.current.appendChild(document.createElement("br")); setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); editorRef.current.focus(); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index eb98fd025..64ec79ef2 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -332,7 +332,7 @@ const Composer: FC = () => { const [quickAskText, setQuickAskText] = useState(); useEffect(() => { - return window.electronAPI?.onQuickAskText((text) => { + window.electronAPI?.getQuickAskText().then((text) => { if (text) setQuickAskText(text); }); }, []); diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 6c7e192db..c8b4c004a 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -10,7 +10,7 @@ interface ElectronAPI { openExternal: (url: string) => void; getAppVersion: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; - onQuickAskText: (callback: (text: string) => void) => () => void; + getQuickAskText: () => Promise; } declare global { From 7cbb67f0dd88f557670b6767c966e50e42dd911c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:35:23 +0200 Subject: [PATCH 29/32] scroll to cursor after inserting clipboard text --- .../components/assistant-ui/inline-mention-editor.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 40bd16f8d..66389cade 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,11 +119,13 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; + // Insert the text and add trailing line breaks for typing space editorRef.current.innerText = initialText; editorRef.current.appendChild(document.createElement("br")); editorRef.current.appendChild(document.createElement("br")); setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); + // Place cursor at the end of the content editorRef.current.focus(); const sel = window.getSelection(); const range = document.createRange(); @@ -131,6 +133,11 @@ export const InlineMentionEditor = forwardRef Date: Wed, 25 Mar 2026 17:05:03 +0200 Subject: [PATCH 30/32] keep panel floating, handle window opens, disable fullscreen --- surfsense_desktop/src/modules/quick-ask.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 45bfe7c04..81b74d986 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { getServerPort } from './server'; @@ -28,6 +28,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { y, type: 'panel', resizable: true, + fullscreenable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -44,7 +45,13 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); - quickAskWindow.on('blur', hideQuickAsk); + quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://localhost')) { + return { action: 'allow' }; + } + shell.openExternal(url); + return { action: 'deny' }; + }); quickAskWindow.on('closed', () => { quickAskWindow = null; From 743172785da56dbd9a750cd380917dfea480ba0b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 18:00:00 +0200 Subject: [PATCH 31/32] escape to hide, clamp panel to screen bounds, disable maximize --- surfsense_desktop/src/modules/quick-ask.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 81b74d986..f7753d1d6 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -13,6 +13,15 @@ function hideQuickAsk(): void { } } +function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } { + const display = screen.getDisplayNearestPoint({ x, y }); + const { x: dx, y: dy, width: dw, height: dh } = display.workArea; + return { + x: Math.max(dx, Math.min(x, dx + dw - w)), + y: Math.max(dy, Math.min(y, dy + dh - h)), + }; +} + function createQuickAskWindow(x: number, y: number): BrowserWindow { if (quickAskWindow && !quickAskWindow.isDestroyed()) { quickAskWindow.setPosition(x, y); @@ -29,6 +38,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { type: 'panel', resizable: true, fullscreenable: false, + maximizable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -45,6 +55,10 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); + quickAskWindow.webContents.on('before-input-event', (_event, input) => { + if (input.key === 'Escape') hideQuickAsk(); + }); + quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http://localhost')) { return { action: 'allow' }; @@ -72,7 +86,8 @@ export function registerQuickAsk(): void { pendingText = text; const cursor = screen.getCursorScreenPoint(); - createQuickAskWindow(cursor.x, cursor.y); + const pos = clampToScreen(cursor.x, cursor.y, 450, 550); + createQuickAskWindow(pos.x, pos.y); }); if (!ok) { From 2af4784e63a7193946a991369f16e6c06a446c59 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 18:26:28 +0200 Subject: [PATCH 32/32] cross-platform panel: toolbar fallback for Windows/Linux --- surfsense_desktop/src/modules/quick-ask.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index f7753d1d6..9009099a3 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -35,7 +35,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, - type: 'panel', + ...(process.platform === 'darwin' + ? { type: 'panel' as const } + : { type: 'toolbar' as const, alwaysOnTop: true }), resizable: true, fullscreenable: false, maximizable: false,