Initial commit: StreamDeck Electron app
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user