-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: make check for updates menu item dynamic #1124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b6fec50
5cf682c
743811e
96b6d23
cd06a14
88635ee
dfa94af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import { | |
| nativeTheme, | ||
| protocol, | ||
| shell, | ||
| MenuItem, | ||
| } from "electron"; | ||
| import type { MenuItemConstructorOptions } from "electron"; | ||
| import * as Effect from "effect/Effect"; | ||
|
|
@@ -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"; | ||
|
|
@@ -41,6 +43,7 @@ import { | |
| reduceDesktopUpdateStateOnDownloadStart, | ||
| reduceDesktopUpdateStateOnInstallFailure, | ||
| reduceDesktopUpdateStateOnNoUpdate, | ||
| reduceDesktopUpdateStateToIdle, | ||
| reduceDesktopUpdateStateOnUpdateAvailable, | ||
| } from "./updateMachine"; | ||
| import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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"; | ||
|
|
@@ -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(); | ||
| 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; | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| updateStateListeners.add((state) => { | ||
| updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInAppMenu, state); | ||
| updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInHelpMenu, state); | ||
| }); | ||
|
|
||
| let applicationMenu: Menu | null = null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unused
|
||
|
|
||
| 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...", | ||
|
|
@@ -601,7 +657,7 @@ function configureApplicationMenu(): void { | |
| { role: "unhide" }, | ||
| { type: "separator" }, | ||
| { role: "quit" }, | ||
| ], | ||
| ]), | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
|
|
@@ -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."); | ||
| }); | ||
|
|
@@ -1407,6 +1498,7 @@ app.on("before-quit", () => { | |
| isQuitting = true; | ||
| updateInstallInFlight = false; | ||
| writeDesktopLogHeader("before-quit received"); | ||
| clearUpdateIdleResetTimer(); | ||
| clearUpdatePollTimer(); | ||
| stopBackend(); | ||
| restoreStdIoCapture?.(); | ||
|
|
@@ -1445,6 +1537,7 @@ if (process.platform !== "win32") { | |
| if (isQuitting) return; | ||
| isQuitting = true; | ||
| writeDesktopLogHeader("SIGINT received"); | ||
| clearUpdateIdleResetTimer(); | ||
| clearUpdatePollTimer(); | ||
| stopBackend(); | ||
| restoreStdIoCapture?.(); | ||
|
|
@@ -1455,6 +1548,7 @@ if (process.platform !== "win32") { | |
| if (isQuitting) return; | ||
| isQuitting = true; | ||
| writeDesktopLogHeader("SIGTERM received"); | ||
| clearUpdateIdleResetTimer(); | ||
| clearUpdatePollTimer(); | ||
| stopBackend(); | ||
| restoreStdIoCapture?.(); | ||
|
|
||


There was a problem hiding this comment.
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,
handleCheckForUpdatesMenuClickcallsinstallDownloadedUpdate()directly, which destroys all windows and callsautoUpdater.quitAndInstall()without any confirmation dialog. The web UI's equivalent click handler useswindow.confirm()before callingbridge.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)
apps/desktop/src/main.ts#L899-L927There was a problem hiding this comment.
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.