Skip to content
Merged
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
13 changes: 13 additions & 0 deletions apps/api/src/app/api/desktop/version/route.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
}
3 changes: 2 additions & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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/main/lib/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { app } from "electron";
import { env } from "main/env.main";
import { PostHog } from "posthog-node";

Expand Down Expand Up @@ -39,6 +40,7 @@ export function track(
...properties,
app_name: "desktop",
platform: process.platform,
desktop_version: app.getVersion(),
},
});
}
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Background />
<AppFrame>
<div className="flex h-full w-full flex-col items-center justify-center gap-6 bg-background p-8">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-amber-500/10">
<HiExclamationTriangle className="h-8 w-8 text-amber-500" />
</div>

<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-xl font-semibold">Update Required</h1>
<p className="max-w-md text-muted-foreground">
{message ||
"A new version of Superset is required to continue. Please update to the latest version."}
</p>
</div>

<div className="flex flex-col items-center gap-1 text-sm text-muted-foreground">
<span>Your version: {currentVersion}</span>
<span>Required version: {minimumVersion}+</span>
</div>

{isError && (
<p className="text-sm text-destructive">
{updateStatus.error || "Update check failed. Please try again."}
</p>
)}

<div className="flex items-center gap-3">
{isReady ? (
<Button
onClick={handleInstall}
disabled={installMutation.isPending}
>
{installMutation.isPending
? "Installing..."
: "Install & Restart"}
</Button>
) : (
<Button
onClick={handleCheckForUpdate}
disabled={isLoading || checkMutation.isPending}
className="gap-2"
>
<HiArrowPath
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
{isChecking
? "Checking..."
: isDownloading
? "Downloading..."
: "Check for Update"}
</Button>
)}

<Button variant="ghost" onClick={handleDownloadManually}>
Download Manually
</Button>
</div>
</div>
</AppFrame>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UpdateRequiredPage } from "./UpdateRequiredPage";
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/hooks/useVersionCheck/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useVersionCheck } from "./useVersionCheck";
88 changes: 88 additions & 0 deletions apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -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<UseVersionCheckResult>({
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;
}
35 changes: 35 additions & 0 deletions apps/desktop/src/renderer/screens/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -167,6 +177,31 @@ export function MainScreen() {
const showStartView =
!isLoading && !activeWorkspace && currentView !== "settings";

// Show loading while version check is in progress
if (isVersionLoading) {
return (
<>
<Background />
<AppFrame>
<div className="flex h-full w-full items-center justify-center bg-background">
<LoadingSpinner />
</div>
</AppFrame>
</>
);
}

// Block app if version is outdated
if (isVersionBlocked && versionRequirements) {
return (
<UpdateRequiredPage
currentVersion={window.App.appVersion}
minimumVersion={versionRequirements.minimumVersion}
message={versionRequirements.message}
/>
);
}

// Show loading while auth state is being determined
if (isAuthLoading) {
return (
Expand Down
3 changes: 2 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.