diff --git a/surfsense_desktop/.env.example b/surfsense_desktop/.env.example index 2d9de7561..f4e797250 100644 --- a/surfsense_desktop/.env.example +++ b/surfsense_desktop/.env.example @@ -3,7 +3,7 @@ # The hosted web frontend URL. Used to intercept OAuth redirects and keep them # inside the desktop app. Set to your production frontend domain. -HOSTED_FRONTEND_URL=https://surfsense.net +HOSTED_FRONTEND_URL=https://surfsense.com # Runtime override for the above (read at app start, no rebuild required). # Useful for self-hosters whose backend NEXT_FRONTEND_URL differs from the diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 0ad279ece..4ee6ea3c4 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,5 +1,6 @@ { "name": "surfsense-desktop", + "productName": "SurfSense", "version": "0.0.25", "description": "SurfSense Desktop App", "main": "dist/main.js", diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 8d2af5107..17daab9a6 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -2,6 +2,8 @@ export const IPC_CHANNELS = { OPEN_EXTERNAL: 'open-external', GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', + UPDATE_DOWNLOADED: 'update:downloaded', + UPDATE_INSTALL_NOW: 'update:install-now', QUICK_ASK_TEXT: 'quick-ask-text', SET_QUICK_ASK_MODE: 'set-quick-ask-mode', GET_QUICK_ASK_MODE: 'get-quick-ask-mode', diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index d918fd90d..ed7eaac66 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -51,6 +51,7 @@ import { stopAgentFilesystemTreeWatch, type AgentFilesystemTreeWatchOptions, } from '../modules/agent-filesystem-tree-watcher'; +import { installDownloadedUpdate } from '../modules/auto-updater'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -70,6 +71,10 @@ export function registerIpcHandlers(): void { return app.getVersion(); }); + ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_NOW, () => { + installDownloadedUpdate(); + }); + ipcMain.handle(IPC_CHANNELS.GET_PERMISSIONS_STATUS, () => { return getPermissionsStatus(); }); diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 492c61f17..632758ba8 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,7 +1,7 @@ import { app } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; -import { startNextServer } from './modules/server'; +import { startNextServer, stopNextServer } from './modules/server'; import { createMainWindow, getMainWindow, markQuitting } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; @@ -19,6 +19,7 @@ import { } from './modules/auto-launch'; registerGlobalErrorHandlers(); +app.setName('SurfSense'); if (!setupDeepLinks()) { app.quit(); @@ -93,6 +94,7 @@ app.on('will-quit', async (e) => { e.preventDefault(); unregisterQuickAsk(); unregisterFolderWatcher(); + stopNextServer(); destroyTray(); await shutdownAnalytics(); app.exit(); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index e323abe53..b318b737d 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,57 +1,201 @@ -import { app, dialog } from 'electron'; +import { app, BrowserWindow, dialog } from 'electron'; +import { IPC_CHANNELS } from '../ipc/channels'; import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; -export function setupAutoUpdater(): void { - if (!app.isPackaged) return; +type AutoUpdater = { + autoDownload: boolean; + on(event: string, listener: (...args: any[]) => void): void; + once(event: string, listener: (...args: any[]) => void): void; + removeListener(event: string, listener: (...args: any[]) => void): void; + checkForUpdates(): Promise; + quitAndInstall(): void; +}; - const version = app.getVersion(); - if (!SEMVER_RE.test(version)) { - console.log(`Auto-updater: skipping — "${version}" is not valid semver`); - return; +type UpdateInfo = { + version: string; +}; + +type UpdateMenuState = + | { status: 'idle' } + | { status: 'downloading'; version: string } + | { status: 'ready'; version: string }; + +let listenersRegistered = false; +let updateMenuState: UpdateMenuState = { status: 'idle' }; +const updateMenuStateListeners = new Set<(state: UpdateMenuState) => void>(); + +export function getUpdateMenuState(): UpdateMenuState { + return updateMenuState; +} + +export function onUpdateMenuStateChange(listener: (state: UpdateMenuState) => void): () => void { + updateMenuStateListeners.add(listener); + return () => { + updateMenuStateListeners.delete(listener); + }; +} + +function setUpdateMenuState(state: UpdateMenuState): void { + updateMenuState = state; + for (const listener of updateMenuStateListeners) { + listener(state); } +} +function getAutoUpdater(): AutoUpdater { const { autoUpdater } = require('electron-updater'); + return autoUpdater as AutoUpdater; +} +function configureAutoUpdater(autoUpdater: AutoUpdater): void { autoUpdater.autoDownload = true; - autoUpdater.on('update-available', (info: { version: string }) => { + if (listenersRegistered) return; + listenersRegistered = true; + + const version = app.getVersion(); + + autoUpdater.on('update-available', (info: UpdateInfo) => { console.log(`Update available: ${info.version}`); + setUpdateMenuState({ status: 'downloading', version: info.version }); trackEvent('desktop_update_available', { current_version: version, new_version: info.version, }); }); - autoUpdater.on('update-downloaded', (info: { version: string }) => { + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { console.log(`Update downloaded: ${info.version}`); + setUpdateMenuState({ status: 'ready', version: info.version }); trackEvent('desktop_update_downloaded', { current_version: version, new_version: 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 }: { response: number }) => { - if (response === 0) { - trackEvent('desktop_update_install_accepted', { new_version: info.version }); - autoUpdater.quitAndInstall(); - } else { - trackEvent('desktop_update_install_deferred', { new_version: info.version }); - } - }); + notifyRenderersUpdateDownloaded(info); + }); + + autoUpdater.on('update-not-available', () => { + setUpdateMenuState({ status: 'idle' }); }); autoUpdater.on('error', (err: Error) => { + setUpdateMenuState({ status: 'idle' }); console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); trackEvent('desktop_update_error', { message: err.message?.split('\n')[0], }); }); +} + +function notifyRenderersUpdateDownloaded(info: UpdateInfo): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.UPDATE_DOWNLOADED, { + version: info.version, + }); + } + } +} + +export function installDownloadedUpdate(): void { + const autoUpdater = getAutoUpdater(); + trackEvent('desktop_update_install_accepted', { source: 'renderer_prompt' }); + autoUpdater.quitAndInstall(); +} + +export function setupAutoUpdater(): void { + if (!app.isPackaged) return; + + const version = app.getVersion(); + if (!SEMVER_RE.test(version)) { + console.log(`Auto-updater: skipping - "${version}" is not valid semver`); + return; + } + + const autoUpdater = getAutoUpdater(); + configureAutoUpdater(autoUpdater); autoUpdater.checkForUpdates().catch(() => {}); } + +export async function checkForUpdatesManually(): Promise { + const currentState = getUpdateMenuState(); + if (currentState.status === 'ready') { + installDownloadedUpdate(); + return; + } + if (currentState.status === 'downloading') return; + + if (!app.isPackaged) { + await dialog.showMessageBox({ + type: 'info', + title: 'Updates Unavailable', + message: 'Updates are only available in packaged builds.', + }); + return; + } + + const version = app.getVersion(); + if (!SEMVER_RE.test(version)) { + await dialog.showMessageBox({ + type: 'info', + title: 'Updates Unavailable', + message: `Version "${version}" is not a valid release version, so updates cannot be checked.`, + }); + return; + } + + const autoUpdater = getAutoUpdater(); + configureAutoUpdater(autoUpdater); + + try { + const result = await new Promise<'not-available' | 'downloaded'>((resolve, reject) => { + const cleanup = () => { + autoUpdater.removeListener('update-available', onAvailable); + autoUpdater.removeListener('update-not-available', onNotAvailable); + autoUpdater.removeListener('update-downloaded', onDownloaded); + autoUpdater.removeListener('error', onError); + }; + const onAvailable = () => {}; + const onNotAvailable = () => { + cleanup(); + resolve('not-available'); + }; + const onDownloaded = () => { + cleanup(); + resolve('downloaded'); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + autoUpdater.once('update-available', onAvailable); + autoUpdater.once('update-not-available', onNotAvailable); + autoUpdater.once('update-downloaded', onDownloaded); + autoUpdater.once('error', onError); + autoUpdater.checkForUpdates().catch((err: Error) => { + cleanup(); + setUpdateMenuState({ status: 'idle' }); + reject(err); + }); + }); + + if (result === 'not-available') { + await dialog.showMessageBox({ + type: 'info', + title: 'No Updates Available', + message: "You're up to date.", + }); + } + } catch (err) { + setUpdateMenuState({ status: 'idle' }); + await dialog.showMessageBox({ + type: 'error', + title: 'Update Check Failed', + message: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 7d5e429bd..d4c0da467 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -1,7 +1,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; -import { getServerPort } from './server'; +import { getServerOrigin } from './server'; import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -23,7 +23,7 @@ function handleDeepLink(url: string) { }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); + win.loadURL(`${getServerOrigin()}/auth/callback?${params}`); } win.show(); diff --git a/surfsense_desktop/src/modules/menu.ts b/surfsense_desktop/src/modules/menu.ts index 128a73a21..629d88a04 100644 --- a/surfsense_desktop/src/modules/menu.ts +++ b/surfsense_desktop/src/modules/menu.ts @@ -1,13 +1,118 @@ -import { Menu } from 'electron'; +import { app, Menu, shell } from 'electron'; +import { + checkForUpdatesManually, + getUpdateMenuState, + installDownloadedUpdate, + onUpdateMenuStateChange, +} from './auto-updater'; + +let updateMenuListenerRegistered = false; + +function getUpdateMenuItem(): Electron.MenuItemConstructorOptions { + const state = getUpdateMenuState(); + + if (state.status === 'downloading') { + return { + label: 'Downloading...', + enabled: false, + }; + } + + if (state.status === 'ready') { + return { + label: 'Install and Restart', + click: () => { + installDownloadedUpdate(); + }, + }; + } + + return { + label: 'Check for Updates...', + click: () => { + void checkForUpdatesManually(); + }, + }; +} + +const privacyPolicyItem: Electron.MenuItemConstructorOptions = { + label: 'Privacy Policy', + click: () => { + void shell.openExternal('https://www.surfsense.com/privacy'); + }, +}; + +const termsOfServiceItem: Electron.MenuItemConstructorOptions = { + label: 'Terms of Service', + click: () => { + void shell.openExternal('https://www.surfsense.com/terms'); + }, +}; export function setupMenu(): void { + if (!updateMenuListenerRegistered) { + updateMenuListenerRegistered = true; + onUpdateMenuStateChange(() => { + setupMenu(); + }); + } + const isMac = process.platform === 'darwin'; + const isDev = !app.isPackaged; + const updateMenuItem = getUpdateMenuItem(); + const viewSubmenu: Electron.MenuItemConstructorOptions[] = [ + { role: 'reload' as const }, + { role: 'forceReload' as const }, + ...(isDev + ? [ + { role: 'toggleDevTools' as const }, + ] + : []), + { type: 'separator' as const }, + { role: 'resetZoom' as const }, + { role: 'zoomIn' as const }, + { role: 'zoomOut' as const }, + { type: 'separator' as const }, + { role: 'togglefullscreen' as const }, + ]; const template: Electron.MenuItemConstructorOptions[] = [ - ...(isMac ? [{ role: 'appMenu' as const }] : []), + ...(isMac + ? [{ + label: app.name, + submenu: [ + { role: 'about' as const }, + updateMenuItem, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }] + : []), { role: 'fileMenu' as const }, { role: 'editMenu' as const }, - { role: 'viewMenu' as const }, + { + label: 'View', + submenu: viewSubmenu, + }, { role: 'windowMenu' as const }, + { + role: 'help' as const, + submenu: [ + ...(!isMac + ? [ + updateMenuItem, + { type: 'separator' as const }, + ] + : []), + privacyPolicyItem, + termsOfServiceItem, + ], + }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index b31ae1bcd..0807e2e08 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,8 +1,8 @@ -import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; +import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform'; -import { getServerPort } from './server'; +import { getServerOrigin } from './server'; import { getShortcuts } from './shortcuts'; import { getActiveSearchSpaceId } from './active-search-space'; import { trackEvent } from './analytics'; @@ -51,6 +51,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { contextIsolation: true, nodeIntegration: false, sandbox: true, + devTools: !app.isPackaged, }, show: false, skipTaskbar: true, @@ -58,7 +59,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { const spaceId = pendingSearchSpaceId; const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard'; - quickAskWindow.loadURL(`http://localhost:${getServerPort()}${route}?quickAssist=true`); + quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); @@ -69,7 +70,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { }); quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://localhost')) { + if (url.startsWith(getServerOrigin())) { return { action: 'allow' }; } shell.openExternal(url); diff --git a/surfsense_desktop/src/modules/screen-capture/screen-region-picker.ts b/surfsense_desktop/src/modules/screen-capture/screen-region-picker.ts index fd771b0f7..0cfc92297 100644 --- a/surfsense_desktop/src/modules/screen-capture/screen-region-picker.ts +++ b/surfsense_desktop/src/modules/screen-capture/screen-region-picker.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, desktopCapturer, nativeImage, screen } from 'electron'; +import { app, BrowserWindow, desktopCapturer, nativeImage, screen } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../../ipc/channels'; function fitNativeImageToWorkArea(img: Electron.NativeImage, display: Electron.Display): Electron.NativeImage { @@ -261,6 +261,7 @@ export function pickScreenRegion(opts?: { windowDataUrl?: string }): Promise { contextIsolation: true, nodeIntegration: false, sandbox: true, + devTools: !app.isPackaged, }, }); diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts index 17fcfb445..fc2fa05c3 100644 --- a/surfsense_desktop/src/modules/server.ts +++ b/surfsense_desktop/src/modules/server.ts @@ -1,14 +1,20 @@ import path from 'path'; -import { app } from 'electron'; +import { app, utilityProcess } from 'electron'; import { getPort } from 'get-port-please'; const isDev = !app.isPackaged; +const SERVER_HOST = '127.0.0.1'; let serverPort = 3000; +let nextServerProcess: ReturnType | null = null; export function getServerPort(): number { return serverPort; } +export function getServerOrigin(): string { + return `http://${SERVER_HOST}:${serverPort}`; +} + function getStandalonePath(): string { if (isDev) { return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); @@ -38,17 +44,55 @@ export async function startNextServer(): Promise { const standalonePath = getStandalonePath(); const serverScript = path.join(standalonePath, 'server.js'); - process.env.PORT = String(serverPort); - // Loopback bind: 0.0.0.0 leaks into request.url and flips window origin via NextResponse.redirect. - process.env.HOSTNAME = 'localhost'; - process.env.NODE_ENV = 'production'; - process.chdir(standalonePath); + const child = utilityProcess.fork(serverScript, [], { + cwd: standalonePath, + env: { + ...process.env, + PORT: String(serverPort), + // Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins. + HOSTNAME: SERVER_HOST, + NODE_ENV: 'production', + }, + serviceName: 'SurfSense Next Server', + stdio: 'pipe', + }); + nextServerProcess = child; - require(serverScript); + child.stdout?.on('data', (chunk) => { + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk) => { + process.stderr.write(chunk); + }); - const ready = await waitForServer(`http://localhost:${serverPort}`); + const handleExit = (code: number) => { + if (nextServerProcess === child) { + nextServerProcess = null; + } + console.error(`Next.js server exited with code ${code}`); + }; + child.on('exit', handleExit); + + let startupExitHandler: ((code: number) => void) | null = null; + const exited = new Promise((_resolve, reject) => { + startupExitHandler = (code: number) => { + reject(new Error(`Next.js server exited before startup completed with code ${code}`)); + }; + child.once('exit', startupExitHandler); + }); + + const ready = await Promise.race([waitForServer(getServerOrigin()), exited]); + if (startupExitHandler) { + child.removeListener('exit', startupExitHandler); + } if (!ready) { + stopNextServer(); throw new Error('Next.js server failed to start within 30 s'); } console.log(`Next.js server ready on port ${serverPort}`); } + +export function stopNextServer(): void { + nextServerProcess?.kill(); + nextServerProcess = null; +} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index f0221fe53..e71168f6e 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -10,6 +10,30 @@ let tray: Tray | null = null; let registeredGeneralAssist: string | null = null; let registeredScreenshotAssist: string | null = null; +function buildContextMenu(screenshotAccelerator: string): Menu { + return Menu.buildFromTemplate([ + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, + { + label: 'Take Screenshot\u2026', + accelerator: screenshotAccelerator || undefined, + click: () => { + trackEvent('desktop_tray_screenshot_clicked'); + void Promise.resolve(runScreenshotAssistShortcut()).catch((err) => { + console.error('[tray] Screenshot Assist failed:', err); + }); + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, + ]); +} + function getTrayIcon(): NativeImage { const iconName = process.platform === 'darwin' @@ -59,22 +83,10 @@ export async function createTray(): Promise { tray = new Tray(getTrayIcon()); tray.setToolTip('SurfSense'); - const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - trackEvent('desktop_tray_quit_clicked'); - app.exit(0); - }, - }, - ]); - - tray.setContextMenu(contextMenu); + const shortcuts = await getShortcuts(); + tray.setContextMenu(buildContextMenu(shortcuts.screenshotAssist)); tray.on('double-click', () => showMainWindow('tray_click')); - const shortcuts = await getShortcuts(); registeredGeneralAssist = registerOne( null, shortcuts.generalAssist, @@ -107,6 +119,7 @@ export async function reregisterScreenshotAssist(): Promise { runScreenshotAssistShortcut, 'Screenshot Assist' ); + tray?.setContextMenu(buildContextMenu(shortcuts.screenshotAssist)); } export function destroyTray(): void { diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 003241ef3..42011d089 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -2,17 +2,18 @@ import { app, BrowserWindow, shell, session } from 'electron'; import path from 'path'; import { trackEvent } from './analytics'; import { showErrorDialog } from './errors'; -import { getServerPort } from './server'; +import { getServerOrigin, getServerPort } from './server'; import { setActiveSearchSpaceId } from './active-search-space'; const isDev = !app.isPackaged; const isMac = process.platform === 'darwin'; +const WINDOW_TITLE = 'SurfSense'; function getHostedFrontendUrl(): string { return ( process.env.SURFSENSE_HOSTED_FRONTEND_URL_OVERRIDE || process.env.HOSTED_FRONTEND_URL || - 'https://surfsense.net' + 'https://surfsense.com' ); } @@ -41,6 +42,7 @@ export function markQuitting(): void { export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow = new BrowserWindow({ + title: WINDOW_TITLE, width: 1280, height: 800, minWidth: 800, @@ -51,6 +53,7 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { nodeIntegration: false, sandbox: true, webviewTag: false, + devTools: !app.isPackaged, }, show: false, ...(isMac @@ -65,10 +68,18 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${getServerPort()}${initialPath}`); + mainWindow.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + mainWindow?.setTitle(WINDOW_TITLE); + }); + mainWindow.webContents.on('did-finish-load', () => { + mainWindow?.setTitle(WINDOW_TITLE); + }); + + mainWindow.loadURL(`${getServerOrigin()}${initialPath}`); mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://localhost')) { + if (url.startsWith(getServerOrigin())) { return { action: 'allow' }; } shell.openExternal(url); @@ -84,8 +95,9 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { try { const u = new URL(details.url); const originalHost = u.host; - u.protocol = 'http:'; - u.host = `localhost:${getServerPort()}`; + const local = new URL(getServerOrigin()); + u.protocol = local.protocol; + u.host = local.host; trackEvent('desktop_oauth_redirect_intercepted', { host: originalHost, path: u.pathname, diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 7d72e9da5..97232179c 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -10,6 +10,14 @@ contextBridge.exposeInMainWorld('electronAPI', { }, openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), + onUpdateDownloaded: (callback: (data: { version: string }) => void) => { + const listener = (_event: unknown, data: { version: string }) => callback(data); + ipcRenderer.on(IPC_CHANNELS.UPDATE_DOWNLOADED, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_DOWNLOADED, listener); + }; + }, + installUpdateNow: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_INSTALL_NOW), 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/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent.tsx index 5af94f7e3..5919abcd6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent.tsx @@ -474,8 +474,10 @@ export function AgentPermissionsContent() { handleConfirmDelete(); }} disabled={deleteMutation.isPending} + className="relative min-w-[88px]" > - {deleteMutation.isPending ? "Deleting…" : "Delete"} + Delete + {deleteMutation.isPending && } diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 4e88709e9..eef03d463 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { RootProvider } from "fumadocs-ui/provider/next"; import { Roboto } from "next/font/google"; import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider"; +import { DesktopUpdateToast } from "@/components/desktop/desktop-update-toast"; import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider"; import { I18nProvider } from "@/components/providers/I18nProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; @@ -154,6 +155,7 @@ export default function RootLayout({ {children} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 90b28dd1a..3e9e9bb27 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -12,16 +12,17 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { getReauthEndpoint } from "@/lib/connector-telemetry"; +import { BACKEND_URL } from "@/lib/env-config"; import { cn } from "@/lib/utils"; import { DateRangeSelector } from "../../components/date-range-selector"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { SummaryConfig } from "../../components/summary-config"; import { VisionLLMConfig } from "../../components/vision-llm-config"; -import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants"; +import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { MCPServiceConfig } from "../components/mcp-service-config"; import { getConnectorConfigComponent } from "../index"; -import { BACKEND_URL } from "@/lib/env-config"; const VISION_LLM_CONNECTOR_TYPES = new Set([ EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 01a911d70..e62b9546a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -1,5 +1,4 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; /** * Connectors that operate in real time (no background indexing). @@ -294,25 +293,5 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); -// ============================================================================ -// CONNECTOR TELEMETRY REGISTRY -// ---------------------------------------------------------------------------- -// Single source of truth for "what does this connector_type look like in -// analytics?". Any connector added to the lists above is automatically -// picked up here, so adding a new integration does NOT require touching -// `lib/posthog/events.ts` or per-connector tracking code. -// ============================================================================ - -// Telemetry types & helpers are now defined in `@/lib/connector-telemetry`. -// Re-exported here for backward compatibility with existing imports. -export type { - ConnectorTelemetryGroup, - ConnectorTelemetryMeta, -} from "@/lib/connector-telemetry"; -export { - getConnectorTelemetryMeta, - getReauthEndpoint, -} from "@/lib/connector-telemetry"; - // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index 41dae221e..27e102d7e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -11,12 +11,13 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { getReauthEndpoint } from "@/lib/connector-telemetry"; +import { BACKEND_URL } from "@/lib/env-config"; import { formatRelativeDate } from "@/lib/format-date"; import { cn } from "@/lib/utils"; -import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants"; +import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants"; import { useConnectorStatus } from "../hooks/use-connector-status"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; -import { BACKEND_URL } from "@/lib/env-config"; interface ConnectorAccountsListViewProps { connectorType: string; connectorTitle: string; diff --git a/surfsense_web/components/desktop/desktop-update-toast.tsx b/surfsense_web/components/desktop/desktop-update-toast.tsx new file mode 100644 index 000000000..367190709 --- /dev/null +++ b/surfsense_web/components/desktop/desktop-update-toast.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Download, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type UpdateToastState = { + version: string; +}; + +export function DesktopUpdateToast() { + const [update, setUpdate] = useState(null); + + useEffect(() => { + const api = window.electronAPI; + if (!api?.onUpdateDownloaded) return; + + return api.onUpdateDownloaded(({ version }) => { + setUpdate({ version }); + }); + }, []); + + if (!update) return null; + + const installAndRestart = () => { + void window.electronAPI?.installUpdateNow(); + }; + + return ( +
+
+
+ +
+ +
+
Update available
+

+ A new version of SurfSense ({update.version}) is now available to install. +

+ +
+ + +
+
+ + +
+
+ ); +} diff --git a/surfsense_web/lib/connector-telemetry.ts b/surfsense_web/lib/connector-telemetry.ts index ef1b3de32..396097445 100644 --- a/surfsense_web/lib/connector-telemetry.ts +++ b/surfsense_web/lib/connector-telemetry.ts @@ -1,9 +1,9 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { - OAUTH_CONNECTORS, COMPOSIO_CONNECTORS, CRAWLERS, + OAUTH_CONNECTORS, OTHER_CONNECTORS, } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index f25d43f5e..2d12169b1 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -83,6 +83,10 @@ interface LocalTextFileResult { error?: string; } +interface UpdateDownloadedEvent { + version: string; +} + interface ElectronAPI { versions: { electron: string; @@ -92,6 +96,8 @@ interface ElectronAPI { }; openExternal: (url: string) => void; getAppVersion: () => Promise; + onUpdateDownloaded: (callback: (data: UpdateDownloadedEvent) => void) => () => void; + installUpdateNow: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void; getQuickAskText: () => Promise;