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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/main/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 15 additions & 0 deletions src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -161,6 +172,7 @@ export default class MenuBuilder {
*/
setCheckForUpdatesMenuEnabled(enabled: boolean) {
this.checkForUpdatesMenuItem.enabled = enabled;
this.refreshMenu();
}

/**
Expand All @@ -170,6 +182,7 @@ export default class MenuBuilder {
*/
setNoUpdateAvailableMenuVisibility(isVisible: boolean) {
this.noUpdateAvailableMenuItem.visible = isVisible;
this.refreshMenu();
}

/**
Expand All @@ -179,6 +192,7 @@ export default class MenuBuilder {
*/
setUpdateAvailableMenuVisibility(isVisible: boolean) {
this.updateAvailableMenuItem.visible = isVisible;
this.refreshMenu();
}

/**
Expand All @@ -188,5 +202,6 @@ export default class MenuBuilder {
*/
setUpdateReadyForInstallMenuVisibility(isVisible: boolean) {
this.updateReadyForInstallMenuItem.visible = isVisible;
this.refreshMenu();
}
}
40 changes: 39 additions & 1 deletion src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Listener[]>;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(((
Expand Down
19 changes: 7 additions & 12 deletions src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
}

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

Expand Down