/* ─── Electron Main Process ────────────────────────────────────────────────── * StreamDeck – natív macOS app a touchscreen-re * * Képességek: * - Ablak a 2. monitorra (ZL480X1920, 1920×480) * - Kiosk mód (nincs Safari címsor, nincs fókusz probléma) * - natív clipboard (copy/paste) – nem kell fókusz * - Billentyűk küldése az aktív app-nak System Events-en keresztül * - Shell parancsok, app indítás, URL megnyitás * ──────────────────────────────────────────────────────────────────────────── */ const { app, BrowserWindow, ipcMain, clipboard, shell, globalShortcut, screen } = require('electron'); const path = require('path'); const { exec, execSync } = require('child_process'); const fs = require('fs'); const os = require('os'); /* ─── Config ───────────────────────────────────────────────────────────────── */ const CONFIG_PATH = path.join(__dirname, 'config.json'); let config = loadConfig(); function loadConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch (e) { console.error('Failed to load config.json:', e.message); return { gridCols: 6, gridRows: 3, pages: [] }; } } /* ─── Window ───────────────────────────────────────────────────────────────── */ let mainWindow = null; function createWindow() { const display = getTouchscreenDisplay(); mainWindow = new BrowserWindow({ x: display ? display.bounds.x : undefined, y: display ? display.bounds.y : undefined, width: display ? display.bounds.width : 1920, height: display ? display.bounds.height : 480, frame: false, // nincs címsor fullscreen: false, kiosk: false, resizable: false, alwaysOnTop: true, // mindig felül skipTaskbar: true, // nem jelenik meg a Dock-ban autoHideMenuBar: true, // menüsor elrejtése fullscreen-ben webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, title: 'StreamDeck', backgroundColor: '#1a1a2e', }); // A touchscreen méretére igazítva, pont a monitorra if (display) { mainWindow.setBounds({ x: display.bounds.x, y: display.bounds.y, width: display.bounds.width, height: display.bounds.height, }); } mainWindow.loadFile(path.join(__dirname, 'public', 'index.html')); // DevTools kikapcsolva élesben if (process.argv.includes('--dev')) { mainWindow.webContents.openDevTools({ mode: 'detach' }); } mainWindow.on('closed', () => { mainWindow = null; }); } function getTouchscreenDisplay() { const displays = screen.getAllDisplays(); // Keressük a ZL480X1920 nevű monitort (vagy a 2. monitort) for (const d of displays) { if (d.size.width === 1920 && (d.size.height === 480 || d.size.height === 479)) { console.log(`Found touchscreen display:`, d.label || d.id, d.bounds); return d; } } // Ha nem találjuk, a második monitor if (displays.length >= 2) { console.log(`Using second display:`, displays[1].label || displays[1].id); return displays[1]; } console.log('No second display found, using primary'); return null; } /* ─── Native Action Executor ────────────────────────────────────────────────── * A config.json action típusok futtatása. * A billentyűk az aktív (előző) app-nak mennek, nem a StreamDeck-nek. * ───────────────────────────────────────────────────────────────────────────── */ async function executeAction(action) { const { type, value } = action; console.log(`Executing action: ${type} = "${value}"`); // Billentyűzet action-ök: elrejtjük az ablakot, hogy a billentyűk // az előzőleg aktív app-hoz menjenek, ne a StreamDeck-hez const stealsFocus = ['keystroke', 'copy', 'paste', 'cut'].includes(type); if (stealsFocus) { app.hide(); // Várjuk meg, amíg tényleg az előző app kapja vissza a fókuszt await waitForAppFocus(); } try { switch (type) { /* ─── Keystroke → az előzőleg aktív app-nak megy ──────────────────── */ case 'keystroke': { const parsed = parseKeystroke(value || ''); if (!parsed) break; let osaCmd; if (parsed.modifiers.length > 0) { const mods = parsed.modifiers.join(' '); osaCmd = `osascript -e 'tell application "System Events" to keystroke "${parsed.key}" using {${mods}}'`; } else { osaCmd = `osascript -e 'tell application "System Events" to keystroke "${parsed.key}"'`; } await execPromise(osaCmd); break; } /* ─── Clipboard: copy ──────────────────────────────────────────────── */ case 'copy': { // Többször próbálkozunk, mert néha a fókuszváltás + billentyűzés nem elég gyors let text = ''; for (let attempt = 0; attempt < 3; attempt++) { await execPromise(`osascript -e 'tell application "System Events" to keystroke "c" using command down'`); await sleep(120); text = clipboard.readText(); if (text) break; await sleep(50); } console.log(`Copied: "${text.substring(0, 80)}..."`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('clipboard-result', { action: 'copy', text }); } break; } /* ─── Clipboard: paste ─────────────────────────────────────────────── */ case 'paste': { // Ha value meg van adva, azt illesztjük be (és utána visszaállítjuk a régit) if (value) { const prevClip = clipboard.readText(); clipboard.writeText(value); await execPromise(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`); await sleep(150); // Visszaállítjuk az eredeti vágólap tartalmat // (kis késleltetéssel, hogy a cél app befejezze a fogadást) clipboard.writeText(prevClip); } else { // Nincs value – simán illesszük be, ami a vágólapon van await execPromise(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`); } break; } /* ─── Clipboard: cut ───────────────────────────────────────────────── */ case 'cut': { let text = ''; for (let attempt = 0; attempt < 3; attempt++) { await execPromise(`osascript -e 'tell application "System Events" to keystroke "x" using command down'`); await sleep(120); text = clipboard.readText(); if (text) break; await sleep(50); } console.log(`Cut: "${text.substring(0, 80)}..."`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('clipboard-result', { action: 'cut', text }); } break; } /* ─── Shell ────────────────────────────────────────────────────────── */ case 'shell': { exec(value || '', { timeout: 15000, env: { ...process.env, PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout, stderr) => { const output = (stdout || '') + (stderr ? '\n' + stderr : ''); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('shell-result', { type, value, output }); } if (err) console.error('shell error:', err.message); console.log('shell output:', output.substring(0, 200)); }); break; } /* ─── AppleScript ──────────────────────────────────────────────────── */ case 'osascript': { await execPromise(`osascript -e '${(value || "").replace(/'/g, "'\\''")}'`); break; } /* ─── App indítás ──────────────────────────────────────────────────── */ case 'app': { shell.openPath(`/Applications/${value}.app`).catch(() => { exec(`open -a '${(value || "").replace(/'/g, "'\\''")}'`); }); break; } /* ─── URL megnyitás ────────────────────────────────────────────────── */ case 'url': { if (value) shell.openExternal(value); break; } /* ─── Sleep ────────────────────────────────────────────────────────── */ case 'sleep': { exec('pmset sleepnow'); break; } default: console.warn(`Unknown action type: ${type}`); } } catch (e) { console.error(`Action failed [${type}]:`, e.message); } // Visszahozzuk az ablakot if (stealsFocus) { // Várjunk egy kicsit, hogy a cél app megkapja a billentyűzet parancsot await sleep(150); app.show(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } } } /* ─── IPC ──────────────────────────────────────────────────────────────────── */ ipcMain.handle('get-config', () => config); ipcMain.handle('reload-config', () => { config = loadConfig(); return config; }); ipcMain.handle('execute-action', async (event, action) => { await executeAction(action); return { ok: true }; }); ipcMain.handle('execute-actions', async (event, actions) => { for (const action of actions) { await executeAction(action); await sleep(50); // kis késleltetés action-ök között } return { ok: true }; }); ipcMain.handle('get-clipboard-text', () => clipboard.readText()); ipcMain.handle('set-clipboard-text', (event, text) => clipboard.writeText(text)); ipcMain.handle('toggle-fullscreen', () => { if (mainWindow && !mainWindow.isDestroyed()) { const newState = !mainWindow.isFullScreen(); mainWindow.setFullScreen(newState); return newState; } return false; }); /* ─── System Stats ────────────────────────────────────────────────────────── * Valós idejű CPU- és memóriafigyelés a Monitor oldalhoz. * A CPU terhelést két mintavétel különbségéből számolja. * ──────────────────────────────────────────────────────────────────────────── */ let cpuPrevSample = null; function sampleCpuTimes() { const cpus = os.cpus(); const total = { idle: 0, tick: 0 }; const cores = cpus.map(cpu => { const tick = cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq; const idle = cpu.times.idle; total.idle += idle; total.tick += tick; return { tick, idle }; }); return { total, cores }; } ipcMain.handle('get-system-stats', () => { const memTotal = os.totalmem(); const memFree = os.freemem(); const memUsed = memTotal - memFree; const current = sampleCpuTimes(); let cpuPercent = 0; const perCore = []; if (cpuPrevSample) { // Overall CPU const idleDelta = current.total.idle - cpuPrevSample.total.idle; const tickDelta = current.total.tick - cpuPrevSample.total.tick; cpuPercent = tickDelta > 0 ? Math.round((1 - idleDelta / tickDelta) * 1000) / 10 : 0; // Per-core current.cores.forEach((core, i) => { const prevCore = cpuPrevSample.cores[i]; if (prevCore) { const dTick = core.tick - prevCore.tick; const dIdle = core.idle - prevCore.idle; const pct = dTick > 0 ? Math.round((1 - dIdle / dTick) * 100) : 0; perCore.push({ usage: Math.min(pct, 100) }); } else { perCore.push({ usage: 0 }); } }); } else { current.cores.forEach(() => perCore.push({ usage: 0 })); } cpuPrevSample = { total: current.total, cores: current.cores }; return { cpu: { usage: cpuPercent, cores: current.cores.length, perCore, }, memory: { total: memTotal, used: memUsed, free: memFree, usagePercent: Math.round((memUsed / memTotal) * 100), }, uptime: os.uptime(), loadavg: os.loadavg(), hostname: os.hostname(), }; }); /* ─── Global Shortcut: Escape kilépés kiosk-ból ─────────────────────────── */ app.whenReady().then(() => { // StreamDeck ikon elrejtése a Dock-ból (overlay app) app.dock && app.dock.hide(); createWindow(); // Ctrl+Cmd+F: fullscreen toggle globalShortcut.register('Control+Command+F', () => { if (mainWindow) { mainWindow.setFullScreen(!mainWindow.isFullScreen()); } }); // F11: toggle kiosk globalShortcut.register('F11', () => { if (mainWindow) { mainWindow.setFullScreen(!mainWindow.isFullScreen()); } }); // Esc: ha fullscreen, kilép belőle globalShortcut.register('Escape', () => { if (mainWindow && mainWindow.isFullScreen()) { mainWindow.setFullScreen(false); } }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { globalShortcut.unregisterAll(); app.quit(); }); /* ─── Helpers ──────────────────────────────────────────────────────────────── */ function execPromise(cmd) { return new Promise((resolve, reject) => { exec(cmd, { timeout: 10000, env: { ...process.env, PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout, stderr) => { if (err) reject(err); else resolve(stdout); }); }); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /** * Várunk amíg az aktuális fókusz átvált a StreamDeck-ről az előző app-ra. * app.hide() után pollozzuk a frontmost app-ot, max 500ms-ig. */ async function waitForAppFocus() { const maxWait = 800; // max 800ms const interval = 40; // 40ms-ként ellenőrizzük let waited = 0; while (waited < maxWait) { try { const frontApp = execSync( `osascript -e 'tell application "System Events" to get name of first process whose frontmost is true' 2>/dev/null`, { timeout: 500, encoding: 'utf-8' } ).trim(); // Ha már nem a StreamDeck az aktív app, kész if (frontApp && frontApp !== 'StreamDeck') { return; } } catch (e) { // Ha hiba van, próbáljuk tovább } await sleep(interval); waited += interval; } // Timeout után is folytatjuk – hátha működik } function parseKeystroke(str) { if (!str) return null; const parts = str.toLowerCase().split('+'); const keyMap = { 'cmd': 'command down', 'command': 'command down', 'ctrl': 'control down', 'control': 'control down', 'alt': 'option down', 'option': 'option down', 'shift': 'shift down', 'up': 'up arrow', 'down': 'down arrow', 'left': 'left arrow', 'right': 'right arrow', 'return': 'return', 'enter': 'return', 'tab': 'tab', 'escape': 'escape', 'space': 'space', 'backspace': 'delete', 'del': 'delete', 'delete': 'delete', 'f1': 'f1', 'f2': 'f2', 'f3': 'f3', 'f4': 'f4', 'f5': 'f5', 'f6': 'f6', 'f7': 'f7', 'f8': 'f8', 'f9': 'f9', 'f10': 'f10', 'f11': 'f11', 'f12': 'f12', }; const key = keyMap[parts[parts.length - 1]] || parts[parts.length - 1]; const modifiers = parts.slice(0, -1).map(p => keyMap[p]).filter(Boolean); return { key, modifiers }; }