453 lines
17 KiB
JavaScript
453 lines
17 KiB
JavaScript
/* ─── 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 };
|
||
}
|