Skip to content

Commit 094759a

Browse files
committed
feat: add Electron-compatible Notification module with libnotify on Linux and best-effort NSUserNotification on macOS
1 parent 8a79b2a commit 094759a

13 files changed

Lines changed: 817 additions & 1 deletion

src/main/api/notification.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { EventEmitter } from 'node:events';
2+
import { UnsupportedPlatformError } from '../../common/errors';
3+
import { currentPlatform } from '../../common/platform';
4+
import { linuxNotificationBackend } from '../platform/linux/gtk-notification';
5+
import { macosNotificationBackend } from '../platform/macos/cocoa-notification';
6+
7+
/**
8+
* Native desktop notifications — the drop-in equivalent of Electron's
9+
* `Notification`.
10+
*
11+
* Extends Node's {@link EventEmitter} so the full listener API
12+
* (`on`/`once`/`addListener`/…) matches Electron's contract. Events:
13+
* - `show` — emitted synchronously from {@link Notification.show}.
14+
* - `close` — emitted when the OS reports the notification was dismissed/closed,
15+
* IF the platform backend can wire it (Linux libnotify exposes a `closed`
16+
* signal; macOS un-bundled cannot, so it is best-effort there).
17+
*
18+
* `click` (and other user-action events) are DEFERRED in v1: they require an OS
19+
* delegate/action wiring that is not yet implemented. They are intentionally not
20+
* advertised so consumers do not rely on events Sambar does not deliver.
21+
*
22+
* The native backend is injectable (mirrors `menu`/`dialog`/`shell`) so the
23+
* class's option-mapping, event wiring, and lifecycle are unit-testable with a
24+
* fake — no FFI required.
25+
*/
26+
27+
export type NotificationOptions = {
28+
readonly title?: string;
29+
readonly body?: string;
30+
readonly subtitle?: string;
31+
readonly silent?: boolean;
32+
};
33+
34+
/** The fields a backend needs to present one notification. */
35+
export type NotificationSpec = {
36+
readonly title: string;
37+
readonly body: string;
38+
readonly subtitle: string;
39+
readonly silent: boolean;
40+
};
41+
42+
/** A live, presented notification the API can close and observe. */
43+
export type NotificationHandle = {
44+
/** Dismiss the notification. Safe to call more than once. */
45+
close(): void;
46+
/** Register a callback fired when the OS closes/dismisses the notification. */
47+
onClosed(callback: () => void): void;
48+
};
49+
50+
/** The native backend the public `Notification` API delegates to. */
51+
export type NotificationBackend = {
52+
/** The HONEST per-platform answer to whether notifications can be delivered. */
53+
isSupported(): boolean;
54+
/** Present a notification and return a handle to close/observe it. */
55+
present(spec: NotificationSpec): NotificationHandle;
56+
};
57+
58+
const macosBackend: NotificationBackend = macosNotificationBackend;
59+
const linuxBackend: NotificationBackend = linuxNotificationBackend;
60+
61+
let backend: NotificationBackend | undefined;
62+
63+
const getBackend = (): NotificationBackend => {
64+
if (backend !== undefined) {
65+
return backend;
66+
}
67+
if (currentPlatform() === 'macos') {
68+
return macosBackend;
69+
}
70+
if (currentPlatform() === 'linux') {
71+
return linuxBackend;
72+
}
73+
throw new UnsupportedPlatformError(`Notification is not supported on ${currentPlatform()} yet`);
74+
};
75+
76+
/** Override the native notification backend. Test-only. */
77+
export const setNotificationBackendForTesting = (fake: NotificationBackend | undefined): void => {
78+
backend = fake;
79+
};
80+
81+
export class Notification extends EventEmitter {
82+
/** Notification title (the bold first line). */
83+
title: string;
84+
/** Notification body text. */
85+
body: string;
86+
/** Secondary line shown under the title (macOS; ignored where unsupported). */
87+
subtitle: string;
88+
/** Whether to suppress the notification sound. */
89+
silent: boolean;
90+
91+
#handle: NotificationHandle | undefined;
92+
93+
constructor(options: NotificationOptions = {}) {
94+
super();
95+
this.title = options.title ?? '';
96+
this.body = options.body ?? '';
97+
this.subtitle = options.subtitle ?? '';
98+
this.silent = options.silent ?? false;
99+
}
100+
101+
/**
102+
* Whether the host platform can actually deliver notifications. Honest:
103+
* - Linux: libnotify loaded and `notify_init` succeeded.
104+
* - macOS: `false` un-bundled (the default notification center is nil without
105+
* an app bundle); reliable delivery needs packaging (a follow-up).
106+
*/
107+
static isSupported(): boolean {
108+
return getBackend().isSupported();
109+
}
110+
111+
/** Display the notification and emit `show`. */
112+
show(): void {
113+
const handle = getBackend().present({
114+
title: this.title,
115+
body: this.body,
116+
subtitle: this.subtitle,
117+
silent: this.silent,
118+
});
119+
this.#handle = handle;
120+
handle.onClosed(() => {
121+
this.emit('close');
122+
});
123+
this.emit('show');
124+
}
125+
126+
/** Dismiss the notification if it is showing. Idempotent. */
127+
close(): void {
128+
const handle = this.#handle;
129+
if (handle === undefined) {
130+
return;
131+
}
132+
this.#handle = undefined;
133+
handle.close();
134+
}
135+
}

