diff --git a/apps/desktop/src/lib/trpc/routers/auto-update/index.ts b/apps/desktop/src/lib/trpc/routers/auto-update/index.ts new file mode 100644 index 00000000000..9d4476656dc --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/auto-update/index.ts @@ -0,0 +1,95 @@ +import { observable } from "@trpc/server/observable"; +import { + autoUpdateEmitter, + checkForUpdates, + dismissUpdate, + getUpdateStatus, + installUpdate, + simulateUpdateReady, +} from "main/lib/auto-updater"; +import { AUTO_UPDATE_EVENTS, type AutoUpdateStatus } from "shared/constants"; +import { publicProcedure, router } from "../.."; + +export interface AutoUpdateEvent { + type: typeof AUTO_UPDATE_EVENTS.STATUS_CHANGED; + status: AutoUpdateStatus; + version?: string; + error?: string; +} + +export const createAutoUpdateRouter = () => { + return router({ + /** + * Subscribe to auto-update status changes + */ + subscribe: publicProcedure.subscription(() => { + return observable((emit) => { + // Emit current status immediately on subscribe + const currentStatus = getUpdateStatus(); + emit.next({ + type: AUTO_UPDATE_EVENTS.STATUS_CHANGED, + ...currentStatus, + }); + + const onStatusChanged = (data: { + status: AutoUpdateStatus; + version?: string; + error?: string; + }) => { + emit.next({ type: AUTO_UPDATE_EVENTS.STATUS_CHANGED, ...data }); + }; + + autoUpdateEmitter.on( + AUTO_UPDATE_EVENTS.STATUS_CHANGED, + onStatusChanged, + ); + + return () => { + autoUpdateEmitter.off( + AUTO_UPDATE_EVENTS.STATUS_CHANGED, + onStatusChanged, + ); + }; + }); + }), + + /** + * Get current update status + */ + getStatus: publicProcedure.query(() => { + return getUpdateStatus(); + }), + + /** + * Trigger install and restart + */ + installAndRestart: publicProcedure.mutation(() => { + installUpdate(); + return { success: true }; + }), + + /** + * Dismiss the update notification for this session + */ + dismiss: publicProcedure.mutation(() => { + dismissUpdate(); + return { success: true }; + }), + + /** + * Check for updates manually + */ + checkForUpdates: publicProcedure.mutation(() => { + checkForUpdates(); + return { success: true }; + }), + + /** + * DEV ONLY: Simulate an update ready state for testing the UI + */ + simulateUpdateReady: publicProcedure.mutation(() => { + simulateUpdateReady(); + return { success: true }; + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 4f72b79fca5..50b0ce7a306 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; import { createAuthRouter } from "./auth"; +import { createAutoUpdateRouter } from "./auto-update"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; @@ -25,6 +26,7 @@ import { createWorkspacesRouter } from "./workspaces"; export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ auth: createAuthRouter(getWindow), + autoUpdate: createAutoUpdateRouter(), user: createUserRouter(), window: createWindowRouter(getWindow), projects: createProjectsRouter(getWindow), diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index ddd2867e126..a79cb971199 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -1,28 +1,120 @@ -import { app, type BrowserWindow, dialog } from "electron"; +import { EventEmitter } from "node:events"; +import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { PLATFORM } from "shared/constants"; +import { + AUTO_UPDATE_EVENTS, + AUTO_UPDATE_STATUS, + type AutoUpdateStatus, + PLATFORM, +} from "shared/constants"; const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 4; // 4 hours const UPDATE_FEED_URL = "https://github.com/superset-sh/superset/releases/latest/download"; -let mainWindow: BrowserWindow | null = null; -let isUpdateDialogOpen = false; +// Event emitter for auto-update status changes +export const autoUpdateEmitter = new EventEmitter(); + +// Current update state +let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE; +let currentVersion: string | undefined; +let currentError: string | undefined; +let isDismissed = false; + +/** + * Get the current update status + */ +export function getUpdateStatus(): { + status: AutoUpdateStatus; + version?: string; + error?: string; +} { + // If dismissed, report as idle to the UI + if (isDismissed && currentStatus === AUTO_UPDATE_STATUS.READY) { + return { status: AUTO_UPDATE_STATUS.IDLE }; + } + return { + status: currentStatus, + version: currentVersion, + error: currentError, + }; +} + +/** + * Emit a status change event + */ +function emitStatusChange( + status: AutoUpdateStatus, + version?: string, + error?: string, +): void { + currentStatus = status; + currentVersion = version; + currentError = error; + + // Don't emit if dismissed and status is ready + if (isDismissed && status === AUTO_UPDATE_STATUS.READY) { + return; + } + + autoUpdateEmitter.emit(AUTO_UPDATE_EVENTS.STATUS_CHANGED, { + status, + version, + error, + }); +} + +/** + * Install the update and restart + */ +export function installUpdate(): void { + autoUpdater.quitAndInstall(false, true); +} -export function setMainWindow(window: BrowserWindow): void { - mainWindow = window; +/** + * Dismiss the update notification for this session + */ +export function dismissUpdate(): void { + isDismissed = true; + // Emit idle status to hide the toast + autoUpdateEmitter.emit(AUTO_UPDATE_EVENTS.STATUS_CHANGED, { + status: AUTO_UPDATE_STATUS.IDLE, + }); } +/** + * DEV ONLY: Simulate an update ready state for testing the UI + */ +export function simulateUpdateReady(): void { + if (env.NODE_ENV !== "development") { + console.warn("[auto-updater] simulateUpdateReady is only available in dev"); + return; + } + isDismissed = false; + emitStatusChange(AUTO_UPDATE_STATUS.READY, "1.0.0-test"); +} + +/** + * Check for updates silently (for background/automatic checks) + * No user-facing dialogs - status is communicated via events to the toast + */ export function checkForUpdates(): void { if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { return; } + isDismissed = false; + emitStatusChange(AUTO_UPDATE_STATUS.CHECKING); autoUpdater.checkForUpdates().catch((error) => { console.error("[auto-updater] Failed to check for updates:", error); + emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); }); } +/** + * Check for updates with user feedback (for menu-triggered checks) + * Shows dialogs for unsupported platforms, errors, and "up to date" states + */ export function checkForUpdatesInteractive(): void { if (env.NODE_ENV === "development") { dialog.showMessageBox({ @@ -32,6 +124,7 @@ export function checkForUpdatesInteractive(): void { }); return; } + if (!PLATFORM.IS_MAC) { dialog.showMessageBox({ type: "info", @@ -41,23 +134,34 @@ export function checkForUpdatesInteractive(): void { return; } + isDismissed = false; + emitStatusChange(AUTO_UPDATE_STATUS.CHECKING); + autoUpdater .checkForUpdates() .then((result) => { - if (!result || !result.updateInfo) { + // If no update is available, show feedback to user + // (update-available case is handled by the toast via events) + if ( + !result?.updateInfo || + result.updateInfo.version === app.getVersion() + ) { dialog.showMessageBox({ type: "info", title: "No Updates", - message: "You are running the latest version.", + message: "You're up to date!", + detail: `Version ${app.getVersion()} is the latest version.`, }); } }) .catch((error) => { console.error("[auto-updater] Failed to check for updates:", error); + emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); dialog.showMessageBox({ type: "error", title: "Update Error", - message: "Failed to check for updates. Please try again later.", + message: "Failed to check for updates.", + detail: "Please try again later.", }); }); } @@ -78,56 +182,37 @@ export function setupAutoUpdater(): void { autoUpdater.on("error", (error) => { console.error("[auto-updater] Error during update check:", error); + emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); + }); + + autoUpdater.on("checking-for-update", () => { + console.info("[auto-updater] Checking for updates..."); + emitStatusChange(AUTO_UPDATE_STATUS.CHECKING); }); autoUpdater.on("update-available", (info) => { console.info( `[auto-updater] Update available: ${info.version}. Downloading...`, ); + emitStatusChange(AUTO_UPDATE_STATUS.DOWNLOADING, info.version); }); autoUpdater.on("update-not-available", () => { console.info("[auto-updater] No updates available"); + emitStatusChange(AUTO_UPDATE_STATUS.IDLE); }); - autoUpdater.on("update-downloaded", (info) => { - if (isUpdateDialogOpen) { - console.info("[auto-updater] Update dialog already open, skipping"); - return; - } - + autoUpdater.on("download-progress", (progress) => { console.info( - `[auto-updater] Update downloaded (${info.version}). Prompting user to restart.`, + `[auto-updater] Download progress: ${progress.percent.toFixed(1)}%`, ); + }); - isUpdateDialogOpen = true; - - const dialogOptions = { - type: "info" as const, - buttons: ["Restart Now", "Later"], - defaultId: 0, - cancelId: 1, - title: "Update Ready", - message: `Version ${info.version} is ready to install`, - detail: - "A new version has been downloaded. Restart the application to apply the update.", - }; - - const showDialog = mainWindow - ? dialog.showMessageBox(mainWindow, dialogOptions) - : dialog.showMessageBox(dialogOptions); - - showDialog - .then((response) => { - isUpdateDialogOpen = false; - if (response.response === 0) { - autoUpdater.quitAndInstall(false, true); - } - }) - .catch((error) => { - isUpdateDialogOpen = false; - console.error("[auto-updater] Failed to show update dialog:", error); - }); + autoUpdater.on("update-downloaded", (info) => { + console.info( + `[auto-updater] Update downloaded (${info.version}). Ready to install.`, + ); + emitStatusChange(AUTO_UPDATE_STATUS.READY, info.version); }); const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 7fded0d927b..2a02399c2ce 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -7,7 +7,6 @@ import { NOTIFICATION_EVENTS, PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; -import { setMainWindow } from "../lib/auto-updater"; import { db } from "../lib/db"; import { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; @@ -52,7 +51,6 @@ export async function MainWindow() { }, }); - setMainWindow(window); createApplicationMenu(); currentWindow = window; diff --git a/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx new file mode 100644 index 00000000000..1f83f64d272 --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx @@ -0,0 +1,97 @@ +import { Button } from "@superset/ui/button"; +import { useEffect, useRef } from "react"; +import { LuGift } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { AUTO_UPDATE_STATUS } from "shared/constants"; + +export function UpdateToast() { + const utils = trpc.useUtils(); + const { data: status } = trpc.autoUpdate.getStatus.useQuery(); + const installMutation = trpc.autoUpdate.installAndRestart.useMutation(); + const dismissMutation = trpc.autoUpdate.dismiss.useMutation({ + onSuccess: () => { + utils.autoUpdate.getStatus.invalidate(); + }, + }); + const simulateMutation = trpc.autoUpdate.simulateUpdateReady.useMutation({ + onSuccess: () => { + utils.autoUpdate.getStatus.invalidate(); + }, + }); + + // Store mutation in ref to avoid effect re-running + const simulateMutationRef = useRef(simulateMutation); + simulateMutationRef.current = simulateMutation; + + // Subscribe to status changes + trpc.autoUpdate.subscribe.useSubscription(undefined, { + onData: () => { + utils.autoUpdate.getStatus.invalidate(); + }, + }); + + // DEV ONLY: Expose test helper on window + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + + const windowWithHelper = window as unknown as { + __testUpdateToast?: () => void; + }; + windowWithHelper.__testUpdateToast = () => { + simulateMutationRef.current.mutate(); + }; + + return () => { + delete windowWithHelper.__testUpdateToast; + }; + }, []); + + const isDownloading = status?.status === AUTO_UPDATE_STATUS.DOWNLOADING; + const isReady = status?.status === AUTO_UPDATE_STATUS.READY; + + // Only show when downloading or ready + if (!status || (!isDownloading && !isReady)) { + return null; + } + + const handleInstall = () => { + installMutation.mutate(); + }; + + const handleLater = () => { + dismissMutation.mutate(); + }; + + return ( +
+
+ + + {isDownloading + ? `Update available! Downloading${status.version ? ` v${status.version}` : ""}...` + : "New update available"} + + {isReady && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/UpdateToast/index.ts b/apps/desktop/src/renderer/components/UpdateToast/index.ts new file mode 100644 index 00000000000..39ba9721959 --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateToast/index.ts @@ -0,0 +1 @@ +export { UpdateToast } from "./UpdateToast"; diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 4829746c6b8..8c71d528bea 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDom from "react-dom/client"; import { ThemedToaster } from "./components/ThemedToaster"; +import { UpdateToast } from "./components/UpdateToast"; import { AppProviders } from "./contexts"; import { AppRoutes } from "./routes"; @@ -12,6 +13,7 @@ ReactDom.createRoot(document.querySelector("app") as HTMLElement).render( + , ); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6c93528bf10..438001fc1e4 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -45,3 +45,20 @@ export const NOTIFICATION_EVENTS = { AGENT_COMPLETE: "agent-complete", FOCUS_TAB: "focus-tab", } as const; + +// Auto-update event types +export const AUTO_UPDATE_EVENTS = { + STATUS_CHANGED: "status-changed", +} as const; + +// Auto-update status values +export const AUTO_UPDATE_STATUS = { + IDLE: "idle", + CHECKING: "checking", + DOWNLOADING: "downloading", + READY: "ready", + ERROR: "error", +} as const; + +export type AutoUpdateStatus = + (typeof AUTO_UPDATE_STATUS)[keyof typeof AUTO_UPDATE_STATUS];