diff --git a/Cargo.lock b/Cargo.lock index 1706429..70e37bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/ELECTRON_API_STATUS.md b/ELECTRON_API_STATUS.md index 62479c9..011a4ca 100644 --- a/ELECTRON_API_STATUS.md +++ b/ELECTRON_API_STATUS.md @@ -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) | diff --git a/README.md b/README.md index ba0a2e6..0529a89 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/gelectron-app/Cargo.toml b/crates/gelectron-app/Cargo.toml index 6ebe747..1beb1c5 100644 --- a/crates/gelectron-app/Cargo.toml +++ b/crates/gelectron-app/Cargo.toml @@ -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 } diff --git a/crates/gelectron-app/src/main.rs b/crates/gelectron-app/src/main.rs index 9428ef9..4f2b154 100644 --- a/crates/gelectron-app/src/main.rs +++ b/crates/gelectron-app/src/main.rs @@ -132,6 +132,16 @@ enum ToRust { ShellShowInFolder { path: String }, #[serde(rename = "shell-move-to-trash")] ShellMoveToTrash { path: String, request_id: Option }, + #[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, }, + #[serde(rename = "notification-event")] + NotificationEvent { + id: String, + event: String, + #[serde(default)] + action_index: Option, + #[serde(default)] + action: Option, + #[serde(default)] + reply: Option, + }, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -180,6 +201,38 @@ struct WindowOpts { icon: Option, } +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +struct NotificationOpts { + #[serde(default)] + title: Option, + #[serde(default)] + subtitle: Option, + #[serde(default)] + body: Option, + #[serde(default)] + silent: Option, + #[serde(default)] + icon: Option, + #[serde(default)] + urgency: Option, + #[serde(default)] + timeout_type: Option, + #[serde(default)] + close_button_text: Option, + #[serde(default)] + toast_xml: Option, + #[serde(default)] + actions: Vec, + #[serde(default)] + has_reply: Option, + #[serde(default)] + reply_placeholder: Option, + #[serde(default)] + sound: Option, + #[serde(default)] + app_name: Option, +} + 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>, + ipc_tx: &mpsc::Sender, +) { + 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::() { + 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::().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); diff --git a/crates/gelectron-core/src/notification.rs b/crates/gelectron-core/src/notification.rs index 4ea5415..a27ee8f 100644 --- a/crates/gelectron-core/src/notification.rs +++ b/crates/gelectron-core/src/notification.rs @@ -13,6 +13,10 @@ pub struct NotificationOptions { pub timeout_type: Option, pub close_button_text: Option, pub toast_xml: Option, + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub sound: Option, } #[napi(object)] @@ -39,12 +43,55 @@ pub fn notification_create(options_json: String) -> Result { 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::() { + 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: ¬ify_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 { - true + #[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 + } } diff --git a/src/electron/native-bridge.js b/src/electron/native-bridge.js index 005aa23..04db2fa 100644 --- a/src/electron/native-bridge.js +++ b/src/electron/native-bridge.js @@ -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; diff --git a/src/electron/notification.js b/src/electron/notification.js index edcff3b..54528f3 100644 --- a/src/electron/notification.js +++ b/src/electron/notification.js @@ -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'); }