src/main/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
type MenuItemType,
2020
} from './api/menu';
2121
export { nativeTheme, type NativeTheme } from './api/native-theme';
22+
export { Notification, type NotificationOptions } from './api/notification';
2223
export { shell, type Shell } from './api/shell';
2324
export {
2425
FFIError,

src/main/module-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const IMPLEMENTED_MODULES = [
6565
'Menu',
6666
'MenuItem',
6767
'nativeTheme',
68+
'Notification',
6869
'shell',
6970
] as const;
7071

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { JSCallback, type Pointer } from 'bun:ffi';
2+
import type {
3+
NotificationBackend,
4+
NotificationHandle,
5+
NotificationSpec,
6+
} from '../../api/notification';
7+
import { cstr } from '../cstr';
8+
import { connectSignal } from './gtk-signals';
9+
import { loadLibnotifyFFI } from './libnotify-ffi';
10+
11+
/**
12+
* Linux notifications via libnotify — the Linux half of Sambar's `Notification`.
13+
*
14+
* libnotify forwards to the session's notification daemon over D-Bus.
15+
* `notify_init('Sambar')` is called once per process before the first
16+
* notification. `notify_notification_new(title, body, NULL)` builds the
17+
* notification; `notify_notification_show(n, NULL)` displays it (returns FALSE if
18+
* there is no daemon — e.g. headless CI — which is expected and not an error);
19+
* `notify_notification_close(n, NULL)` dismisses it.
20+
*
21+
* The `NotifyNotification::closed` signal is wired via the existing
22+
* {@link connectSignal} (`g_signal_connect_data`) so the `Notification`'s `close`
23+
* event fires when the daemon/user dismisses it.
24+
*
25+
* JSCallback lifecycle (a past SIGSEGV class): the `closed` handler thunk MUST
26+
* stay reachable for the life of the connection — Bun GCs an unreferenced
27+
* {@link JSCallback}, and the daemon would then call into freed memory. Each live
28+
* notification therefore RETAINS its callback in the returned handle's closure,
29+
* and {@link JSCallback.close} is deferred to a later tick (never called
30+
* synchronously inside the handler's own invocation).
31+
*/
32+
33+
/** ABI shape for `NotifyNotification::closed`: `(notification, user_data) -> void`. */
34+
export const CLOSED_CB_DEF = { args: ['ptr', 'ptr'], returns: 'void' } as const;
35+
36+
/**
37+
* Every `closed`-signal {@link JSCallback} currently wired to a live
38+
* notification. Retained at module scope so Bun cannot GC the native thunk while
39+
* the daemon still holds its pointer (the SIGSEGV-avoidance retain). Each entry
40+
* is removed (and the callback closed on a later tick) when its notification
41+
* fires `closed` or is explicitly closed.
42+
*/
43+
const liveCallbacks = new Set<JSCallback>();
44+
45+
let initialized = false;
46+
47+
/** Ensure `notify_init('Sambar')` has run once. Returns whether init succeeded. */
48+
const ensureInit = (): boolean => {
49+
const notify = loadLibnotifyFFI();
50+
if (initialized || notify.symbols.notify_is_initted() !== 0) {
51+
initialized = true;
52+
return true;
53+
}
54+
const ok = notify.symbols.notify_init(cstr('Sambar')) !== 0;
55+
initialized = ok;
56+
return ok;
57+
};
58+
59+
const present = (spec: NotificationSpec): NotificationHandle => {
60+
const notify = loadLibnotifyFFI();
61+
ensureInit();
62+
63+
const notification = notify.symbols.notify_notification_new(
64+
cstr(spec.title),
65+
cstr(spec.body),
66+
// `cstring` cannot be null via the FFI binding, so an empty icon name (no
67+
// icon) is passed instead of NULL — equivalent for our purposes.
68+
cstr(''),
69+
);
70+
if (notification === null) {
71+
throw new Error('notify_notification_new() returned null');
72+
}
73+
74+
// No daemon (headless CI) makes show return FALSE — expected, not an error.
75+
notify.symbols.notify_notification_show(notification, null);
76+
77+
return {
78+
close: () => {
79+
notify.symbols.notify_notification_close(notification, null);
80+
},
81+
// Wire the daemon's `closed` signal to `cb`. The thunk is retained in
82+
// `liveCallbacks` until it fires; it is closed on a LATER tick (never
83+
// synchronously inside its own invocation — that would free the trampoline
84+
// the daemon is about to return into).
85+
onClosed: (cb) => {
86+
const callback = new JSCallback((_notification: Pointer, _userData: Pointer): void => {
87+
cb();
88+
setTimeout(() => {
89+
liveCallbacks.delete(callback);
90+
callback.close();
91+
}, 0);
92+
}, CLOSED_CB_DEF);
93+
liveCallbacks.add(callback);
94+
connectSignal(notification, 'closed', callback);
95+
},
96+
};
97+
};
98+
99+
const isSupported = (): boolean => {
100+
try {
101+
return ensureInit();
102+
} catch {
103+
return false;
104+
}
105+
};
106+
107+
/** The Linux native notification backend (libnotify). */
108+
export const linuxNotificationBackend: NotificationBackend = {
109+
isSupported,
110+
present,
111+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { dlopen, FFIType } from 'bun:ffi';
2+
import { UnsupportedPlatformError } from '../../../common/errors';
3+
import { currentPlatform } from '../../../common/platform';
4+
5+
/**
6+
* Loads the libnotify symbols behind Sambar's `Notification` API on Linux.
7+
*
8+
* libnotify is the freedesktop desktop-notification client library; it forwards
9+
* to the session's notification daemon over D-Bus. CI runs headless (xvfb) with
10+
* NO notification daemon, so `notify_notification_show` may return FALSE / no-op
11+
* there — that is EXPECTED. The integration test therefore asserts only that the
12+
* symbols resolve, `notify_init` runs, and construct/show/close do not throw; it
13+
* does NOT assert a banner appeared.
14+
*
15+
* Declared separately from {@link loadLibnotifyFFI} so unit tests can assert ABI
16+
* shapes (arg arrays, return types) without `dlopen` on a non-Linux host.
17+
*
18+
* Convention (matches the existing Linux loaders): `gboolean` is modelled as
19+
* {@link FFIType.i32} (compare `!== 0`); the `NotifyNotification*` handle and the
20+
* `GError**` out-param are real pointers ({@link FFIType.pointer}); `cstring`
21+
* args are NUL-terminated UTF-8 strings. The `GError**` arg is always passed as
22+
* `null` (failures are reported via the gboolean return, not unwrapped).
23+
*
24+
* Only callable on Linux — throws {@link UnsupportedPlatformError} otherwise so
25+
* the module stays safely importable on macOS for unit testing.
26+
*/
27+
28+
const LIBNOTIFY_PATH = 'libnotify.so.4';
29+
30+
/** The libnotify FFI symbol descriptor table (from `libnotify.so.4`). */
31+
export const LIBNOTIFY_FFI_SYMBOLS = {
32+
// (app_name) -> gboolean; call once per process before creating notifications.
33+
notify_init: {
34+
args: [FFIType.cstring],
35+
returns: FFIType.i32,
36+
},
37+
notify_is_initted: {
38+
args: [],
39+
returns: FFIType.i32,
40+
},
41+
// (summary, body, icon) -> NotifyNotification*; body/icon may be NULL.
42+
notify_notification_new: {
43+
args: [FFIType.cstring, FFIType.cstring, FFIType.cstring],
44+
returns: FFIType.pointer,
45+
},
46+
// (notification, GError** /*null*/) -> gboolean (FALSE if no daemon).
47+
notify_notification_show: {
48+
args: [FFIType.pointer, FFIType.pointer],
49+
returns: FFIType.i32,
50+
},
51+
// (notification, GError** /*null*/) -> gboolean.
52+
notify_notification_close: {
53+
args: [FFIType.pointer, FFIType.pointer],
54+
returns: FFIType.i32,
55+
},
56+
// (notification, timeout_ms) -> void; -1 = default, 0 = never expire.
57+
notify_notification_set_timeout: {
58+
args: [FFIType.pointer, FFIType.i32],
59+
returns: FFIType.void,
60+
},
61+
} as const;
62+
63+
const cache: { ffi: ReturnType<typeof dlopen<typeof LIBNOTIFY_FFI_SYMBOLS>> | undefined } = {
64+
ffi: undefined,
65+
};
66+
67+
export const loadLibnotifyFFI = () => {
68+
const platform = currentPlatform();
69+
if (platform !== 'linux') {
70+
throw new UnsupportedPlatformError(
71+
`loadLibnotifyFFI() is only supported on Linux; current platform is ${platform}`,
72+
);
73+
}
74+
if (cache.ffi) {
75+
return cache.ffi;
76+
}
77+
const ffi = dlopen(LIBNOTIFY_PATH, LIBNOTIFY_FFI_SYMBOLS);
78+
cache.ffi = ffi;
79+
return ffi;
80+
};

0 commit comments

Comments
 (0)