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
20 changes: 2 additions & 18 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,10 @@ import {
registerUpdaterHandlers,
} from './handlers';
import { getIdleTrayIcon } from './icons';
import {
configureWindowEvents,
handleProtocolURL,
initializeAppLifecycle,
onFirstRunMaybe,
} from './lifecycle';
import { configureWindowEvents, initializeAppLifecycle, onFirstRunMaybe } from './lifecycle';
import MenuBuilder from './menu';
import { applyOzonePlatform } from './ozone';
import AppUpdater from './updater';
import { isDevMode } from './utils';

// Runs at module load: the Ozone platform is read during app startup, so this
// has to happen before `app.whenReady()` below.
Expand All @@ -48,18 +42,14 @@ const mb = menubar({
const menuBuilder = new MenuBuilder(mb);
const contextMenu = menuBuilder.buildMenu();

// Register your app as the handler for a custom protocol
const protocol = isDevMode() ? 'gitify-dev' : 'gitify';
app.setAsDefaultProtocolClient(protocol);

const appUpdater = new AppUpdater(mb, menuBuilder);

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

initializeAppLifecycle(mb, contextMenu, protocol);
initializeAppLifecycle(mb, contextMenu);

// Configure window event handlers (Escape key, DevTools resize)
configureWindowEvents(mb, menuBuilder);
Expand All @@ -75,9 +65,3 @@ app
.catch((err) => {
logError('main', 'Failed to initialize application', toError(err));
});

// Handle gitify:// custom protocol URL events for OAuth 2.0 callback
app.on('open-url', (event, url) => {
event.preventDefault();
handleProtocolURL(mb, url, protocol);
});
36 changes: 5 additions & 31 deletions src/main/lifecycle/startup.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import type { Menubar } from 'electron-menubar';

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

import { handleProtocolURL, initializeAppLifecycle } from './startup';
import { initializeAppLifecycle } from './startup';

const requestSingleInstanceLockMock = vi.fn(() => true);
const appOnMock = vi.fn();
const appQuitMock = vi.fn();

vi.mock('electron', () => ({
app: {
requestSingleInstanceLock: () => requestSingleInstanceLockMock(),
on: (...a: unknown[]) => appOnMock(...a),
quit: () => appQuitMock(),
} satisfies Pick<Electron.App, 'requestSingleInstanceLock' | 'on' | 'quit'>,
}));

const sendRendererEventMock = vi.fn();
vi.mock('../events', () => ({
sendRendererEvent: (...a: unknown[]) => sendRendererEventMock(...a),
} satisfies Pick<Electron.App, 'requestSingleInstanceLock' | 'quit'>,
}));

const logInfoMock = vi.fn();
const logWarnMock = vi.fn();
vi.mock('../../shared/logger', () => ({
logInfo: (...a: unknown[]) => logInfoMock(...a),
logWarn: (...a: unknown[]) => logWarnMock(...a),
}));

Expand All @@ -46,7 +35,7 @@ describe('main/lifecycle/startup.ts', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;

initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify');
initializeAppLifecycle(mb as unknown as Menubar, contextMenu);

expect(mb.on).toHaveBeenCalledWith('ready', expect.any(Function));
});
Expand All @@ -56,7 +45,7 @@ describe('main/lifecycle/startup.ts', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;

initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify');
initializeAppLifecycle(mb as unknown as Menubar, contextMenu);

expect(appQuitMock).toHaveBeenCalled();
expect(logWarnMock).toHaveBeenCalled();
Expand All @@ -66,7 +55,7 @@ describe('main/lifecycle/startup.ts', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;

initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify');
initializeAppLifecycle(mb as unknown as Menubar, contextMenu);

const readyHandler = (mb.on as unknown as ReturnType<typeof vi.fn>).mock.calls[0]?.[1];
expect(readyHandler).toBeDefined();
Expand All @@ -75,19 +64,4 @@ describe('main/lifecycle/startup.ts', () => {
expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu);
});
});

describe('handleProtocolURL', () => {
it('forwards matching protocol URL to renderer as AUTH_CALLBACK', () => {
const mb = createMb();
const url = 'gitify://callback?code=abc123';
handleProtocolURL(mb as unknown as Menubar, url, 'gitify');
expect(sendRendererEventMock).toHaveBeenCalledWith(mb, EVENTS.AUTH_CALLBACK, url);
});

it('ignores URLs that do not match the protocol', () => {
const mb = createMb();
handleProtocolURL(mb as unknown as Menubar, 'https://github.com', 'gitify');
expect(sendRendererEventMock).not.toHaveBeenCalled();
});
});
});
53 changes: 8 additions & 45 deletions src/main/lifecycle/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import { app } from 'electron';
import type { Menubar } from 'electron-menubar';

import { APPLICATION } from '../../shared/constants';
import { EVENTS } from '../../shared/events';
import { logInfo, logWarn } from '../../shared/logger';

