diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index 1048bb074..0787eb126 100644 --- a/src/main/handlers/system.test.ts +++ b/src/main/handlers/system.test.ts @@ -9,6 +9,10 @@ vi.mock('../lifecycle/window', () => ({ applyKeepWindowOnBlur: vi.fn(), })); +vi.mock('../../shared/logger', () => ({ + logInfo: vi.fn(), +})); + const onMock = vi.fn(); const handleMock = vi.fn(); @@ -23,6 +27,9 @@ vi.mock('electron', () => ({ shell: { openExternal: vi.fn(), } satisfies Pick, + powerMonitor: { + on: vi.fn(), + } satisfies Pick, })); describe('main/handlers/system.ts', () => { @@ -39,6 +46,9 @@ describe('main/handlers/system.ts', () => { setGlobalShortcut: setGlobalShortcutMock, window: { isVisible: vi.fn().mockReturnValue(false), + webContents: { + send: vi.fn(), + }, }, } as unknown as Menubar; }); @@ -73,6 +83,53 @@ describe('main/handlers/system.ts', () => { }); }); + describe('SYSTEM_WAKE', () => { + it('registers powerMonitor listeners for resume and unlock-screen', async () => { + const { powerMonitor } = await import('electron'); + registerSystemHandlers(menubar); + + const registeredEvents = vi + .mocked(powerMonitor.on) + .mock.calls.map((call) => call[0] as string); + + expect(registeredEvents).toContain('resume'); + expect(registeredEvents).toContain('unlock-screen'); + }); + + it('both powerMonitor events send a SYSTEM_WAKE renderer event', async () => { + const { powerMonitor } = await import('electron'); + registerSystemHandlers(menubar); + + const calls = vi.mocked(powerMonitor.on).mock.calls as unknown as Array<[string, () => void]>; + const resumeHandler = calls.find((c) => c[0] === 'resume')?.[1]; + const unlockHandler = calls.find((c) => c[0] === 'unlock-screen')?.[1]; + const webContentsSend = ( + menubar.window as unknown as { webContents: { send: ReturnType } } + ).webContents.send; + + resumeHandler?.(); + expect(webContentsSend).toHaveBeenCalledWith(EVENTS.SYSTEM_WAKE); + + webContentsSend.mockClear(); + unlockHandler?.(); + expect(webContentsSend).toHaveBeenCalledWith(EVENTS.SYSTEM_WAKE); + }); + + it('invoking the wake handler sends SYSTEM_WAKE to the renderer', async () => { + const { powerMonitor } = await import('electron'); + registerSystemHandlers(menubar); + + const calls = vi.mocked(powerMonitor.on).mock.calls as unknown as Array<[string, () => void]>; + const resumeHandler = calls.find((c) => c[0] === 'resume')?.[1]; + resumeHandler?.(); + + expect( + (menubar.window as unknown as { webContents: { send: ReturnType } }) + .webContents.send, + ).toHaveBeenCalledWith(EVENTS.SYSTEM_WAKE); + }); + }); + describe('UPDATE_KEEP_WINDOW_ON_BLUR', () => { it('forwards the value to applyKeepWindowOnBlur', () => { registerSystemHandlers(menubar); diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index f3a77c74f..659cc1475 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -1,9 +1,10 @@ -import { app, shell } from 'electron'; +import { app, powerMonitor, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; import { EVENTS } from '../../shared/events'; +import { logInfo } from '../../shared/logger'; -import { handleMainEvent, onMainEvent } from '../events'; +import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; import { applyKeepWindowOnBlur } from '../lifecycle/window'; /** @@ -19,13 +20,6 @@ export function registerSystemHandlers(mb: Menubar): void { */ let lastRegisteredAccelerator: string | null = null; - /** - * Open the given URL in the user's default browser, with an option to activate the app. - */ - onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }) => - shell.openExternal(url, { activate }), - ); - /** * Register or unregister a global keyboard shortcut that toggles the menubar window visibility. * @@ -54,6 +48,29 @@ export function registerSystemHandlers(mb: Menubar): void { return { success: false }; }); + /** + * Handle system wake from sleep/hibernate + */ + powerMonitor.on('resume', () => { + sendRendererEvent(mb, EVENTS.SYSTEM_WAKE); + logInfo('power-monitor', 'resume event triggered, will refetch data'); + }); + + /** + * Handle screen unlock (user returned to device) + */ + powerMonitor.on('unlock-screen', () => { + sendRendererEvent(mb, EVENTS.SYSTEM_WAKE); + logInfo('power-monitor', 'unlock-screen event triggered, will refetch data'); + }); + + /** + * Open the given URL in the user's default browser, with an option to activate the app. + */ + onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }) => + shell.openExternal(url, { activate }), + ); + /** * Update the application's auto-launch setting based on the provided configuration. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 9575d9694..056df22bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -190,6 +190,19 @@ export const api = { onRendererEvent(EVENTS.RESET_APP, () => callback()); }, + /** + * Register a callback invoked when the system wakes from sleep or the user + * unlocks their screen. + * + * Use this to refetch stale data and re-sync online/offline state without + * waiting for the next scheduled poll interval. + * + * @param callback - Called with no arguments on every wake event. + */ + onSystemWake: (callback: () => void) => { + onRendererEvent(EVENTS.SYSTEM_WAKE, () => callback()); + }, + /** * Register a callback invoked when the main process delivers an OAuth callback URL. * diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index cbb6cedf8..6c3128322 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -102,6 +102,7 @@ function createGitifyBridgeApi(): Window['gitify'] { notificationSoundPath: vi.fn(), onAuthCallback: vi.fn(), onResetApp: vi.fn(), + onSystemWake: vi.fn(), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), diff --git a/src/renderer/components/GlobalEffects.test.tsx b/src/renderer/components/GlobalEffects.test.tsx index a96e750fd..08be84dfc 100644 --- a/src/renderer/components/GlobalEffects.test.tsx +++ b/src/renderer/components/GlobalEffects.test.tsx @@ -107,4 +107,27 @@ describe('renderer/components/GlobalEffects.tsx', () => { expect(useAccountsStore.getState().accounts[0]?.token).toBe(mockGitHubCloudAccount.token); }); }); + + describe('system wake (useAccounts)', () => { + it('refetches accounts when the system wakes', async () => { + const refreshAccountSpy = vi + .spyOn(authUtils, 'refreshAccount') + .mockImplementation(async (account) => account); + + await act(async () => { + renderWithProviders(, { + accounts: [mockGitHubCloudAccount], + }); + }); + + const callCountAfterMount = refreshAccountSpy.mock.calls.length; + + const wakeCallback = vi.mocked(window.gitify.onSystemWake).mock.calls[0][0]; + await act(async () => { + wakeCallback(); + }); + + expect(refreshAccountSpy.mock.calls.length).toBeGreaterThan(callCountAfterMount); + }); + }); }); diff --git a/src/renderer/hooks/useAccounts.ts b/src/renderer/hooks/useAccounts.ts index c167aaa1b..cc9e0bf4f 100644 --- a/src/renderer/hooks/useAccounts.ts +++ b/src/renderer/hooks/useAccounts.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; @@ -50,6 +50,14 @@ export const useAccounts = (): AccountsState => { await refetch(); }, [refetch]); + // Refetch accounts immediately when the system wakes from sleep or the user + // unlocks their screen, without waiting for the next scheduled interval. + useEffect(() => { + window.gitify.onSystemWake(() => { + refetch(); + }); + }, [refetch]); + return { refetchAccounts, }; diff --git a/src/renderer/hooks/useNotifications.test.tsx b/src/renderer/hooks/useNotifications.test.tsx index 089fbbd7d..acc83fca5 100644 --- a/src/renderer/hooks/useNotifications.test.tsx +++ b/src/renderer/hooks/useNotifications.test.tsx @@ -370,4 +370,21 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(result.current.notifications[0].account).toEqual(mockGitHubEnterpriseServerAccount); }); }); + + describe('system wake', () => { + it('refetches notifications when the system wakes', async () => { + useAccountsStore.setState({ accounts: [mockGitHubCloudAccount] }); + getAllNotificationsMock.mockResolvedValue(mockSingleAccountNotifications); + + renderNotificationsHook(); + await waitFor(() => expect(getAllNotificationsMock).toHaveBeenCalledTimes(1)); + + const wakeCallback = vi.mocked(window.gitify.onSystemWake).mock.calls[0][0]; + act(() => { + wakeCallback(); + }); + + await waitFor(() => expect(getAllNotificationsMock).toHaveBeenCalledTimes(2)); + }); + }); }); diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index dc8f7b54f..092dab94b 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -227,6 +227,14 @@ export const useNotifications = ({ await refetch(); }, [refetch]); + // Refetch notifications immediately when the system wakes from sleep or the + // user unlocks their screen, without waiting for the next poll interval. + useEffect(() => { + window.gitify.onSystemWake(() => { + refetch(); + }); + }, [refetch]); + const removeAccountNotifications = useCallback( async (account: Account) => { const accountUUID = getAccountUUID(account); diff --git a/src/renderer/hooks/useOnlineStatus.test.ts b/src/renderer/hooks/useOnlineStatus.test.ts index e98c433a7..db8f819d6 100644 --- a/src/renderer/hooks/useOnlineStatus.test.ts +++ b/src/renderer/hooks/useOnlineStatus.test.ts @@ -31,4 +31,22 @@ describe('renderer/hooks/useOnlineStatus.ts', () => { expect(result.current).toBe(true); }); + + it('re-probes online state when the system wakes', () => { + act(() => { + onlineManager.setOnline(false); + }); + + renderHook(() => useOnlineStatus()); + + // Simulate a wake event by invoking the callback registered with onSystemWake + const wakeCallback = vi.mocked(window.gitify.onSystemWake).mock.calls[0][0]; + + act(() => { + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); + wakeCallback(); + }); + + expect(onlineManager.isOnline()).toBe(true); + }); }); diff --git a/src/renderer/hooks/useOnlineStatus.ts b/src/renderer/hooks/useOnlineStatus.ts index 2305b079d..852c904dd 100644 --- a/src/renderer/hooks/useOnlineStatus.ts +++ b/src/renderer/hooks/useOnlineStatus.ts @@ -19,6 +19,11 @@ export function useOnlineStatus(): boolean { const unsubscribe = onlineManager.subscribe(handle); handle(); + // Re-probe network state when the system wakes from sleep or the user + // unlocks their screen. The browser's online/offline events may not have + // fired yet by the time the renderer runs after a sleep cycle. + window.gitify.onSystemWake(() => onlineManager.setOnline(navigator.onLine)); + return () => unsubscribe(); }, []); diff --git a/src/shared/events.ts b/src/shared/events.ts index 734ce3d74..c68362eea 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -23,6 +23,7 @@ export const EVENTS = { OPEN_EXTERNAL: `${P}open-external`, RESET_APP: `${P}reset-app`, TWEMOJI_DIRECTORY: `${P}twemoji-directory`, + SYSTEM_WAKE: `${P}system-wake`, } as const; /** Union type of all valid IPC event name strings. */ @@ -114,6 +115,7 @@ export type EventContracts = AssertEventCoverage<{ [EVENTS.OPEN_EXTERNAL]: { request: IOpenExternal; response: undefined }; [EVENTS.RESET_APP]: { request: undefined; response: undefined }; [EVENTS.TWEMOJI_DIRECTORY]: { request: undefined; response: string }; + [EVENTS.SYSTEM_WAKE]: { request: undefined; response: undefined }; }>; /** Request payload type for a given event. */