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',
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,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,21 +47,10 @@ 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) => {
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 {
if (!app.isPackaged) return;
@ -108,25 +132,31 @@ export async function checkForUpdatesManually(): Promise<void> {
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<void> {
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<void> {
});
}
} catch (err) {
manualUpdateCheckInProgress = false;
await dialog.showMessageBox({
type: 'error',
title: 'Update Check Failed',

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

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