Adding Project Files
This commit is contained in:
708
main.js
Normal file
708
main.js
Normal file
@@ -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 <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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user