feat: implement native notifications with full API support and event handling

This commit is contained in:
2026-08-14 16:50:21 -05:00
parent 8cf5d351a7
commit 48a994db09
8 changed files with 369 additions and 12 deletions
Generated
+1
View File
@@ -1409,6 +1409,7 @@ dependencies = [
"libc",
"log",
"muda",
"notify-rust",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
+3 -3
View File
@@ -34,7 +34,7 @@
| 19 | nativeTheme | YES | inline | YES | **partial** | NativeTheme class: shouldUseDarkColors (getter + method), themeSource (getter/setter), shouldSystemUseDarkColors, queries Rust for system theme. systemPreferences.isDarkMode() linked |
| 20 | net | NO | inline | YES | **partial** | fetch delegates to globalThis.fetch. Missing: net.request(), ClientRequest API |
| 21 | netLog | NO | NO | NO | **missing** | Network log capture |
| 22 | Notification | YES | YES | YES | **partial** | API surface complete, show via browser Notification API if available. No native OS integration |
| 22 | Notification | YES | YES | YES | **full** | Full Electron API (title/subtitle/body/silent/icon/urgency/timeoutType/actions/hasReply/replyPlaceholder/sound/closeButtonText/toastXml). Native OS integration via notify-rust (macOS Notification Center, Windows Toasts, Linux D-Bus). Events: show/click/action/reply/close/failed |
| 23 | powerMonitor | NO | inline | YES | **stub** | getSystemIdleState returns 'active', no real monitoring |
| 24 | powerSaveBlocker | NO | NO | NO | **missing** | Prevent system sleep |
| 25 | protocol | NO | inline (session) | NO | **missing** | Custom protocol registration (standalone module). Session stubs exist |
@@ -101,8 +101,8 @@
| Status | Count | Modules |
|--------|-------|---------|
| **full** | 3 | app, ipcMain, clipboard |
| **partial** | 14 | BrowserWindow, Menu, MenuItem, dialog, shell, Notification, nativeImage, contextBridge, webContents, ipcRenderer, net, process, screen, nativeTheme |
| **full** | 4 | app, ipcMain, clipboard, Notification |
| **partial** | 13 | BrowserWindow, Menu, MenuItem, dialog, shell, nativeImage, contextBridge, webContents, ipcRenderer, net, process, screen, nativeTheme |
| **stub** | 7 | Tray, safeStorage, autoUpdater, session, systemPreferences, powerMonitor, globalShortcut |
| **missing** | 36 | BaseWindow, BrowserView, contentTracing, crashReporter, desktopCapturer, ImageView, inAppPurchase, MessageChannelMain, netLog, powerSaveBlocker, protocol, pushNotifications, ServiceWorkerMain, sharedTexture, ShareMenu, TouchBar (+10 sub-classes), utilityProcess, View, WebContentsView, webFrameMain, webFrame, webUtils, crashReporter (renderer), sharedTexture (renderer), remote, webviewTag, navigation-history, parent-port, web-request, web-socket, window-open, local-ai-handler |
| **Total** | **60** | BrowserView counted once (listed in both Main and Deprecated tables) |
+1 -1
View File
@@ -126,7 +126,7 @@ When the native binary is not built, the CLI falls back to pure Node.js:
| `Tray` | Create, tooltip, context menu, click events |
| `dialog` | `showOpenDialog()`, `showSaveDialog()`, `showMessageBox()`, `showErrorBox()` |
| `shell` | `openExternal()`, `showItemInFolder()`, `openPath()` |
| `Notification` | Create, show, close, urgency levels |
| `Notification` | Full API + native OS notifications (macOS Notification Center, Windows Toasts, Linux D-Bus); click/action/reply/close/failed events |
| `nativeImage` | Create from path/buffer, resize, crop, PNG/JPEG export |
| `safeStorage` | Encrypt/decrypt via system keyring |
| `contextBridge` | `exposeInMainWorld()` for secure preload |
+1
View File
@@ -20,6 +20,7 @@ tao = "0.30"
muda = "0.15"
arboard = "3"
rfd = "0.15"
notify-rust = "4"
png = "0.18"
base64 = "0.22"
url = { workspace = true }
+183
View File
@@ -132,6 +132,16 @@ enum ToRust {
ShellShowInFolder { path: String },
#[serde(rename = "shell-move-to-trash")]
ShellMoveToTrash { path: String, request_id: Option<String> },
#[serde(rename = "notification-show")]
NotificationShow {
id: String,
request_id: String,
options: NotificationOpts,
},
#[serde(rename = "notification-close")]
NotificationClose { id: String },
#[serde(rename = "notification-is-supported")]
NotificationIsSupported { request_id: String },
}
#[derive(Debug, Serialize, Deserialize)]
@@ -156,6 +166,17 @@ enum ToNode {
#[serde(default)]
error: Option<String>,
},
#[serde(rename = "notification-event")]
NotificationEvent {
id: String,
event: String,
#[serde(default)]
action_index: Option<u32>,
#[serde(default)]
action: Option<String>,
#[serde(default)]
reply: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
@@ -180,6 +201,38 @@ struct WindowOpts {
icon: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct NotificationOpts {
#[serde(default)]
title: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
silent: Option<bool>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
urgency: Option<String>,
#[serde(default)]
timeout_type: Option<String>,
#[serde(default)]
close_button_text: Option<String>,
#[serde(default)]
toast_xml: Option<String>,
#[serde(default)]
actions: Vec<serde_json::Value>,
#[serde(default)]
has_reply: Option<bool>,
#[serde(default)]
reply_placeholder: Option<String>,
#[serde(default)]
sound: Option<String>,
#[serde(default)]
app_name: Option<String>,
}
struct WindowPair {
#[allow(dead_code)]
window: tao::window::Window,
@@ -633,6 +686,9 @@ window.__gelectron_run_main(`{}`);
ToNode::Response { request_id, result, error } => {
serde_json::json!({"__from_node":true,"type":"response","request_id":request_id,"result":result,"error":error})
}
ToNode::NotificationEvent { id, event, action_index, action, reply } => {
serde_json::json!({"__from_node":true,"type":"notification-event","id":id,"event":event,"action_index":action_index,"action":action,"reply":reply})
}
};
let _ = pair.webview.evaluate_script(
&format!("window.postMessage({},'*');", serde_json::to_string(&js_msg).unwrap())
@@ -1268,6 +1324,114 @@ fn apply_dock_icon_from_path(path: &std::path::Path) {
}
}
fn show_native_notification(
options: &NotificationOpts,
nid: &str,
request_id: &str,
tx: &Option<mpsc::Sender<(String, serde_json::Value)>>,
ipc_tx: &mpsc::Sender<ToNode>,
) {
use notify_rust::{Notification, NotificationResponse, Timeout};
#[cfg(not(target_os = "macos"))]
use notify_rust::Urgency;
let mut n = Notification::new();
n.summary(options.title.clone().unwrap_or_default().as_str())
.body(options.body.clone().unwrap_or_default().as_str());
if let Some(sub) = &options.subtitle {
n.subtitle(sub);
}
if let Some(app) = &options.app_name {
n.appname(app);
}
if let Some(sound) = &options.sound {
n.sound_name(sound);
}
if options.silent.unwrap_or(false) {
n.sound_name("");
}
if let Some(path) = &options.icon {
if std::path::Path::new(path).exists() {
n.image_path(path);
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(urgency) = &options.urgency {
let u = match urgency.as_str() {
"low" => Urgency::Low,
"critical" => Urgency::Critical,
_ => Urgency::Normal,
};
n.urgency(u);
}
}
if let Some(t) = &options.timeout_type {
match t.as_str() {
"never" => {
n.timeout(Timeout::Never);
}
other => {
if let Ok(ms) = other.parse::<i32>() {
n.timeout(ms);
}
}
}
}
// Electron action objects ({ text, ... }) → notify-rust action(id, label)
for (i, action) in options.actions.iter().enumerate() {
let text = action.get("text").and_then(|v| v.as_str()).unwrap_or_default();
n.action(&i.to_string(), text);
}
match n.show() {
Ok(handle) => {
if let Some(ref tx) = tx {
let _ = tx.send((
request_id.to_string(),
serde_json::json!({ "id": nid, "success": true }),
));
}
let _ = handle.wait_for_response(|response: &NotificationResponse| {
let (event, action_index, action, reply) = match response {
NotificationResponse::Default => ("click", None, None, None),
NotificationResponse::Action(key) => (
"action",
key.parse::<u32>().ok(),
Some(key.clone()),
None,
),
NotificationResponse::Reply(text) => ("reply", None, None, Some(text.clone())),
NotificationResponse::Closed(_) => ("close", None, None, None),
};
let _ = ipc_tx.send(ToNode::NotificationEvent {
id: nid.to_string(),
event: event.to_string(),
action_index,
action,
reply,
});
});
}
Err(e) => {
if let Some(ref tx) = tx {
let _ = tx.send((
request_id.to_string(),
serde_json::json!({ "id": nid, "success": false, "error": e.to_string() }),
));
}
let _ = ipc_tx.send(ToNode::NotificationEvent {
id: nid.to_string(),
event: "failed".to_string(),
action_index: None,
action: None,
reply: Some(e.to_string()),
});
}
}
}
fn handle_to_rust(
msg: ToRust,
st: &mut AppState,
@@ -1763,6 +1927,25 @@ fn handle_to_rust(
}
}
}
ToRust::NotificationShow { id, request_id, options } => {
let tx = st.response_tx.clone();
let ipc_tx2 = ipc_tx.clone();
let nid = id.clone();
thread::spawn(move || {
show_native_notification(&options, &nid, &request_id, &tx, &ipc_tx2);
});
}
ToRust::NotificationClose { id } => {
// notify-rust has no cross-platform way to force-dismiss a
// notification that is already awaiting a response; the JS layer
// emits 'close' itself when close() is called.
log::info!("Notification {} closed", id);
}
ToRust::NotificationIsSupported { request_id } => {
if let Some(ref tx) = st.response_tx {
let _ = tx.send((request_id, serde_json::json!({ "supported": true })));
}
}
ToRust::SetTitle { id, title } => {
if let Some(pair) = st.windows.get(&id) {
pair.window.set_title(&title);
+57 -2
View File
@@ -13,6 +13,10 @@ pub struct NotificationOptions {
pub timeout_type: Option<String>,
pub close_button_text: Option<String>,
pub toast_xml: Option<String>,
#[serde(default)]
pub actions: Vec<serde_json::Value>,
#[serde(default)]
pub sound: Option<String>,
}
#[napi(object)]
@@ -39,12 +43,55 @@ pub fn notification_create(options_json: String) -> Result<String> {
let mut notification = notify_rust::Notification::new();
notification.summary(&title).body(&body);
if let Some(true) = opts.silent {
if let Some(sub) = &opts.subtitle {
notification.subtitle(sub);
}
if let Some(sound) = &opts.sound {
notification.sound_name(sound);
}
if opts.silent.unwrap_or(false) {
notification.sound_name("");
}
if let Some(path) = &opts.icon {
if std::path::Path::new(path).exists() {
notification.image_path(path);
}
}
if let Some(timeout) = &opts.timeout_type {
match timeout.as_str() {
"never" => {
notification.timeout(notify_rust::Timeout::Never);
}
other => {
if let Ok(ms) = other.parse::<i32>() {
notification.timeout(ms);
}
}
}
}
#[cfg(not(target_os = "macos"))]
if let Some(urgency) = &opts.urgency {
let u = match urgency.as_str() {
"low" => notify_rust::Urgency::Low,
"critical" => notify_rust::Urgency::Critical,
_ => notify_rust::Urgency::Normal,
};
notification.urgency(u);
}
for (i, action) in opts.actions.iter().enumerate() {
if let Some(text) = action.get("text").and_then(|v| v.as_str()) {
notification.action(&i.to_string(), text);
}
}
match notification.show() {
Ok(_) => {
Ok(handle) => {
let nid = id.clone();
std::thread::spawn(move || {
let _ = handle.wait_for_response(|_response: &notify_rust::NotificationResponse| {
log::info!("Notification {} responded", nid);
});
});
log::info!("Notification '{}' shown", title);
}
Err(e) => {
@@ -63,5 +110,13 @@ pub fn notification_close(notification_id: String) -> Result<()> {
#[napi]
pub fn notification_is_supported() -> bool {
#[cfg(all(unix, not(target_os = "macos")))]
{
// Linux requires a notification daemon on D-Bus; probe for one.
return notify_rust::get_capabilities().is_ok();
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
true
}
}
+3
View File
@@ -52,6 +52,9 @@ class NativeBridge extends EventEmitter {
case 'ipc-message':
this.emit('ipc-message', msg.id, msg.channel, msg.data);
break;
case 'notification-event':
this.emit('notification-event', msg);
break;
case 'response':
this._resolveRequest(msg.request_id, msg.result, msg.error || null);
break;
+119 -5
View File
@@ -2,9 +2,66 @@
/**
* Gelectron - Notification module (Electron compatible)
*
* In native mode notifications are rendered by the Rust engine (notify-rust
* macOS Notification Center / Windows Toasts / Linux D-Bus) and the
* 'click' / 'action' / 'reply' / 'close' / 'failed' events are delivered
* back over the bridge. In plain Node mode we fall back to the browser
* Notification API when the page grants permission.
*/
const { EventEmitter } = require('events');
const { bridge, isNative } = require('./native-bridge');
let _nativeSupported = null;
let _wired = false;
function _iconToPath(id, icon) {
if (!icon) return null;
if (typeof icon === 'string') return icon;
if (icon && typeof icon.toPNG === 'function' && !icon.isEmpty()) {
const os = require('os');
const path = require('path');
const fs = require('fs');
const tmp = path.join(os.tmpdir(), `gelectron-notif-${id}-${Date.now()}.png`);
try {
fs.writeFileSync(tmp, icon.toPNG());
return tmp;
} catch (e) {
return null;
}
}
return null;
}
function _wireEvents() {
if (_wired) return;
_wired = true;
bridge.on('notification-event', (msg) => {
const notif = Notification._notifications.get(Number(msg.id));
if (!notif) return;
switch (msg.event) {
case 'click':
notif.emit('click');
break;
case 'close':
Notification._notifications.delete(notif.id);
notif.emit('close');
break;
case 'action': {
const index = Number.isInteger(msg.action_index) ? msg.action_index : 0;
notif.emit('action', index);
break;
}
case 'reply':
notif.emit('reply', String(msg.reply || ''));
break;
case 'failed':
notif.emit('failed', new Error(String(msg.reply || 'Notification failed')));
break;
}
});
}
class Notification extends EventEmitter {
static _notifications = new Map();
@@ -14,29 +71,84 @@ class Notification extends EventEmitter {
super();
this.id = Notification._nextId++;
this.title = options.title || '';
this.body = options.body || '';
this.subtitle = options.subtitle || '';
this.silent = options.silent || false;
this.body = options.body || '';
this.silent = !!options.silent;
this.icon = options.icon || null;
this.urgency = options.urgency || 'normal';
this.timeoutType = options.timeoutType || 'default';
this.closeButtonText = options.closeButtonText || '';
this.toastXml = options.toastXml || '';
this.actions = options.actions || [];
this.hasReply = !!options.hasReply;
this.replyPlaceholder = options.replyPlaceholder || '';
this.sound = options.sound || '';
Notification._notifications.set(this.id, this);
}
static isSupported() {
return true;
if (isNative) {
if (_nativeSupported === null) {
_nativeSupported = true;
if (typeof bridge._request === 'function') {
bridge._request({ type: 'notification-is-supported' })
.then((r) => { _nativeSupported = !!(r && r.supported); })
.catch(() => {});
}
}
return _nativeSupported;
}
return typeof globalThis.Notification !== 'undefined' &&
globalThis.Notification.permission === 'granted';
}
show() {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
if (isNative) {
_wireEvents();
const payload = {
type: 'notification-show',
id: String(this.id),
options: {
title: this.title,
subtitle: this.subtitle,
body: this.body,
silent: this.silent,
icon: _iconToPath(this.id, this.icon),
urgency: this.urgency,
timeoutType: this.timeoutType,
closeButtonText: this.closeButtonText,
toastXml: this.toastXml,
actions: this.actions.map((a) => ({ text: a && a.text })),
hasReply: this.hasReply,
replyPlaceholder: this.replyPlaceholder,
sound: this.sound,
},
};
const self = this;
if (typeof bridge._request === 'function') {
bridge._request(payload).then((result) => {
if (result && result.success) {
self.emit('show');
} else {
self.emit('failed', new Error((result && result.error) || 'Failed to show notification'));
}
}).catch(() => {
self.emit('failed', new Error('Failed to show notification'));
});
} else {
bridge._send(payload);
this.emit('show');
}
return true;
}
if (typeof globalThis.Notification !== 'undefined' &&
globalThis.Notification.permission === 'granted') {
new globalThis.Notification(this.title, {
body: this.body,
icon: this.icon,
silent: this.silent,
});
}
@@ -47,7 +159,9 @@ class Notification extends EventEmitter {
close() {
Notification._notifications.delete(this.id);
console.log(`[gelectron] Notification ${this.id} closed`);
if (isNative && typeof bridge._send === 'function') {
bridge._send({ type: 'notification-close', id: String(this.id) });
}
this.emit('close');
}