Skip to content
Open
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 src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { app } from 'electron';
import log from 'electron-log';
import { menubar } from 'menubar';

import { EVENTS } from '../shared/events';

import { Paths, WindowConfig } from './config';
import { onMainEvent } from './events';
import {
registerAppHandlers,
registerStorageHandlers,
Expand Down Expand Up @@ -43,6 +46,13 @@ app.setAsDefaultProtocolClient(protocol);

const appUpdater = new AppUpdater(mb, menuBuilder);

// Keep update-prompt quiet frequency in sync with renderer settings.
onMainEvent(EVENTS.UPDATE_PROMPT_QUIET_FREQUENCY, (_, frequency: string) => {
appUpdater.setUpdatePromptQuietFrequency(
frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'NEVER',
);
});

app.whenReady().then(async () => {
await onFirstRunMaybe();

Expand Down
13 changes: 13 additions & 0 deletions src/main/updater.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';

import { dialog } from 'electron';
import type { Menubar } from 'menubar';

Expand Down Expand Up @@ -33,6 +36,8 @@ vi.mock('electron-updater', () => ({
},
}));

const userDataPath = path.join(process.cwd(), '.gitify-test-userdata');

// Mock electron (dialog + basic Menu API used by MenuBuilder constructor)
vi.mock('electron', () => {
class MenuItem {
Expand All @@ -41,6 +46,7 @@ vi.mock('electron', () => {
}
}
return {
app: { getPath: vi.fn(() => userDataPath) },
dialog: { showMessageBox: vi.fn() },
MenuItem,
Menu: { buildFromTemplate: vi.fn() },
Expand Down Expand Up @@ -76,6 +82,13 @@ describe('main/updater.ts', () => {
delete listeners[k];
}

try {
fs.mkdirSync(userDataPath, { recursive: true });
fs.unlinkSync(path.join(userDataPath, 'update-prompt-quiet.json'));
} catch {
// Ignore: missing state file is expected for fresh tests.
}

menubar = {
app: {
isPackaged: true,
Expand Down
137 changes: 135 additions & 2 deletions src/main/updater.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { dialog, type MessageBoxOptions } from 'electron';
import fs from 'node:fs';
import path from 'node:path';

import { app, dialog, type MessageBoxOptions } from 'electron';
import { autoUpdater } from 'electron-updater';
import type { Menubar } from 'menubar';

Expand All @@ -7,6 +10,19 @@ import { logError, logInfo } from '../shared/logger';

import type MenuBuilder from './menu';

type UpdatePromptQuietFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'NEVER';

const UPDATE_PROMPT_QUIET_STATE_FILE = 'update-prompt-quiet.json';

const UPDATE_PROMPT_QUIET_WINDOW_MS: Record<
Exclude<UpdatePromptQuietFrequency, 'NEVER'>,
number
> = {
DAILY: 24 * 60 * 60 * 1000,
WEEKLY: 7 * 24 * 60 * 60 * 1000,
MONTHLY: 30 * 24 * 60 * 60 * 1000,
};

/**
* Updater class for handling application updates.
*
Expand All @@ -23,9 +39,21 @@ export default class AppUpdater {
private noUpdateMessageTimeout?: NodeJS.Timeout;
private periodicInterval?: NodeJS.Timeout;

private updatePromptQuietFrequency: UpdatePromptQuietFrequency = 'DAILY';
private lastUpdatePromptAtMs?: number;
private readonly quietStatePath: string;

constructor(menubar: Menubar, menuBuilder: MenuBuilder) {
this.menubar = menubar;
this.menuBuilder = menuBuilder;

this.quietStatePath = path.join(
app.getPath('userData'),
UPDATE_PROMPT_QUIET_STATE_FILE,
);

this.loadUpdatePromptQuietState();

// Disable electron-updater's own logging to avoid duplicate log messages
// We'll handle all logging through our event listeners
autoUpdater.logger = null;
Expand Down Expand Up @@ -87,7 +115,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);
void this.maybeShowUpdateReadyDialog(event.releaseName);
});

autoUpdater.on('update-not-available', () => {
Expand Down Expand Up @@ -115,6 +143,111 @@ export default class AppUpdater {
});
}

/**
* Update how often we show the update-ready dialog.
* This controls only the dialog frequency, not the update check/download behavior.
*/
public setUpdatePromptQuietFrequency(
frequency: UpdatePromptQuietFrequency,
): void {
if (
frequency !== 'DAILY' &&
frequency !== 'WEEKLY' &&
frequency !== 'MONTHLY' &&
frequency !== 'NEVER'
) {
return;
}

this.updatePromptQuietFrequency = frequency;
this.persistUpdatePromptQuietState();
}

private loadUpdatePromptQuietState(): void {
try {
const raw = fs.readFileSync(this.quietStatePath, 'utf8');
const parsed = JSON.parse(raw) as {
updatePromptQuietFrequency?: UpdatePromptQuietFrequency;
lastUpdatePromptAtMs?: number;
};

if (
parsed.updatePromptQuietFrequency === 'DAILY' ||
parsed.updatePromptQuietFrequency === 'WEEKLY' ||
parsed.updatePromptQuietFrequency === 'MONTHLY' ||
parsed.updatePromptQuietFrequency === 'NEVER'
) {
this.updatePromptQuietFrequency = parsed.updatePromptQuietFrequency;
}

if (
typeof parsed.lastUpdatePromptAtMs === 'number' &&
Number.isFinite(parsed.lastUpdatePromptAtMs)
) {
this.lastUpdatePromptAtMs = parsed.lastUpdatePromptAtMs;
}
} catch {
// Best-effort persistence: missing/invalid state should not break app updates.
}
}

private persistUpdatePromptQuietState(): void {
try {
fs.writeFileSync(
this.quietStatePath,
JSON.stringify(
{
updatePromptQuietFrequency: this.updatePromptQuietFrequency,
lastUpdatePromptAtMs: this.lastUpdatePromptAtMs,
},
null,
0,
),
'utf8',
);
} catch {
// Ignore persistence errors — update prompting should still work.
}
}

private getQuietWindowMs(
frequency: UpdatePromptQuietFrequency,
): number | null {
if (frequency === 'NEVER') {
return null;
}

return UPDATE_PROMPT_QUIET_WINDOW_MS[
frequency as Exclude<UpdatePromptQuietFrequency, 'NEVER'>
];
}

private async maybeShowUpdateReadyDialog(releaseName: string) {
const quietWindowMs = this.getQuietWindowMs(
this.updatePromptQuietFrequency,
);

// NEVER means: never show dialog.
if (quietWindowMs === null) {
return;
}

const now = Date.now();

// Global quiet-scope: only show once per window.
if (
this.lastUpdatePromptAtMs !== undefined &&
now - this.lastUpdatePromptAtMs < quietWindowMs
) {
logInfo('app updater', 'Quiet prompt window active; skipping dialog');
return;
}

this.lastUpdatePromptAtMs = now;
this.persistUpdatePromptQuietState();
this.showUpdateReadyDialog(releaseName);
}

/**
* Run an immediate update check on application launch.
*/
Expand Down
12 changes: 12 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ export const api = {
},
},

/** Application update prompt controls. */
updates: {
/**
* Set how often Gitify shows the “Application Update / Restart / Later” dialog
* after downloading an update.
*
* @param frequency - One of: DAILY | WEEKLY | MONTHLY | NEVER
*/
setUpdatePromptQuietFrequency: (frequency: string) =>
sendMainEvent(EVENTS.UPDATE_PROMPT_QUIET_FREQUENCY, frequency),
},

/** Electron web frame zoom controls. */
zoom: {
/**
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ window.gitify = {
quit: vi.fn(),
show: vi.fn(),
},
updates: {
setUpdatePromptQuietFrequency: vi.fn(),
},
twemojiDirectory: vi.fn().mockResolvedValue('/mock/images/assets'),
openExternalLink: vi.fn(),
decryptValue: vi.fn().mockResolvedValue('decrypted'),
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Theme,
type Token,
type TraySettingsState,
UpdatePromptQuietFrequency,
} from '../types';

import {
Expand Down Expand Up @@ -48,6 +49,7 @@ const mockNotificationSettings: NotificationSettingsState = {
markAsDoneOnOpen: false,
markAsDoneOnUnsubscribe: false,
delayNotificationState: false,
updatePromptQuietFrequency: UpdatePromptQuietFrequency.DAILY,
};

const mockTraySettings: TraySettingsState = {
Expand Down

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

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

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

19 changes: 19 additions & 0 deletions src/renderer/components/settings/NotificationSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ describe('renderer/components/settings/NotificationSettings.tsx', () => {
expect(updateSettingMock).toHaveBeenCalledWith('fetchType', 'INACTIVITY');
});

it('should update update prompt quiet frequency with dropdown', async () => {
await act(async () => {
renderWithAppContext(<NotificationSettings />, {
updateSetting: updateSettingMock,
});
});

await userEvent.selectOptions(
screen.getByTestId('settings-update-prompt-quiet-frequency'),
'WEEKLY',
);

expect(updateSettingMock).toHaveBeenCalledTimes(1);
expect(updateSettingMock).toHaveBeenCalledWith(
'updatePromptQuietFrequency',
'WEEKLY',
);
});

describe('fetch interval settings', () => {
it('should update the fetch interval values when using the buttons', async () => {
await act(async () => {
Expand Down
Loading
Loading