#!/bin/env node const fs = require('fs'); const path = require("node:path"); const child_process = require("node:child_process"); const constants = require("node:constants"); const os = require('os'); /** * @typedef {{ * message: string, * summary: string, * expire: number, * printId: boolean, * replaceid: number, * urgency: "low"|"normal"|"critical" * }} Notification * * @typedef {{ * replace(notification: Partial), * remove() * }} ReplyNotification */ class NotificationHandler { static #NOTIFICATION_COMMAND = 'notify-send -a "Remap Orchestrator" -i "/usr/share/icons/Gruvbox-Material-Dark/64x64/devices/input-keyboard.svg" -c "system,remapping"' static NOTIFICATION_MAX_EXPIRE = 2**16; /** * @param {Partial} notification * @return {string} */ #createNotificationCommand(notification) { const command = [NotificationHandler.#NOTIFICATION_COMMAND]; if (notification.expire) { command.push(`-t ${notification.expire}`); } if (notification.printId ?? false) { command.push("-p"); } if (notification.replaceid) { command.push(`-r ${notification.replaceid}`); } if (notification.urgency) { command.push(`-u ${notification.urgency}`) } command.push(`"${notification.summary ?? ''}"`); command.push(`"${notification.message ?? ''}"`); return command.join(' '); } /** * @param {Partial} notification * @param {boolean} sync * @returns {Buffer|ChildProcess} */ sendNotification(notification, sync = false) { const command = this.#createNotificationCommand({ expire: 5_000, ...notification }) if (sync) { return child_process.execSync(command); } return child_process.exec(command); } /** * @param {Partial} notification * @returns {ReplyNotification} */ sendReplaceableNotification(notification) { const id = parseInt(this.sendNotification({ ...notification, printId: true, expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE }, true).toString()) const sendNotification = this.sendNotification.bind(this); return { /** * @param {Partial} notification */ replace(notification) { sendNotification({ ...notification, replaceid: id }) }, remove() { sendNotification( { replaceid: id, expire: 1 } ); } } } } /** * @typedef {"DEBUG"|"INFO"|"WARN"|"ERROR"|"CRITICAL"} LogType * @typedef {"ORCHESTRATOR"|string} LogOrigin * @typedef {"CHATTY"|"INFORMATIONAL"|"DEFAULT"|"ERRORSONLY"} VerboseMode */ class Logger { /** * @type {Map} */ static SHOULD_SEND_MAP = new Map( [ [ 'CHATTY', ['DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'] ], [ 'INFORMATIONAL', ['INFO', 'WARN', 'ERROR', 'CRITICAL'] ], [ 'DEFAULT', ['WARN', 'ERROR', 'CRITICAL'] ], [ 'ERRORSONLY', ['ERROR', 'CRITICAL'] ] ] ) /** @type {VerboseMode} */ #verboseMode; /** * @param {VerboseMode} verboseMode */ constructor(verboseMode) { this.#verboseMode = verboseMode; } /** * @param {string} message * @param {LogOrigin} origin * @param {LogType} type * @param {Date|null} date */ log( message, origin = "ORCHESTRATOR", type = "INFO", date = null ) { if (!this.#shouldSend(type)) { return; } const method = this.#getMethod(type); if (!date) { date = new Date(); } const prefix = `[${date.toISOString()}] ${origin}:${type}>`; const content = this.#getMessageLines(prefix, message); method(content); } /** * @param {LogType} type */ #getMethod(type) { switch (type) { case "DEBUG": return console.debug; default: case "INFO": return console.info; case "WARN": return console.warn; case "ERROR": case "CRITICAL": return console.error; } } /** * @param {string} prefix * @param {string} message */ #getMessageLines(prefix, message) { return message.split(os.EOL) .filter(line => line) .map((line, index) => `${prefix} ${index > 0 ? ' ' : ''}${line}`) .join(os.EOL); } /** * @param {LogType} type * @returns {boolean} */ #shouldSend(type) { return Logger.SHOULD_SEND_MAP.get(this.#verboseMode).includes(type); } } /** * @typedef {"waiting"|"running"|"failed"|"destroy"|"invalid"} FileContainerState * * @typedef {{ * filepath: Path, * device: DeviceName, * process?: ChildProcess, * state: FileContainerState * }} FileContainer * * @typedef {string} Path * @typedef {string} DeviceName **/ class Orchestrator { /** @type string */ static REMAP_COMMAND_TEMPLATE = 'evremap remap {file}'; static NOTIFICATION_STARTING = `Starting remap for {device}...` static NOTIFICATION_READY = 'Remap for {device} ready' static NOTIFICATION_CONNECTION_LOST = 'Lost connection to {device}...' static NOTIFICATION_GENERIC_PROCESS_STOP = "Remap process for '{device}' was requested to stop."; static NOTIFICATION_MISSING_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a missing file."; static NOTIFICATION_INVALID_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a invalid file."; /** * @type {Map} */ #fileMap /** * @type Set */ #devices /** * @type {string} */ #path /** @type {NotificationHandler} */ #notifications /** @type {Logger} */ #logger /** @type {'waiting'|'running'|'stopping'|'stopped'|'failed'} */ #state constructor( path, logger ) { this.#path = path this.#fileMap = new Map(); this.#devices = new Set(); this.#notifications = new NotificationHandler(); this.#logger = logger; this.#state = 'waiting'; } async run() { if (!this.#hasRightToRun()) { this.#logger.log("Can not run orchestrator!", "ORCHESTRATOR", "CRITICAL") this.#notifications.sendNotification({ summary: "Unable to run Remap Orchestrator", message: "Evremap requires additional rights from the user to run. Please see the evremap GitHub page under 'Running it' to learn how to set the rights.", expire: 20_000 }) this.#state = 'failed'; return; } this.#state = 'running'; do { const prepareSteps = [ this.#updateFileMap(), this.#updateDevices() ]; await Promise.all(prepareSteps); this.#actFiles() await new Promise(resolve => setTimeout(resolve, 1000)); } while(this.#state === 'running'); this.#state = 'stopped'; } stop() { if (['waiting', 'failed', 'stopping'].includes(this.#state)) { return; } this.#state = 'stopping'; this.#fileMap.values().forEach((file) => { this.#destroyProcess(file, 'GENERIC'); }) } async #updateFileMap() { const directory = await fs.promises.opendir(this.#path); const availableFiles = []; let directoryEntry; while (directoryEntry = await directory.read()) { if (directoryEntry.name.includes('##')) { // YADM alternative files. We want only the resulting file. continue; } if (!directoryEntry.name.endsWith('.toml')) { // evremap files ends with toml continue; } const filePath = path.resolve(directoryEntry.parentPath, directoryEntry.name); availableFiles.push(filePath); if (this.#fileMap.has(filePath)) { continue; } this.#fileMap.set(filePath, this.#createFileContainer(filePath)); } await directory.close(); this.#fileMap.values() .filter(fileContainer => !availableFiles.includes(fileContainer.filepath)) .forEach(value => { this.#fileMap.set(value.filepath, { ...value, state: "destroy" }) this.#logger.log( `Marked ${value.filepath} as 'destroy' since file doesn't exist anymore...`, "ORCHESTRATOR", 'DEBUG' ); }) this.#checkDeviceDuplicates(); } /** * @param {Path} path * @returns {FileContainer} */ #createFileContainer(path) { const grepResult = child_process.execSync(`grep device_name "${path}"`).toString(); const device = grepResult.split('=')[1].replaceAll('"', '').trim(); this.#logger.log( `Create file watcher for ${device} with '${path}'`, "ORCHESTRATOR", "DEBUG" ) return { filepath: path, state: "waiting", device: device } } #checkDeviceDuplicates() { /** * @type {Map} */ const deviceMap = new Map(); for (let value of this.#fileMap.values()) { if (value.state === 'destroy') { continue; } if (!deviceMap.has(value.device)) { deviceMap.set(value.device, []) } deviceMap.get(value.device).push(value); } const duplicates = deviceMap.values().filter(devices => devices.some(device => device.state !== 'invalid') && devices.length > 1); for (let duplicate of duplicates) { const container = duplicate[0]; this.#notifications.sendNotification( { summary: "Duplicate remap files for device", message: `The following device ${container.device} has multiple remap files. This is not possible. Please make sure only one is avaliable. Marking all files as invalid and unloading currently loaded files... Files: ${duplicate.map((file) => `- ${file.filepath}`).join('\n')}`, expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE } ) duplicate.forEach((file) => { this.#fileMap.set(file.filepath, { ...file, state: "invalid" }); }) this.#logger.log( `Marking device ${container.device} invalid due to multiple files. Files: ${duplicate.map((file) => `- ${file.filepath}`).join('\n')}\` `, "ORCHESTRATOR", "WARN" ); } const previousDuplicates = deviceMap.values().filter(devices => devices[0].state === 'invalid' && devices.length < 2); previousDuplicates.forEach((previousDuplicate) => { const duplicate = previousDuplicate[0]; this.#fileMap.set(duplicate.filepath, { ...duplicate, state: 'waiting' }) this.#logger.log( `==> Marking device ${duplicate.device} as valid, due to having only one file`, 'ORCHESTRATOR', 'INFO' ); }) } async #updateDevices() { /** * @var {string} */ const evremapResult = await new Promise((resolve, reject) => { child_process.exec('evremap list-devices | grep Name:', (error, stdout, stderr) => { if (error) { reject(error); return; } resolve(stdout); }) }) /** * @type {DeviceName[]} */ const devices = evremapResult.split('\n').map((result) => result.replace('Name: ', '')) this.#devices = new Set(devices); } #actFiles() { for (let file of this.#fileMap.values()) { switch (file.state) { case "running": continue; case "failed": case "waiting": this.#runProcess(file); break; case "destroy": this.#fileMap.delete(file.filepath); this.#destroyProcess(file, 'REMOVED_FILE'); break; case 'invalid': this.#destroyProcess(file, 'INVALID_FILE'); break; } } } s /** * @param {FileContainer} file */ #runProcess(file) { if (!this.#devices.has(file.device)) { return; } const message = this.#notifications.sendReplaceableNotification({ summary: "Please wait...", message: Orchestrator.NOTIFICATION_STARTING.replace('{device}', file.device), urgency: "critical", }); this.#logger.log( `Started remap for device '${file.device}' with file '${file.filepath}'`, 'ORCHESTRATOR', 'INFO' ); const command = Orchestrator.REMAP_COMMAND_TEMPLATE.replace('{file}', file.filepath) const process = child_process.exec(command); process.stderr.on('data', (data) => { data = data.toString(); if (data.includes("Going into read loop")) { message.replace({ summary: "Device ready", message: Orchestrator.NOTIFICATION_READY.replace("{device}", file.device), expire: 5_000, urgency: 'normal' }) } const [dataMetadata, dataMessage] = data.split('] '); if (!dataMessage) { this.#logger.log( dataMetadata, `EVREMAP[${file.device}]`, 'WARN', ) return; } const [time, type, origin ] = dataMetadata.replace('[', '').split(' ').filter(value => value); this.#logger.log( dataMessage.trim(), origin.toUpperCase().replace('EVREMAP', `EVREMAP[${file.device}]`), type, new Date(Date.parse(time)) ) }) process.on('exit', (code, signal) => { const unexpected = code === null || code === 0; this.#logger.log( `Device '${file.device}' ${unexpected ? "unexpectedly" : ''} left with code '${code}'.`, `EVREMAP[${file.device}]`, unexpected ? 'WARN' : 'INFO' ) if (unexpected) { return } message.replace({ summary: "Device left unexpected...", message: Orchestrator.NOTIFICATION_CONNECTION_LOST.replace('{device}', file.device) }) this.#fileMap.set(file.filepath, { ...file, state: 'failed' }) }) this.#fileMap.set(file.filepath, { ...file, process: process, state: 'running' }) } /** * @param {FileContainer} file * @param {"GENERIC", "REMOVED_FILE", "INVALID_FILE"} reason */ #destroyProcess(file, reason) { if (!file.process) { return; } if (file.process.exitCode !== null || file.process.killed) { return; } let notificationMessage = ''; let logMessage = ''; switch (reason) { default: case "GENERIC": notificationMessage = Orchestrator.NOTIFICATION_GENERIC_PROCESS_STOP.replace('{device}', file.device) logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to unknown reason` break; case "REMOVED_FILE": notificationMessage = Orchestrator.NOTIFICATION_MISSING_FILE_PROCESS_STOP.replace('{device}', file.device) logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to a removed file` break; case "INVALID_FILE": notificationMessage = Orchestrator.NOTIFICATION_INVALID_FILE_PROCESS_STOP.replace('{device}', file.device) logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to a invalid file` break; } this.#logger.log( logMessage, 'ORCHESTRATOR', 'INFO' ) this.#notifications.sendNotification({ summary: "Stopping process", message: notificationMessage, }) file.process.kill("SIGTERM"); } #hasRightToRun() { try { const result = child_process.execSync('evremap list-devices').toString(); return !result.includes('os error 13'); } catch (e) { this.#logger.log( e.toString(), 'ORCHESTRATOR', 'ERROR' ) return false; } } } /** * @param {Orchestrator} orchestrator */ function prepareProcess(orchestrator) { const stopMethod = orchestrator.stop.bind(orchestrator); process.on('SIGTERM', stopMethod); process.on('SIGINT', stopMethod); process.on("exit", stopMethod); } const evremapPath = path.resolve(process.env.HOME, '.config/evremap'); const logger = new Logger("CHATTY"); const orchestrator = new Orchestrator(evremapPath, logger); prepareProcess(orchestrator); orchestrator.run()