Skip to content
Closed
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
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ Rules:

App-level side effects are owned by a single host. `GlobalEffects` mounts exactly once (inside the query and theme providers) and owns theme application, tray updates, global shortcut registration, periodic account refreshes, and notification polling and alerts.

Operating-system notifications are created by the main process behind the typed `window.gitify.nativeNotifications` bridge. The single notification host publishes the filtered new-notification set, marks a notification read when its OS action is used, opens the target when its body is clicked, and removes published notifications when they leave the unfiltered cache. The sandboxed preload only forwards requests and listeners; it never constructs an OS notification.

Consumers read the shared query cache and stores, and **do not register their own** polling, alerts, wake, or focus listeners. This keeps background work (for example, firing one poll per interval) from multiplying with the number of mounted components. When you add a new background effect, add it to the host, not to a leaf component.

Subscription is separate from side effects, but it multiplies the same way: every TanStack Query observer re-runs the query's `select` and structural-sharing walk whenever the cached data changes. A component that renders once per notification — the notification row, the repository header — must therefore not subscribe. It obtains notification actions (mark as read/done, unsubscribe, remove an account, refetch) with `useNotificationActions`, which never calls `useQuery`. Components that read notification data use `useNotifications` and stay subscribed by design.
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ guard fails the run outside Linux rather than letting that happen.
Note that these baselines capture the `backdrop-filter` fallback for Glass, not
the macOS native vibrancy material, which Chromium cannot render.

#### 🔔 Native notification checks

Operating-system notifications are created in the main process. Each unread notification offers a single **Mark as read** action, and clicking the body opens the notification target.

- **macOS**: native notifications require a code-signed app. Electron ships ad-hoc _linker_-signed, which macOS rejects for `UNUserNotificationCenter`, so `pnpm dev` re-signs the development Electron bundle automatically before starting. Ad-hoc signing is the default and needs no certificate; set `GITIFY_DEV_SIGN_IDENTITY` to use a Developer ID or Apple Development identity. Run `pnpm dev` again after `pnpm install` or an Electron upgrade. The same step sets `NSUserNotificationAlertStyle: alert` for development, and the packaged app declares it in its `Info.plist`.
- **Windows**: toast interactions arrive through the notification instance events; verify Action Center persistence and cold-start activation manually.
- **Linux**: the action button appears only when the desktop notification server advertises the `actions` capability. Without it, the notification still shows without a button.

Verify the notification content, body click, Mark as read, and removal-on-read on macOS, Windows, and an action-capable Linux desktop before releasing a change in this area.

### Translations

Gitify's interface is translated through [i18next][i18next-website]. The app ships English as the only locale today, but the infrastructure supports additional languages — adding one is a contributor-friendly change.
Expand Down
2 changes: 2 additions & 0 deletions electron-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const config = {
icon: 'assets/images/app-icon.icns',
extendInfo: {
LSUIElement: true,
// Required for extra action buttons on macOS notifications.
NSUserNotificationAlertStyle: 'alert',
},
identity: 'Adam Setch (5KD23H9729)',
type: 'distribution',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"build": "vp build",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "pnpm codegen --watch",
"predev": "node scripts/sign-dev-electron.mjs",
"dev": "concurrently --names \"vite,codegen\" --prefix-colors \"blue,magenta\" \"vp dev\" \"pnpm codegen:watch\"",
"devtools": "concurrently --names \"dev,devtools\" --prefix-colors \"cyan,green\" \"pnpm dev\" \"react-devtools\"",
"start": "pnpm build && pnpm dev",
Expand Down
131 changes: 131 additions & 0 deletions scripts/sign-dev-electron.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Prepare the development Electron bundle for native notification testing.
*
* Electron ships ad-hoc *linker*-signed, which macOS rejects for
* `UNUserNotificationCenter` with `UNErrorDomain error 1`. A real `codesign`
* pass fixes that, and an ad-hoc signature (`-`) is enough for local testing —
* contributors do not need an Apple Developer certificate.
*
* Runs automatically from `predev` on macOS. Set `GITIFY_DEV_SIGN_IDENTITY` to
* use a Developer ID / Apple Development identity instead of ad-hoc.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');

if (process.platform !== 'darwin') {
process.exit(0);
}

const require = createRequire(import.meta.url);
const electronBinary = require('electron');
const electronApp = electronBinary?.replace(/\/Contents\/MacOS\/Electron$/, '');

if (!electronApp || !existsSync(electronApp)) {
console.warn('[sign-dev] could not locate the development Electron bundle; skipping');
process.exit(0);
}

const plist = path.join(electronApp, 'Contents', 'Info.plist');
const entitlements = path.join(repoRoot, 'assets', 'entitlements.mac.plist');

const requestedIdentity = process.env.GITIFY_DEV_SIGN_IDENTITY?.trim();
const identity = requestedIdentity || findIdentity() || '-';
const adHoc = identity === '-';

if (isPrepared(electronApp, plist, identity)) {
console.log('[sign-dev] development Electron is already signed for notifications');
process.exit(0);
}

setAlertStyle(plist);

console.log(
adHoc
? '[sign-dev] signing development Electron ad-hoc for native notifications'
: `[sign-dev] signing development Electron with ${identity}`,
);

execFileSync(
'codesign',
[
'--force',
'--deep',
'--options',
'runtime',
'--entitlements',
entitlements,
'--sign',
identity,
electronApp,
],
{ stdio: 'inherit' },
);

/** Prefer a Developer ID, then an Apple Development identity, when present. */
function findIdentity() {
try {
const identities = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
encoding: 'utf8',
});

return (
identities.match(/"([^"]*Developer ID Application:[^"]*)"/)?.[1] ??
identities.match(/"([^"]*Apple Development:[^"]*)"/)?.[1] ??
null
);
} catch {
return null;
}
}

