diff --git a/package.json b/package.json index ba709e4b4..078a4b77b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "dependencies": { "electron-log": "5.4.4", - "electron-menubar": "10.1.8", + "electron-menubar": "10.2.0", "electron-updater": "6.8.9", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6dd42acc..1b15a87de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 5.4.4 version: 5.4.4 electron-menubar: - specifier: 10.1.8 - version: 10.1.8(electron@43.3.0(supports-color@10.2.2)) + specifier: 10.2.0 + version: 10.2.0(electron@43.3.0(supports-color@10.2.2)) electron-updater: specifier: 6.8.9 version: 6.8.9(supports-color@10.2.2) @@ -2538,8 +2538,8 @@ packages: resolution: {integrity: sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==} engines: {node: '>= 14'} - electron-menubar@10.1.8: - resolution: {integrity: sha512-FD1Qku60xCbSgQTV4SXLaHh/NugT1Vp7PT9icb7A7lejzzCJj3e1LJLpt2aV+Nf3D/MqlqjcAcwwfbfEDJXu0w==} + electron-menubar@10.2.0: + resolution: {integrity: sha512-CMhVE91BZg5OQkViOHOknM2+HQMwwopxJjNR+Uuu7flc37I/MdujpeOgGPTAQJXgPnwYsPZ+VXo5K2kB+QrLpA==} peerDependencies: electron: 43.3.0 @@ -6809,7 +6809,7 @@ snapshots: electron-log@5.4.4: {} - electron-menubar@10.1.8(electron@43.3.0(supports-color@10.2.2)): + electron-menubar@10.2.0(electron@43.3.0(supports-color@10.2.2)): dependencies: electron: 43.3.0(supports-color@10.2.2) diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index daeac685c..66b78a791 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -94,6 +94,7 @@ describe('main/menu.ts', () => { app: { quit: vi.fn() }, showWindow: vi.fn(), hideWindow: vi.fn(), + refreshContextMenu: vi.fn(), tray: { isDestroyed: vi.fn(() => false), setContextMenu: vi.fn(), @@ -201,6 +202,12 @@ describe('main/menu.ts', () => { // oxlint-disable-next-line dot-notation -- This is a test expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(false); }); + + it('republishes the menu so Linux picks up the visibility change', () => { + menuBuilder.setUpdateReadyForInstallMenuVisibility(true); + + expect(menubar.refreshContextMenu).toHaveBeenCalled(); + }); }); describe('windowVisibilityMenuItems', () => { diff --git a/src/main/menu.ts b/src/main/menu.ts index 926960134..e14535ff0 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -153,6 +153,17 @@ export default class MenuBuilder { this.hideWindowMenuItem.visible = isVisible; } + /** + * Publish in-place menu item changes to the tray. + * + * Linux serves libappindicator a cached serialization of the menu, so an + * item mutated after the menu was attached keeps rendering its old state + * until the menu is set again. A no-op on macOS and Windows. + */ + private refreshMenu() { + this.menubar.refreshContextMenu(); + } + /** * Enable or disable the "Check for updates" menu item. * Disabled while an update check is in progress. @@ -161,6 +172,7 @@ export default class MenuBuilder { */ setCheckForUpdatesMenuEnabled(enabled: boolean) { this.checkForUpdatesMenuItem.enabled = enabled; + this.refreshMenu(); } /** @@ -170,6 +182,7 @@ export default class MenuBuilder { */ setNoUpdateAvailableMenuVisibility(isVisible: boolean) { this.noUpdateAvailableMenuItem.visible = isVisible; + this.refreshMenu(); } /** @@ -179,6 +192,7 @@ export default class MenuBuilder { */ setUpdateAvailableMenuVisibility(isVisible: boolean) { this.updateAvailableMenuItem.visible = isVisible; + this.refreshMenu(); } /** @@ -188,5 +202,6 @@ export default class MenuBuilder { */ setUpdateReadyForInstallMenuVisibility(isVisible: boolean) { this.updateReadyForInstallMenuItem.visible = isVisible; + this.refreshMenu(); } } diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index ce4586d22..43c9babd0 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -17,7 +17,7 @@ import MenuBuilder from './menu'; import AppUpdater from './updater'; // Mock electron-updater with an EventEmitter-like interface -type UpdateDownloadedEvent = { releaseName: string }; +type UpdateDownloadedEvent = { releaseName?: string | null; version?: string }; type ListenerArgs = UpdateDownloadedEvent | object | undefined; type Listener = (arg: ListenerArgs) => void; type ListenerMap = Record; @@ -124,6 +124,23 @@ describe('main/updater.ts', () => { expect(menuBuilder.setUpdateReadyForInstallMenuVisibility).toHaveBeenCalledWith(true); }); + it('falls back to the version when the release has no name', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, // "Later" button index + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: null, version: '1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: `${APPLICATION.NAME} 1.2.3 has been downloaded`, + }), + ); + }); + it('invokes quitAndInstall when user clicks Restart', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, // "Restart" button index @@ -270,6 +287,27 @@ describe('main/updater.ts', () => { expect(menubar.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME); }); + it('keeps checking on schedule after an error', async () => { + vi.useFakeTimers(); + try { + await updater.start(); + + // Let the first scheduled check run, which registers the interval + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + const callsBeforeError = vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length; + + emit('error', new Error('offline')); + + await vi.advanceTimersByTimeAsync(APPLICATION.UPDATE_CHECK_INTERVAL_MS); + + expect(vi.mocked(autoUpdater.checkForUpdatesAndNotify).mock.calls.length).toBeGreaterThan( + callsBeforeError, + ); + } finally { + vi.useRealTimers(); + } + }); + it('performs initial check and schedules periodic checks', async () => { const originalSetInterval = globalThis.setInterval; const setIntervalSpy = vi.spyOn(globalThis, 'setInterval').mockImplementation((( diff --git a/src/main/updater.ts b/src/main/updater.ts index 214014d84..a41eacd9f 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -21,7 +21,6 @@ export default class AppUpdater { private readonly menuBuilder: MenuBuilder; private started = false; private noUpdateMessageTimeout?: NodeJS.Timeout; - private periodicInterval?: NodeJS.Timeout; constructor(menubar: Menubar, menuBuilder: MenuBuilder) { this.menubar = menubar; @@ -82,7 +81,7 @@ export default class AppUpdater { this.setTooltipWithStatus('A new update is ready to install'); this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); - this.showUpdateReadyDialog(event.releaseName ?? undefined); + this.showUpdateReadyDialog(event.releaseName ?? event.version); }); autoUpdater.on('update-not-available', () => { @@ -139,7 +138,7 @@ export default class AppUpdater { // This avoids an immediate duplicate check on startup. setTimeout(async () => { await runScheduledCheck(); - this.periodicInterval = setInterval(runScheduledCheck, APPLICATION.UPDATE_CHECK_INTERVAL_MS); + setInterval(runScheduledCheck, APPLICATION.UPDATE_CHECK_INTERVAL_MS); }, APPLICATION.UPDATE_CHECK_INTERVAL_MS); } @@ -164,6 +163,8 @@ export default class AppUpdater { /** * Reset tray tooltip and all update-related menu items to their default state. + * Leaves the periodic check schedule running so a cancelled or failed check + * does not stop the app looking for later updates. */ private resetState() { this.menubar.tray.setToolTip(APPLICATION.NAME); @@ -174,26 +175,20 @@ export default class AppUpdater { // Clear any pending timeout this.clearNoUpdateTimeout(); - - // Clear periodic interval if present - if (this.periodicInterval) { - clearInterval(this.periodicInterval); - this.periodicInterval = undefined; - } } /** * Show a dialog informing the user that an update is ready to install. * If the user chooses to restart, quitAndInstall is called immediately. * - * @param releaseName - The version string shown in the dialog message. + * @param release - The release name shown in the dialog message. */ - private showUpdateReadyDialog(releaseName?: string) { + private showUpdateReadyDialog(release: string) { const dialogOpts: MessageBoxOptions = { type: 'info', buttons: ['Restart', 'Later'], title: 'Application Update', - message: `${APPLICATION.NAME} ${releaseName} has been downloaded`, + message: `${APPLICATION.NAME} ${release} has been downloaded`, detail: 'Restart to apply the update. You can also restart later from the tray menu.', };