From 3cfb7aa9b48dd9680407112dbc010792062a6f70 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 1 Nov 2026 12:22:47 -0500 Subject: [PATCH 1/3] feat(updater): link release notes from update dialog Accept the downloaded-update event, add a Release notes action when release information is present, and open the matching GitHub release page. Guard against repeat dialogs for the same version and reset the guard on update-available or update-cancelled. --- src/main/updater.test.ts | 137 ++++++++++++++++++++++++++++++++++++++- src/main/updater.ts | 44 +++++++++++-- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 54c14587c..b70221754 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,4 +1,4 @@ -import { dialog } from 'electron'; +import { dialog, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; import { APPLICATION } from '../shared/constants'; @@ -17,7 +17,11 @@ import MenuBuilder from './menu'; import AppUpdater from './updater'; // Mock electron-updater with an EventEmitter-like interface -type UpdateDownloadedEvent = { releaseName?: string | null; version?: string }; +type UpdateDownloadedEvent = { + releaseName?: string | null; + version?: string; + releaseNotes?: string | null; +}; type ListenerArgs = UpdateDownloadedEvent | object | undefined; type Listener = (arg: ListenerArgs) => void; type ListenerMap = Record; @@ -201,6 +205,135 @@ describe('main/updater.ts', () => { ), ); }); + + it('offers Release notes and opens the matching GitHub release page', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 2, // "Release notes" button index + checkboxChecked: false, + }); + vi.mocked(shell.openExternal).mockResolvedValue(); + + await updater.start(); + + emit('update-downloaded', { + releaseName: 'v1.2.3', + version: '1.2.3', + releaseNotes: 'What changed', + }); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Restart', 'Later', 'Release notes'], + cancelId: 1, + }), + ); + + await vi.waitFor(() => + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/gitify-app/gitify/releases/tag/v1.2.3', + ), + ); + }); + + it('normalizes a version that already carries a leading v', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 2, + checkboxChecked: false, + }); + vi.mocked(shell.openExternal).mockResolvedValue(); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: 'v1.2.3' }); + + await vi.waitFor(() => + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/gitify-app/gitify/releases/tag/v1.2.3', + ), + ); + }); + + it('omits Release notes when the update carries no release information', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Restart', 'Later'], + cancelId: 1, + }), + ); + }); + + it('logs a failure when the release page cannot be opened', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 2, + checkboxChecked: false, + }); + vi.mocked(shell.openExternal).mockRejectedValueOnce(new Error('no browser')); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + + await vi.waitFor(() => + expect(logError).toHaveBeenCalledWith( + 'app updater', + 'Failed to open release notes', + expect.any(Error), + ), + ); + }); + + it('shows at most one dialog for the same downloaded version', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); + }); + + it('allows a later update to prompt again after update-available', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + emit('update-available'); + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(2); + }); + + it('allows a later update to prompt again after update-cancelled', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + emit('update-cancelled'); + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(2); + }); }); describe('update event handlers & scheduling', () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index fbb4e4aa1..07316d7ef 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,6 +1,6 @@ -import { dialog, type MessageBoxOptions } from 'electron'; +import { dialog, type MessageBoxOptions, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { autoUpdater } from 'electron-updater'; +import { autoUpdater, type UpdateDownloadedEvent } from 'electron-updater'; import { APPLICATION } from '../shared/constants'; import { logError, logInfo, toError } from '../shared/logger'; @@ -22,6 +22,7 @@ export default class AppUpdater { private notificationsEnabled = true; private started = false; private noUpdateMessageTimeout?: NodeJS.Timeout; + private lastPromptedVersion: string | null = null; /** * @param menubar - The menubar instance whose tray and window the updater reports status through. @@ -80,6 +81,7 @@ export default class AppUpdater { autoUpdater.on('update-available', () => { logInfo('auto updater', 'Update available'); + this.lastPromptedVersion = null; this.setTooltipWithStatus('A new update is available'); this.menuBuilder.setUpdateAvailableMenuVisibility(true); }); @@ -94,7 +96,7 @@ export default class AppUpdater { this.menuBuilder.setUpdateAvailableMenuVisibility(false); this.menuBuilder.setUpdateReadyForInstallMenuVisibility(true); if (this.notificationsEnabled) { - this.showUpdateReadyDialog(event.releaseName ?? event.version); + this.showUpdateReadyDialog(event); } }); @@ -114,6 +116,7 @@ export default class AppUpdater { autoUpdater.on('update-cancelled', () => { logInfo('auto updater', 'Update cancelled'); + this.lastPromptedVersion = null; this.resetState(); }); @@ -206,13 +209,26 @@ export default class AppUpdater { /** * Show a dialog informing the user that an update is ready to install. * If the user chooses to restart, quitAndInstall is called immediately. + * When the downloaded update carries release information, a Release notes + * action opens the matching GitHub release page. * - * @param release - The release name shown in the dialog message. + * @param event - The downloaded-update event with the release identity. */ - private showUpdateReadyDialog(release: string) { + private showUpdateReadyDialog(event: UpdateDownloadedEvent) { + if (event.version === this.lastPromptedVersion) { + return; + } + + const hasReleaseInfo = Boolean(event.releaseNotes) || Boolean(event.version); + const release = event.releaseName ?? event.version; + const buttons = ['Restart', 'Later', ...(hasReleaseInfo ? ['Release notes'] : [])]; + + this.lastPromptedVersion = event.version; + const dialogOpts: MessageBoxOptions = { type: 'info', - buttons: ['Restart', 'Later'], + buttons, + cancelId: 1, title: 'Application Update', message: `${APPLICATION.NAME} ${release} has been downloaded`, detail: 'Restart to apply the update. You can also restart later from the tray menu.', @@ -223,10 +239,26 @@ export default class AppUpdater { .then((returnValue) => { if (returnValue.response === 0) { autoUpdater.quitAndInstall(); + } else if (returnValue.response === 2 && hasReleaseInfo && event.version) { + this.openReleaseNotes(event.version); } }) .catch((err) => { logError('app updater', 'Failed to show update ready dialog', toError(err)); }); } + + /** + * Open the GitHub release page for a downloaded version. + * + * @param version - The downloaded version, with or without a leading `v`. + */ + private openReleaseNotes(version: string) { + const normalizedVersion = version.replace(/^v/, ''); + const url = `${APPLICATION.GITHUB_BASE_URL}/${APPLICATION.REPO_SLUG}/releases/tag/v${normalizedVersion}`; + + shell.openExternal(url).catch((err) => { + logError('app updater', 'Failed to open release notes', toError(err)); + }); + } } From b0025087b3a4f8fe49912ebb70407b70e0df4639 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 1 Nov 2026 16:46:00 -0500 Subject: [PATCH 2/3] fix(updater): avoid re-prompting for the same downloaded version A routine update check for the same pending version must not show the ready dialog again after the user chose Later. Keep the tray Restart to install entry; a different version still prompts normally. --- src/main/updater.test.ts | 17 ++++++++++++++++- src/main/updater.ts | 5 ++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index b70221754..5cc746060 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -305,7 +305,7 @@ describe('main/updater.ts', () => { expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); }); - it('allows a later update to prompt again after update-available', async () => { + it('does not prompt again for the same version after update-available', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false, @@ -317,6 +317,21 @@ describe('main/updater.ts', () => { emit('update-available'); emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); + }); + + it('prompts again when a different version is downloaded', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v1.2.3', version: '1.2.3' }); + emit('update-available'); + emit('update-downloaded', { releaseName: 'v1.3.0', version: '1.3.0' }); + expect(dialog.showMessageBox).toHaveBeenCalledTimes(2); }); diff --git a/src/main/updater.ts b/src/main/updater.ts index 07316d7ef..5c507b8f6 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -81,7 +81,10 @@ export default class AppUpdater { autoUpdater.on('update-available', () => { logInfo('auto updater', 'Update available'); - this.lastPromptedVersion = null; + // Keep the prompt guard for this version: a routine check for the same + // pending version must not re-prompt after the user chose Later. A + // different version will not match `lastPromptedVersion` and prompts + // normally. Only a cancelled download clears the guard. this.setTooltipWithStatus('A new update is available'); this.menuBuilder.setUpdateAvailableMenuVisibility(true); }); From 56227c17ce07559081997137675257abbcc9fc8c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 1 Nov 2026 16:50:27 -0500 Subject: [PATCH 3/3] docs: document update-ready dialog and menu behavior Record the one-dialog-per-version rule, the persistent Restart to install menu entry, and the different-version/cancellation behavior. --- ARCHITECTURE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6f0dd25a8..ed68bb809 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,6 +80,16 @@ Operating-system notifications are created by the main process behind the typed Subscription is separate from side effects, but it multiplies the same way: every TanStack Query observer re-runs the query's `select` and structural-sharing walk whenever the cached data changes. A component that renders once per notification — the notification row, the repository header — must therefore not subscribe. It obtains notification actions (mark as read/done, unsubscribe, remove an account, refetch) with `useNotificationActions`, which never calls `useQuery`. Components that read notification data use `useNotifications` and stay subscribed by design. +## Application updates + +Updates are a main-process concern (`src/main/updater.ts`), driven by `electron-updater` events and surfaced through the tray/menu and a native ready dialog. + +- `update-available` updates the tooltip and menu; it does not clear the dialog guard. +- `update-downloaded` marks the menu's **Restart to install update** entry visible and, when notifications are enabled, shows the ready dialog once per downloaded version. +- The dialog offers **Restart**, **Later**, and **Release notes** when the downloaded update has release information. Release notes opens the matching GitHub release page. +- **Later** dismisses only the dialog. The menu entry remains the install path, and a later check for the same downloaded version does not prompt again. A different version prompts normally; `update-cancelled` clears the guard. The guard is process-local. +- With notifications disabled, the dialog is skipped and the menu entry remains. + ## Forge adapters Multi-forge support is adapter-based. Each forge implements the `ForgeAdapter` interface in `src/renderer/utils/forges/`, and shared code routes through `getAdapter(forge)` (forge-wide members) or `getAccountAdapter(account)` (account-scoped operations). Shared code never imports forge-specific modules directly, and there is no forge-specific branching outside an adapter module. See `CONTRIBUTING.md` for the conditions for adding a forge.