/**
* _ _
* | \| |
* | .` |
* |_|\_|eptune
*
* Capstone Project 2022
*/
let Notifier = require("node-notifier"); // does not work with windows action center!
let Client = require('./Client.js');
let { PipeDataReceivedEventArgs } = require('./NeptuneRunner.js');
let { Logger } = require('./LogMan');
let EventEmitter = require("node:events");
const crypto = require("node:crypto");
/**
* Neptune
*/
const Neptune = global.Neptune;
/**
* Neptune Server Notification class
*
* Notification acts as an abstraction layer for sending the notification to the computer via node-notifier.
* You create the notification, push it out, and the Notification class handles the notification from there and emits Events to notify you of changes.
*
*/
class Notification extends EventEmitter {
/**
* Notification Id shared between the server and client, used to reference a notification
* @type {number}
*/
#id;
get id() {
return this.#id;
}
/**
* Client id that own this notification
* @type {number}
*/
get clientId() {
return this.#client.clientId;
}
/**
* Client this notification belongs to
* @type {Client}
*/
#client;
/**
* Notifier id, provided by node-notify. *Should* be the same as the id.
* @type {number}
*/
#notifierId
/**
* Data returned by Notifier.notify().
*/
#notifierNotification;
/**
* @type {Logger}
*/
#log;
/**
* Might be pulled directly into this class later, for now I am lazy
* @type {NotificationData}
*/
data;
#neptuneRunnerId;
/**
* @typedef {Object} NotificationAction
* @property {string} id - The 'name' of the action
* @property {string} type - Button, textbox, or combobox
* @property {string} [contents] - The contents (title/text/name) of the button
* @property {string} [hintText] - Unique to text box and combobox, the "hint"
* @property {boolean} [allowGeneratedReplies] - Allow those generated smart replies (for textbox only)
* @property {string[]} [choices] - Choices the user gets (for combobox only)
*/
/**
* @typedef {Object} TimerData
* @property {boolean} countingDown - Whether the chronometer is counting down (true) or up (false)
*/
/**
* @typedef {Object} Progress
* @property {number} value - Current position
* @property {number} max - Maximum value
* @property {boolean} isIndeterminate - Indeterminate state of the progress bar
*/
/**
* @typedef {Object} Contents
* @property {string} text - The text description
* @property {string} subtext - Subtext
* @property {string} image - Image in base64
* @property {NotificationAction[]} actions - Buttons or textboxes, things the user can interact with
* @property {TimerData} timerData - Data related to timer
* @property {Progress} progress - Progress data
*/
/**
* @typedef {Object} NotificationData
* @property {string} action - What to do with this data, how to process (create, remove, update)
* @property {string} applicationName - The app that created the notification
* @property {string} applicationPackage - The package name of that application
* @property {number} notificationId - Notification ID provided by Android, used to refer to this notification on either end
* @property {string} notificationIcon - Base64 representation of the notification icon
* @property {string} title - Title of the notification
* @property {string} type - Notification type (standard, timer, progress, media, call)
* @property {Contents} contents - Content of the notification
* @property {boolean} onlyAlertOnce - Only play the sound, vibrate, and ticker if the notification is not already showing
* @property {string} priority - Can be "max", "high", "default", "low", and "min"
* @property {string} timestamp - When this item was displayed
* @property {boolean} isSilent - Display this / is silent
*/
/**
* Data provided by the client
* @typedef {object} NotificationDataLegacy
* @property {string} action - What the do with this data, how to process (create, remove, update)
* @property {string} applicationName - The app that created the notification (it's friendly name)
* @property {string} applicationPackage - The package name of the application that created the notification
* @property {number} notificationId - Notification Id provided by Android, used to refer to this notification on either end
* @property {string} notificationIcon - Base64 representation of the notification icon. This must be saved to the disk before being used (Windows only accepts URIs to icons)
* @property {string} title - Title of the notification
* @property {string} type - Notification type (text, image, inline, chronometer, progress bar). This will determine the data in `contents`.
* @property {(TextNotification)} contents - Content of the notification (type set by above)
* @property {object} extras - Unused currently, reserved for misc data
* @property {boolean} persistent - Notification is persistent
* @property {number} color - Color of the notification
* @property {boolean} onlyAlertOnce - Only let the sound, vibration and ticker to be played if the notification is not already showing.
* @property {string} category - Category of notification, https://developer.android.com/reference/android/app/Notification#category
* @property {number} priority - Android #setImportance
* @property {string} timestamp - ISO date time stamp
* @property {number} timeoutAfter - Duration in milliseconds after which this notification should be canceled, if it is not already canceled
* @property {boolean} isActive - Display this notification
*/
/**
* @param {Client} client - Client this notification came from
* @param {NotificationData} data - The notification data provided by the client
*/
constructor(client, data) {
super();
this.#client = client;
// not testing if data is proper just yet, but hoping it follows the API doc
this.#log = Neptune.logMan.getLogger("Notification-" + data.notificationId);
this.#log.debug("New notification created. Title: " + data.title + " .. type: " + data.type + " .. text: " + data.contents.text);
this.data = data;
if (this.data.contents === null) {
this.data.contents = {
text: " ",
}
}
this.#id = data.notificationId;
this.#neptuneRunnerId = crypto.createHash("sha1").update(data.notificationId).digest('hex')
}
/**
* Pushes the notification out to the OS
* @return {void}
*/
push() {
if (this.data.action == "delete") {
this.delete();
}
let logger = this.#log;
let maybeThis = this;
let name = this.#client.friendlyName;
let pushNotification = function() { // Using notifier
try {
// don't do media ones :!
if (maybeThis.data.type === "media")
return;
// send the notification
let text = maybeThis.data.contents.text + maybeThis.data.contents.subtext
if (text === undefined)
text = " "
if (text.length == 0)
text = " "
let hasTextbox = false;
let choices = []; // combobox choices
let actions = []; // buttons
if (maybeThis.data.contents.actions !== undefined) {
for (var i = 0; i<maybeThis.data.contents.actions.length; i++) {
let action = maybeThis.data.contents.actions[i];
if (action.type == "combobox") {
choices = action.choices;
} else if (action.type == "textbox") {
hasTextbox = true;
} else if (action.type == "button") {
actions.push(action.contents);
}
}
}
let notifierData = {
title: name + maybeThis.data.title,
message: text,
id: maybeThis.data.notificationId,
sound: maybeThis.data.isSilent === false,
actions: actions,
reply: false,
};
if (global.NeptuneRunnerIPC.pipeAuthenticated) {
notifierData.appID = "Neptune.Server.V2"
}
maybeThis.#notifierNotification = Notifier.notify(notifierData, (err, response, metadata) => {
try {
if (err) {
logger.error("Notifier error: " + err, false);
maybeThis.error();
} else {
let action = metadata.action === "dismissed"? "dismissed" : "activated";
let id = metadata.button === undefined? "" : Buffer.from(metadata.button, "utf8").toString("base64");
let text = metadata.text === undefined? "" : Buffer.from(metadata.text, "utf8").toString("base64");
let dataPackage = {
action: action,
actionParameters: {
id: id,
text: text,
comboBoxChoice: undefined,
}
}
logger.silly("action metadata: ", false);
logger.silly(metadata, false);
if (action == "activated") {
maybeThis.activate(dataPackage);
} else if (action == "dismissed") {
maybeThis.dismiss(dataPackage);
}
}
} catch(e) {
logger.error("Unable to react to notifier notification. See log for more details");
logger.error(e, false);
}
});
} catch (e) {
logger.error("Unable to push notification to system! Error: " + e.message);
logger.error(e, false);
// maybe use QT to push balloon notification ..?
// damn straight
maybeThis.error();
}
}
if (process.platform === "win32" && global.NeptuneRunnerIPC !== undefined) {
if (!global.NeptuneRunnerIPC.pipeAuthenticated) {
pushNotification();
return;
}
try {
let data = {
action: this.data.action,
id: this.#neptuneRunnerId,
clientId: this.#client.clientId,
clientName: this.#client.friendlyName,
applicationName: this.data.applicationName,
applicationPackage: this.data.applicationPackage,
notificationIcon: this.data.notificationIcon,
title: this.data.title,
type: this.data.type,
onlyAlertOnce: this.data.onlyAlertOnce,
priority: this.data.priority,
timestamp: this.data.timestamp,
isSilent: this.data.isActive,
}
data.text = this.data.contents.text;
data.subtext = this.data.contents.subtext;
let contentsJsonString = JSON.stringify(this.data.contents);
let contentsBase64Data = Buffer.from(contentsJsonString, 'utf8').toString('base64');
let contentsDataString = `data:text/json;base64, ${contentsBase64Data}`;
data.contents = contentsDataString;
this.#log.silly(data, false);
global.NeptuneRunnerIPC.sendData("notify-push", data);
let func = this.#IPCActivation;
let actuallyThis = this;
if (global.NeptuneRunnerIPC._events['notify-client_' + this.clientId + ":" + this.#neptuneRunnerId] !== undefined)
delete global.NeptuneRunnerIPC._events['notify-client_' + this.clientId + ":" + this.#neptuneRunnerId];
if (global.NeptuneRunnerIPC._events['notify-client_' + this.clientId + ":" + this.#neptuneRunnerId] === undefined) {
global.NeptuneRunnerIPC.once('notify-client_' + this.clientId + ":" + this.#neptuneRunnerId, (data) => {
func(actuallyThis, data);
});
}
} catch (e) {
this.#log.error("Issue pushing notification via NeptuneRunner, error: " + e.message);
this.#log.debug(e);
pushNotification();
}
} else {
pushNotification();
}
}
/**
* Processes IPC activation (NeptuneRunner)
* @param {Notification} notification
* @param {PipeDataReceivedEventArgs} ipcData
*/
#IPCActivation(notification, ipcData) {
try {
let data = ipcData.toDictionary();
let actionString = data.Command == "notify-activated"? "activated" : "dismissed";
let dataPackage = {
action: actionString,
actionParameters: {
id: data.buttonId,
text: data.textboxText,
comboBoxChoice: data.comboBoxSelectedItem
}
}
if (ipcData.Command == "notify-activated") {
notification.activate(dataPackage);
} else if (ipcData.Command == "notify-dismissed") {
notification.dismiss(dataPackage);
} else if (ipcData.Command == "notify-error") {
this.#log.error("Failed to display a notification using NeptuneRunner, see console for more info.");
this.#log.error("Failed reason: " + ipcData["failureReason"] + " -- more details: " + ipcData["failureMoreDetails"], false);
error(); // backup methods
}
} catch (e) {
console.error(e);
//this.#log.error("Error on processing IPC activation data, check log for details.");
//this.#log.error(e, false);
}
}
/**
* Called when the notification failed to be displayed.
*
* Utilizes the TrayIcon we created for MainWindow to send out a balloon tooltip icon.
*
*/
error() {
// do some check to whether or not we actually have to do this
if (global.Neptune.sendNotification !== undefined && typeof global.Neptune.sendNotification === "function") {
let text = this.data.contents.text + this.data.contents.subtext
if (text === undefined)
text = " "
if (text.length == 0)
text = " "
let maybeThis = this;
global.Neptune.sendNotification(this.#client.friendlyName + ": " + this.data.title, text, 5000, () => {
maybeThis.activate({
action: "activated",
actionParameters: {}
});
});
}
}
/**
* @typedef {object} NotificationActionParameters
* @property {string} [id] - Id of the button clicked
* @property {string} [text] - Optional text input (if action is a text box)
* @property {string} [comboBoxChoice] - Optional selected choice of the combo box.
*/
/**
* @typedef {object} UpdateNotificationData
* @property {string} action - Activated or dismissed.
* @property {NotificationActionParameters} [actionParameters] - Data related to user input.
*/
/**
* The notification was activated (clicked). Causes class to emit 'activate'
* @param {UpdateNotificationData} [data] - User input from the notification
*/
activate(data) {
this.#log.info("Activated!");
this.#log.info(data, false);
this.emit("activate", data);
}
/**
* Tells the client to dismiss this notification.
*/
dismiss() {
// Simulate a dismiss (swiped away)
this.#log.info("Dismissed!");
this.emit("dismissed");
}
/**
* Deletes the notification from this computer.
*/
delete() {
//?
this.emit('dismissed');
if (process.platform === "win32") {
global.NeptuneRunnerIPC.sendData('notify-delete', {
clientId: this.#client.clientId,
id: this.data.notificationId,
})
} else {
if (!this.#notifierNotification !== undefined) {
this.#notifierNotification.close();
}
}
}
/**
* Updates the notification data and the toast notification on the OS side.
* @param {NotificationData} data - The notification data provided by the client
*/
update(data) {
this.#log.debug("Updating notification..");
this.#log.silly(data);
this.data = data;
this.#id = data.notificationId;
this.push();
}
}
module.exports = Notification;