Initial commit: StreamDeck Electron app

This commit is contained in:
Dávid Ádám
2026-06-06 13:52:05 +02:00
commit 96ef83c904
12 changed files with 3462 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
/* ─── 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();
await sleep(80);
}
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': {
await execPromise(`osascript -e 'tell application "System Events" to keystroke "c" using command down'`);
await sleep(100);
const text = clipboard.readText();
console.log(`Copied: "${text.substring(0, 80)}..."`);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('clipboard-result', { action: 'copy', text });
}
break;
}
/* ─── Clipboard: paste ─────────────────────────────────────────────── */
case 'paste': {
const stored = value || clipboard.readText();
clipboard.writeText(stored);
await execPromise(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`);
break;
}
/* ─── Clipboard: cut ───────────────────────────────────────────────── */
case 'cut': {
await execPromise(`osascript -e 'tell application "System Events" to keystroke "x" using command down'`);
await sleep(100);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('clipboard-result', { action: 'cut', text: clipboard.readText() });
}
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) {
await sleep(50);
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)); }
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 };
}