Files
StreamDeck/electron-main.js

453 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─── 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 };
}