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
95 changes: 95 additions & 0 deletions apps/desktop/src/lib/trpc/routers/auto-update/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { observable } from "@trpc/server/observable";
import {
autoUpdateEmitter,
checkForUpdates,
dismissUpdate,
getUpdateStatus,
installUpdate,
simulateUpdateReady,
} from "main/lib/auto-updater";
import { AUTO_UPDATE_EVENTS, type AutoUpdateStatus } from "shared/constants";
import { publicProcedure, router } from "../..";

export interface AutoUpdateEvent {
type: typeof AUTO_UPDATE_EVENTS.STATUS_CHANGED;
status: AutoUpdateStatus;
version?: string;
error?: string;
}

export const createAutoUpdateRouter = () => {
return router({
/**
* Subscribe to auto-update status changes
*/
subscribe: publicProcedure.subscription(() => {
return observable<AutoUpdateEvent>((emit) => {
// Emit current status immediately on subscribe
const currentStatus = getUpdateStatus();
emit.next({
type: AUTO_UPDATE_EVENTS.STATUS_CHANGED,
...currentStatus,
});

const onStatusChanged = (data: {
status: AutoUpdateStatus;
version?: string;
error?: string;
}) => {
emit.next({ type: AUTO_UPDATE_EVENTS.STATUS_CHANGED, ...data });
};

autoUpdateEmitter.on(
AUTO_UPDATE_EVENTS.STATUS_CHANGED,
onStatusChanged,
);

return () => {
autoUpdateEmitter.off(
AUTO_UPDATE_EVENTS.STATUS_CHANGED,
onStatusChanged,
);
};
});
}),

/**
* Get current update status
*/
getStatus: publicProcedure.query(() => {
return getUpdateStatus();
}),

/**
* Trigger install and restart
*/
installAndRestart: publicProcedure.mutation(() => {
installUpdate();
return { success: true };
}),

/**
* Dismiss the update notification for this session
*/
dismiss: publicProcedure.mutation(() => {
dismissUpdate();
return { success: true };
}),

/**
* Check for updates manually
*/
checkForUpdates: publicProcedure.mutation(() => {
checkForUpdates();
return { success: true };
}),

/**
* DEV ONLY: Simulate an update ready state for testing the UI
*/
simulateUpdateReady: publicProcedure.mutation(() => {
simulateUpdateReady();
return { success: true };
}),
});
};
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +26,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),
Expand Down
173 changes: 129 additions & 44 deletions apps/desktop/src/main/lib/auto-updater.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,120 @@
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 { PLATFORM } from "shared/constants";
import {
AUTO_UPDATE_EVENTS,
AUTO_UPDATE_STATUS,
type AutoUpdateStatus,
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;
// Event emitter for auto-update status changes
export const autoUpdateEmitter = new EventEmitter();

// Current update state
let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE;
let currentVersion: string | undefined;
let currentError: string | undefined;
let isDismissed = false;

/**
* Get the current update status
*/
export function getUpdateStatus(): {
status: AutoUpdateStatus;
version?: string;
error?: string;
} {
// If dismissed, report as idle to the UI
if (isDismissed && currentStatus === AUTO_UPDATE_STATUS.READY) {
return { status: AUTO_UPDATE_STATUS.IDLE };
}
return {
status: currentStatus,
version: currentVersion,
error: currentError,
};
}

/**
* Emit a status change event
*/
function emitStatusChange(
status: AutoUpdateStatus,
version?: string,
error?: string,
): void {
currentStatus = status;
currentVersion = version;
currentError = error;

// Don't emit if dismissed and status is ready
if (isDismissed && status === AUTO_UPDATE_STATUS.READY) {
return;
}

autoUpdateEmitter.emit(AUTO_UPDATE_EVENTS.STATUS_CHANGED, {
status,
version,
error,
});
}

/**
* Install the update and restart
*/
export function installUpdate(): void {
autoUpdater.quitAndInstall(false, true);
}

export function setMainWindow(window: BrowserWindow): void {
mainWindow = window;
/**
* Dismiss the update notification for this session
*/
export function dismissUpdate(): void {
isDismissed = true;
// Emit idle status to hide the toast
autoUpdateEmitter.emit(AUTO_UPDATE_EVENTS.STATUS_CHANGED, {
status: AUTO_UPDATE_STATUS.IDLE,
});
}

/**
* DEV ONLY: Simulate an update ready state for testing the UI
*/
export function simulateUpdateReady(): void {
if (env.NODE_ENV !== "development") {
console.warn("[auto-updater] simulateUpdateReady is only available in dev");
return;
}
isDismissed = false;
emitStatusChange(AUTO_UPDATE_STATUS.READY, "1.0.0-test");
}

/**
* Check for updates silently (for background/automatic checks)
* No user-facing dialogs - status is communicated via events to the toast
*/
export function checkForUpdates(): void {
if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) {
return;
}
isDismissed = false;
emitStatusChange(AUTO_UPDATE_STATUS.CHECKING);
autoUpdater.checkForUpdates().catch((error) => {
console.error("[auto-updater] Failed to check for updates:", error);
emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message);
});
}

/**
* Check for updates with user feedback (for menu-triggered checks)
* Shows dialogs for unsupported platforms, errors, and "up to date" states
*/
export function checkForUpdatesInteractive(): void {
if (env.NODE_ENV === "development") {
dialog.showMessageBox({
Expand All @@ -32,6 +124,7 @@ export function checkForUpdatesInteractive(): void {
});
return;
}

if (!PLATFORM.IS_MAC) {
dialog.showMessageBox({
type: "info",
Expand All @@ -41,23 +134,34 @@ export function checkForUpdatesInteractive(): void {
return;
}

isDismissed = false;
emitStatusChange(AUTO_UPDATE_STATUS.CHECKING);

autoUpdater
.checkForUpdates()
.then((result) => {
if (!result || !result.updateInfo) {
// If no update is available, show feedback to user
// (update-available case is handled by the toast via events)
if (
!result?.updateInfo ||
result.updateInfo.version === app.getVersion()
) {
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);
emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message);
dialog.showMessageBox({
type: "error",
title: "Update Error",
message: "Failed to check for updates. Please try again later.",
message: "Failed to check for updates.",
detail: "Please try again later.",
});
});
}
Expand All @@ -78,56 +182,37 @@ export function setupAutoUpdater(): void {

autoUpdater.on("error", (error) => {
console.error("[auto-updater] Error during update check:", error);
emitStatusChange(AUTO_UPDATE_STATUS.ERROR, undefined, error.message);
});

autoUpdater.on("checking-for-update", () => {
console.info("[auto-updater] Checking for updates...");
emitStatusChange(AUTO_UPDATE_STATUS.CHECKING);
});

autoUpdater.on("update-available", (info) => {
console.info(
`[auto-updater] Update available: ${info.version}. Downloading...`,
);
emitStatusChange(AUTO_UPDATE_STATUS.DOWNLOADING, info.version);
});

autoUpdater.on("update-not-available", () => {
console.info("[auto-updater] No updates available");
emitStatusChange(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.`,
);
emitStatusChange(AUTO_UPDATE_STATUS.READY, info.version);
});

const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS);
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 { db } from "../lib/db";
import { createApplicationMenu } from "../lib/menu";
import { playNotificationSound } from "../lib/notification-sound";
Expand Down Expand Up @@ -52,7 +51,6 @@ export async function MainWindow() {
},
});

setMainWindow(window);
createApplicationMenu();

currentWindow = window;
Expand Down
Loading