Files
Dreckshub/main.js
2025-11-17 13:53:14 +01:00

709 lines
24 KiB
JavaScript

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 <script>" as you run manually
const attempts = process.platform === "win32"
? [ ["python", script, "--list"], ["python3", script, "--list"], ["py", "-3", script, "--list"], ["py", script, "--list"] ]
: [ ["python3", script, "--list"], ["python", script, "--list"] ];
for (const att of attempts) {
const exe = att[0];
const args = att.slice(1);
try {
// removed verbose per-attempt logging to avoid noise
const out = await new Promise((resolve, reject) => {
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
if (err) return reject({ err, stderr });
resolve((stdout || "").toString());
});
child.on("error", (e) => reject({ err: e, stderr: "" }));
});
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
_runningSessions = lines.map(l => l.toLowerCase());
// previously sent a refreshRunningSessions: found ... log here — removed to reduce noise
return;
} catch (e) {
// intentionally quiet on per-attempt failures; continue trying others
}
}
// all attempts failed — keep previous cache. Do not spam logs; only keep a very concise console hint.
// (If you want visible errors for troubleshooting, reintroduce a targeted sendVolumeLog or console.warn here.)
// sendVolumeLog("refreshRunningSessions: all interpreter attempts failed. Install Python or ensure 'python'/'py' is on PATH.");
}
function startSessionRefresher() {
if (_sessionRefreshTimer) return;
// initial immediate refresh
refreshRunningSessions();
_sessionRefreshTimer = setInterval(refreshRunningSessions, _sessionRefreshIntervalMs);
}
function stopSessionRefresher() {
if (_sessionRefreshTimer) {
clearInterval(_sessionRefreshTimer);
_sessionRefreshTimer = null;
}
}
// Queue + rate-limit for invoking set_volume.py to avoid process storming
const _volumeCallQueue = [];
let _volumeActive = 0;
const _volumeMaxConcurrent = 2; // small concurrency to avoid overload
const _perProcLast = {}; // procName -> { lastValue, lastTimeMillis }
const _minValueDelta = 0.01; // only call if value changed by > 1%
const _minIntervalMs = 0; // at least 200ms between calls per proc
function enqueueVolumeCall(procName, value, slider) {
if (!procName) return;
const now = Date.now();
const key = String(procName).toLowerCase();
const prev = _perProcLast[key] || { lastValue: null, lastTime: 0 };
const deltaOk = prev.lastValue === null || Math.abs(prev.lastValue - value) > _minValueDelta;
const timeOk = (now - prev.lastTime) >= _minIntervalMs;
// Only enqueue if value significantly changed or interval passed
if (!deltaOk && !timeOk) {
// skip noisy updates
return;
}
// optimistic update to prevent duplicate rapid enqueues
_perProcLast[key] = { lastValue: value, lastTime: now };
// dedupe: if a queued entry for same proc exists, replace its value with the newest
const existingIndex = _volumeCallQueue.findIndex(it => String(it.procName).toLowerCase() === key);
if (existingIndex >= 0) {
_volumeCallQueue[existingIndex].value = Number(value);
_volumeCallQueue[existingIndex].slider = slider || null;
} else {
_volumeCallQueue.push({ procName: String(procName), value: Number(value), slider: slider || null });
}
// cap queue size to avoid runaway growth
const maxQueue = 800;
if (_volumeCallQueue.length > maxQueue) {
_volumeCallQueue.splice(0, _volumeCallQueue.length - maxQueue);
}
processVolumeQueue();
}
// Replace processVolumeQueue() internals to prefer persistent python server
function processVolumeQueue() {
if (_volumeActive >= _volumeMaxConcurrent) return;
if (_volumeCallQueue.length === 0) return;
// Attempt to send to persistent python server first
(async () => {
const item = _volumeCallQueue.shift();
_volumeActive++;
// Ensure server is running (best-effort)
const haveServer = await ensurePythonServer();
if (haveServer && pythonServer && pythonServer.stdin && !pythonServer.killed) {
try {
const payload = JSON.stringify({ cmd: "set", process: item.procName, value: Number(item.value) }) + "\n";
pythonServer.stdin.write(payload, "utf8", (err) => {
if (err) {
console.error("pythonServer write error:", err);
}
});
// we assume the Python server will apply the change quickly; log concisely
sendVolumeLog(`sent to python-server: ${item.procName}=${item.value}`, false);
} catch (err) {
// fall back to spawning a process if write fails
sendVolumeLog(`python-server write failed, falling back: ${err}`, true);
// fallback to per-call attempts (existing execFile logic)
trySpawnFallback(item).catch(e => {
sendVolumeLog(`fallback spawn failed: ${e}`, true);
});
} finally {
_volumeActive = Math.max(0, _volumeActive - 1);
setImmediate(processVolumeQueue);
}
return;
}
// If no server, use original spawn attempts (keeps previous behavior)
try {
await trySpawnFallback(item);
} catch (e) {
sendVolumeLog(`All interpreter attempts failed for '${item.procName}'.`, true);
} finally {
_volumeActive = Math.max(0, _volumeActive - 1);
setImmediate(processVolumeQueue);
}
})();
}
// Try to start a persistent python server (tries several interpreters).
function ensurePythonServer() {
if (pythonServer && !pythonServer.killed) return Promise.resolve(true);
const script = path.join(__dirname, "set_volume.py");
const interpreters = process.platform === "win32"
? ["python", "python3", "py"]
: ["python3", "python"];
let lastErr = null;
return new Promise((resolve) => {
(function tryInterp(i) {
if (i >= interpreters.length) {
console.error("Could not start python server:", lastErr);
pythonServer = null;
pythonServerReady = false;
return resolve(false);
}
const exe = interpreters[i];
try {
const child = spawn(exe, [script, "--server-stdin"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
let started = false;
child.on("error", (err) => {
lastErr = err;
// try next interpreter
tryInterp(i + 1);
});
child.stdout.on("data", (chunk) => {
// accumulate or just log; we don't need to parse every response here
try {
const s = chunk.toString();
pythonServerStdoutBuffer += s;
// optionally parse complete lines if needed
} catch (e) { /* ignore */ }
});
child.stderr.on("data", (chunk) => {
console.error("python server stderr:", chunk.toString());
});
child.on("close", (code) => {
pythonServer = null;
pythonServerReady = false;
console.warn("python server closed, code=", code);
});
// treat as started
pythonServer = child;
pythonServerReady = true;
return resolve(true);
} catch (e) {
lastErr = e;
tryInterp(i + 1);
}
})(0);
});
}
// helper: existing per-call spawn attempts factored out so we can reuse it as fallback
async function trySpawnFallback(item) {
const script = path.join(__dirname, "set_volume.py");
const attempts = process.platform === "win32"
? [ ["python", script, String(item.procName), String(item.value)], ["python3", script, String(item.procName), String(item.value)], ["py", "-3", script, String(item.procName), String(item.value)], ["py", script, String(item.procName), String(item.value)] ]
: [ ["python3", script, String(item.procName), String(item.value)], ["python", script, String(item.procName), String(item.value)] ];
for (const att of attempts) {
const exe = att[0];
const args = att.slice(1);
try {
sendVolumeLog(`exec ${exe} ${args.join(" ")} -> ${item.procName} value=${item.value}`, false);
await new Promise((resolve, reject) => {
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
if (err) {
sendVolumeLog(`${exe} error: ${err.message}. stderr: ${stderr ? stderr.trim() : ""}`, true);
return reject(err);
}
return resolve();
});
child.on("error", (spawnErr) => {
sendVolumeLog(`${exe} spawn error: ${spawnErr.message}`, true);
reject(spawnErr);
});
});
sendVolumeLog(`set_volume succeeded for '${item.procName}'`, true);
return;
} catch (e) {
sendVolumeLog(`${exe} failed for '${item.procName}': ${String(e)}`, true);
// try next
}
}
throw new Error("All attempts failed");
}
// expose a simpler API used by other handlers
function runPythonSetVolume(procName, value, slider) {
enqueueVolumeCall(procName, value, slider);
}
// start session refresher once (do not start it repeatedly per update)
startSessionRefresher();
// throttle serial updates processed (avoid queueing on every single sample)
let _lastUpdateAt = 0;
const _updateThrottleMs = 120; // process ~16 updates/sec
// add listener to actually apply per-process volume changes via Python helper
arduinoInstance.on("set-volume", (info) => {
try {
// info: { process: procName, value: normalizedFloat, slider: sliderNumber }
const procName = info && info.process ? info.process : "";
const value = (typeof info.value === "number") ? info.value : 0;
const slider = typeof info.slider !== "undefined" ? info.slider : null;
if (!procName) {
sendVolumeLog(`set-volume received but no process name in info: ${JSON.stringify(info)}`);
return;
}
sendVolumeLog(`set-volume received -> process="${procName}" value=${value} slider=${slider}`);
// call Python helper (now queued/rate-limited)
runPythonSetVolume(procName, value, slider);
} catch (err) {
console.error("set-volume handler failed:", err);
sendVolumeLog(`set-volume handler failed: ${String(err)}`);
}
});
// no explicit start() needed — ArduinoThread constructor wires port and events
return { ok: true };
} catch (err) {
console.error("connect-com failed:", err);
arduinoInstance = null;
return { ok: false, message: String(err) };
}
});
// before registering disconnect, remove any previous handler
if (ipcMain.removeHandler) {
try { ipcMain.removeHandler("disconnect-com"); } catch (e) { /* ignore */ }
}
ipcMain.handle("disconnect-com", async () => {
try {
if (!arduinoInstance) return { ok: false, message: "Not connected" };
// stop session refresher if running
try { stopSessionRefresher(); } catch (e) { /* ignore */ }
try { arduinoInstance.stop(); } catch (e) { /* ignore */ }
arduinoInstance = null;
return { ok: true };
} catch (err) {
console.error("disconnect-com failed:", err);
return { ok: false, message: String(err) };
}
});
app.on("window-all-closed", () => {
// destroy tray if present
try { destroyTray(); } catch (e) { /* ignore */ }
if (process.platform !== "darwin") {
app.quit();
}
// destroy tray if present
try { destroyTray(); } catch (e) { /* ignore */ }
if (process.platform !== "darwin") {
app.quit();
}
});