yadm-config/.config/evremap/orchestrator
2025-05-18 18:29:55 +02:00

774 lines
20 KiB
JavaScript
Executable file

#!/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<Notification>),
* 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>} 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>} 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>} 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>} 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<VerboseMode, LogType[]>}
*/
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);
}
}
class HyprlandSupport {
static HYPR_INPUT_CONFIG_FILE = path.join(process.env.HOME, '.config/hypr/config/settings/Input.conf')
static TARGET_CONFIG_FILE = path.join(process.env.HOME, '.cache/evremap_hyprland_config.conf')
#lastFile = '';
/**
* @returns {boolean}
*/
get isValid() {
return process.env.DESKTOP_SESSION === 'hyprland';
}
/**
* @param {FileContainer[]} activeFiles
*/
update(files) {
const configurations = this.#getConfigurations();
const activeFiles = files.filter((file) => file.state === 'running');
const activeConfigurations =
configurations.filter((config) =>
activeFiles.some((file) => {
const normalisedDeviceName = this.#getNormalizedDeviceName(file.device.name)
return config.name === normalisedDeviceName
})
)
this.#createFile(activeFiles, activeConfigurations)
}
#getConfigurations() {
const config = fs.readFileSync(HyprlandSupport.HYPR_INPUT_CONFIG_FILE, 'utf-8');
const lines = config.split('\n').filter(line => line);
const configurations = [];
let currentConfiguration;
let inDevice = false;
for (const line of lines) {
if (!inDevice) {
if (line === 'device {') {
inDevice = true;
currentConfiguration = {};
}
continue;
}
if (line === '}') {
inDevice = false;
configurations.push(currentConfiguration);
currentConfiguration = {};
continue;
}
const [ name, value ] = line.split('=');
currentConfiguration[name.trim()] = value.trim()
}
return configurations;
}
#createFile(files, configurations) {
let configFileContent = "";
for (const file of files) {
const configuration = configurations.find(
(config) => config.name === this.#getNormalizedDeviceName(file.device.name)
)
if (!configuration) {
continue
}
configuration.name = `evremap-virtual-input-for-${file.device.path}`;
const entry = Object.entries(configuration)
.map(([name, value]) => ` ${name} = ${value}`)
.join('\n');
configFileContent += `device {\n${entry}\n}\n`;
}
if (this.#lastFile === configFileContent) {
return;
}
this.#lastFile = configFileContent
fs.writeFileSync(HyprlandSupport.TARGET_CONFIG_FILE, configFileContent);
console.log("Writing new config")
}
/**
* @param {string} deviceName
* @returns {string}
*/
#getNormalizedDeviceName(deviceName) {
return deviceName.replaceAll(' ', '-').toLowerCase();
}
}
/**
* @typedef {"waiting"|"running"|"failed"|"destroy"|"invalid"} FileContainerState
*
* @typedef {{
* filepath: Path,
* device: Device,
* process?: ChildProcess,
* state: FileContainerState
* }} FileContainer
*
* @typedef {string} Path
* @typedef {string} DeviceName
*
* @typedef {{
* name: DeviceName
* path: string,
* physical: string,
* }} Device
**/
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<Path, FileContainer>}
*/
#fileMap
/**
* @type Set<Device>
*/
#devices
/**
* @type {string}
*/
#path
/** @type {NotificationHandler} */
#notifications
/** @type {Logger} */
#logger
/** @type {HyprlandSupport} */
#support;
/** @type {'waiting'|'running'|'stopping'|'stopped'|'failed'} */
#state
constructor(
path,
logger,
support
) {
this.#path = path
this.#fileMap = new Map();
this.#devices = new Set();
this.#notifications = new NotificationHandler();
this.#logger = logger;
this.#state = 'waiting';
this.#support = support;
}
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 {
await this.#updateDevices();
await this.#updateFileMap();
this.#actFiles()
if (this.#support.isValid) {
this.#support.update([...this.#fileMap.values()]);
}
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 deviceName = grepResult.split('=')[1].replaceAll('"', '').trim();
this.#logger.log(
`Create file watcher for ${deviceName} with '${path}'`,
"ORCHESTRATOR",
"DEBUG"
)
/**
* @type {Device}
*/
const selectedDevice = [...this.#devices.values()].find((device) => device.name === deviceName) ?? { name: deviceName }
return {
filepath: path,
state: "waiting",
device: selectedDevice
}
}
#checkDeviceDuplicates() {
/**
* @type {Map<DeviceName, FileContainer[]>}
*/
const deviceMap = new Map();
for (let value of this.#fileMap.values()) {
if (value.state === 'destroy') {
continue;
}
if (!deviceMap.has(value.device.name)) {
deviceMap.set(value.device.name, [])
}
deviceMap.get(value.device.name).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.name} 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.name} 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.name} 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', (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
})
})
const lines = evremapResult.split('\n').filter(line => line);
/**
* @type {Device[]}
*/
const devices = [];
for (let i = 0; i < lines.length; i += 3) {
const nameLine = lines[i];
const pathLine = lines[i + 1];
const physLine = lines[i + 2];
/**
* @type {Device}
*/
const device = {
name: nameLine.split(':')[1]?.trim() ?? '',
path: pathLine.split(':')[1]?.trim() ?? '',
physical: physLine.split(':')[1]?.trim() ?? ''
}
devices.push(device);
}
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.name),
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.name),
expire: 5_000,
urgency: 'normal'
})
}
const [dataMetadata, dataMessage] = data.split('] ');
if (!dataMessage) {
this.#logger.log(
dataMetadata,
`EVREMAP[${file.device.name}]`,
'WARN',
)
return;
}
const [time, type, origin ] = dataMetadata.replace('[', '').split(' ').filter(value => value);
this.#logger.log(
dataMessage.trim(),
origin.toUpperCase().replace('EVREMAP', `EVREMAP[${file.device.name}]`),
type,
new Date(Date.parse(time))
)
})
process.on('exit', (code, signal) => {
const unexpected = code === null || code === 0;
this.#logger.log(
`Device '${file.device.name}' ${unexpected ? "unexpectedly" : ''} left with code '${code}'.`,
`EVREMAP[${file.device.name}]`,
unexpected ? 'WARN' : 'INFO'
)
if (unexpected) {
return
}
message.replace({
summary: "Device left unexpected...",
message: Orchestrator.NOTIFICATION_CONNECTION_LOST.replace('{device}', file.device.name)
})
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.name)
logMessage = `Removing process for device '${file.device.name}' from file '${file.filepath}', due to unknown reason`
break;
case "REMOVED_FILE":
notificationMessage = Orchestrator.NOTIFICATION_MISSING_FILE_PROCESS_STOP.replace('{device}', file.device.name)
logMessage = `Removing process for device '${file.device.name}' from file '${file.filepath}', due to a removed file`
break;
case "INVALID_FILE":
notificationMessage = Orchestrator.NOTIFICATION_INVALID_FILE_PROCESS_STOP.replace('{device}', file.device.name)
logMessage = `Removing process for device '${file.device.name}' 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 support = new HyprlandSupport();
const orchestrator = new Orchestrator(evremapPath, logger, support);
prepareProcess(orchestrator);
orchestrator.run()