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
16 changes: 9 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ The renderer is layered. **Runtime imports flow in one direction only** — a mo

Every kind of renderer state has exactly one owner. Put new state with the mechanism that owns its kind:

| Kind of state | Owner | Examples |
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
| Server / async data | TanStack Query | the notification list, periodic account refreshes |
| Persisted client state | Zustand with `persist` | `accounts`, `settings` |
| Session client state | Zustand without `persist` | `filters`, notification action failures |
| Derived values | A store selector/action, or a pure `utils/` function | `isLoggedIn`, `hasMultipleAccounts`, notification status |
| Ephemeral view state | React `useState` / `useMemo` | row expand/collapse, exit animations |
| Kind of state | Owner | Examples |
| ---------------------- | ---------------------------------------------------- | ------------------------------------------------------------------ |
| Server / async data | TanStack Query | the notification list, periodic account refreshes |
| Persisted client state | Zustand with `persist` | `accounts`, `settings` |
| Session client state | Zustand without `persist` | `filters`, notification action failures, notification focus intent |
| Derived values | A store selector/action, or a pure `utils/` function | `isLoggedIn`, `hasMultipleAccounts`, notification status |
| Ephemeral view state | React `useState` / `useMemo` | row expand/collapse, exit animations |

Rules:

Expand All @@ -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, routes OS actions and clicks back into the existing mutation/recovery pipeline, 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 @@ -91,6 +91,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 and have platform-specific prerequisites:

- **macOS**: native notifications require a properly signed app; the ad-hoc-signed Electron development binary is rejected by `UNUserNotificationCenter`. Run `pnpm sign:dev` once after installing dependencies (and again after changing the Electron version), then restart `pnpm dev`. Action buttons additionally require `NSUserNotificationAlertStyle: alert` in the packaged app.
- **Windows**: action buttons use the toast action support; `app.setAppUserModelId` is set when the menubar is ready. Verify Action Center persistence and cold-start activation manually.
- **Linux**: action buttons appear only when the desktop notification server advertises the `actions` capability. Without it, the notification still shows without buttons.

Verify the notification content, click behavior, actions, and removal-on-read on a signed macOS build, 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 @@ -58,6 +58,7 @@
"codegen:watch": "pnpm codegen --watch",
"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\"",
"sign:dev": "bash scripts/sign-dev-electron.sh",
"start": "pnpm build && pnpm dev",
"package:linux": "electron-builder --linux",
"package:macos": "electron-builder --mac",
Expand Down
84 changes: 84 additions & 0 deletions scripts/sign-dev-electron.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Sign the development Electron binary so macOS accepts native notifications.
#
# Electron's main-process Notification API uses UNUserNotificationCenter, which
# rejects ad-hoc/linker-signed binaries with UNErrorDomain error 1. A proper
# Developer ID or Apple Development signature is required even for local
# development.
#
# This signs the Electron.app inside the installed `electron` package. Run it
# after `pnpm install` and after changing the Electron version.
#
# Usage:
# pnpm sign:dev
# GITIFY_DEV_SIGN_IDENTITY="Apple Development: ..." pnpm sign:dev

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Skipping dev Electron signing: not macOS."
exit 0
fi

IDENTITY="${GITIFY_DEV_SIGN_IDENTITY:-}"

if [[ -z "$IDENTITY" ]]; then
IDENTITY="$(
security find-identity -v -p codesigning 2>/dev/null |
sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' |
head -1
)"
fi

if [[ -z "$IDENTITY" ]]; then
IDENTITY="$(
security find-identity -v -p codesigning 2>/dev/null |
sed -n 's/.*"\(Apple Development:.*\)"/\1/p' |
head -1
)"
fi

if [[ -z "$IDENTITY" ]]; then
echo "No macOS code-signing identity found." >&2
echo "Install a Developer ID or Apple Development certificate, or set" >&2
echo "GITIFY_DEV_SIGN_IDENTITY to one from 'security find-identity -v -p codesigning'." >&2
exit 1
fi

