Classes/Client.js

/**
 *      _  _ 
 *     | \| |
 *     | .` |
 *     |_|\_|eptune
 *
 * 		Capstone Project 2022
 * 
 * 		The client device object for server (client being the other guy)
 */

/** Connection manager */
const ConnectionManager = require('./ConnectionManager.js');
const NotificationManager = require('./NotificationManager.js');
const IPAddress = require('./IPAddress.js');
const Notification = require('./Notification.js');
const crypto = require("node:crypto")
const fs = require("node:fs");


const NepConfig = require('./NeptuneConfig.js');

/** @type {NepConfig} */
var NeptuneConfig = global.Neptune.config;


const EventEmitter = require("node:events");


const ClientConfig = require('./ClientConfig.js');
const Clipboard = require('./Clipboard.js');
const path = require('node:path');


/**
 * Represents a client device
 */
class Client extends ClientConfig {
	/** @type {ConnectionManager} */
	#connectionManager;

	/** @type {NotificationManager} */
	#notificationManager;


	// Temp
	#secret;



	// Not config items, but related to the client
	/**
	 * Current battery level
	 * @type {number}
	 */
	batteryLevel;
	/**
	 * Battery temperature reported by the client in terms of degrees C
	 * @type {number}
	 */
	batteryTemperature;
	/**
	 * Charger type reported by the client (ac, discharging, etc)
	 * @type {string}
	 */
	batteryChargerType;

	/**
	 * Battery charge time remaining reported by the client in seconds.
	 * @type {number}
	 */
	batteryTimeRemaining;


	/**
	 * Event emitter
	 * @type {EventEmitter}
	 */
	eventEmitter;


