Skip to content
Merged
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
10 changes: 10 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 150 additions & 2 deletions src/main/updater.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dialog } from 'electron';
import { dialog, shell } from 'electron';
import type { Menubar } from 'electron-menubar';

import { APPLICATION } from '../shared/constants';
Expand All @@ -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<string, Listener[]>;
Expand Down Expand Up @@ -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', () => {
Expand Down
47 changes: 41 additions & 6 deletions src/main/updater.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
}
});

Expand All @@ -114,6 +119,7 @@ export default class AppUpdater {

autoUpdater.on('update-cancelled', () => {
logInfo('auto updater', 'Update cancelled');
this.lastPromptedVersion = null;
this.resetState();
});

Expand Down Expand Up @@ -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.',
Expand All @@ -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));
});
}
}
Loading