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