mirror of
https://github.com/mileswolfallen2/gelectron.git
synced 2026-09-08 04:43:14 +00:00
feat: implement native notifications with full API support and event handling
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: ¬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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user