/** Skip re-signing when the bundle already has what notifications need. */
function isPrepared(app, plistPath, identity) {
try {
const verify = spawnSync('codesign', ['--verify', '--deep', app], { encoding: 'utf8' });

if (verify.status !== 0) {
return false;
}

const signature = spawnSync('codesign', ['-dv', '--verbose=2', app], { encoding: 'utf8' });
const flags = `${signature.stdout}${signature.stderr}`;
const alertStyle = execFileSync(
'/usr/libexec/PlistBuddy',
['-c', 'Print :NSUserNotificationAlertStyle', plistPath],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
);

if (alertStyle.trim() !== 'alert') {
return false;
}

return identity === '-'
? !flags.includes('linker-signed')
: flags.includes(`Authority=${identity}`);
} catch {
return false;
}
}

function setAlertStyle(plistPath) {
try {
execFileSync('/usr/libexec/PlistBuddy', [
'-c',
'Set :NSUserNotificationAlertStyle alert',
plistPath,
]);
} catch {
execFileSync('/usr/libexec/PlistBuddy', [
'-c',
'Add :NSUserNotificationAlertStyle string alert',
plistPath,
]);
}
}
9 changes: 8 additions & 1 deletion src/__helpers__/gitify-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export function createGitifyBridgeApi(platform: TestPlatform = 'macos'): Window[
setShowUpdateNotifications: vi.fn(),
setUseX11Backend: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
nativeNotifications: {
show: vi.fn(),
remove: vi.fn(),
removeAll: vi.fn(),
notifyReady: vi.fn(),
onAction: vi.fn(() => vi.fn()),
onClick: vi.fn(() => vi.fn()),
},
};
}
1 change: 1 addition & 0 deletions src/main/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './app';
export * from './github-cli';
export * from './native-notifications';
export * from './storage';
export * from './system';
export * from './tray';
Expand Down
105 changes: 105 additions & 0 deletions src/main/handlers/native-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Menubar } from 'electron-menubar';

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

const mocks = vi.hoisted(() => ({
service: {
show: vi.fn(),
remove: vi.fn(),
removeAll: vi.fn(),
markRendererReady: vi.fn(),
destroy: vi.fn(),
},
onMainEvent: vi.fn(),
appOnce: vi.fn(),
}));

vi.mock('../notifications', () => ({
NativeNotificationService: vi.fn(function () {
return mocks.service;
}),
}));

vi.mock('../events', () => ({
onMainEvent: (...args: unknown[]) => mocks.onMainEvent(...args),
}));

vi.mock('electron', () => ({
app: {
once: (...args: unknown[]) => mocks.appOnce(...args),
},
}));

import {
__resetNativeNotificationServiceForTests,
registerNativeNotificationHandlers,
} from './native-notifications';

