Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/src/app/api/public/desktop/version/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { env } from "@/env";

export function GET() {
return Response.json({
minimumSupportedVersion: env.DESKTOP_MINIMUM_SUPPORTED_VERSION ?? null,
});
}
1 change: 1 addition & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/lib/trpc/routers/auto-update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { observable } from "@trpc/server/observable";
import {
type AutoUpdateStatusEvent,
autoUpdateEmitter,
checkForUpdates,
dismissUpdate,
getUpdateStatus,
installUpdate,
Expand Down Expand Up @@ -33,6 +34,10 @@ export const createAutoUpdateRouter = () => {
return getUpdateStatus();
}),

check: publicProcedure.mutation(() => {
checkForUpdates();
}),

install: publicProcedure.mutation(() => {
installUpdate();
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -39,6 +40,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(),
tasks: createTasksRouter(),
versionGate: createVersionGateRouter(),
});
};

Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/lib/trpc/routers/version-gate/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createVersionGateRouter>;
101 changes: 101 additions & 0 deletions apps/desktop/src/main/lib/version-gate.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<VersionGateStatus> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<string | number | null>(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 (
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/renderer/screens/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -167,6 +171,23 @@ export function MainScreen() {
const showStartView =
!isLoading && !activeWorkspace && currentView !== "settings";

if (isVersionGateLoading) {
return (
<>
<Background />
<AppFrame>
<div className="flex h-full w-full items-center justify-center bg-background">
<LoadingSpinner />
</div>
</AppFrame>
</>
);
}

if (versionGateStatus?.isUpdateRequired) {
return <UpdateRequiredScreen status={versionGateStatus} />;
}

// Show loading while auth state is being determined
if (isAuthLoading) {
return (
Expand Down
131 changes: 131 additions & 0 deletions apps/desktop/src/renderer/screens/update-required/index.tsx
Original file line number Diff line number Diff line change
@@ -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<AutoUpdateStatusEvent>({
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 (
<>
<Background />
<AppFrame>
<div className="flex h-full w-full items-center justify-center bg-background p-6">
<div className="flex w-full max-w-md flex-col gap-4 text-center">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-medium">Update required</h1>
<p className="text-sm text-muted-foreground">
Your version of Superset is no longer supported.
</p>
</div>

<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
<div>
Current:{" "}
<span className="font-medium">{status.currentVersion}</span>
</div>
{status.minimumSupportedVersion && (
<div>
Minimum:{" "}
<span className="font-medium">
{status.minimumSupportedVersion}
</span>
</div>
)}
</div>

<div className="flex flex-col gap-2">
{isError ? (
<p className="text-sm text-destructive">
Update check failed{update.error ? `: ${update.error}` : ""}
</p>
) : isDownloading ? (
<p className="text-sm text-muted-foreground">
Downloading update
{update.version ? ` (${update.version})` : ""}...
</p>
) : isReady ? (
<p className="text-sm text-muted-foreground">
Update is ready to install.
</p>
) : canAutoUpdate ? (
<p className="text-sm text-muted-foreground">
Click update to download and install the latest version.
</p>
) : (
<p className="text-sm text-muted-foreground">
Please download and install the latest version.
</p>
)}

<Button onClick={handleUpdate} disabled={isButtonDisabled}>
{buttonLabel}
</Button>
</div>
</div>
</div>
</AppFrame>
</>
);
}
1 change: 1 addition & 0 deletions apps/desktop/src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 7 additions & 0 deletions apps/desktop/src/shared/types/version-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface VersionGateStatus {
currentVersion: string;
minimumSupportedVersion: string | null;
isUpdateRequired: boolean;
autoUpdateSupported: boolean;
configFetchError?: string;
}
Loading