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..003f69a7e7d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/auto-update/index.ts @@ -0,0 +1,56 @@ +import { observable } from "@trpc/server/observable"; +import { + type AutoUpdateStatusEvent, + autoUpdateEmitter, + dismissUpdate, + getUpdateStatus, + installUpdate, + simulateDownloading, + simulateError, + simulateUpdateReady, +} from "main/lib/auto-updater"; +import { publicProcedure, router } from "../.."; + +export const createAutoUpdateRouter = () => { + return router({ + subscribe: publicProcedure.subscription(() => { + return observable((emit) => { + emit.next(getUpdateStatus()); + + const onStatusChanged = (event: AutoUpdateStatusEvent) => { + emit.next(event); + }; + + autoUpdateEmitter.on("status-changed", onStatusChanged); + + return () => { + autoUpdateEmitter.off("status-changed", onStatusChanged); + }; + }); + }), + + getStatus: publicProcedure.query(() => { + return getUpdateStatus(); + }), + + install: publicProcedure.mutation(() => { + installUpdate(); + }), + + dismiss: publicProcedure.mutation(() => { + dismissUpdate(); + }), + + simulateReady: publicProcedure.mutation(() => { + simulateUpdateReady(); + }), + + simulateDownloading: publicProcedure.mutation(() => { + simulateDownloading(); + }), + + simulateError: publicProcedure.mutation(() => { + simulateError(); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index bb53dea8f41..dc0e9e21664 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"; @@ -26,6 +27,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..0bc07423b84 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -1,25 +1,71 @@ -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 { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { 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; +export interface AutoUpdateStatusEvent { + status: AutoUpdateStatus; + version?: string; + error?: string; +} + +export const autoUpdateEmitter = new EventEmitter(); + +let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE; +let currentVersion: string | undefined; +let isDismissed = false; + +function emitStatus( + status: AutoUpdateStatus, + version?: string, + error?: string, +): void { + currentStatus = status; + currentVersion = version; + + if (isDismissed && status === AUTO_UPDATE_STATUS.READY) { + return; + } + + autoUpdateEmitter.emit("status-changed", { status, version, error }); +} + +export function getUpdateStatus(): AutoUpdateStatusEvent { + if (isDismissed && currentStatus === AUTO_UPDATE_STATUS.READY) { + return { status: AUTO_UPDATE_STATUS.IDLE }; + } + return { status: currentStatus, version: currentVersion }; +} + +export function installUpdate(): void { + if (env.NODE_ENV === "development") { + console.info("[auto-updater] Install skipped in dev mode"); + emitStatus(AUTO_UPDATE_STATUS.IDLE); + return; + } + autoUpdater.quitAndInstall(false, true); +} -export function setMainWindow(window: BrowserWindow): void { - mainWindow = window; +export function dismissUpdate(): void { + isDismissed = true; + autoUpdateEmitter.emit("status-changed", { status: AUTO_UPDATE_STATUS.IDLE }); } export function checkForUpdates(): void { if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { return; } + isDismissed = false; + emitStatus(AUTO_UPDATE_STATUS.CHECKING); autoUpdater.checkForUpdates().catch((error) => { console.error("[auto-updater] Failed to check for updates:", error); + emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); }); } @@ -41,19 +87,28 @@ export function checkForUpdatesInteractive(): void { return; } + isDismissed = false; + emitStatus(AUTO_UPDATE_STATUS.CHECKING); + autoUpdater .checkForUpdates() .then((result) => { - if (!result || !result.updateInfo) { + if ( + !result?.updateInfo || + result.updateInfo.version === app.getVersion() + ) { + emitStatus(AUTO_UPDATE_STATUS.IDLE); 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); + emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); dialog.showMessageBox({ type: "error", title: "Update Error", @@ -62,6 +117,28 @@ export function checkForUpdatesInteractive(): void { }); } +export function simulateUpdateReady(): void { + if (env.NODE_ENV !== "development") return; + isDismissed = false; + emitStatus(AUTO_UPDATE_STATUS.READY, "99.0.0-test"); +} + +export function simulateDownloading(): void { + if (env.NODE_ENV !== "development") return; + isDismissed = false; + emitStatus(AUTO_UPDATE_STATUS.DOWNLOADING, "99.0.0-test"); +} + +export function simulateError(): void { + if (env.NODE_ENV !== "development") return; + isDismissed = false; + emitStatus( + AUTO_UPDATE_STATUS.ERROR, + undefined, + "Simulated error for testing", + ); +} + export function setupAutoUpdater(): void { if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { return; @@ -78,56 +155,37 @@ export function setupAutoUpdater(): void { autoUpdater.on("error", (error) => { console.error("[auto-updater] Error during update check:", error); + emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); + }); + + autoUpdater.on("checking-for-update", () => { + console.info("[auto-updater] Checking for updates..."); + emitStatus(AUTO_UPDATE_STATUS.CHECKING); }); autoUpdater.on("update-available", (info) => { console.info( `[auto-updater] Update available: ${info.version}. Downloading...`, ); + emitStatus(AUTO_UPDATE_STATUS.DOWNLOADING, info.version); }); autoUpdater.on("update-not-available", () => { console.info("[auto-updater] No updates available"); + emitStatus(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.`, + ); + emitStatus(AUTO_UPDATE_STATUS.READY, info.version); }); const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); diff --git a/apps/desktop/src/main/lib/local-db/index.ts b/apps/desktop/src/main/lib/local-db/index.ts index d0f86055a5b..238eeed0207 100644 --- a/apps/desktop/src/main/lib/local-db/index.ts +++ b/apps/desktop/src/main/lib/local-db/index.ts @@ -19,6 +19,7 @@ function ensureAppHomeDirExists() { mkdirSync(SUPERSET_HOME_DIR, { recursive: true }); } ensureAppHomeDirExists(); + /** * Gets the migrations directory path. * diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 88cc9483fc2..8c7ccd1eb4a 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,6 +1,12 @@ import { COMPANY } from "@superset/shared/constants"; import { app, Menu, shell } from "electron"; -import { checkForUpdatesInteractive } from "./auto-updater"; +import { env } from "main/env.main"; +import { + checkForUpdatesInteractive, + simulateDownloading, + simulateError, + simulateUpdateReady, +} from "./auto-updater"; import { menuEmitter } from "./menu-events"; export function createApplicationMenu() { @@ -72,6 +78,27 @@ export function createApplicationMenu() { }, ]; + // DEV ONLY: Add Dev menu + if (env.NODE_ENV === "development") { + template.push({ + label: "Dev", + submenu: [ + { + label: "Simulate Update Downloading", + click: () => simulateDownloading(), + }, + { + label: "Simulate Update Ready", + click: () => simulateUpdateReady(), + }, + { + label: "Simulate Update Error", + click: () => simulateError(), + }, + ], + }); + } + if (process.platform === "darwin") { template.unshift({ label: app.name, diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 65161e62783..c49659d112b 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -10,7 +10,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 { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { @@ -54,7 +53,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..0d6f8946b3c --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx @@ -0,0 +1,98 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { AUTO_UPDATE_STATUS, RELEASES_URL } from "shared/auto-update"; + +interface UpdateToastProps { + toastId: string | number; + status: "downloading" | "ready" | "error"; + version?: string; + error?: string; +} + +export function UpdateToast({ + toastId, + status, + version, + error, +}: UpdateToastProps) { + const openUrl = trpc.external.openUrl.useMutation(); + const installMutation = trpc.autoUpdate.install.useMutation(); + const dismissMutation = trpc.autoUpdate.dismiss.useMutation({ + onSuccess: () => { + toast.dismiss(toastId); + }, + }); + + const isDownloading = status === AUTO_UPDATE_STATUS.DOWNLOADING; + const isReady = status === AUTO_UPDATE_STATUS.READY; + const isError = status === AUTO_UPDATE_STATUS.ERROR; + + const handleSeeChanges = () => { + openUrl.mutate(RELEASES_URL); + }; + + const handleInstall = () => { + installMutation.mutate(); + }; + + const handleLater = () => { + dismissMutation.mutate(); + }; + + return ( +
+ +
+ {isError ? ( + <> + + Update failed + + + {error || "Please try again later"} + + + ) : isDownloading ? ( + <> + Downloading update... + + {version ? `Version ${version}` : "Please wait"} + + + ) : ( + <> + Update available + + {version + ? `Version ${version} is ready to install` + : "Ready to install"} + + + )} +
+ {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..002a8e0121b --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateToast/index.ts @@ -0,0 +1,2 @@ +export { UpdateToast } from "./UpdateToast"; +export { useUpdateListener } from "./useUpdateListener"; diff --git a/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx b/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx new file mode 100644 index 00000000000..34c8584cee0 --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx @@ -0,0 +1,54 @@ +import { toast } from "@superset/ui/sonner"; +import { useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { AUTO_UPDATE_STATUS } from "shared/auto-update"; +import { UpdateToast } from "./UpdateToast"; + +export function useUpdateListener() { + const toastIdRef = useRef(null); + + trpc.autoUpdate.subscribe.useSubscription(undefined, { + onData: (event) => { + const { status, version, error } = event; + + if ( + status === AUTO_UPDATE_STATUS.IDLE || + status === AUTO_UPDATE_STATUS.CHECKING + ) { + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + return; + } + + if ( + status === AUTO_UPDATE_STATUS.DOWNLOADING || + status === AUTO_UPDATE_STATUS.READY || + status === AUTO_UPDATE_STATUS.ERROR + ) { + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + } + + const toastId = toast.custom( + (id) => ( + + ), + { + duration: Number.POSITIVE_INFINITY, + position: "bottom-right", + unstyled: true, + }, + ); + + toastIdRef.current = toastId; + } + }, + }); +} diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index aaeac15edbb..a70939dc814 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -251,4 +251,9 @@ .no-drag { -webkit-app-region: no-drag; } + + /* Disable sonner toast hover animation for update toast only */ + [data-sonner-toast]:has(.update-toast) { + transform: translateY(0); + } } diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index d73c70db7f4..51f89669b64 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -5,6 +5,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; +import { useUpdateListener } from "renderer/components/UpdateToast"; import { trpc } from "renderer/lib/trpc"; import { SignInScreen } from "renderer/screens/sign-in"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; @@ -63,6 +64,7 @@ export function MainScreen() { const tabs = useTabsStore((s) => s.tabs); useAgentHookListener(); + useUpdateListener(); trpc.menu.subscribe.useSubscription(undefined, { onData: (event) => { diff --git a/apps/desktop/src/shared/auto-update.ts b/apps/desktop/src/shared/auto-update.ts new file mode 100644 index 00000000000..e9075c7c5fa --- /dev/null +++ b/apps/desktop/src/shared/auto-update.ts @@ -0,0 +1,12 @@ +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]; + +export const RELEASES_URL = "https://github.com/superset-sh/superset/releases"; diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6c93528bf10..7d8a43f569a 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -9,9 +9,7 @@ export const PLATFORM = { // Ports - different for dev vs prod to allow running both simultaneously export const PORTS = { - // Vite dev server port VITE_DEV_SERVER: env.NODE_ENV === "development" ? 5927 : 4927, - // Notification HTTP server port NOTIFICATIONS: env.NODE_ENV === "development" ? 31416 : 31415, }; @@ -34,13 +32,11 @@ export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; export const CONFIG_FILE_NAME = "config.json"; -// Config file template export const CONFIG_TEMPLATE = `{ "setup": [], "teardown": [] }`; -// Notification event types export const NOTIFICATION_EVENTS = { AGENT_COMPLETE: "agent-complete", FOCUS_TAB: "focus-tab",