mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #1437 from AnishSarkar22/fix/electron-nextjs
feat: improve desktop updates, branding, and local server lifecycle
This commit is contained in:
commit
5f4d62c225
23 changed files with 503 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "surfsense-desktop",
|
||||
"productName": "SurfSense",
|
||||
"version": "0.0.25",
|
||||
"description": "SurfSense Desktop App",
|
||||
"main": "dist/main.js",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
82
surfsense_web/components/desktop/desktop-update-toast.tsx
Normal file
82
surfsense_web/components/desktop/desktop-update-toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
6
surfsense_web/types/window.d.ts
vendored
6
surfsense_web/types/window.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue