From a5fac3a4863fccc8ae9ee20a927ab93018f58dd7 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 21 Jul 2026 15:45:06 +1000 Subject: [PATCH 1/4] feat(events): monitor power state Signed-off-by: Adam Setch --- src/main/handlers/system.test.ts | 31 ++++++++++++++++++++++++ src/main/handlers/system.ts | 15 +++++++++++- src/preload/index.ts | 13 ++++++++++ src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/hooks/useAccounts.ts | 10 +++++++- src/renderer/hooks/useNotifications.ts | 8 ++++++ src/renderer/hooks/useOnlineStatus.ts | 5 ++++ src/shared/events.ts | 2 ++ 8 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index 1048bb074..1030a1276 100644 --- a/src/main/handlers/system.test.ts +++ b/src/main/handlers/system.test.ts @@ -23,6 +23,9 @@ vi.mock('electron', () => ({ shell: { openExternal: vi.fn(), } satisfies Pick, + powerMonitor: { + on: vi.fn(), + } satisfies Pick, })); describe('main/handlers/system.ts', () => { @@ -73,6 +76,34 @@ 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]; + + // Both handlers should be the same function (sendWakeEvent) + expect(resumeHandler).toBeDefined(); + expect(unlockHandler).toBeDefined(); + expect(resumeHandler).toBe(unlockHandler); + }); + }); + 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..8e88da8c3 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 { handleMainEvent, onMainEvent } from '../events'; +import { sendRendererEvent } from '../events'; import { applyKeepWindowOnBlur } from '../lifecycle/window'; /** @@ -67,4 +68,16 @@ export function registerSystemHandlers(mb: Menubar): void { onMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, (_, value: boolean) => { applyKeepWindowOnBlur(mb, value); }); + + /** + * Forward `powerMonitor` wake events to the renderer so hooks can refetch + * stale data and re-sync online state after a sleep/unlock cycle. + * + * Both `resume` (waking from sleep) and `unlock-screen` (user unlocks the + * machine without a prior sleep) are treated identically: the user is back + * at their device and data should be refreshed immediately. + */ + const sendWakeEvent = () => sendRendererEvent(mb, EVENTS.SYSTEM_WAKE); + powerMonitor.on('resume', sendWakeEvent); + powerMonitor.on('unlock-screen', sendWakeEvent); } 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/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.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.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. */ From 69bd9216f91335169c678fd20d60325c4ebd2c36 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 21 Jul 2026 16:18:56 +1000 Subject: [PATCH 2/4] feat(events): monitor power state Signed-off-by: Adam Setch --- src/main/handlers/system.test.ts | 17 ++++++++++++++ .../components/GlobalEffects.test.tsx | 23 +++++++++++++++++++ src/renderer/hooks/useNotifications.test.tsx | 17 ++++++++++++++ src/renderer/hooks/useOnlineStatus.test.ts | 18 +++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index 1030a1276..b5fd9d009 100644 --- a/src/main/handlers/system.test.ts +++ b/src/main/handlers/system.test.ts @@ -42,6 +42,9 @@ describe('main/handlers/system.ts', () => { setGlobalShortcut: setGlobalShortcutMock, window: { isVisible: vi.fn().mockReturnValue(false), + webContents: { + send: vi.fn(), + }, }, } as unknown as Menubar; }); @@ -102,6 +105,20 @@ describe('main/handlers/system.ts', () => { expect(unlockHandler).toBeDefined(); expect(resumeHandler).toBe(unlockHandler); }); + + 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', () => { 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/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/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); + }); }); From 4ef4a4a4e082e2d3311ba3ef8a42f98d4ef74c69 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 22 Jul 2026 01:05:09 +1000 Subject: [PATCH 3/4] feat(forge): add bitbucket cloud support Signed-off-by: Adam Setch --- src/main/handlers/system.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index 8e88da8c3..a186544ff 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -3,8 +3,7 @@ import type { Menubar } from 'electron-menubar'; import { EVENTS } from '../../shared/events'; -import { handleMainEvent, onMainEvent } from '../events'; -import { sendRendererEvent } from '../events'; +import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; import { applyKeepWindowOnBlur } from '../lifecycle/window'; /** From afe4abfb9cdec7382feb05e4441aa92426cfda9e Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 22 Jul 2026 01:25:06 +1000 Subject: [PATCH 4/4] feat(events): monitor power state Signed-off-by: Adam Setch --- src/main/handlers/system.test.ts | 17 ++++++++++--- src/main/handlers/system.ts | 43 ++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index b5fd9d009..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(); @@ -99,11 +103,16 @@ describe('main/handlers/system.ts', () => { 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); - // Both handlers should be the same function (sendWakeEvent) - expect(resumeHandler).toBeDefined(); - expect(unlockHandler).toBeDefined(); - expect(resumeHandler).toBe(unlockHandler); + webContentsSend.mockClear(); + unlockHandler?.(); + expect(webContentsSend).toHaveBeenCalledWith(EVENTS.SYSTEM_WAKE); }); it('invoking the wake handler sends SYSTEM_WAKE to the renderer', async () => { diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index a186544ff..659cc1475 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -2,6 +2,7 @@ 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, 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. */ @@ -67,16 +84,4 @@ export function registerSystemHandlers(mb: Menubar): void { onMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, (_, value: boolean) => { applyKeepWindowOnBlur(mb, value); }); - - /** - * Forward `powerMonitor` wake events to the renderer so hooks can refetch - * stale data and re-sync online state after a sleep/unlock cycle. - * - * Both `resume` (waking from sleep) and `unlock-screen` (user unlocks the - * machine without a prior sleep) are treated identically: the user is back - * at their device and data should be refreshed immediately. - */ - const sendWakeEvent = () => sendRendererEvent(mb, EVENTS.SYSTEM_WAKE); - powerMonitor.on('resume', sendWakeEvent); - powerMonitor.on('unlock-screen', sendWakeEvent); }