feat(auto-updater, ui): implement update notification and installation prompt in desktop application

This commit is contained in:
Anish Sarkar 2026-05-25 23:24:26 +05:30
parent 74fff64779
commit c0fefa4db1
7 changed files with 155 additions and 18 deletions

View file

@ -2,6 +2,8 @@ export const IPC_CHANNELS = {
OPEN_EXTERNAL: 'open-external', OPEN_EXTERNAL: 'open-external',
GET_APP_VERSION: 'get-app-version', GET_APP_VERSION: 'get-app-version',
DEEP_LINK: 'deep-link', DEEP_LINK: 'deep-link',
UPDATE_DOWNLOADED: 'update:downloaded',
UPDATE_INSTALL_NOW: 'update:install-now',
QUICK_ASK_TEXT: 'quick-ask-text', QUICK_ASK_TEXT: 'quick-ask-text',
SET_QUICK_ASK_MODE: 'set-quick-ask-mode', SET_QUICK_ASK_MODE: 'set-quick-ask-mode',
GET_QUICK_ASK_MODE: 'get-quick-ask-mode', GET_QUICK_ASK_MODE: 'get-quick-ask-mode',

View file

@ -51,6 +51,7 @@ import {
stopAgentFilesystemTreeWatch, stopAgentFilesystemTreeWatch,
type AgentFilesystemTreeWatchOptions, type AgentFilesystemTreeWatchOptions,
} from '../modules/agent-filesystem-tree-watcher'; } from '../modules/agent-filesystem-tree-watcher';
import { installDownloadedUpdate } from '../modules/auto-updater';
let authTokens: { bearer: string; refresh: string } | null = null; let authTokens: { bearer: string; refresh: string } | null = null;
@ -70,6 +71,10 @@ export function registerIpcHandlers(): void {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_NOW, () => {
installDownloadedUpdate();
});
ipcMain.handle(IPC_CHANNELS.GET_PERMISSIONS_STATUS, () => { ipcMain.handle(IPC_CHANNELS.GET_PERMISSIONS_STATUS, () => {
return getPermissionsStatus(); return getPermissionsStatus();
}); });

View file

