diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42a7f05
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+#Folders
+/node_modules/
+
+#Files
+/config.json
\ No newline at end of file
diff --git a/arduino_thread.js b/arduino_thread.js
new file mode 100644
index 0000000..b406ed6
--- /dev/null
+++ b/arduino_thread.js
@@ -0,0 +1,170 @@
+const { SerialPort } = require('serialport');
+const fs = require('fs');
+const EventEmitter = require('events');
+// add parser import
+const { ReadlineParser } = require('@serialport/parser-readline');
+
+class ArduinoThread extends EventEmitter {
+ constructor(comPort, baudRate = 9600, sliderCount = 3, yamlPath = 'config.json', verbose = false) {
+ super();
+
+ if (typeof comPort !== 'string') throw new Error('comPort must be a string');
+ if (typeof baudRate !== 'number') throw new Error('baudRate must be a number');
+ if (typeof sliderCount !== 'number') throw new Error('sliderCount must be a number');
+
+ this.port = new SerialPort({ path: comPort, baudRate, autoOpen: true });
+ this.sliderCount = sliderCount;
+ this.yamlPath = yamlPath; // now points to config.json by default
+ this.verbose = !!verbose;
+
+ this.sendBuffer = [];
+ this.updateList = Array(sliderCount).fill(0);
+ this.isRunning = true;
+ this.pendingMessage = null;
+
+ // pipe through a newline-based parser so we always get full lines
+ try {
+ this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\n' }));
+ this.parser.on('data', (line) => this._handleData(line));
+ } catch (err) {
+ // fallback to raw data event if parser unavailable
+ this.port.on('data', (data) => this._handleData(data));
+ }
+
+ // keep error/close handlers on the port
+ this.port.on('error', (err) => {
+ this.emit('error', err);
+ if (this.verbose) console.error('Serial error:', err);
+ });
+ this.port.on('close', () => {
+ this.isRunning = false;
+ if (this.verbose) console.log('Serial port closed');
+ });
+ }
+
+ _handleData(data) {
+ // accept string or Buffer; trim newline/whitespace
+ const input = (typeof data === 'string') ? data.trim() : data.toString().trim();
+
+ // acknowledgement handling
+ if (input === 'OK' && this.pendingMessage) {
+ if (this.verbose) console.log(`Message confirmed: ${this.pendingMessage}`);
+ this.pendingMessage = null;
+ // drain buffer if present
+ if (this.sendBuffer.length > 0) {
+ const next = this.sendBuffer.shift();
+ this._writeMessage(next);
+ }
+ return;
+ }
+
+ const numbers = input.split('|');
+ if (numbers.length === this.sliderCount) {
+ try {
+ const values = numbers.map((n) => parseInt(n, 10));
+ values.forEach((val, i) => {
+ if (!Number.isFinite(val)) throw new Error('parse error');
+ if (val !== this.updateList[i]) {
+ this._setVolume(val, i);
+ }
+ });
+ this.updateList = values;
+ this.emit('update', values); // notify listeners (GUI)
+ if (this.verbose) console.log('Slider update ->', values);
+ } catch (err) {
+ this.emit('warn', { msg: 'Invalid slider data', raw: input });
+ if (this.verbose) console.warn('Invalid slider data:', input);
+ }
+ } else {
+ // Not an update; emit for consumers
+ this.emit('raw', input);
+ if (this.verbose) console.warn('Unexpected input:', input);
+ }
+ }
+
+ send(message) {
+ if (!message || typeof message !== 'string') return;
+ if (this.pendingMessage) {
+ // queue if another message is awaiting ACK
+ this.sendBuffer.push(message);
+ return;
+ }
+ this._writeMessage(message);
+ }
+
+ _writeMessage(message) {
+ if (!this.port || !this.port.writable) {
+ const err = new Error('Serial port not writable');
+ this.emit('error', err);
+ if (this.verbose) console.error(err);
+ return;
+ }
+
+ this.pendingMessage = message;
+ this.port.write(message + '\n', (err) => {
+ if (err) {
+ this.emit('error', err);
+ if (this.verbose) console.error('Error writing:', err.message);
+ this.pendingMessage = null;
+ } else if (this.verbose) {
+ console.log('Wrote message:', message);
+ }
+ });
+
+ // fallback timeout if device never replies OK
+ setTimeout(() => {
+ if (this.pendingMessage) {
+ this.emit('warn', { msg: "Did not receive OK from device", message: this.pendingMessage });
+ if (this.verbose) console.warn('Did not receive OK from device for', this.pendingMessage);
+ this.pendingMessage = null;
+ // try to drain next queued message if any
+ if (this.sendBuffer.length > 0) {
+ const next = this.sendBuffer.shift();
+ this._writeMessage(next);
+ }
+ }
+ }, 100); // adjustable timeout
+ }
+
+ _setVolume(value, sliderNumber) {
+ const normalized = value / 1023;
+
+ if (!fs.existsSync(this.yamlPath)) {
+ this.emit('warn', { msg: 'Config file missing', path: this.yamlPath });
+ if (this.verbose) console.warn('Config file missing:', this.yamlPath);
+ return;
+ }
+
+ try {
+ const file = fs.readFileSync(this.yamlPath, 'utf8');
+ const config = JSON.parse(file || '{}'); // parse JSON config.json
+ const processes = config[sliderNumber] || [];
+
+ processes.forEach((proc) => {
+ // Emit an event so the application can perform the platform-specific change.
+ this.emit('set-volume', { process: proc, value: normalized, slider: sliderNumber });
+ if (this.verbose) {
+ console.log(`Would set volume for "${proc}" to ${normalized.toFixed(2)} (slider ${sliderNumber})`);
+ }
+ });
+ } catch (err) {
+ this.emit('error', err);
+ if (this.verbose) console.error('Config JSON error:', err);
+ }
+ }
+
+ stop() {
+ this.isRunning = false;
+ try {
+ if (this.port && this.port.isOpen) this.port.close();
+ } catch (e) {
+ if (this.verbose) console.warn('Error closing port', e);
+ }
+ }
+
+ getValues() {
+ return this.updateList.slice();
+ }
+}
+
+module.exports = ArduinoThread;
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..89ec91a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,543 @@
+
+
+
+
+
+ Dreckshub
+
+
+
+
+
+
+
+
+
+
+
+
+
Makropad — Main
+
Content for Makropad on the first tab.
+
+
+
+
+
Makropad Ghostwire — Main
+
Alternate content for Makropad Ghostwire on the first tab.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placeholder B
+ Content for placeholder B.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..4bb34cc
--- /dev/null
+++ b/main.js
@@ -0,0 +1,708 @@
+const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } = require("electron");
+const fs = require("fs");
+const path = require("path");
+const { exec } = require("child_process");
+const { execFile } = require("child_process");
+const { spawn } = require("child_process");
+
+// add ArduinoThread module and instance holder
+const ArduinoThread = require("./arduino_thread");
+let arduinoInstance = null;
+
+// NEW: persistent python server helpers (globals)
+let pythonServer = null;
+let pythonServerStdoutBuffer = "";
+let pythonServerReady = false;
+
+let mainWindow;
+let tray = null;
+let isQuiting = false;
+const tagsFilePath = path.join(__dirname, "config.json");
+
+// load a single app/tray icon to reuse (tray.png expected next to index.html/main.js)
+const iconPath = path.join(__dirname, "tray.png");
+let appIcon;
+try {
+ if (fs.existsSync(iconPath)) {
+ const img = nativeImage.createFromPath(iconPath);
+ // prefer a multi-size icon where possible; keep as-is if empty
+ appIcon = img && !img.isEmpty() ? img : undefined;
+ }
+} catch (e) {
+ appIcon = undefined;
+}
+
+function readConfig() {
+ try {
+ if (fs.existsSync(tagsFilePath)) {
+ return JSON.parse(fs.readFileSync(tagsFilePath, "utf8"));
+ }
+ } catch (err) {
+ console.error("readConfig error:", err);
+ }
+ return {};
+}
+
+function writeConfig(obj) {
+ try {
+ fs.writeFileSync(tagsFilePath, JSON.stringify(obj, null, 2));
+ } catch (err) {
+ console.error("writeConfig error:", err);
+ }
+}
+
+// helper to run a command and return stdout
+function execPromise(cmd) {
+ return new Promise((resolve, reject) => {
+ exec(cmd, { encoding: "utf8", windowsHide: true }, (err, stdout, stderr) => {
+ if (err) return reject(err);
+ resolve(stdout || "");
+ });
+ });
+}
+
+// --- NEW: tray helpers ---
+function createTray() {
+ try {
+ if (tray) return;
+ // use the shared appIcon if available
+ tray = new Tray(appIcon || undefined);
+ const contextMenu = Menu.buildFromTemplate([
+ {
+ label: "Show",
+ click: () => {
+ if (mainWindow) {
+ mainWindow.show();
+ mainWindow.focus();
+ }
+ }
+ },
+ {
+ label: "Quit",
+ click: () => {
+ // user explicitly wants to quit
+ isQuiting = true;
+ app.quit();
+ }
+ }
+ ]);
+ tray.setToolTip("Dreckshub");
+ tray.setContextMenu(contextMenu);
+ tray.on("double-click", () => {
+ if (mainWindow) {
+ mainWindow.show();
+ mainWindow.focus();
+ }
+ });
+ } catch (err) {
+ console.error("createTray failed:", err);
+ }
+}
+
+function destroyTray() {
+ try {
+ if (tray) {
+ tray.destroy();
+ tray = null;
+ }
+ } catch (err) {
+ console.error("destroyTray failed:", err);
+ }
+}
+
+/* Ensure tray state matches config.closeToTray */
+function updateTrayState(enable) {
+ if (enable) {
+ createTray();
+ } else {
+ destroyTray();
+ }
+}
+
+app.whenReady().then(() => {
+ // set macOS dock icon early so the app uses tray.png instead of default electron icon
+ if (process.platform === "darwin" && appIcon) {
+ try { app.dock && app.dock.setIcon && app.dock.setIcon(appIcon); } catch (err) { /* ignore */ }
+ }
+
+ // restore saved window bounds if present
+ const cfg = readConfig();
+ const wb = cfg.windowBounds || null;
+ const winOptions = {
+ // start with the typical fixed size used previously
+ width: 1466,
+ height: 642,
+ // prevent user resizing
+ resizable: false,
+ // set window/taskbar icon (Windows/Linux)
+ icon: appIcon || undefined,
+ webPreferences: {
+ preload: path.join(__dirname, "preload.js"),
+ },
+ autoHideMenuBar: true,
+ };
+
+ if (wb && typeof wb.width === "number" && typeof wb.height === "number") {
+ winOptions.width = wb.width;
+ winOptions.height = wb.height;
+ if (typeof wb.x === "number" && typeof wb.y === "number") {
+ winOptions.x = wb.x;
+ winOptions.y = wb.y;
+ }
+ }
+
+ mainWindow = new BrowserWindow(winOptions);
+ // window is non-resizable; no minimum size call needed
+
+ mainWindow.loadFile("index.html");
+
+ // after content loads, apply maximized state if saved
+ mainWindow.webContents.once("did-finish-load", () => {
+ try {
+ if (wb && wb.isMaximized) {
+ mainWindow.maximize();
+ }
+ } catch (err) {
+ console.error("apply maximize failed:", err);
+ }
+ });
+
+ // create tray if setting enabled in config
+ updateTrayState(Boolean(cfg && cfg.closeToTray));
+
+ // save bounds when window is closing
+ mainWindow.on("close", (e) => {
+ try {
+ // If close-to-tray is enabled and user didn't explicitly choose to quit, hide instead of closing
+ const cfgNow = readConfig();
+ const useTray = Boolean(cfgNow && cfgNow.closeToTray);
+ if (useTray && !isQuiting) {
+ e.preventDefault();
+ if (mainWindow) mainWindow.hide();
+ return;
+ }
+
+ // allow closing — save bounds as before
+ // getBounds returns the un-maximized bounds; good for restoring windowed state
+ const bounds = mainWindow.getBounds();
+ const isMax = mainWindow.isMaximized();
+ const curCfg = readConfig();
+ curCfg.windowBounds = {
+ x: bounds.x,
+ y: bounds.y,
+ width: bounds.width,
+ height: bounds.height,
+ isMaximized: isMax,
+ };
+ writeConfig(curCfg);
+ } catch (err) {
+ console.error("save window bounds failed:", err);
+ }
+ });
+
+ mainWindow.on("closed", () => {
+ mainWindow = null;
+ });
+});
+
+ipcMain.on("save-tags", (event, data) => {
+ // merge incoming tag data with existing config so windowBounds (and future fields) are preserved
+ try {
+ const cfg = readConfig() || {};
+ // If data is an array => legacy behavior for slider0
+ if (Array.isArray(data)) {
+ cfg.slider0 = data;
+ } else if (data && typeof data === "object") {
+ // copy all keys provided (slider0, slider1, slider2, sliderAssignments, etc.)
+ Object.keys(data).forEach((k) => {
+ cfg[k] = data[k];
+ });
+ }
+ writeConfig(cfg);
+
+ // react to closeToTray changes immediately
+ try {
+ updateTrayState(Boolean(cfg.closeToTray));
+ } catch (err) {
+ console.error("updateTrayState failed after save-tags:", err);
+ }
+ } catch (err) {
+ console.error("save-tags merge failed:", err);
+ }
+});
+
+ipcMain.handle("load-tags", () => {
+ const cfg = readConfig() || {};
+ // ensure defaults for all three sliders for backward compatibility
+ if (!Array.isArray(cfg.slider0)) cfg.slider0 = [];
+ if (!Array.isArray(cfg.slider1)) cfg.slider1 = [];
+ if (!Array.isArray(cfg.slider2)) cfg.slider2 = [];
+ return cfg;
+});
+
+ipcMain.handle("list-serial-ports", async () => {
+ try {
+ // Windows: prefer .NET SerialPort.GetPortNames() via PowerShell (reliable, returns COM names)
+ if (process.platform === "win32") {
+ try {
+ const psCmd = `powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames() | ForEach-Object { $_ }"`;
+ const out = await execPromise(psCmd);
+ const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
+ if (lines.length > 0) {
+ // Ensure unique, return objects with label=value
+ const uniq = Array.from(new Set(lines));
+ return uniq.map(p => ({ value: p, label: p }));
+ }
+
+ // Fallback 1: Get-PnpDevice friendly names containing (COMx)
+ const psFallback = `powershell -NoProfile -Command "Get-PnpDevice -Status OK | Where-Object { $_.FriendlyName -match '\\(COM\\d+\\)' -or $_.Name -match '\\(COM\\d+\\)' } | ForEach-Object { ($_.FriendlyName -or $_.Name) }"`;
+ const out2 = await execPromise(psFallback);
+ const lines2 = out2.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
+ const ports = [];
+ const re = /(COM\d+)/i;
+ for (const l of lines2) {
+ const m = l.match(re);
+ if (m) ports.push({ value: m[1], label: l });
+ }
+ if (ports.length > 0) {
+ // dedupe by value
+ const seen = new Set();
+ return ports.filter(p => {
+ if (seen.has(p.value)) return false;
+ seen.add(p.value);
+ return true;
+ });
+ }
+
+ // Fallback 2: 'mode' command parsing
+ const out3 = await execPromise("mode");
+ const lines3 = out3.split(/\r?\n/);
+ const reMode = /(COM\d+)/gi;
+ const portsMode = [];
+ for (const l of lines3) {
+ const m = l.match(reMode);
+ if (m) {
+ for (const token of m) portsMode.push({ value: token, label: token });
+ }
+ }
+ return portsMode;
+ } catch (err) {
+ console.error("list-serial-ports (win) primary/fallback failed:", err);
+ return [];
+ }
+ } else {
+ // macOS / Linux: scan common device files
+ try {
+ const out = await execPromise("ls /dev/tty.* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null || true");
+ const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
+ return lines.map(p => ({ value: p, label: p }));
+ } catch (err) {
+ console.error("list-serial-ports (unix) failed:", err);
+ return [];
+ }
+ }
+ } catch (err) {
+ console.error("list-serial-ports failed:", err);
+ return [];
+ }
+});
+
+// --- new IPC handlers to start/stop and forward serial updates ---
+// Ensure we don't double-register handlers if the file is reloaded in dev reloads:
+if (ipcMain.removeHandler) {
+ try { ipcMain.removeHandler("connect-com"); } catch (e) { /* ignore */ }
+}
+ipcMain.handle("connect-com", async (event, { port, baudRate = 9600 }) => {
+ try {
+ // if already connected, return info
+ if (arduinoInstance) {
+ return { ok: false, message: "Already connected" };
+ }
+ if (!port) return { ok: false, message: "No port specified" };
+
+ // create and wire instance (verbose disabled to avoid noisy slider logs)
+ arduinoInstance = new ArduinoThread(port, Number(baudRate) || 9600, 3, "config.json", false);
+
+ // Keep last normalized value per slider so we only act when the slider actually changed.
+ // Index = slider number (0..2). null means "unknown / not initialized".
+ let _lastSliderNormalized = [null, null, null];
+ const _minSliderDeltaForChange = 0.01; // 1% change required to be considered different
+
+ arduinoInstance.on("update", (values) => {
+ // forward to renderer
+ try {
+ if (mainWindow && mainWindow.webContents) {
+ mainWindow.webContents.send("serial-update", values);
+ }
+ } catch (e) { /* ignore */ }
+
+ // Also ensure the Python helper is invoked for each slider value.
+ // This is a fallback in case the ArduinoThread did not emit 'set-volume'.
+ try {
+ const cfg = readConfig() || {};
+ // values expected as integers (0..1023)
+ if (Array.isArray(values)) {
+ // ensure we have a session cache and refresher running
+ startSessionRefresher();
+ values.forEach((raw, idx) => {
+ const num = Number(raw);
+ if (!Number.isFinite(num)) return;
+ let normalized = Math.max(0, Math.min(1, num / 1023));
+ // snap near-zero to exact 0 to avoid tiny audible residuals
+ if (normalized < 0.02) normalized = 0;
+
+ // only continue if the slider value changed enough since last time
+ const prev = _lastSliderNormalized[idx];
+ const changed = prev === null || Math.abs(prev - normalized) > _minSliderDeltaForChange;
+ if (!changed) return; // skip unchanged slider
+ _lastSliderNormalized[idx] = normalized;
+
+ const key = `slider${idx}`;
+ const procs = Array.isArray(cfg[key]) ? cfg[key] : [];
+ if (!procs || procs.length === 0) return;
+
+ // match configured process names against running sessions (case-insensitive)
+ procs.forEach((proc) => {
+ if (!proc) return;
+ const procLow = String(proc).toLowerCase();
+ const matched = _runningSessions.some(s => {
+ return s === procLow || s.includes(procLow) || procLow.includes(s);
+ });
+ if (matched) {
+ // enqueue only when matched; DO NOT produce per-update verbose logs
+ runPythonSetVolume(proc, normalized, idx);
+ }
+ });
+ });
+ }
+ } catch (err) {
+ console.error("update handler fallback failed:", err);
+ sendVolumeLog(`update handler fallback failed: ${String(err)}`, true);
+ }
+ });
+ arduinoInstance.on("raw", (raw) => {
+ try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-raw", raw); } catch (e) {}
+ });
+ arduinoInstance.on("error", (err) => {
+ try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-error", String(err)); } catch (e) {}
+ });
+ arduinoInstance.on("warn", (w) => {
+ try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-warn", w); } catch (e) {}
+ });
+
+ // --- new helper to call the python volume setter and emit logs to renderer ---
+ // send only concise important logs to renderer to avoid flooding DevTools
+ const _verboseVolumeLogs = false;
+ function sendVolumeLog(msg, force = false) {
+ try {
+ // keep main-process console for troubleshooting but avoid massive logs
+ if (_verboseVolumeLogs || force || /\b(error|failed|succeed|succeeded)\b/i.test(String(msg))) {
+ console.log(msg);
+ try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("volume-log", String(msg)); } catch (e) {}
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ // cached running audio sessions (lowercase strings)
+ let _runningSessions = [];
+ let _sessionRefreshTimer = null;
+ const _sessionRefreshIntervalMs = 5000; // less frequent to avoid many spawns
+
+ // ask set_volume.py to list sessions (tries several invocation patterns)
+ async function refreshRunningSessions() {
+ const script = path.join(__dirname, "set_volume.py");
+ // Try invocation patterns; prefer "python