	/**
	 * Whether this client has connected and is connected.
	 */
	get isConnected() {
		if (this.#connectionManager === undefined)
			return false;
		if (this.#connectionManager.hasNegotiated)
			return true; // replace by a ping that is successful
	}



	/**
	 * @typedef {object} constructorData
	 * @property {IPAddress} IPAddress 
	 * @property {string} clientId Unique Id of the device (provided by the device)
	 * @property {string} friendlyName Friendly name of the device
	 * @property {Date} dateAdded If null, current date time
	 * @property {string} [pairId] Id representing the pair between the devices
	 * @property {string} [pairKey] Shared secret between the two devices
	 */


	/**
	 * Initialize a new Client from the configuration file. If the file does not exist it'll be created.
	 * 
	 * Any deviations will error out.
	 * @param {ConfigurationManager} configManager ConfigurationManager instance
	 * @param {string} clientId Unique id of the client (this will be used as a part of the config file name)
	 */
	constructor(configurationManager, clientId) {
		NeptuneConfig = global.Neptune.config;
		super(configurationManager, configurationManager.configDirectory + NeptuneConfig.clientDirectory + "client_" + clientId + ".json")
		this.clientId = clientId;
		this.#notificationManager = new NotificationManager(this);
		this.log = global.Neptune.logMan.getLogger("Client-" + clientId);

		this.eventEmitter = new EventEmitter();
	}

	/**
	 * Called after a socket has been opened with this client
	 * @param {Buffer} secret - Shared secret key
	 * @param {object} miscData - Misc data, such as the createdAt date
	 */
	setupConnectionManager(secret, miscData) {
		if (this.#connectionManager !== undefined) {
			try {
				this.#connectionManager.removeAllListeners();
				this.#connectionManager.destroy(); // Close that one!
			} catch (e) {}
			finally {
				this.#connectionManager = undefined;
			}
		}
		
		this.#connectionManager = new ConnectionManager(this, secret, miscData);
		this.#secret = crypto.createHash('sha256').update(secret).digest('hex');

		this.log.debug("Connection manager setup successful, listening for commands.");

		this.#connectionManager.hasNegotiated = true;

		this.#connectionManager.on('command', (command, data) => {
			this.#handleCommand(command, data);
		});

		this.#connectionManager.on('websocket_connected', () => {
			this.eventEmitter.emit("websocket_connected");
		});
		this.#connectionManager.on('websocket_disconnected', () => {
			this.eventEmitter.emit("websocket_disconnected");
		});
		this.#connectionManager.on('paired', () => {
			this.eventEmitter.emit('paired');
		});

		this.eventEmitter.emit("connected");
	}

	/**
	 * Request handler
	 * @param {string} command
	 * @param {object} data
	 */
	#handleCommand(command, data) {
		if (command === undefined)
			return;
		command = command.toLowerCase();

		if (command == "/api/v1/echo") {
			this.sendRequest("/api/v1/echoed", data);

		} else if (command == "/api/v1/server/ping") {
			let receivedAt = new Date(data["timestamp"]);
			let receivedAtUTC = new Date(receivedAt.getTime() + receivedAt.getTimezoneOffset() * 60000); // Adjusted to UTC
			let now = new Date();
			let nowUTC = new Date(now.getTime() + now.getTimezoneOffset() * 60000);

			this.sendRequest("/api/v1/client/pong", {
				receivedAt: data["timestamp"],
				timestamp: now.toISOString() // becomes UTC
			});
			let elapsedTime = nowUTC.getTime() - receivedAtUTC.getTime();
			this.log.debug("Ping response time ~~: " + elapsedTime  + "ms");

		} else if (command == "/api/v1/server/pong") {
			let receivedAt = new Date(data["receivedAt"]); // time we sent the PING
			let receivedAtUTC = new Date(receivedAt.getTime() + receivedAt.getTimezoneOffset() * 60000); // Adjusted to UTC
			let timestamp = new Date(data["timestamp"]); // time client received the PING

			let now = new Date();
			let nowUTC = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
			let elapsedTime = nowUTC.getTime() - receivedAtUTC.getTime();
			//let elapsedTime = (Math.random() * (0.1 - 0.001) + 0.001).toFixed(3);

			this.log.debug("Pong response time: " + elapsedTime + "ms");
			this.#connectionManager.emit('pong', {
				receivedAt: receivedAt, // Time WE sent the ping request
				timestamp: timestamp, // Time CLIENT replied
				timeNow: now, // NOW
				totalTime: elapsedTime/2, // One-way time (but actually RTT)
				RTT: elapsedTime, // Round trip time
			});

		} else if (command == "/api/v1/server/unpair") {
			this.log.debug("Unpair request from client, dropping");
			this.pairId = "";
			this.pairKey = "";
			this.delete();
			this.sendRequest("/api/v1/server/unpair", {});

		}  else if (command == "/api/v1/server/disconnect") {
			this.#connectionManager.disconnect();

		} else if (command == "/api/v1/client/battery/info") {
			if (data["level"] !== undefined)
				this.batteryLevel = data["level"]
			if (data["temperature"] !== undefined)
				this.batteryTemperature = data["temperature"]
			if (data["chargerType"] !== undefined)
				this.batteryChargerType = data["chargerType"]
			if (data["batteryTimeRemaining"] !== undefined)
				this.batteryTimeRemaining = data["batteryTimeRemaining"]

			// let timeRemainingMsg = (this.batteryTimeRemaining !== undefined)? ", " + this.batteryTimeRemaining/60 + " minutes until full" : "";
			let timeRemainingMsg = "";
			let chargeMessage = (this.batteryChargerType!="discharging")? "charging via " + this.batteryChargerType + timeRemainingMsg : "discharging";
			this.log.debug("Received battery data from client. Client is at " + this.batteryLevel + "% and is " + chargeMessage + ". Temperature: " + this.batteryTemperature);

			this.eventEmitter.emit("configuration_update");
			this.sendRequest("/api/v1/server/ack", {});


		} else if (command == "/api/v1/server/notifications/send" || command == "/api/v1/server/notifications/update") {
			if (this.notificationSettings.enabled !== true)
				return;

			if (Array.isArray(data)) {
				for (var i = 0; i<data.length; i++) {
					this.receiveNotification(new Notification(this, data[i]));
				}
			} else {
				// invalid data.. should be an array but whatever
				this.receiveNotification(new Notification(this, data));
			}
			this.sendRequest("/api/v1/server/ack", {});

		// Configuration
		} else if (command == "/api/v1/server/configuration/set" || command == "/api/v1/client/configuration/data") {
			this.log.silly("Updating client config using: " + data);
			
			// Set things we care to update
			if (typeof data["notificationSettings"] === "object") {
				this.notificationSettings.enabled = (data["notificationSettings"].enabled === false)? false : true;
			}

			// Clipboard
			if (typeof data["clipboardSettings"] === "object") {
				if (data["clipboardSettings"].enabled === false) // Only allow client to disable it
					this.clipboardSettings.enabled = false;

				if (typeof data["clipboardSettings"].synchronizeClipboardToServer === "boolean") // For parity
					this.clipboardSettings.synchronizeClipboardToServer = data["clipboardSettings"].synchronizeClipboardToServer;
			}

			if (typeof data["fileSharingSettings"] === "object") {
				if (data["fileSharingSettings"].enabled === false)
					this.fileSharingSettings.enabled = false;
			}

			if (typeof data["friendlyName"] === "string")
				this.friendlyName = data["friendlyName"];

			this.save();
			this.eventEmitter.emit("configuration_update");

			this.sendRequest("/api/v1/server/ack", {});

			//this.fromJSON(data);
		} else if (command == "/api/v1/server/configuration/get") {
			this.syncConfiguration(true);


		// Clipboard
		} else if (command == "/api/v1/server/clipboard/set" || command == "/api/v1/client/clipboard/data") {
			let isReponse = command === "/api/v1/client/clipboard/data"; // Response from client, that is

			if (this.clipboardSettings.enabled) {
				if (this.clipboardSettings.allowClientToSet || isReponse) { // Allowed to set?
					this.clipboardModificationsLocked = true; // DO NOT SEND WHAT WE JUST GOT! RACE CONDITION!
					let maybeThis = this;
					setTimeout(() => { maybeThis.clipboardModificationsLocked = false }, 1000);

					this.log.silly(data);
					Clipboard.setStandardizedClipboardData(data).then((success) => {
						if (success)
							this.sendRequest("/api/v1/server/clipboard/uploadStatus", { status: "okay" });
						else
							this.sendRequest("/api/v1/server/clipboard/uploadStatus", { status: "failed" });
					}).catch((err) => {
						this.log.error("Error retrieving clipboard data using Clipboard class. Falling back to QT?")
						this.log.debug(err);

						if (!isReponse) {
							this.sendRequest("/api/v1/server/clipboard/uploadStatus", {
								status: "failed",
								errorMessage: "Unknown server error"
							});
						}
					});
				} else {
					if (!isReponse)
						this.sendRequest("/api/v1/server/clipboard/uploadStatus", { status: "setBlocked" });
				}
			} else {
				if (!isReponse)
					this.sendRequest("/api/v1/server/clipboard/uploadStatus", { status: "clipboardSharingOff" });
			}

		} else if (command == "/api/v1/server/clipboard/get") {
			if (this.clipboardSettings.enabled) {
				if (this.clipboardSettings.allowClientToGet) {
					this.log.debug("Client requested clipboard data, sending.");
					this.sendClipboard(true);
				} else
					this.sendRequest("/api/v1/server/clipboard/data", { status: "getBlocked" });
			} else
				this.sendRequest("/api/v1/server/clipboard/data", { status: "clipboardSharingOff" });
		} else if (command == "/api/v1/client/clipboard/uploadStatus") {
			this.clipboardModificationsLocked = false;

		} else if (command == "/api/v1/server/filesharing/upload/newfileuuid") {
			// Client is uploading a file.
			if (this.fileSharingSettings.enabled && this.fileSharingSettings.allowClientToUpload) {
				let saveToDirectory = this.getReceivedFilesDirectory();
				let fileUUIDPackage = global.Neptune.filesharing.newClientUpload(this, data["filename"], saveToDirectory);
				if (fileUUIDPackage === undefined) {
					this.log.error("Unable to accept incoming file " + data["filename"]);
					return;
				}
				
				this.log.info("Client request file upload, new fileUUID: " + fileUUIDPackage.fileUUID);
				this.sendRequest("/api/v1/server/filesharing/upload/fileUUID", {
					fileUUID: fileUUIDPackage.fileUUID,
					requestId: data.requestId,
					authenticationCode: fileUUIDPackage.authenticationCode,
				});
			}

		}
	}

	getReceivedFilesDirectory() {
		let saveToDirectory = this.fileSharingSettings.receivedFilesDirectory;
		if (saveToDirectory === undefined || !fs.existsSync(saveToDirectory)) {
			this.log.debug("Received files directory not set or does not exist, using ./data/receivedFiles/");
			saveToDirectory = "./data/receivedFiles/";
		} else {
			try {
				// Check if the path points to a valid directory
				let stats = fs.statSync(saveToDirectory);
				if (!stats.isDirectory()) {
					this.log.debug("Received files directory exists, but it's not actually a directory (?), using ./data/receivedFiles/");
					saveToDirectory = "./data/receivedFiles/";
				}
			} catch (_) {
				saveToDirectory = "./data/receivedFiles/";
			}
		}


		this.log.debug("Received files directory: " + saveToDirectory);
		return saveToDirectory;
	}

	setupConnectionManagerWebsocket(webSocket) {
		this.#connectionManager.setWebsocket(webSocket);
	}
	processHTTPRequest(data, callback) { // ehh
		this.#connectionManager.processHTTPRequest(data, callback);
	}
	/**
	 * Send a request (or response) to the client. (connectionManager.sendRequest)
	 * 
	 * @param {string} command - The client endpoint to send to.
	 * @param {object} data - Data to send (will be converted to Json).
	 */
	sendRequest(command, data) { // Refine later
		if (this.#connectionManager !== undefined) {
			if (this.#connectionManager.initiateConnection()) {
				return this.#connectionManager.sendRequest(command, data);
			}
		}
	}


	/**
	 * Returns the conInitUUID
	 * @return {string}
	 */
	getConInitUUID() {
		return this.#connectionManager.getConInitUUID();
	}
	/**
	 * Returns the socketUUID
	 * @return {string}
	 */
	getSocketUUID() {
		return this.#connectionManager.getSocketUUID();
	}

	getConnectionManager() {
		return this.#connectionManager;
	}


	/**
	 * For connection manager
	 * 
	 */
	getSecret() {
		return this.#secret;
	}


	/**
	 * @typedef {object} NotificationActionParameters
	 * @property {string} id - Id of the action
	 * @property {string} [text] - Optional text input (if action is a text box)
	 */
	/**
	 * @typedef {object} UpdateNotificationData
	 * @property {string} action - Activated or dismissed.
	 * @property {NotificationActionParameters} [actionParameters] - Data related to user input.
	 */

	/**
	 * This will send the Notification ??
	 * @param {Notification} notification - Notification to update
	 * @param {UpdateNotificationData} metaData - Actions preformed on notification
	 * @return {boolean}
	 */
	updateNotification(notification, metaData) {
		this.log.silly("Updating notification: " + notification.id);

		// not sure to be honest
		if (!(notification instanceof Notification))
			throw new TypeError("notification expected instance of Notification got " + (typeof notification).toString());

		if (metaData === undefined) {
			metaData = {
				action: "dismiss",
				actionParameters: {}
			}
		}

		let notificationActionData = {
			applicationName: notification.data.applicationName,
			applicationPackage: notification.data.applicationPackage,
			notificationId: notification.data.notificationId,
			action: metaData.action === undefined? "dismiss" : metaData.action,
			actionParameters: metaData.actionParameters,
		};

		this.sendRequest("/api/v1/client/notifications/update", notificationActionData)
	}

	/**
	 * Send out clipboard over to the client.
	 * @return {void}
	 */
	sendClipboard(isResponse) {
		let apiUrl = "/api/v1/client/clipboard/set"
		if (isResponse)
			apiUrl = "/api/v1/server/clipboard/data";

		if (this.clipboardSettings.enabled) {
			if (this.clipboardSettings.allowClientToGet || isResponse) {
				Clipboard.getStandardizedClipboardData().then((clipboardData) => {
					this.sendRequest(apiUrl, {
						data: clipboardData,
						status: "okay",
					});
				}).catch((err) => {
					try {
						this.log.error("Error retrieving clipboard data using Clipboard class. Falling back to QT.")
						this.log.error(err);

						// let clipboard = NodeGUI.QApplication.clipboard();
						// let data = {};
						// let text = clipboard.text();
						// if (text != undefined && text != "") {
						// 	data.Text = "data:text/plain;base64, " + Buffer.from(text).toString('base64');
						// }
						// add picture support

						this.sendRequest("/api/v1/server/data", {
							data: data,
							status: "failed",
							errorMessage: "Main clipboard retrieval failed, using fallback method (simple data mode)."
						});
					} catch (simpleModeError) {
						this.log.error("Error retrieving clipboard data using QT.")
						this.log.error(err);
						this.sendRequest("/api/v1/server/data", {
							data: {},
							status: "failed",
							errorMessage: "Unknown server error."
						});
					} 
				});
			}
		}
	}

	/**
	 * Get the client's clipboard. Is should be noted, this only _requests_ the clipboard data. Clipboard data will be processed by the request handler.
	 * @return {void}
	 */
	getClipboard() {
		this.sendRequest("/api/v1/client/clipboard/get", {});
	}

	/**
	 * Get the client's battery info. Is should be noted, this only _requests_ the battery data. Battery data will be processed by the request handler.
	 * @return {void}
	 */
	getBattery() {
		this.sendRequest("/api/v1/client/battery/get", {});
	}

	/**
	 * Request that the client downloads a file.
	 * @param {string} filePath - File to send
	 * @return {boolean}
	 */
	sendFile(filePath) {
		let fileSharingObject = global.Neptune.filesharing.newClientDownload(this, filePath);

		if (fileSharingObject === undefined) {
			this.log.error("fileSharingObject is undefined, why? File path: " + filePath);
			throw new Error("file sharing object is undefined.");
		}

		this.log.info("Server request file download (-> client), new fileUUID: " + fileSharingObject.fileUUID);
		this.sendRequest("/api/v1/client/filesharing/receive", {
			fileUUID: fileSharingObject.fileUUID,
			authenticationCode: fileSharingObject.authenticationCode,
			fileName: path.basename(filePath),
		});
	}


	/**
	 * ConnectionManager received notification data, push this to the NotificationManager
	 * @param {Notification} notification Notification received
	 */
	receiveNotification(notification) {
		let maybeThis = this;
		notification.on("dismissed", (metaData) => {
			if (metaData === undefined) { metaData = {}; }
			metaData.action = "dismiss";
			maybeThis.updateNotification(notification, metaData);
		});
		notification.on('activate', (metaData) => {
			// activate the notification on the client device
			if (metaData === undefined) { metaData = {}; }
			metaData.action = "activate";
			maybeThis.updateNotification(notification, metaData);
		});
		this.#notificationManager.newNotification(notification);
		notification.push();
	}



	/**
	 * @typedef {object} PingData
	 * @property {Date} pingSentAt - Time we sent ping request.
	 * @property {Date} pongSentAt - Time client sent the pong response (questionable reliability).
	 * @property {Date} pongReceivedAt - Time we received the pong response.
	 * @property {number} pingTime - Time between sending the ping request and client receiving the ping in ms.
	 * @property {number} RTT - Total time to ping and receive a response (use this) in ms.
	 */

	/**
	 * Send ping packet, return delay
	 * @return {Promise<PingData>} Delay in MS
	 */
	ping() {
		return new Promise((resolve, reject) => {
			if (this.#connectionManager === undefined)
				resolve(0);

			this.#connectionManager.once('pong', (data) => {
				resolve({
					pingSentAt: data["receivedAt"],
					pongSentAt: data["timestamp"],
					pongReceivedAt: data["timeNow"],
					pingTime: data["totalTime"],
					RTT: data["RTT"]
				});
			});

			this.sendRequest('/api/v1/client/ping', {
				timestamp: new Date().toISOString(),
			});
		});
	}

	/**
	 * Send configuration settings to the client device. Sync the config.
	 * @param {boolean} [isResponse=false] - Whether this is in response to the get configuration request.
	 */
	syncConfiguration(isResponse) {
		let apiUrl = "/api/v1/client/configuration/set";
		if (isResponse)
			apiUrl = "/api/v1/server/configuration/data";

		this.sendRequest(apiUrl, {
			friendlyName: NeptuneConfig.friendlyName,
			notificationSettings: {
				enabled: this.notificationSettings.enabled
			},
			clipboardSettings: {
				enabled: this.clipboardSettings.enabled,
				allowClientToSet: this.clipboardSettings.allowClientToSet,
				allowClientToGet: this.clipboardSettings.allowClientToGet,
				synchronizeClipboardToClient: this.clipboardSettings.synchronizeClipboardToClient,
			},
			fileSharingSettings: {
				enabled: this.fileSharingSettings.enabled,
				allowClientToUpload: this.fileSharingSettings.allowClientToUpload,
				allowClientToDownload: this.fileSharingSettings.allowClientToDownload
			}
		});
	}

	/**
	 * This will unpair a client
	 * @return {boolean} True if unpair request sent, or false if already unpaired
	 */
	unpair() {
		if (this.isPaired) {
			try {
				this.log.info("Unpairing");
				if (this.#connectionManager != undefined)
					this.#connectionManager.unpair();
				this._pairId = undefined;
				this._pairKey = undefined;
			} finally {
				return true;
			}
		} else
			return false
	}

	destroyConnectionManager(force) {
		if (this.#connectionManager !== undefined)
			this.#connectionManager.destroy(force);
	}

	delete() {
		try { this.unpair(); } catch {}

		setTimeout(() => {
			try {
				this.destroyConnectionManager(true);
				if (this.#notificationManager != undefined)
					this.#notificationManager.destroy(true);
			} catch (err) {
				this.log.error(err, false);
			}
			finally {
				try {
					if (this.eventEmitter !== undefined)
						this.eventEmitter.emit('deleted');

					if (global.Neptune.clientManager !== undefined)
						global.Neptune.clientManager.dropClient(this);
				} catch {}

				super.delete();

				if (this.log !== undefined)
					this.log.warn("Deleted");
			}
		}, 500);
	}
}

module.exports = Client;