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',
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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;
|
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>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue