diff --git a/apps/api/src/app/api/public/desktop/version/route.ts b/apps/api/src/app/api/public/desktop/version/route.ts new file mode 100644 index 000000000..7ad292211 --- /dev/null +++ b/apps/api/src/app/api/public/desktop/version/route.ts @@ -0,0 +1,7 @@ +import { env } from "@/env"; + +export function GET() { + return Response.json({ + minimumSupportedVersion: env.DESKTOP_MINIMUM_SUPPORTED_VERSION ?? null, + }); +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a1cfec766..db74db22e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -14,6 +14,7 @@ export const env = createEnv({ ELECTRIC_URL: z.string().url(), ELECTRIC_SECRET: z.string().min(16), BLOB_READ_WRITE_TOKEN: z.string(), + DESKTOP_MINIMUM_SUPPORTED_VERSION: z.string().optional(), DESKTOP_AUTH_SECRET: z.string().min(32), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), 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 003f69a7e..8e15e0787 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/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 7cc2330e3..1d3fa7350 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -16,6 +16,7 @@ import { createTasksRouter } from "./tasks"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createUserRouter } from "./user"; +import { createVersionGateRouter } from "./version-gate"; import { createWindowRouter } from "./window"; import { createWorkspacesRouter } from "./workspaces"; @@ -39,6 +40,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { uiState: createUiStateRouter(), ringtone: createRingtoneRouter(), tasks: createTasksRouter(), + versionGate: createVersionGateRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/version-gate/index.ts b/apps/desktop/src/lib/trpc/routers/version-gate/index.ts new file mode 100644 index 000000000..d0916b953 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/version-gate/index.ts @@ -0,0 +1,16 @@ +import { getVersionGateStatus } from "main/lib/version-gate"; +import { publicProcedure, router } from "../.."; + +export const createVersionGateRouter = () => { + return router({ + getStatus: publicProcedure.query(async () => { + return getVersionGateStatus(); + }), + + refresh: publicProcedure.mutation(async () => { + return getVersionGateStatus({ refresh: true }); + }), + }); +}; + +export type VersionGateRouter = ReturnType; diff --git a/apps/desktop/src/main/lib/version-gate.ts b/apps/desktop/src/main/lib/version-gate.ts new file mode 100644 index 000000000..0c1df9bb5 --- /dev/null +++ b/apps/desktop/src/main/lib/version-gate.ts @@ -0,0 +1,101 @@ +import { app } from "electron"; +import { env } from "main/env.main"; +import { PLATFORM } from "shared/constants"; +import type { VersionGateStatus } from "shared/types"; +import { isSemverLt } from "shared/utils/semver"; + +const FETCH_TIMEOUT_MS = 3000; +const VERSION_CONFIG_URL = `${env.NEXT_PUBLIC_API_URL}/api/public/desktop/version`; + +let cachedStatus: VersionGateStatus | null = null; + +async function fetchMinimumSupportedVersion(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(VERSION_CONFIG_URL, { + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Config request failed (${response.status})`); + } + + const body = (await response.json()) as unknown; + if ( + typeof body === "object" && + body !== null && + "minimumSupportedVersion" in body && + typeof body.minimumSupportedVersion === "string" && + body.minimumSupportedVersion.trim() !== "" + ) { + return body.minimumSupportedVersion.trim(); + } + + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function getVersionGateStatus( + options: { refresh?: boolean } = {}, +): Promise { + if (cachedStatus && !options.refresh) { + return cachedStatus; + } + + const currentVersion = app.getVersion(); + const autoUpdateSupported = env.NODE_ENV !== "development" && PLATFORM.IS_MAC; + + try { + const minimumSupportedVersion = await fetchMinimumSupportedVersion(); + if (!minimumSupportedVersion) { + cachedStatus = { + currentVersion, + minimumSupportedVersion: null, + isUpdateRequired: false, + autoUpdateSupported, + }; + return cachedStatus; + } + + const isLt = isSemverLt(currentVersion, minimumSupportedVersion); + if (isLt === null) { + console.warn( + "[version-gate] Invalid semver in minimumSupportedVersion:", + minimumSupportedVersion, + ); + cachedStatus = { + currentVersion, + minimumSupportedVersion, + isUpdateRequired: false, + autoUpdateSupported, + configFetchError: "Invalid semver in version gate config", + }; + return cachedStatus; + } + + cachedStatus = { + currentVersion, + minimumSupportedVersion, + isUpdateRequired: isLt, + autoUpdateSupported, + }; + return cachedStatus; + } catch (error) { + console.warn("[version-gate] Failed to fetch version gate config:", error); + cachedStatus = { + currentVersion, + minimumSupportedVersion: null, + isUpdateRequired: false, + autoUpdateSupported, + configFetchError: error instanceof Error ? error.message : String(error), + }; + return cachedStatus; + } +} diff --git a/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx b/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx index 34c8584ce..fbef35e58 100644 --- a/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx +++ b/apps/desktop/src/renderer/components/UpdateToast/useUpdateListener.tsx @@ -1,14 +1,23 @@ import { toast } from "@superset/ui/sonner"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { trpc } from "renderer/lib/trpc"; import { AUTO_UPDATE_STATUS } from "shared/auto-update"; import { UpdateToast } from "./UpdateToast"; -export function useUpdateListener() { +export function useUpdateListener(options: { enabled?: boolean } = {}) { + const enabled = options.enabled ?? true; const toastIdRef = useRef(null); + useEffect(() => { + if (!enabled && toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + }, [enabled]); + trpc.autoUpdate.subscribe.useSubscription(undefined, { onData: (event) => { + if (!enabled) return; const { status, version, error } = event; if ( diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index ec040bcb3..43558a11b 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -10,6 +10,7 @@ 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 { UpdateRequiredScreen } from "renderer/screens/update-required"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; @@ -35,10 +36,13 @@ function LoadingSpinner() { export function MainScreen() { const utils = trpc.useUtils(); + const { data: versionGateStatus, isError: isVersionGateError } = + trpc.versionGate.getStatus.useQuery(); const { data: authState } = trpc.auth.getState.useQuery(); const isSignedIn = !!process.env.SKIP_ENV_VALIDATION || (authState?.isSignedIn ?? false); const isAuthLoading = !process.env.SKIP_ENV_VALIDATION && !authState; + const isVersionGateLoading = !isVersionGateError && !versionGateStatus; // Subscribe to auth state changes trpc.auth.onStateChange.useSubscription(undefined, { @@ -70,7 +74,7 @@ export function MainScreen() { const tabs = useTabsStore((s) => s.tabs); useAgentHookListener(); - useUpdateListener(); + useUpdateListener({ enabled: !versionGateStatus?.isUpdateRequired }); trpc.menu.subscribe.useSubscription(undefined, { onData: (event) => { @@ -167,6 +171,23 @@ export function MainScreen() { const showStartView = !isLoading && !activeWorkspace && currentView !== "settings"; + if (isVersionGateLoading) { + return ( + <> + + +
+ +
+
+ + ); + } + + if (versionGateStatus?.isUpdateRequired) { + return ; + } + // Show loading while auth state is being determined if (isAuthLoading) { return ( diff --git a/apps/desktop/src/renderer/screens/update-required/index.tsx b/apps/desktop/src/renderer/screens/update-required/index.tsx new file mode 100644 index 000000000..f8b478155 --- /dev/null +++ b/apps/desktop/src/renderer/screens/update-required/index.tsx @@ -0,0 +1,131 @@ +import { Button } from "@superset/ui/button"; +import { useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { + AUTO_UPDATE_STATUS, + type AutoUpdateStatus, + RELEASES_URL, +} from "shared/auto-update"; +import type { VersionGateStatus } from "shared/types"; +import { AppFrame } from "../main/components/AppFrame"; +import { Background } from "../main/components/Background"; + +interface UpdateRequiredScreenProps { + status: VersionGateStatus; +} + +interface AutoUpdateStatusEvent { + status: AutoUpdateStatus; + version?: string; + error?: string; +} + +export function UpdateRequiredScreen({ status }: UpdateRequiredScreenProps) { + const openUrl = trpc.external.openUrl.useMutation(); + const checkMutation = trpc.autoUpdate.check.useMutation(); + const installMutation = trpc.autoUpdate.install.useMutation(); + const [update, setUpdate] = useState({ + status: AUTO_UPDATE_STATUS.IDLE, + }); + + trpc.autoUpdate.subscribe.useSubscription(undefined, { + onData: (event) => setUpdate(event), + }); + + const isChecking = update.status === AUTO_UPDATE_STATUS.CHECKING; + const isDownloading = update.status === AUTO_UPDATE_STATUS.DOWNLOADING; + const isReady = update.status === AUTO_UPDATE_STATUS.READY; + const isError = update.status === AUTO_UPDATE_STATUS.ERROR; + + const canAutoUpdate = status.autoUpdateSupported; + + const buttonLabel = isReady + ? "Install update" + : canAutoUpdate + ? isChecking || isDownloading + ? "Updating..." + : "Update now" + : "Open download page"; + + const isButtonDisabled = + installMutation.isPending || + checkMutation.isPending || + isChecking || + isDownloading; + + const handleUpdate = () => { + if (isReady) { + installMutation.mutate(); + return; + } + + if (canAutoUpdate) { + checkMutation.mutate(); + return; + } + + openUrl.mutate(RELEASES_URL); + }; + + return ( + <> + + +
+
+
+

Update required

+

+ Your version of Superset is no longer supported. +

+
+ +
+
+ Current:{" "} + {status.currentVersion} +
+ {status.minimumSupportedVersion && ( +
+ Minimum:{" "} + + {status.minimumSupportedVersion} + +
+ )} +
+ +
+ {isError ? ( +

+ Update check failed{update.error ? `: ${update.error}` : ""} +

+ ) : isDownloading ? ( +

+ Downloading update + {update.version ? ` (${update.version})` : ""}... +

+ ) : isReady ? ( +

+ Update is ready to install. +

+ ) : canAutoUpdate ? ( +

+ Click update to download and install the latest version. +

+ ) : ( +

+ Please download and install the latest version. +

+ )} + + +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index a47714c62..c41b125b6 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -4,5 +4,6 @@ export * from "./electron"; export * from "./mosaic"; export * from "./ports"; export * from "./tab"; +export * from "./version-gate"; export * from "./workspace"; export * from "./worktree"; diff --git a/apps/desktop/src/shared/types/version-gate.ts b/apps/desktop/src/shared/types/version-gate.ts new file mode 100644 index 000000000..5210bfd7f --- /dev/null +++ b/apps/desktop/src/shared/types/version-gate.ts @@ -0,0 +1,7 @@ +export interface VersionGateStatus { + currentVersion: string; + minimumSupportedVersion: string | null; + isUpdateRequired: boolean; + autoUpdateSupported: boolean; + configFetchError?: string; +} diff --git a/apps/desktop/src/shared/utils/semver.ts b/apps/desktop/src/shared/utils/semver.ts new file mode 100644 index 000000000..4848f9c8f --- /dev/null +++ b/apps/desktop/src/shared/utils/semver.ts @@ -0,0 +1,78 @@ +type SemverPrereleaseId = string | number; + +interface ParsedSemver { + major: number; + minor: number; + patch: number; + prerelease: SemverPrereleaseId[]; +} + +function parsePrereleaseIdentifier(part: string): SemverPrereleaseId { + if (/^(0|[1-9]\d*)$/.test(part)) { + return Number(part); + } + return part; +} + +export function parseSemver(input: string): ParsedSemver | null { + const version = input.trim().replace(/^v/, ""); + const match = version.match( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([^+]+))?(?:\+(.+))?$/, + ); + + if (!match) return null; + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + const prereleaseStr = match[4]; + + const prerelease = prereleaseStr + ? prereleaseStr.split(".").map(parsePrereleaseIdentifier) + : []; + + return { major, minor, patch, prerelease }; +} + +export function compareSemver(a: string, b: string): number | null { + const parsedA = parseSemver(a); + const parsedB = parseSemver(b); + + if (!parsedA || !parsedB) return null; + + if (parsedA.major !== parsedB.major) return parsedA.major - parsedB.major; + if (parsedA.minor !== parsedB.minor) return parsedA.minor - parsedB.minor; + if (parsedA.patch !== parsedB.patch) return parsedA.patch - parsedB.patch; + + const aHasPre = parsedA.prerelease.length > 0; + const bHasPre = parsedB.prerelease.length > 0; + + if (!aHasPre && !bHasPre) return 0; + if (!aHasPre) return 1; + if (!bHasPre) return -1; + + const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length); + for (let i = 0; i < length; i += 1) { + const aId = parsedA.prerelease[i]; + const bId = parsedB.prerelease[i]; + + if (aId === undefined) return -1; + if (bId === undefined) return 1; + if (aId === bId) continue; + + const aIsNum = typeof aId === "number"; + const bIsNum = typeof bId === "number"; + if (aIsNum && bIsNum) return aId - bId; + if (aIsNum) return -1; + if (bIsNum) return 1; + return aId.localeCompare(bId); + } + + return 0; +} + +export function isSemverLt(a: string, b: string): boolean | null { + const cmp = compareSemver(a, b); + if (cmp === null) return null; + return cmp < 0; +}