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. diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 54c14587c..5cc746060 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,150 @@ 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('does not prompt again for the same version 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(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); + }); + + 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..5c507b8f6 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,10 @@ export default class AppUpdater { autoUpdater.on('update-available', () => { logInfo('auto updater', 'Update available'); + // 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); }); @@ -94,7 +99,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 +119,7 @@ export default class AppUpdater { autoUpdater.on('update-cancelled', () => { logInfo('auto updater', 'Update cancelled'); + this.lastPromptedVersion = null; this.resetState(); }); @@ -206,13 +212,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 +242,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)); + }); + } }