ELECTRON_BIN="$(node -p "require('electron')")"
ELECTRON_APP="${ELECTRON_BIN%/Contents/MacOS/Electron}"

if [[ ! -d "$ELECTRON_APP" ]]; then
echo "Could not locate the installed Electron.app at: $ELECTRON_APP" >&2
exit 1
fi

PLIST="$ELECTRON_APP/Contents/Info.plist"

# Electron's notification action support requires the alert notification style.
# The stock development Electron bundle does not declare it.
if /usr/libexec/PlistBuddy -c 'Print :NSUserNotificationAlertStyle' "$PLIST" >/dev/null 2>&1; then
/usr/libexec/PlistBuddy -c 'Set :NSUserNotificationAlertStyle alert' "$PLIST"
else
/usr/libexec/PlistBuddy -c 'Add :NSUserNotificationAlertStyle string alert' "$PLIST"
fi

echo "Signing $ELECTRON_APP"
echo "Identity: $IDENTITY"

codesign \
--force \
--deep \
--options runtime \
--entitlements "$REPO_ROOT/assets/entitlements.mac.plist" \
--sign "$IDENTITY" \
"$ELECTRON_APP"

codesign --verify --deep --strict "$ELECTRON_APP"

echo "Signed development Electron binary. Restart pnpm dev to pick up the new signature."
codesign -dv --verbose=2 "$ELECTRON_APP" 2>&1 | grep -E 'Identifier=|Authority=|TeamIdentifier='
10 changes: 9 additions & 1 deletion src/__helpers__/gitify-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ 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().mockResolvedValue({ supported: true }),
remove: vi.fn(),
removeAll: vi.fn(),
notifyReady: vi.fn(),
isSupported: vi.fn().mockResolvedValue(true),
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
99 changes: 99 additions & 0 deletions src/main/handlers/native-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Menubar } from 'electron-menubar';

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

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

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

vi.mock('../events', () => ({
handleMainEvent: (...args: unknown[]) => mocks.handleMainEvent(...args),
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.handleMainEvent.mockClear();
mocks.onMainEvent.mockClear();
mocks.appOnce.mockClear();
mocks.service.start.mockClear();
mocks.service.destroy.mockClear();
});

it('registers the show and support handlers plus the interaction listeners', () => {
registerNativeNotificationHandlers(mb);

expect(mocks.service.start).toHaveBeenCalledTimes(1);
expect(mocks.handleMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_SHOW,
expect.any(Function),
);
expect(mocks.handleMainEvent).toHaveBeenCalledWith(
EVENTS.NATIVE_NOTIFICATION_SUPPORTED,
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('is idempotent', () => {
const first = registerNativeNotificationHandlers(mb);
const second = registerNativeNotificationHandlers(mb);

expect(first).toBe(second);
expect(mocks.service.start).toHaveBeenCalledTimes(1);
expect(mocks.handleMainEvent).toHaveBeenCalledTimes(2);
expect(mocks.onMainEvent).toHaveBeenCalledTimes(3);
});

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);
});
});
43 changes: 43 additions & 0 deletions src/main/handlers/native-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { app } from 'electron';
import type { Menubar } from 'electron-menubar';

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

import { handleMainEvent, 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;
instance.start();

handleMainEvent(EVENTS.NATIVE_NOTIFICATION_SHOW, (_, payload) => instance.show(payload));
handleMainEvent(EVENTS.NATIVE_NOTIFICATION_SUPPORTED, () => instance.isSupported());
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 @@ -86,7 +87,11 @@ describe('main/index.ts', () => {
});

it('applies the Ozone backend before Electron becomes ready', () => {
expect(calls).toEqual(['ozone', 'whenReady']);
expect(calls.slice(0, 2)).toEqual(['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 @@ -57,6 +58,7 @@ app
// Register IPC handlers for various channels
registerTrayHandlers(mb);
registerSystemHandlers(mb);
registerNativeNotificationHandlers(mb);
registerStorageHandlers();
registerGitHubCliHandlers();
registerAppHandlers(mb);
Expand Down
Loading
Loading