import { sendRendererEvent } from '../events';
import { logWarn } from '../../shared/logger';

/**
* Set up core application lifecycle events including tray ready setup,
* protocol URL handling, and single-instance enforcement.
* Set up core application lifecycle events: tray ready setup and
* single-instance enforcement.
*
* The tray's context-menu wiring (Linux `setContextMenu` vs macOS/Windows
* right-click popup) and the macOS `setIgnoreDoubleClickEvents` default
Expand All @@ -18,60 +15,26 @@ import { sendRendererEvent } from '../events';
*
* @param mb - The menubar instance to attach lifecycle events to.
* @param contextMenu - The tray context menu to attach via `mb.setContextMenu`.
* @param protocol - The custom protocol string (e.g. 'gitify' or 'gitify-dev').
*/
export function initializeAppLifecycle(
mb: Menubar,
contextMenu: Electron.Menu,
protocol: string,
): void {
export function initializeAppLifecycle(mb: Menubar, contextMenu: Electron.Menu): void {
mb.on('ready', () => {
mb.app.setAppUserModelId(APPLICATION.ID);
mb.tray.setToolTip(APPLICATION.NAME);
mb.setContextMenu(contextMenu);
});

preventSecondInstance(mb, protocol);
}

/**
* Handle a gitify:// protocol URL by forwarding it to the renderer process
* as an AUTH_CALLBACK event.
*
* @param mb - The menubar instance to forward the event through.
* @param url - The protocol URL to handle.
* @param protocol - The custom protocol string to match.
*/
export function handleProtocolURL(mb: Menubar, url: string, protocol: string): void {
if (url.startsWith(`${protocol}://`)) {
logInfo('main:handleUrl', `forwarding URL ${url} to renderer process`);
sendRendererEvent(mb, EVENTS.AUTH_CALLBACK, url);
}
preventSecondInstance();
}

/**
* Enforce a single application instance. If a second instance is launched,
* any protocol URL in the command line is forwarded to the existing instance.
*
* @param mb - The menubar instance to show when a second instance is detected.
* @param protocol - The custom protocol string to extract from command line args.
* Enforce a single application instance. A second launch quits immediately so
* the already-running tray instance remains the only one.
*/
function preventSecondInstance(mb: Menubar, protocol: string): void {
function preventSecondInstance(): void {
const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
logWarn('main:gotTheLock', 'Second instance detected, quitting');
app.quit();
return;
}

app.on('second-instance', (_event, commandLine) => {
logInfo('main:second-instance', 'Second instance was launched. Extracting command to forward');

const url = commandLine.find((arg) => arg.startsWith(`${protocol}://`));

if (url) {
handleProtocolURL(mb, url, protocol);
}
});
}
10 changes: 0 additions & 10 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,6 @@ export const api = {
return onRendererEvent(EVENTS.SYSTEM_WAKE, () => callback());
},

/**
* Register a callback invoked when the main process delivers an OAuth callback URL.
*
* @param callback - Called with the full callback URL (e.g. `gitify://oauth?code=...`).
* @returns A function that removes the listener.
*/
onAuthCallback: (callback: (url: string) => void) => {
return onRendererEvent(EVENTS.AUTH_CALLBACK, (_, url) => callback(url));
},

/**
* Display a native OS notification.
*
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { AccountScopesRoute } from './routes/AccountScopes';
import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/LoginWithPersonalAccessToken';
import { FiltersRoute } from './routes/Filters';
import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken';
import { GitHubLoginWithCustomOAuthAppRoute } from './routes/github/LoginWithCustomOAuthApp';
import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow';
import { GitHubLoginWithCLIRoute } from './routes/github/LoginWithGitHubCLI';
import { GitHubLoginWithOAuthAppRoute } from './routes/github/LoginWithOAuthApp';
import { GitHubLoginWithPersonalAccessTokenRoute } from './routes/github/LoginWithPersonalAccessToken';
import { GitLabLoginWithPersonalAccessTokenRoute } from './routes/gitlab/LoginWithPersonalAccessToken';
import { LoginRoute } from './routes/Login';
Expand Down Expand Up @@ -108,7 +108,10 @@ export function App() {
element={<GitHubLoginWithPersonalAccessTokenRoute />}
path="/login/github/personal-access-token"
/>
<Route element={<GitHubLoginWithOAuthAppRoute />} path="/login/github/oauth-app" />
<Route
element={<GitHubLoginWithCustomOAuthAppRoute />}
path="/login/github/custom-oauth-app"
/>
<Route
element={<GiteaLoginWithPersonalAccessTokenRoute />}
path="/login/gitea/personal-access-token"
Expand Down
1 change: 0 additions & 1 deletion src/renderer/__helpers__/hook-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ function buildLoginsDefaults(): LoginsState {
loginWithDeviceFlowPoll: vi.fn(),
loginWithDeviceFlowComplete: vi.fn(),
loginWithCli: vi.fn(),
loginWithOAuthApp: vi.fn(),
loginWithPersonalAccessToken: vi.fn(),
logoutFromAccount: vi.fn(),
};
Expand Down
1 change: 0 additions & 1 deletion src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ const LOGIN_KEYS = [
'loginWithDeviceFlowPoll',
'loginWithDeviceFlowComplete',
'loginWithCli',
'loginWithOAuthApp',
'loginWithPersonalAccessToken',
'logoutFromAccount',
] as const;
Expand Down
1 change: 0 additions & 1 deletion src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ function createGitifyBridgeApi(): Window['gitify'] {
useUnreadActiveIcon: vi.fn(),
},
notificationSoundPath: vi.fn(),
onAuthCallback: vi.fn(() => vi.fn()),
onResetApp: vi.fn(() => vi.fn()),
onSystemWake: vi.fn(() => vi.fn()),
setAutoLaunch: vi.fn(),
Expand Down
1 change: 0 additions & 1 deletion src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ function createGitifyBridgeApi(): Window['gitify'] {
useUnreadActiveIcon: vi.fn(),
},
notificationSoundPath: vi.fn(),
onAuthCallback: vi.fn(() => vi.fn()),
onResetApp: vi.fn(() => vi.fn()),
onSystemWake: vi.fn(() => vi.fn()),
setAutoLaunch: vi.fn(),
Expand Down
18 changes: 13 additions & 5 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { Constants } from '../constants';

import type { Account, AccountNotifications, GitifyError, Hostname, Token } from '../types';
import type {
Account,
AccountNotifications,
ClientID,
GitifyError,
Hostname,
Token,
} from '../types';

import { getRecommendedScopeNames } from '../utils/auth/scopes';
import { mockGitifyUser } from './user-mocks';

export const mockGitHubAppAccount: Account = {
export const mockGitifyOAuthAppAccount: Account = {
forge: 'github',
platform: 'GitHub Cloud',
method: 'GitHub App',
method: 'Gitify OAuth App',
token: 'token-987654321' as Token,
hostname: Constants.GITHUB_HOSTNAME,
user: mockGitifyUser,
Expand Down Expand Up @@ -37,12 +44,13 @@ export const mockGitHubCliAccount: Account = {
scopes: ['gist', 'read:org', 'repo', 'workflow'],
};

export const mockOAuthAccount: Account = {
export const mockCustomOAuthAppAccount: Account = {
forge: 'github',
platform: 'GitHub Enterprise Server',
method: 'OAuth App',
method: 'Custom OAuth App',
token: 'token-1234568790' as Token,
hostname: 'github.gitify.io' as Hostname,
clientId: 'CUSTOM_CLIENT_ID_123' as ClientID,
user: mockGitifyUser,
scopes: getRecommendedScopeNames(),
};
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/__mocks__/notifications-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {

import {
mockGiteaAccount,
mockGitHubAppAccount,
mockGitifyOAuthAppAccount,
mockGitHubCloudAccount,
mockGitHubEnterpriseServerAccount,
} from './account-mocks';
Expand Down Expand Up @@ -332,7 +332,7 @@ export function mockPartialGitifyNotification(
repository?: Partial<GitifyRepository>,
): GitifyNotification {
const mockNotification: Partial<GitifyNotification> = {
account: mockGitHubAppAccount,
account: mockGitifyOAuthAppAccount,
reason: {
code: 'subscribed',
title: 'Updated',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';

import { renderWithProviders } from '../../__helpers__/test-utils';
import {
mockGitHubAppAccount,
mockGitifyOAuthAppAccount,
mockGitHubEnterpriseServerAccount,
} from '../../__mocks__/account-mocks';

Expand Down Expand Up @@ -91,7 +91,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => {
] as const)('should toggle %s checkbox', async (testId, setting) => {
await act(async () => {
renderWithProviders(<AppearanceSettings />, {
accounts: [mockGitHubAppAccount],
accounts: [mockGitifyOAuthAppAccount],
});
});

Expand Down Expand Up @@ -135,15 +135,15 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => {
it('reactively hides the account header checkbox when a second account is added', async () => {
await act(async () => {
renderWithProviders(<AppearanceSettings />, {
accounts: [mockGitHubAppAccount],
accounts: [mockGitifyOAuthAppAccount],
});
});

expect(screen.getByTestId('checkbox-showAccountHeader')).toBeInTheDocument();

await act(async () => {
useAccountsStore.setState({
accounts: [mockGitHubAppAccount, mockGitHubEnterpriseServerAccount],
accounts: [mockGitifyOAuthAppAccount, mockGitHubEnterpriseServerAccount],
});
});

Expand Down
Loading
Loading