mirror of
https://github.com/mileswolfallen2/gelectron.git
synced 2026-09-08 12:43:16 +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",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"muda",
|
"muda",
|
||||||
|
"notify-rust",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
"objc2-foundation 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 23 | powerMonitor | NO | inline | YES | **stub** | getSystemIdleState returns 'active', no real monitoring |
|
||||||
| 24 | powerSaveBlocker | NO | NO | NO | **missing** | Prevent system sleep |
|
| 24 | powerSaveBlocker | NO | NO | NO | **missing** | Prevent system sleep |
|
||||||
| 25 | protocol | NO | inline (session) | NO | **missing** | Custom protocol registration (standalone module). Session stubs exist |
|
| 25 | protocol | NO | inline (session) | NO | **missing** | Custom protocol registration (standalone module). Session stubs exist |
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
|
|
||||||
| Status | Count | Modules |
|
| Status | Count | Modules |
|
||||||
|--------|-------|---------|
|
|--------|-------|---------|
|
||||||
| **full** | 3 | app, ipcMain, clipboard |
|
| **full** | 4 | app, ipcMain, clipboard, Notification |
|
||||||
| **partial** | 14 | BrowserWindow, Menu, MenuItem, dialog, shell, Notification, nativeImage, contextBridge, webContents, ipcRenderer, net, process, screen, nativeTheme |
|
| **partial** | 13 | BrowserWindow, Menu, MenuItem, dialog, shell, nativeImage, contextBridge, webContents, ipcRenderer, net, process, screen, nativeTheme |
|
||||||
| **stub** | 7 | Tray, safeStorage, autoUpdater, session, systemPreferences, powerMonitor, globalShortcut |
|
| **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 |
|
| **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) |
|
| **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 |
|
| `Tray` | Create, tooltip, context menu, click events |
|
||||||
| `dialog` | `showOpenDialog()`, `showSaveDialog()`, `showMessageBox()`, `showErrorBox()` |
|
| `dialog` | `showOpenDialog()`, `showSaveDialog()`, `showMessageBox()`, `showErrorBox()` |
|
||||||
| `shell` | `openExternal()`, `showItemInFolder()`, `openPath()` |
|
| `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 |
|
| `nativeImage` | Create from path/buffer, resize, crop, PNG/JPEG export |
|
||||||
| `safeStorage` | Encrypt/decrypt via system keyring |
|
| `safeStorage` | Encrypt/decrypt via system keyring |
|
||||||
| `contextBridge` | `exposeInMainWorld()` for secure preload |
|
| `contextBridge` | `exposeInMainWorld()` for secure preload |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tao = "0.30"
|
|||||||
muda = "0.15"
|
muda = "0.15"
|
||||||
arboard = "3"
|
arboard = "3"
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
|
notify-rust = "4"
|
||||||
png = "0.18"
|
png = "0.18"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ enum ToRust {
|
|||||||
ShellShowInFolder { path: String },
|
ShellShowInFolder { path: String },
|
||||||
#[serde(rename = "shell-move-to-trash")]
|
#[serde(rename = "shell-move-to-trash")]
|
||||||
ShellMoveToTrash { path: String, request_id: Option<String> },
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -156,6 +166,17 @@ enum ToNode {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<String>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
@@ -180,6 +201,38 @@ struct WindowOpts {
|
|||||||
icon: Option<String>,
|
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 {
|
struct WindowPair {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
window: tao::window::Window,
|
window: tao::window::Window,
|
||||||
@@ -633,6 +686,9 @@ window.__gelectron_run_main(`{}`);
|
|||||||
ToNode::Response { request_id, result, error } => {
|
ToNode::Response { request_id, result, error } => {
|
||||||
serde_json::json!({"__from_node":true,"type":"response","request_id":request_id,"result":result,"error":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(
|
let _ = pair.webview.evaluate_script(
|
||||||
&format!("window.postMessage({},'*');", serde_json::to_string(&js_msg).unwrap())
|
&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(
|
fn handle_to_rust(
|
||||||
msg: ToRust,
|
msg: ToRust,
|
||||||
st: &mut AppState,
|
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 } => {
|
ToRust::SetTitle { id, title } => {
|
||||||
if let Some(pair) = st.windows.get(&id) {
|
if let Some(pair) = st.windows.get(&id) {
|
||||||
pair.window.set_title(&title);
|
pair.window.set_title(&title);
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct NotificationOptions {
|
|||||||
pub timeout_type: Option<String>,
|
pub timeout_type: Option<String>,
|
||||||
pub close_button_text: Option<String>,
|
pub close_button_text: Option<String>,
|
||||||
pub toast_xml: Option<String>,
|
pub toast_xml: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actions: Vec<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sound: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[napi(object)]
|
#[napi(object)]
|
||||||
@@ -39,12 +43,55 @@ pub fn notification_create(options_json: String) -> Result<String> {
|
|||||||
let mut notification = notify_rust::Notification::new();
|
let mut notification = notify_rust::Notification::new();
|
||||||
notification.summary(&title).body(&body);
|
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("");
|
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() {
|
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);
|
log::info!("Notification '{}' shown", title);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -63,5 +110,13 @@ pub fn notification_close(notification_id: String) -> Result<()> {
|
|||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn notification_is_supported() -> bool {
|
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
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class NativeBridge extends EventEmitter {
|
|||||||
case 'ipc-message':
|
case 'ipc-message':
|
||||||
this.emit('ipc-message', msg.id, msg.channel, msg.data);
|
this.emit('ipc-message', msg.id, msg.channel, msg.data);
|
||||||
break;
|
break;
|
||||||
|
case 'notification-event':
|
||||||
|
this.emit('notification-event', msg);
|
||||||
|
break;
|
||||||
case 'response':
|
case 'response':
|
||||||
this._resolveRequest(msg.request_id, msg.result, msg.error || null);
|
this._resolveRequest(msg.request_id, msg.result, msg.error || null);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,9 +2,66 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gelectron - Notification module (Electron compatible)
|
* 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 { 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 {
|
class Notification extends EventEmitter {
|
||||||
static _notifications = new Map();
|
static _notifications = new Map();
|
||||||
@@ -14,29 +71,84 @@ class Notification extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
this.id = Notification._nextId++;
|
this.id = Notification._nextId++;
|
||||||
this.title = options.title || '';
|
this.title = options.title || '';
|
||||||
this.body = options.body || '';
|
|
||||||
this.subtitle = options.subtitle || '';
|
this.subtitle = options.subtitle || '';
|
||||||
this.silent = options.silent || false;
|
this.body = options.body || '';
|
||||||
|
this.silent = !!options.silent;
|
||||||
this.icon = options.icon || null;
|
this.icon = options.icon || null;
|
||||||
this.urgency = options.urgency || 'normal';
|
this.urgency = options.urgency || 'normal';
|
||||||
this.timeoutType = options.timeoutType || 'default';
|
this.timeoutType = options.timeoutType || 'default';
|
||||||
this.closeButtonText = options.closeButtonText || '';
|
this.closeButtonText = options.closeButtonText || '';
|
||||||
this.toastXml = options.toastXml || '';
|
this.toastXml = options.toastXml || '';
|
||||||
this.actions = options.actions || [];
|
this.actions = options.actions || [];
|
||||||
|
this.hasReply = !!options.hasReply;
|
||||||
this.replyPlaceholder = options.replyPlaceholder || '';
|
this.replyPlaceholder = options.replyPlaceholder || '';
|
||||||
|
this.sound = options.sound || '';
|
||||||
|
|
||||||
Notification._notifications.set(this.id, this);
|
Notification._notifications.set(this.id, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isSupported() {
|
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() {
|
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, {
|
new globalThis.Notification(this.title, {
|
||||||
body: this.body,
|
body: this.body,
|
||||||
icon: this.icon,
|
icon: this.icon,
|
||||||
|
silent: this.silent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +159,9 @@ class Notification extends EventEmitter {
|
|||||||
|
|
||||||
close() {
|
close() {
|
||||||
Notification._notifications.delete(this.id);
|
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');
|
this.emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user