@ -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'; import { trackEvent } from './analytics';
const SEMVER_RE = /^\d+\.\d+\.\d+/; const SEMVER_RE = /^\d+\.\d+\.\d+/;
@ -17,6 +18,7 @@ type UpdateInfo = {
}; };
let listenersRegistered = false; let listenersRegistered = false;
let manualUpdateCheckInProgress = false;
function getAutoUpdater(): AutoUpdater { function getAutoUpdater(): AutoUpdater {
const { autoUpdater } = require('electron-updater'); const { autoUpdater } = require('electron-updater');
@ -45,21 +47,10 @@ function configureAutoUpdater(autoUpdater: AutoUpdater): void {
current_version: version, current_version: version,
new_version: info.version, new_version: info.version,
}); });
dialog.showMessageBox({ if (!manualUpdateCheckInProgress) {
type: 'info', notifyRenderersUpdateDownloaded(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 });
} }
}); });
});
autoUpdater.on('error', (err: Error) => { autoUpdater.on('error', (err: Error) => {
console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]);
@ -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<void> {
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 { export function setupAutoUpdater(): void {
if (!app.isPackaged) return; if (!app.isPackaged) return;
@ -108,25 +132,31 @@ export async function checkForUpdatesManually(): Promise<void> {
configureAutoUpdater(autoUpdater); configureAutoUpdater(autoUpdater);
try { 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 = () => { const cleanup = () => {
manualUpdateCheckInProgress = false;
autoUpdater.removeListener('update-available', onAvailable); autoUpdater.removeListener('update-available', onAvailable);
autoUpdater.removeListener('update-not-available', onNotAvailable); autoUpdater.removeListener('update-not-available', onNotAvailable);
autoUpdater.removeListener('update-downloaded', onDownloaded);
autoUpdater.removeListener('error', onError); autoUpdater.removeListener('error', onError);
}; };
const onAvailable = (info: UpdateInfo) => { const onAvailable = (info: UpdateInfo) => {
cleanup();
void dialog.showMessageBox({ void dialog.showMessageBox({
type: 'info', type: 'info',
title: 'Update Available', title: 'Update Available',
message: `Version ${info.version} is available and will download in the background.`, message: `Version ${info.version} is available and will download in the background.`,
}); });
resolve('available');
}; };
const onNotAvailable = () => { const onNotAvailable = () => {
cleanup(); cleanup();
resolve('not-available'); resolve('not-available');
}; };
const onDownloaded = (info: UpdateInfo) => {
cleanup();
void showNativeInstallDialog(autoUpdater, info);
resolve('downloaded');
};
const onError = (err: Error) => { const onError = (err: Error) => {
cleanup(); cleanup();
reject(err); reject(err);
@ -134,6 +164,7 @@ export async function checkForUpdatesManually(): Promise<void> {
autoUpdater.once('update-available', onAvailable); autoUpdater.once('update-available', onAvailable);
autoUpdater.once('update-not-available', onNotAvailable); autoUpdater.once('update-not-available', onNotAvailable);
autoUpdater.once('update-downloaded', onDownloaded);
autoUpdater.once('error', onError); autoUpdater.once('error', onError);
autoUpdater.checkForUpdates().catch((err: Error) => { autoUpdater.checkForUpdates().catch((err: Error) => {
cleanup(); cleanup();
@ -149,6 +180,7 @@ export async function checkForUpdatesManually(): Promise<void> {
}); });
} }
} catch (err) { } catch (err) {
manualUpdateCheckInProgress = false;
await dialog.showMessageBox({ await dialog.showMessageBox({
type: 'error', type: 'error',
title: 'Update Check Failed', title: 'Update Check Failed',

View file

@ -10,6 +10,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url),
getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), 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) => { onDeepLink: (callback: (url: string) => void) => {
const listener = (_event: unknown, url: string) => callback(url); const listener = (_event: unknown, url: string) => callback(url);
ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener); ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener);

View file

@ -3,6 +3,7 @@ import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next"; import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google"; import { Roboto } from "next/font/google";
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider"; import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
import { DesktopUpdatePrompt } from "@/components/desktop/DesktopUpdatePrompt";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider"; import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider"; import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider";
@ -154,6 +155,7 @@ export default function RootLayout({
<GlobalLoadingProvider>{children}</GlobalLoadingProvider> <GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ZeroProvider> </ZeroProvider>
</ReactQueryClientProvider> </ReactQueryClientProvider>
<DesktopUpdatePrompt />
<Toaster /> <Toaster />
<AnnouncementToastProvider /> <AnnouncementToastProvider />
</RootProvider> </RootProvider>

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 UpdatePromptState = {
version: string;
};
export function DesktopUpdatePrompt() {
const [update, setUpdate] = useState<UpdatePromptState | 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 prompt"
onClick={() => setUpdate(null)}
>
<X className="size-4" strokeWidth={1.8} />
</Button>
</div>
</div>
);
}

View file

@ -83,6 +83,10 @@ interface LocalTextFileResult {
error?: string; error?: string;
} }
interface UpdateDownloadedEvent {
version: string;
}
interface ElectronAPI { interface ElectronAPI {
versions: { versions: {
electron: string; electron: string;
@ -92,6 +96,8 @@ interface ElectronAPI {
}; };
openExternal: (url: string) => void; openExternal: (url: string) => void;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
onUpdateDownloaded: (callback: (data: UpdateDownloadedEvent) => void) => () => void;
installUpdateNow: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void; onDeepLink: (callback: (url: string) => void) => () => void;
onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void; onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void;
getQuickAskText: () => Promise<string>; getQuickAskText: () => Promise<string>;