From f3e1ab3e690fe6de61700a8c76923dfb5c0a761c Mon Sep 17 00:00:00 2001 From: WANG Kaixun <12310803@mail.sustech.edu.cn> Date: Fri, 20 Mar 2026 11:58:46 +0800 Subject: [PATCH 1/3] feat: add user setting for update notification interval --- src/main/index.ts | 17 ++- src/main/updater.test.ts | 13 ++ src/main/updater.ts | 137 +++++++++++++++++- src/preload/index.ts | 12 ++ src/renderer/__helpers__/vitest.setup.ts | 3 + src/renderer/__mocks__/state-mocks.ts | 2 + .../AccountNotifications.test.tsx.snap | 4 +- .../NotificationFooter.test.tsx.snap | 8 +- .../NotificationRow.test.tsx.snap | 8 +- .../settings/NotificationSettings.test.tsx | 19 +++ .../settings/NotificationSettings.tsx | 57 +++++++- src/renderer/context/defaults.ts | 2 + .../__snapshots__/Settings.test.tsx.snap | 60 ++++++++ src/renderer/types.ts | 9 ++ src/shared/events.ts | 1 + 15 files changed, 337 insertions(+), 15 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index b61cde0ea..87cf78420 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, @@ -31,7 +34,8 @@ const mb = menubar({ index: Paths.indexHtml, browserWindow: WindowConfig, preloadWindow: true, - showDockIcon: false, // Hide the app from the macOS dock + // Keep Dock icon in development to make the app easy to find/debug. + showDockIcon: isDevMode(), }); const menuBuilder = new MenuBuilder(mb); @@ -43,6 +47,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(); @@ -50,6 +61,10 @@ app.whenReady().then(async () => { initializeAppLifecycle(mb, contextMenu, protocol); + if (isDevMode()) { + mb.showWindow(); + } + // Configure window event handlers (Escape key, DevTools resize) configureWindowEvents(mb); diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index fb7d1f727..1f6896770 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { dialog } from 'electron'; import type { Menubar } from 'menubar'; @@ -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 { @@ -41,6 +46,7 @@ vi.mock('electron', () => { } } return { + app: { getPath: vi.fn(() => userDataPath) }, dialog: { showMessageBox: vi.fn() }, MenuItem, Menu: { buildFromTemplate: vi.fn() }, @@ -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, diff --git a/src/main/updater.ts b/src/main/updater.ts index 941d12586..ae4ab95f3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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'; @@ -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, + number +> = { + DAILY: 24 * 60 * 60 * 1000, + WEEKLY: 7 * 24 * 60 * 60 * 1000, + MONTHLY: 30 * 24 * 60 * 60 * 1000, +}; + /** * Updater class for handling application updates. * @@ -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; @@ -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', () => { @@ -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 + ]; + } + + 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. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 695d4ff8b..4ca27c13a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { /** diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 30b4e80a5..c9ae8029e 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -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'), diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 66246ad44..6a892167f 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -14,6 +14,7 @@ import { Theme, type Token, type TraySettingsState, + UpdatePromptQuietFrequency, } from '../types'; import { @@ -48,6 +49,7 @@ const mockNotificationSettings: NotificationSettingsState = { markAsDoneOnOpen: false, markAsDoneOnUnsubscribe: false, delayNotificationState: false, + updatePromptQuietFrequency: UpdatePromptQuietFrequency.DAILY, }; const mockTraySettings: TraySettingsState = { diff --git a/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap index 3fe7a42a4..8aff51cc1 100644 --- a/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap @@ -871,7 +871,7 @@ exports[`renderer/components/notifications/AccountNotifications.tsx > should ren - May 20, 2017 + May 21, 2017
should ren - May 20, 2017 + May 21, 2017
security ale - May 20, 2017 + May 21, 2017
security ale - May 20, 2017 + May 21, 2017
should defau - May 20, 2017 + May 21, 2017
should rende - May 20, 2017 + May 21, 2017
should render i - May 20, 2017 + May 21, 2017
should render i - May 20, 2017 + May 21, 2017
should render i - May 20, 2017 + May 21, 2017
should render i - May 20, 2017 + May 21, 2017
{ expect(updateSettingMock).toHaveBeenCalledWith('fetchType', 'INACTIVITY'); }); + it('should update update prompt quiet frequency with dropdown', async () => { + await act(async () => { + renderWithAppContext(, { + 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 () => { diff --git a/src/renderer/components/settings/NotificationSettings.tsx b/src/renderer/components/settings/NotificationSettings.tsx index f4ed9d520..328631577 100644 --- a/src/renderer/components/settings/NotificationSettings.tsx +++ b/src/renderer/components/settings/NotificationSettings.tsx @@ -15,7 +15,14 @@ import { SyncIcon, TagIcon, } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; +import { + Button, + ButtonGroup, + IconButton, + Select, + Stack, + Text, +} from '@primer/react'; import { formatDuration, millisecondsToMinutes } from 'date-fns'; @@ -30,7 +37,12 @@ import { FieldLabel } from '../fields/FieldLabel'; import { RadioGroup } from '../fields/RadioGroup'; import { Title } from '../primitives/Title'; -import { FetchType, GroupBy, Size } from '../../types'; +import { + FetchType, + GroupBy, + Size, + UpdatePromptQuietFrequency, +} from '../../types'; import { hasAlternateScopes, @@ -51,6 +63,12 @@ export const NotificationSettings: FC = () => { setFetchInterval(settings.fetchInterval); }, [settings.fetchInterval]); + useEffect(() => { + window.gitify.updates?.setUpdatePromptQuietFrequency?.( + settings.updatePromptQuietFrequency, + ); + }, [settings.updatePromptQuietFrequency]); + return (
Notifications @@ -184,6 +202,41 @@ export const NotificationSettings: FC = () => { + + + + + should render itself & its children 1`]
+
+ + + + + +
Date: Fri, 20 Mar 2026 12:06:17 +0800 Subject: [PATCH 2/3] feat: update notification interval settings --- src/main/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 87cf78420..832919325 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,7 +35,7 @@ const mb = menubar({ browserWindow: WindowConfig, preloadWindow: true, // Keep Dock icon in development to make the app easy to find/debug. - showDockIcon: isDevMode(), + showDockIcon: false, }); const menuBuilder = new MenuBuilder(mb); @@ -61,10 +61,6 @@ app.whenReady().then(async () => { initializeAppLifecycle(mb, contextMenu, protocol); - if (isDevMode()) { - mb.showWindow(); - } - // Configure window event handlers (Escape key, DevTools resize) configureWindowEvents(mb); From 6709dacbc2a27f7a1c046e697109f988b666260e Mon Sep 17 00:00:00 2001 From: WANG Kaixun <12310803@mail.sustech.edu.cn> Date: Fri, 20 Mar 2026 12:07:45 +0800 Subject: [PATCH 3/3] feat: update notification interval settings --- src/main/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 832919325..ee88e8817 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,8 +34,7 @@ const mb = menubar({ index: Paths.indexHtml, browserWindow: WindowConfig, preloadWindow: true, - // Keep Dock icon in development to make the app easy to find/debug. - showDockIcon: false, + showDockIcon: false, // Hide the app from the macOS dock }); const menuBuilder = new MenuBuilder(mb);