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
57 changes: 57 additions & 0 deletions src/main/handlers/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -23,6 +27,9 @@ vi.mock('electron', () => ({
shell: {
openExternal: vi.fn(),
} satisfies Pick<Electron.Shell, 'openExternal'>,
powerMonitor: {
on: vi.fn(),
} satisfies Pick<Electron.PowerMonitor, 'on'>,
}));

describe('main/handlers/system.ts', () => {
Expand All @@ -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;
});
Expand Down Expand Up @@ -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<typeof vi.fn> } }
).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<typeof vi.fn> } })
.webContents.send,
).toHaveBeenCalledWith(EVENTS.SYSTEM_WAKE);
});
});

describe('UPDATE_KEEP_WINDOW_ON_BLUR', () => {
it('forwards the value to applyKeepWindowOnBlur', () => {
registerSystemHandlers(menubar);
Expand Down
35 changes: 26 additions & 9 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
*/
Expand Down
13 changes: 13 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
23 changes: 23 additions & 0 deletions src/renderer/components/GlobalEffects.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<GlobalEffects />, {
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);
});
});
});
10 changes: 9 additions & 1 deletion src/renderer/hooks/useAccounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';

import { useQuery } from '@tanstack/react-query';

Expand Down Expand Up @@ -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,
};
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/hooks/useNotifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
8 changes: 8 additions & 0 deletions src/renderer/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/hooks/useOnlineStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions src/renderer/hooks/useOnlineStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}, []);

Expand Down
2 changes: 2 additions & 0 deletions src/shared/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down