Merge pull request #1437 from AnishSarkar22/fix/electron-nextjs

feat: improve desktop updates, branding, and local server lifecycle
This commit is contained in:
Rohan Verma 2026-05-26 13:36:58 -07:00 committed by GitHub
commit 5f4d62c225
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 503 additions and 91 deletions

View file

@ -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

View file

@ -1,5 +1,6 @@
{
"name": "surfsense-desktop",
"productName": "SurfSense",
"version": "0.0.25",
"description": "SurfSense Desktop App",
"main": "dist/main.js",

View file

@ -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',

View file

@ -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();
});

View file

@ -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();

View file

@ -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<unknown>;
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<void> {
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),
});
}
}

View file

@ -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();

View file

@ -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));
}

View file

@ -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);

View file

@ -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<str
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
devTools: !app.isPackaged,
},
});

View file

@ -1,4 +1,4 @@
import { BrowserWindow, desktopCapturer, ipcMain, screen } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, screen } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../../ipc/channels';
@ -185,6 +185,7 @@ export function pickOpenWindowCapture(): Promise<PickedWindowResult | null> {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
devTools: !app.isPackaged,
},
});

View file

@ -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<typeof utilityProcess.fork> | 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<void> {
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<never>((_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;
}

View file

@ -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<void> {
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<void> {
runScreenshotAssistShortcut,
'Screenshot Assist'
);
tray?.setContextMenu(buildContextMenu(shortcuts.screenshotAssist));
}
export function destroyTray(): void {

View file

@ -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,

View file

@ -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);

View file

@ -474,8 +474,10 @@ export function AgentPermissionsContent() {
handleConfirmDelete();
}}
disabled={deleteMutation.isPending}
className="relative min-w-[88px]"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
<span className={deleteMutation.isPending ? "opacity-0" : ""}>Delete</span>
{deleteMutation.isPending && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -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({
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ZeroProvider>
</ReactQueryClientProvider>
<DesktopUpdateToast />
<Toaster />
<AnnouncementToastProvider />
</RootProvider>

View file

@ -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<SearchSourceConnector["connector_type"]>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,

View file

@ -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<string, AutoIndexConfig> = {
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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";

View file

@ -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;

View file

@ -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<UpdateToastState | null>(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 (
<div className="pointer-events-none fixed bottom-5 right-5 z-[100]">
<div
className={cn(
"pointer-events-auto relative flex w-[360px] max-w-[calc(100vw-2.5rem)] gap-3 rounded-md border border-popover-border",
"bg-popover p-4 text-popover-foreground shadow-md"
)}
aria-live="polite"
>
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground">
<Download className="size-5" strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="pr-8 text-sm font-semibold tracking-tight">Update available</div>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
A new version of SurfSense ({update.version}) is now available to install.
</p>
<div className="mt-3 flex items-center gap-4">
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-sm font-semibold hover:bg-transparent hover:text-foreground"
onClick={installAndRestart}
>
Install and restart
</Button>
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-sm font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => setUpdate(null)}
>
Not now
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 size-7 text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label="Dismiss update toast"
onClick={() => setUpdate(null)}
>
<X className="size-4" strokeWidth={1.8} />
</Button>
</div>
</div>
);
}

View file

@ -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";

View file

@ -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<string>;
onUpdateDownloaded: (callback: (data: UpdateDownloadedEvent) => void) => () => void;
installUpdateNow: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void;
getQuickAskText: () => Promise<string>;