describe('main/handlers/native-notifications.ts', () => {
const mb = {} as Menubar;

beforeEach(() => {
__resetNativeNotificationServiceForTests();
mocks.onMainEvent.mockClear();
mocks.appOnce.mockClear();
mocks.service.destroy.mockClear();
});

it('registers the notification commands and interaction listener', () => {
registerNativeNotificationHandlers(mb);

expect(mocks.onMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_SHOW,
expect.any(Function),
);
expect(mocks.onMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_REMOVE,
expect.any(Function),
);
expect(mocks.onMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_REMOVE_ALL,
expect.any(Function),
);
expect(mocks.onMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_READY,
expect.any(Function),
);
expect(mocks.appOnce).toHaveBeenCalledWith('will-quit', expect.any(Function));
});

it('forwards commands to the service', () => {
registerNativeNotificationHandlers(mb);

const handlers = Object.fromEntries(
mocks.onMainEvent.mock.calls.map(([event, handler]) => [event, handler]),
) as Record<string, (_event: unknown, payload: unknown) => void>;

const payload = { id: 'n1a2b3c4d5e6', title: 'Title', body: 'Body' };
handlers[EVENTS.NATIVE_NOTIFICATION_SHOW]({}, payload);
handlers[EVENTS.NATIVE_NOTIFICATION_REMOVE]({}, { ids: [payload.id] });
handlers[EVENTS.NATIVE_NOTIFICATION_REMOVE_ALL]({}, undefined);
handlers[EVENTS.NATIVE_NOTIFICATION_READY]({}, undefined);

expect(mocks.service.show).toHaveBeenCalledWith(payload);
expect(mocks.service.remove).toHaveBeenCalledWith({ ids: [payload.id] });
expect(mocks.service.removeAll).toHaveBeenCalledTimes(1);
expect(mocks.service.markRendererReady).toHaveBeenCalledTimes(1);
});

it('is idempotent', () => {
const first = registerNativeNotificationHandlers(mb);
const second = registerNativeNotificationHandlers(mb);

expect(first).toBe(second);
expect(mocks.onMainEvent).toHaveBeenCalledTimes(4);
});

it('destroys the service on will-quit', () => {
registerNativeNotificationHandlers(mb);
const willQuit = mocks.appOnce.mock.calls[0][1] as () => void;

willQuit();

expect(mocks.service.destroy).toHaveBeenCalledTimes(1);
});
});
41 changes: 41 additions & 0 deletions src/main/handlers/native-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { app } from 'electron';
import type { Menubar } from 'electron-menubar';

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

import { onMainEvent } from '../events';
import { NativeNotificationService } from '../notifications';

let service: NativeNotificationService | null = null;

/**
* Register the operating-system notification service and its typed IPC
* handlers. Idempotent: a second call reuses the existing service rather than
* registering duplicate handlers.
*/
export function registerNativeNotificationHandlers(mb: Menubar): NativeNotificationService {
if (service) {
return service;
}

const instance = new NativeNotificationService(mb);
service = instance;

onMainEvent(EVENTS.NATIVE_NOTIFICATION_SHOW, (_, payload) => instance.show(payload));
onMainEvent(EVENTS.NATIVE_NOTIFICATION_REMOVE, (_, payload) => instance.remove(payload));
onMainEvent(EVENTS.NATIVE_NOTIFICATION_REMOVE_ALL, () => instance.removeAll());
onMainEvent(EVENTS.NATIVE_NOTIFICATION_READY, () => instance.markRendererReady());

app.once('will-quit', () => {
instance.destroy();
service = null;
});

return instance;
}

/** @internal Reset the module-level service between tests. */
export function __resetNativeNotificationServiceForTests(): void {
service?.destroy();
service = null;
}
7 changes: 6 additions & 1 deletion src/main/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ vi.mock('./config', () => ({
vi.mock('./handlers', () => ({
registerAppHandlers: vi.fn(),
registerGitHubCliHandlers: vi.fn(),
registerNativeNotificationHandlers: vi.fn(() => calls.push('native-notifications')),
registerStorageHandlers: vi.fn(),
registerSystemHandlers: vi.fn(),
registerTrayHandlers: vi.fn(),
Expand Down Expand Up @@ -87,7 +88,11 @@ describe('main/index.ts', () => {
});

it('configures user data and the Ozone backend before Electron becomes ready', () => {
expect(calls).toEqual(['userData', 'ozone', 'whenReady']);
expect(calls.slice(0, 3)).toEqual(['userData', 'ozone', 'whenReady']);
});

it('registers native notification handlers during startup', async () => {
await vi.waitFor(() => expect(calls).toContain('native-notifications'));
});

it('logs an error when Electron fails to become ready', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Paths, WindowConfig } from './config';
import {
registerAppHandlers,
registerGitHubCliHandlers,
registerNativeNotificationHandlers,
registerStorageHandlers,
registerSystemHandlers,
registerTrayHandlers,
Expand Down Expand Up @@ -67,6 +68,7 @@ app
// Register IPC handlers for various channels
registerTrayHandlers(mb);
registerSystemHandlers(mb);
registerNativeNotificationHandlers(mb);
registerStorageHandlers();
registerGitHubCliHandlers();
registerAppHandlers(mb);
Expand Down
Loading
Loading