diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts new file mode 100644 index 000000000..18002b520 --- /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', + QUICK_ASK_TEXT: 'quick-ask-text', +} 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 e0a6c3be5..3ab41073b 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,258 +1,20 @@ -import { app, BrowserWindow, shell, ipcMain, session, dialog, clipboard, Menu } from 'electron'; -import path from 'path'; -import { getPort } from 'get-port-please'; -import { autoUpdater } from 'electron-updater'; +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 { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; +import { registerIpcHandlers } from './ipc/handlers'; -function showErrorDialog(title: string, error: unknown): void { - const err = error instanceof Error ? error : new Error(String(error)); - console.error(`${title}:`, err); +registerGlobalErrorHandlers(); - 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); -}); - -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, - 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:${serverPort}/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:${serverPort}`); - 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) => { - 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(); -}); - -// Deep link handling -function handleDeepLink(url: string) { - if (!url.startsWith(`${PROTOCOL}://`)) return; - - deepLinkUrl = url; - - if (!mainWindow) 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:${serverPort}/auth/callback?${params}`); - } - - mainWindow.show(); - mainWindow.focus(); -} - -// Single instance lock — second instance passes deep link to first -const gotTheLock = app.requestSingleInstanceLock(); -if (!gotTheLock) { +if (!setupDeepLinks()) { 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); - - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.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; - - 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[] = [ - ...(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)); -} +registerIpcHandlers(); // App lifecycle app.whenReady().then(async () => { @@ -264,18 +26,15 @@ app.whenReady().then(async () => { setTimeout(() => app.quit(), 0); return; } - createWindow(); + createMainWindow(); + registerQuickAsk(); 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) { - createWindow(); + createMainWindow(); } }); }); @@ -287,5 +46,5 @@ app.on('window-all-closed', () => { }); app.on('will-quit', () => { - // Server runs in-process — no child process to kill + unregisterQuickAsk(); }); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts new file mode 100644 index 000000000..2e7680953 --- /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.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + }); + + autoUpdater.checkForUpdates().catch(() => {}); +} 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; + } +} 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); + }); +} 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)); +} diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts new file mode 100644 index 000000000..9009099a3 --- /dev/null +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -0,0 +1,108 @@ +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } 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()) { + quickAskWindow.hide(); + } +} + +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); + quickAskWindow.show(); + quickAskWindow.focus(); + return quickAskWindow; + } + + quickAskWindow = new BrowserWindow({ + width: 450, + height: 550, + x, + y, + ...(process.platform === 'darwin' + ? { type: 'panel' as const } + : { type: 'toolbar' as const, alwaysOnTop: true }), + resizable: true, + fullscreenable: false, + maximizable: 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.webContents.on('before-input-event', (_event, input) => { + if (input.key === 'Escape') hideQuickAsk(); + }); + + quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://localhost')) { + return { action: 'allow' }; + } + shell.openExternal(url); + return { action: 'deny' }; + }); + + quickAskWindow.on('closed', () => { + quickAskWindow = null; + }); + + return quickAskWindow; +} + +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; + + pendingText = text; + const cursor = screen.getCursorScreenPoint(); + const pos = clampToScreen(cursor.x, cursor.y, 450, 550); + createQuickAskWindow(pos.x, pos.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 { + globalShortcut.unregister(SHORTCUT); +} diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts new file mode 100644 index 000000000..e2f078a8c --- /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 = '0.0.0.0'; + 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}`); +} diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts new file mode 100644 index 000000000..245814cad --- /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()}/dashboard`); + + 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; +} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index dd4b89cf8..9c857de1b 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), 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); }; }, + 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 dacc845ec..66389cade 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,29 @@ 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(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + // Scroll to cursor via a temporary anchor element + const anchor = document.createElement("span"); + range.insertNode(anchor); + anchor.scrollIntoView({ block: "end" }); + anchor.remove(); + }, [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 195afc090..1644b0163 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -306,6 +306,13 @@ const Composer: FC = () => { const aui = useAui(); const hasAutoFocusedRef = useRef(false); + const [quickAskText, setQuickAskText] = useState(); + useEffect(() => { + window.electronAPI?.getQuickAskText().then((text) => { + if (text) setQuickAskText(text); + }); + }, []); + const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); @@ -512,6 +519,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={quickAskText} className="min-h-[24px]" /> diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 4d4abc9c1..c8b4c004a 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; + onDeepLink: (callback: (url: string) => void) => () => void; + getQuickAskText: () => Promise; +} + declare global { interface Window { posthog?: PostHog; + electronAPI?: ElectronAPI; } }