From 0a7071772eb4dd3b0203db590b9a6120b14f514f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 30 Dec 2025 12:02:49 -0500 Subject: [PATCH] feat(desktop): add required update mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to force desktop users to update when breaking changes are deployed. The app checks version requirements from the API on startup and blocks usage if outdated. Changes: - Add GET /api/desktop/version endpoint returning minimum version - Add useVersionCheck hook with network reconnection handling - Add UpdateRequiredPage component for blocking UI - Add version gate in MainScreen before auth check - Expose app version via window.App.appVersion - Add check mutation to auto-update tRPC router Behavior: - Checks version on startup - Re-checks when network comes back online (if not verified) - Fail-open on network errors (allows offline use) - Uses existing auto-updater for install flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/api/src/app/api/desktop/version/route.ts | 13 ++ apps/desktop/electron.vite.config.ts | 3 +- apps/desktop/package.json | 1 + .../src/lib/trpc/routers/auto-update/index.ts | 5 + apps/desktop/src/main/lib/analytics/index.ts | 2 + apps/desktop/src/preload/index.ts | 3 + .../PostHogUserIdentifier.tsx | 6 +- .../UpdateRequiredPage/UpdateRequiredPage.tsx | 122 ++++++++++++++++++ .../components/UpdateRequiredPage/index.ts | 1 + .../renderer/hooks/useVersionCheck/index.ts | 1 + .../hooks/useVersionCheck/useVersionCheck.ts | 88 +++++++++++++ .../src/renderer/screens/main/index.tsx | 35 +++++ bun.lock | 3 +- 13 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/api/desktop/version/route.ts create mode 100644 apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx create mode 100644 apps/desktop/src/renderer/components/UpdateRequiredPage/index.ts create mode 100644 apps/desktop/src/renderer/hooks/useVersionCheck/index.ts create mode 100644 apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts new file mode 100644 index 00000000000..3708aca2569 --- /dev/null +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -0,0 +1,13 @@ +const MINIMUM_DESKTOP_VERSION = "0.0.39"; + +/** + * Used to force the desktop app to update, in cases where we can't support + * multiple versions of the desktop app easily. + */ +export async function GET() { + return Response.json({ + minimumVersion: MINIMUM_DESKTOP_VERSION, + // Uncomment and customize when forcing an update: + // message: "We've upgraded our authentication system. Please update to continue.", + }); +} diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 993579f5d42..2ce99bdabed 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -8,7 +8,7 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import injectProcessEnvPlugin from "rollup-plugin-inject-process-env"; import type { Plugin } from "vite"; import tsconfigPathsPlugin from "vite-tsconfig-paths"; -import { main, resources } from "./package.json"; +import { main, resources, version } from "./package.json"; // Dev server port - must match PORTS.VITE_DEV_SERVER in src/shared/constants.ts const DEV_SERVER_PORT = 5927; @@ -137,6 +137,7 @@ export default defineConfig({ "process.env.SKIP_ENV_VALIDATION": JSON.stringify( process.env.SKIP_ENV_VALIDATION || "", ), + __APP_VERSION__: JSON.stringify(version), }, build: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ccd19f22147..f4f14fc932b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -109,6 +109,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "semver": "^7.7.3", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", "strip-ansi": "^7.1.2", diff --git a/apps/desktop/src/lib/trpc/routers/auto-update/index.ts b/apps/desktop/src/lib/trpc/routers/auto-update/index.ts index 003f69a7e7d..8e15e0787e1 100644 --- a/apps/desktop/src/lib/trpc/routers/auto-update/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auto-update/index.ts @@ -2,6 +2,7 @@ import { observable } from "@trpc/server/observable"; import { type AutoUpdateStatusEvent, autoUpdateEmitter, + checkForUpdates, dismissUpdate, getUpdateStatus, installUpdate, @@ -33,6 +34,10 @@ export const createAutoUpdateRouter = () => { return getUpdateStatus(); }), + check: publicProcedure.mutation(() => { + checkForUpdates(); + }), + install: publicProcedure.mutation(() => { installUpdate(); }), diff --git a/apps/desktop/src/main/lib/analytics/index.ts b/apps/desktop/src/main/lib/analytics/index.ts index 0b0178c8b95..d0745f44a1a 100644 --- a/apps/desktop/src/main/lib/analytics/index.ts +++ b/apps/desktop/src/main/lib/analytics/index.ts @@ -1,3 +1,4 @@ +import { app } from "electron"; import { env } from "main/env.main"; import { PostHog } from "posthog-node"; @@ -39,6 +40,7 @@ export function track( ...properties, app_name: "desktop", platform: process.platform, + desktop_version: app.getVersion(), }, }); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 04fcd7ab913..a004f5330a7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import { exposeElectronTRPC } from "trpc-electron/main"; +declare const __APP_VERSION__: string; + declare global { interface Window { App: typeof API; @@ -14,6 +16,7 @@ declare global { const API = { sayHelloFromBridge: () => console.log("\nHello from bridgeAPI! 👋\n\n"), username: process.env.USER, + appVersion: __APP_VERSION__, }; // Store mapping of user listeners to wrapped listeners for proper cleanup diff --git a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index cda3b50a282..04deabb0ae1 100644 --- a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -11,7 +11,11 @@ export function PostHogUserIdentifier() { useEffect(() => { if (user) { - posthog.identify(user.id, { email: user.email, name: user.name }); + posthog.identify(user.id, { + email: user.email, + name: user.name, + desktop_version: window.App.appVersion, + }); posthog.reloadFeatureFlags(); setUserId({ userId: user.id }); diff --git a/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx b/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx new file mode 100644 index 00000000000..a284e54fa4f --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx @@ -0,0 +1,122 @@ +import { Button } from "@superset/ui/button"; +import { useState } from "react"; +import { HiArrowPath, HiExclamationTriangle } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { AppFrame } from "renderer/screens/main/components/AppFrame"; +import { Background } from "renderer/screens/main/components/Background"; +import { + AUTO_UPDATE_STATUS, + type AutoUpdateStatus, + RELEASES_URL, +} from "shared/auto-update"; + +interface UpdateRequiredPageProps { + currentVersion: string; + minimumVersion: string; + message?: string; +} + +export function UpdateRequiredPage({ + currentVersion, + minimumVersion, + message, +}: UpdateRequiredPageProps) { + const openUrl = trpc.external.openUrl.useMutation(); + const checkMutation = trpc.autoUpdate.check.useMutation(); + const installMutation = trpc.autoUpdate.install.useMutation(); + + // Track update status via subscription for real-time updates + const [updateStatus, setUpdateStatus] = useState<{ + status: AutoUpdateStatus; + error?: string; + }>({ status: AUTO_UPDATE_STATUS.IDLE }); + + // Subscribe to auto-update status changes + trpc.autoUpdate.subscribe.useSubscription(undefined, { + onData: (event) => { + setUpdateStatus({ status: event.status, error: event.error }); + }, + }); + + const isChecking = updateStatus.status === AUTO_UPDATE_STATUS.CHECKING; + const isDownloading = updateStatus.status === AUTO_UPDATE_STATUS.DOWNLOADING; + const isReady = updateStatus.status === AUTO_UPDATE_STATUS.READY; + const isError = updateStatus.status === AUTO_UPDATE_STATUS.ERROR; + const isLoading = isChecking || isDownloading; + + const handleCheckForUpdate = () => { + checkMutation.mutate(); + }; + + const handleInstall = () => { + installMutation.mutate(); + }; + + const handleDownloadManually = () => { + openUrl.mutate(RELEASES_URL); + }; + + return ( + <> + + +
+
+ +
+ +
+

Update Required

+

+ {message || + "A new version of Superset is required to continue. Please update to the latest version."} +

+
+ +
+ Your version: {currentVersion} + Required version: {minimumVersion}+ +
+ + {isError && ( +

+ {updateStatus.error || "Update check failed. Please try again."} +

+ )} + +
+ {isReady ? ( + + ) : ( + + )} + + +
+
+
+ + ); +} diff --git a/apps/desktop/src/renderer/components/UpdateRequiredPage/index.ts b/apps/desktop/src/renderer/components/UpdateRequiredPage/index.ts new file mode 100644 index 00000000000..43e467c785f --- /dev/null +++ b/apps/desktop/src/renderer/components/UpdateRequiredPage/index.ts @@ -0,0 +1 @@ +export { UpdateRequiredPage } from "./UpdateRequiredPage"; diff --git a/apps/desktop/src/renderer/hooks/useVersionCheck/index.ts b/apps/desktop/src/renderer/hooks/useVersionCheck/index.ts new file mode 100644 index 00000000000..6cd4de0f794 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useVersionCheck/index.ts @@ -0,0 +1 @@ +export { useVersionCheck } from "./useVersionCheck"; diff --git a/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts new file mode 100644 index 00000000000..4d055235c61 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { env } from "renderer/env.renderer"; +import { lt } from "semver"; + +interface VersionRequirements { + minimumVersion: string; + message?: string; +} + +interface UseVersionCheckResult { + isLoading: boolean; + isBlocked: boolean; + requirements: VersionRequirements | null; + error: Error | null; +} + +export function useVersionCheck(): UseVersionCheckResult { + const [state, setState] = useState({ + isLoading: true, + isBlocked: false, + requirements: null, + error: null, + }); + + // Track if we've successfully verified the version + const hasVerified = useRef(false); + + const checkVersion = useCallback(async () => { + // Don't show loading state on re-checks (only on initial load) + if (!hasVerified.current) { + setState((prev) => ({ ...prev, isLoading: true })); + } + + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/desktop/version`, + ); + + if (!response.ok) { + // Fail open - if API is down, don't block users + setState({ + isLoading: false, + isBlocked: false, + requirements: null, + error: null, + }); + return; + } + + const requirements: VersionRequirements = await response.json(); + const currentVersion = window.App.appVersion; + const isBlocked = lt(currentVersion, requirements.minimumVersion); + + hasVerified.current = true; + setState({ + isLoading: false, + isBlocked, + requirements, + error: null, + }); + } catch (error) { + // Fail open on network errors + setState({ + isLoading: false, + isBlocked: false, + requirements: null, + error: error instanceof Error ? error : new Error("Unknown error"), + }); + } + }, []); + + useEffect(() => { + // Initial check + checkVersion(); + + // Re-check when network comes back online (in case initial check failed) + const handleOnline = () => { + if (!hasVerified.current) { + checkVersion(); + } + }; + + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, [checkVersion]); + + return state; +} diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index ec040bcb367..3e4c5fcbd1c 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -7,7 +7,9 @@ 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 { UpdateRequiredPage } from "renderer/components/UpdateRequiredPage"; import { useUpdateListener } from "renderer/components/UpdateToast"; +import { useVersionCheck } from "renderer/hooks/useVersionCheck"; import { trpc } from "renderer/lib/trpc"; import { SignInScreen } from "renderer/screens/sign-in"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; @@ -35,6 +37,14 @@ function LoadingSpinner() { export function MainScreen() { const utils = trpc.useUtils(); + + // Version check - blocks app if outdated + const { + isLoading: isVersionLoading, + isBlocked: isVersionBlocked, + requirements: versionRequirements, + } = useVersionCheck(); + const { data: authState } = trpc.auth.getState.useQuery(); const isSignedIn = !!process.env.SKIP_ENV_VALIDATION || (authState?.isSignedIn ?? false); @@ -167,6 +177,31 @@ export function MainScreen() { const showStartView = !isLoading && !activeWorkspace && currentView !== "settings"; + // Show loading while version check is in progress + if (isVersionLoading) { + return ( + <> + + +
+ +
+
+ + ); + } + + // Block app if version is outdated + if (isVersionBlocked && versionRequirements) { + return ( + + ); + } + // Show loading while auth state is being determined if (isAuthLoading) { return ( diff --git a/bun.lock b/bun.lock index ed184e4f7e0..4b961cccc2d 100644 --- a/bun.lock +++ b/bun.lock @@ -121,7 +121,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.38", + "version": "0.0.39", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -201,6 +201,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "semver": "^7.7.3", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", "strip-ansi": "^7.1.2",