From c0fefa4db1a67387bc3411852a8b590f4471e41e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 25 May 2026 23:24:26 +0530 Subject: [PATCH] feat(auto-updater, ui): implement update notification and installation prompt in desktop application --- surfsense_desktop/src/ipc/channels.ts | 2 + surfsense_desktop/src/ipc/handlers.ts | 5 ++ surfsense_desktop/src/modules/auto-updater.ts | 68 +++++++++++---- surfsense_desktop/src/preload.ts | 8 ++ surfsense_web/app/layout.tsx | 2 + .../desktop/DesktopUpdatePrompt.tsx | 82 +++++++++++++++++++ surfsense_web/types/window.d.ts | 6 ++ 7 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 surfsense_web/components/desktop/DesktopUpdatePrompt.tsx 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/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index c0fea6634..4745fa24b 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ -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+/; @@ -17,6 +18,7 @@ type UpdateInfo = { }; let listenersRegistered = false; +let manualUpdateCheckInProgress = false; function getAutoUpdater(): AutoUpdater { const { autoUpdater } = require('electron-updater'); @@ -45,20 +47,9 @@ function configureAutoUpdater(autoUpdater: AutoUpdater): void { 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 }); - } - }); + if (!manualUpdateCheckInProgress) { + notifyRenderersUpdateDownloaded(info); + } }); autoUpdater.on('error', (err: Error) => { @@ -69,6 +60,39 @@ function configureAutoUpdater(autoUpdater: AutoUpdater): void { }); } +function notifyRenderersUpdateDownloaded(info: UpdateInfo): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.UPDATE_DOWNLOADED, { + version: info.version, + }); + } + } +} + +async function showNativeInstallDialog(autoUpdater: AutoUpdater, info: UpdateInfo): Promise { + const { response } = await dialog.showMessageBox({ + type: 'info', + buttons: ['Restart', 'Later'], + defaultId: 0, + title: 'Update Ready', + message: `Version ${info.version} has been downloaded. Restart to apply the update.`, + }); + + if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); + autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_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; @@ -108,25 +132,31 @@ export async function checkForUpdatesManually(): Promise { configureAutoUpdater(autoUpdater); try { - const result = await new Promise<'available' | 'not-available'>((resolve, reject) => { + manualUpdateCheckInProgress = true; + const result = await new Promise<'not-available' | 'downloaded'>((resolve, reject) => { const cleanup = () => { + manualUpdateCheckInProgress = false; autoUpdater.removeListener('update-available', onAvailable); autoUpdater.removeListener('update-not-available', onNotAvailable); + autoUpdater.removeListener('update-downloaded', onDownloaded); autoUpdater.removeListener('error', onError); }; const onAvailable = (info: UpdateInfo) => { - cleanup(); void dialog.showMessageBox({ type: 'info', title: 'Update Available', message: `Version ${info.version} is available and will download in the background.`, }); - resolve('available'); }; const onNotAvailable = () => { cleanup(); resolve('not-available'); }; + const onDownloaded = (info: UpdateInfo) => { + cleanup(); + void showNativeInstallDialog(autoUpdater, info); + resolve('downloaded'); + }; const onError = (err: Error) => { cleanup(); reject(err); @@ -134,6 +164,7 @@ export async function checkForUpdatesManually(): Promise { 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(); @@ -149,6 +180,7 @@ export async function checkForUpdatesManually(): Promise { }); } } catch (err) { + manualUpdateCheckInProgress = false; await dialog.showMessageBox({ type: 'error', title: 'Update Check Failed', 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/layout.tsx b/surfsense_web/app/layout.tsx index 4e88709e9..4bb15c607 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 { DesktopUpdatePrompt } from "@/components/desktop/DesktopUpdatePrompt"; 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/desktop/DesktopUpdatePrompt.tsx b/surfsense_web/components/desktop/DesktopUpdatePrompt.tsx new file mode 100644 index 000000000..091121141 --- /dev/null +++ b/surfsense_web/components/desktop/DesktopUpdatePrompt.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 UpdatePromptState = { + version: string; +}; + +export function DesktopUpdatePrompt() { + 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/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;