mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(auto-updater, ui): implement update notification and installation prompt in desktop application
This commit is contained in:
parent
74fff64779
commit
c0fefa4db1
7 changed files with 155 additions and 18 deletions
|
|
@ -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,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<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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
82
surfsense_web/components/desktop/DesktopUpdatePrompt.tsx
Normal file
82
surfsense_web/components/desktop/DesktopUpdatePrompt.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 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>
|
||||
);
|
||||
}
|
||||
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