Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"dependencies": {
"effect": "catalog:",
"electron": "40.6.0",
"electron": "40.7.0",
"electron-updater": "^6.6.2"
},
"devDependencies": {
Expand Down
170 changes: 132 additions & 38 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
nativeTheme,
protocol,
shell,
MenuItem,
} from "electron";
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
Expand All @@ -23,6 +24,7 @@ import type {
DesktopUpdateCheckResult,
DesktopUpdateState,
} from "@t3tools/contracts";
import { DesktopUpdateStatusFriendlyLabelMap } from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";

import type { ContextMenuItem } from "@t3tools/contracts";
Expand All @@ -41,6 +43,7 @@ import {
reduceDesktopUpdateStateOnDownloadStart,
reduceDesktopUpdateStateOnInstallFailure,
reduceDesktopUpdateStateOnNoUpdate,
reduceDesktopUpdateStateToIdle,
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
Expand Down Expand Up @@ -76,6 +79,7 @@ const LOG_FILE_MAX_FILES = 10;
const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
const AUTO_UPDATE_TRANSIENT_IDLE_RESET_DELAY_MS = 5_000;
const DESKTOP_UPDATE_CHANNEL = "latest";
const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;

Expand Down Expand Up @@ -292,6 +296,9 @@ let updateDownloadInFlight = false;
let updateInstallInFlight = false;
let updaterConfigured = false;
let updateState: DesktopUpdateState = initialUpdateState();
let updateIdleResetTimer: ReturnType<typeof setTimeout> | null = null;
const updateStateListeners = new Set<(state: DesktopUpdateState) => void>();
updateStateListeners.add(() => emitUpdateState());

function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
if (updateInstallInFlight) return "install";
Expand Down Expand Up @@ -529,64 +536,113 @@ function dispatchMenuAction(action: string): void {
}

function handleCheckForUpdatesMenuClick(): void {
const disabledReason = getAutoUpdateDisabledReason({
isDevelopment,
isPackaged: app.isPackaged,
platform: process.platform,
appImage: process.env.APPIMAGE,
disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
});
if (disabledReason) {
console.info("[desktop-updater] Manual update check requested, but updates are disabled.");
void dialog.showMessageBox({
type: "info",
title: "Updates unavailable",
message: "Automatic updates are not available right now.",
detail: disabledReason,
buttons: ["OK"],
});
return;
}
switch (updateState.status) {
case "idle":
{
const disabledReason = getAutoUpdateDisabledReason({
isDevelopment,
isPackaged: app.isPackaged,
platform: process.platform,
appImage: process.env.APPIMAGE,
disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
});
if (disabledReason) {
console.info(
"[desktop-updater] Manual update check requested, but updates are disabled.",
);
void dialog.showMessageBox({
type: "info",
title: "Updates unavailable",
message: "Automatic updates are not available right now.",
detail: disabledReason,
buttons: ["OK"],
});
return;
}

if (!BrowserWindow.getAllWindows().length) {
mainWindow = createWindow();
if (!BrowserWindow.getAllWindows().length) {
mainWindow = createWindow();
}
void checkForUpdatesFromMenu();
}
break;
case "available":
void downloadAvailableUpdate();
break;
case "downloaded":
void installDownloadedUpdate();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menu click installs update without user confirmation

High Severity

When the native menu item shows "Update Downloaded" and the user clicks it, handleCheckForUpdatesMenuClick calls installDownloadedUpdate() directly, which destroys all windows and calls autoUpdater.quitAndInstall() without any confirmation dialog. The web UI's equivalent click handler uses window.confirm() before calling bridge.installUpdate(), but the native menu path has no such safeguard. A user clicking the menu item to learn more about the downloaded update would have their app quit and restart immediately with no chance to save work.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works just like the web UI, so I think its fine.

break;
default:
break;
}
void checkForUpdatesFromMenu();
}

async function checkForUpdatesFromMenu(): Promise<void> {
await checkForUpdates("menu");

if (updateState.status === "up-to-date") {
void dialog.showMessageBox({
await dialog.showMessageBox({
type: "info",
title: "You're up to date!",
message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
buttons: ["OK"],
});
resetUpdateStateToIdleIfNeeded();
} else if (updateState.status === "error") {
void dialog.showMessageBox({
await dialog.showMessageBox({
type: "warning",
title: "Update check failed",
message: "Could not check for updates.",
detail: updateState.message ?? "An unknown error occurred. Please try again later.",
buttons: ["OK"],
});
resetUpdateStateToIdleIfNeeded();
}
}

function makeCheckForUpdatesMenuItem(): MenuItem {
return new MenuItem({
label: DesktopUpdateStatusFriendlyLabelMap["idle"],
click: handleCheckForUpdatesMenuClick,
});
}
const checkForUpdatesMenuItemInAppMenu = makeCheckForUpdatesMenuItem();
const checkForUpdatesMenuItemInHelpMenu = makeCheckForUpdatesMenuItem();

function updateCheckForUpdatesMenuItem(menuItem: MenuItem, state: DesktopUpdateState): void {
menuItem.label = DesktopUpdateStatusFriendlyLabelMap[state.status];
switch (state.status) {
case "checking":
case "downloading":
case "disabled":
case "error":
case "up-to-date":
menuItem.enabled = false;
break;
case "idle":
case "available":
case "downloaded":
menuItem.enabled = true;
break;
}
}

updateStateListeners.add((state) => {
updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInAppMenu, state);
updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInHelpMenu, state);
});

let applicationMenu: Menu | null = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused applicationMenu variable is dead code

Low Severity

The applicationMenu variable is declared and assigned in configureApplicationMenu but never read anywhere in the codebase. It's dead code that adds unnecessary state tracking.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will remove.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, no this isn't dead code (I don't think). I think it's meant to keep the reference alive so out dynamic updating of menu items works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did some additional research, it might not be necessary, but I don't think it really needs to be fixed.


function configureApplicationMenu(): void {
const template: MenuItemConstructorOptions[] = [];
const template: (MenuItemConstructorOptions | MenuItem)[] = [];

if (process.platform === "darwin") {
template.push({
label: app.name,
submenu: [
submenu: Menu.buildFromTemplate([
{ role: "about" },
{
label: "Check for Updates...",
click: () => handleCheckForUpdatesMenuClick(),
},
checkForUpdatesMenuItemInAppMenu,
{ type: "separator" },
{
label: "Settings...",
Expand All @@ -601,7 +657,7 @@ function configureApplicationMenu(): void {
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
]),
});
}

Expand Down Expand Up @@ -641,16 +697,12 @@ function configureApplicationMenu(): void {
{ role: "windowMenu" },
{
role: "help",
submenu: [
{
label: "Check for Updates...",
click: () => handleCheckForUpdatesMenuClick(),
},
],
submenu: Menu.buildFromTemplate([checkForUpdatesMenuItemInHelpMenu]),
},
);

Menu.setApplicationMenu(Menu.buildFromTemplate(template));
applicationMenu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(applicationMenu);
}

function resolveResourcePath(fileName: string): string | null {
Expand Down Expand Up @@ -741,9 +793,46 @@ function emitUpdateState(): void {
}
}

function shouldResetUpdateStateToIdle(state: DesktopUpdateState): boolean {
return state.status === "up-to-date" || state.errorContext === "check";
}

function clearUpdateIdleResetTimer(): void {
if (updateIdleResetTimer) {
clearTimeout(updateIdleResetTimer);
updateIdleResetTimer = null;
}
}

function resetUpdateStateToIdleIfNeeded(): boolean {
clearUpdateIdleResetTimer();
if (!shouldResetUpdateStateToIdle(updateState)) {
return false;
}
setUpdateState(reduceDesktopUpdateStateToIdle(updateState));
return true;
}

function scheduleUpdateStateIdleReset(delayMs = AUTO_UPDATE_TRANSIENT_IDLE_RESET_DELAY_MS): void {
clearUpdateIdleResetTimer();
if (!shouldResetUpdateStateToIdle(updateState)) {
return;
}
updateIdleResetTimer = setTimeout(() => {
updateIdleResetTimer = null;
resetUpdateStateToIdleIfNeeded();
}, delayMs);
updateIdleResetTimer.unref();
}

function setUpdateState(patch: Partial<DesktopUpdateState>): void {
updateState = { ...updateState, ...patch };
emitUpdateState();
if (!shouldResetUpdateStateToIdle(updateState)) {
clearUpdateIdleResetTimer();
}
for (const listener of updateStateListeners) {
listener(updateState);
}
}

function shouldEnableAutoUpdates(): boolean {
Expand Down Expand Up @@ -778,6 +867,7 @@ async function checkForUpdates(reason: string): Promise<boolean> {
setUpdateState(
reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
);
scheduleUpdateStateIdleReset();
console.error(`[desktop-updater] Failed to check for updates: ${message}`);
return true;
} finally {
Expand Down Expand Up @@ -903,6 +993,7 @@ function configureAutoUpdater(): void {
});
autoUpdater.on("update-not-available", () => {
setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
scheduleUpdateStateIdleReset();
lastLoggedDownloadMilestone = -1;
console.info("[desktop-updater] No updates available.");
});
Expand Down Expand Up @@ -1407,6 +1498,7 @@ app.on("before-quit", () => {
isQuitting = true;
updateInstallInFlight = false;
writeDesktopLogHeader("before-quit received");
clearUpdateIdleResetTimer();
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
Expand Down Expand Up @@ -1445,6 +1537,7 @@ if (process.platform !== "win32") {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGINT received");
clearUpdateIdleResetTimer();
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
Expand All @@ -1455,6 +1548,7 @@ if (process.platform !== "win32") {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGTERM received");
clearUpdateIdleResetTimer();
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/updateMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
reduceDesktopUpdateStateOnDownloadStart,
reduceDesktopUpdateStateOnInstallFailure,
reduceDesktopUpdateStateOnNoUpdate,
reduceDesktopUpdateStateToIdle,
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";

Expand Down Expand Up @@ -117,6 +118,24 @@ describe("updateMachine", () => {
expect(state.errorContext).toBeNull();
});

it("returns transient check results back to idle", () => {
const upToDate = reduceDesktopUpdateStateToIdle({
...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
enabled: true,
status: "up-to-date",
checkedAt: "2026-03-04T00:00:00.000Z",
message: "stale",
errorContext: "check",
canRetry: true,
});

expect(upToDate.status).toBe("idle");
expect(upToDate.checkedAt).toBe("2026-03-04T00:00:00.000Z");
expect(upToDate.message).toBeNull();
expect(upToDate.errorContext).toBeNull();
expect(upToDate.canRetry).toBe(false);
});

it("tracks available, download start, and progress cleanly", () => {
const available = reduceDesktopUpdateStateOnUpdateAvailable(
{
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/updateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export function reduceDesktopUpdateStateOnNoUpdate(
};
}

export function reduceDesktopUpdateStateToIdle(state: DesktopUpdateState): DesktopUpdateState {
return {
...state,
status: "idle",
availableVersion: null,
downloadedVersion: null,
downloadPercent: null,
message: null,
errorContext: null,
canRetry: false,
};
}

export function reduceDesktopUpdateStateOnDownloadStart(
state: DesktopUpdateState,
): DesktopUpdateState {
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,25 +259,25 @@ describe("canCheckForUpdate", () => {
expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true);
});

it("returns true when up-to-date", () => {
expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true);
it("returns false when up-to-date is still being shown", () => {
expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(false);
});

it("returns true when an update is available", () => {
it("returns false when an update is already available", () => {
expect(
canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }),
).toBe(true);
).toBe(false);
});

it("returns true on error so the user can retry", () => {
it("returns false on check errors until the state returns to idle", () => {
expect(
canCheckForUpdate({
...baseState,
status: "error",
errorContext: "check",
message: "network",
}),
).toBe(true);
).toBe(false);
});
});

Expand Down
Loading
Loading