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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
config.json
*.log
.DS_Store
+111
View File
@@ -0,0 +1,111 @@
# StreamDeck Custom Touchscreen Stream Deck
A native macOS Stream Deck replacement for HDMI touchscreens. Built with **Electron**, it stays on a dedicated secondary monitor and sends keystrokes/clipboard/shell commands to whatever app you're working in — **without stealing focus**.
![screenshot](screenshot.png)
## Features
- **Full grid of programmable buttons** 6×3 configurable per page
- **Multiple pages** swipe or tap arrows/dots to navigate
- **Realtime system monitor page** CPU usage (overall + per-core), memory usage, uptime, load
- **Native macOS actions**:
- `keystroke` sends keyboard shortcuts to your **currently active app** (e.g. `cmd+v`, `ctrl+up`, `space`)
- `copy` / `paste` / `cut` clipboard operations via NSPasteboard
- `shell` runs shell commands
- `osascript` runs AppleScript
- `app` opens any macOS application
- `url` opens URLs in default browser
- `sleep` puts Mac to sleep
- **Touchscreen-optimized** large touch targets, frameless window, auto-fullscreen
- **No focus stealing** app hides itself before sending keystrokes, so clipboard and shortcuts go to your previous app, not the StreamDeck
## Requirements
- **macOS** (tested on Sequoia 15.x)
- **Node.js** 18+ (for development / `npm install`)
- **Touchscreen** with USB touch controller (e.g. WCH `USB2IIC_CTP_CONTROL`)
- **Touch-Base / UPDD touch driver** **required separately!** The touchscreen **will not work** without this driver on macOS. Download and install from [touch-base.com](https://www.touch-base.com/). After installation, use the UPDD Commander to map the touch device to your secondary monitor.
## Quick Start
```bash
# 1. Install dependencies
cd streamdeck
npm install
# 2. Copy config template and customize
cp config.template.json config.json
# Edit config.json to set up your buttons
# 3. Run!
./start.sh # Electron app (touchscreen)
# or
./start.sh server # Web-based mode for testing in browser
```
For the Electron app:
- The window opens automatically on your **second monitor** (touchscreen)
- Fullscreen is enabled automatically on start
- Press **F11** to toggle fullscreen
- Press **F2** to show the debug event overlay
## Button Configuration
Edit `config.json`. Each button can have:
```json
{
"label": "Button Name",
"icon": "🔔",
"color": "blue",
"actions": [
{ "type": "keystroke", "value": "cmd+v" },
{ "type": "shell", "value": "open -a Safari" }
]
}
```
### Available action types
| Type | Value | Effect |
|---|---|---|
| `keystroke` | `cmd+v`, `ctrl+shift+z`, `space`, `f2` | Sends keyboard shortcut to active app |
| `shell` | `open -a Safari` | Runs shell command in background |
| `osascript` | `set volume 50` | Runs AppleScript |
| `app` | `Visual Studio Code` | Opens application |
| `url` | `https://github.com` | Opens URL in browser |
| `copy` | _none_ | Copies selected text (Cmd+C) |
| `paste` | `optional text` | Pastes text (Cmd+V) |
| `cut` | _none_ | Cuts selected text (Cmd+X) |
| `sleep` | _none_ | Puts Mac to sleep |
### Key syntax
- Modifiers: `cmd`, `ctrl`, `alt`, `shift`
- Special keys: `up`, `down`, `left`, `right`, `space`, `return`, `tab`, `escape`, `f1``f12`
- Combine with `+` (e.g. `cmd+shift+z`, `ctrl+up`)
- Regular characters are typed literally (e.g. `a`, `1`, `Hello`)
## Project Structure
```
streamdeck/
├── config.json # Your button configuration (local, not in git)
├── config.template.json # Template for config
├── electron-main.js # Electron main process (window, IPC, actions)
├── preload.js # Context bridge (renderer ↔ main)
├── server.js # Node.js web server for browser mode
├── package.json
├── start.sh # Convenience launcher
├── public/
│ ├── index.html
│ ├── app.js # Frontend logic
│ └── style.css # Touch-optimized UI
└── README.md
```
## License
MIT
+24
View File
@@ -0,0 +1,24 @@
{
"gridCols": 6,
"gridRows": 3,
"pages": [
{
"title": "General",
"buttons": [
{ "label": "Safari", "icon": "🌐", "color": "blue", "actions": [{ "type": "app", "value": "Safari" }] },
{ "label": "Terminal", "icon": "⬛", "color": "dark", "actions": [{ "type": "app", "value": "Terminal" }] },
{ "label": "VS Code", "icon": "💻", "color": "purple", "actions": [{ "type": "app", "value": "Visual Studio Code" }] },
{ "label": "Spotlight", "icon": "🔍", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+space" }] },
{ "label": "Mute", "icon": "🔇", "color": "orange", "actions": [{ "type": "keystroke", "value": "cmd+shift+m" }] },
{ "label": "Lock", "icon": "🔒", "color": "red", "actions": [{ "type": "shell", "value": "/System/Library/CoreServices/Menu\\ Extras/User.menu/Contents/Resources/CGSession -suspend" }] },
{ "label": "Copy", "icon": "📋", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+c" }] },
{ "label": "Paste", "icon": "📌", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+v" }] },
{ "label": "Cut", "icon": "✂️", "color": "orange", "actions": [{ "type": "keystroke", "value": "cmd+x" }] },
{ "label": "Save", "icon": "💾", "color": "blue", "actions": [{ "type": "keystroke", "value": "cmd+s" }] },
{ "label": "Undo", "icon": "↩️", "color": "teal", "actions": [{ "type": "keystroke", "value": "cmd+z" }] },
{ "label": "Redo", "icon": "↪️", "color": "teal", "actions": [{ "type": "keystroke", "value": "cmd+shift+z" }] }
]
}
]
}
+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 };
}
+1722
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "streamdeck-custom",
"version": "1.0.0",
"description": "Custom Stream Deck for HDMI touchscreen",
"main": "electron-main.js",
"scripts": {
"start": "electron .",
"electron": "electron .",
"server": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"ws": "^8.16.0"
},
"devDependencies": {
"electron": "^33.0.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
/* ─── Preload Script ─────────────────────────────────────────────────────────
* Context bridge a renderer és main process között.
* Exponálja a natív API-kat a weboldalnak.
* ──────────────────────────────────────────────────────────────────────────── */
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
reloadConfig: () => ipcRenderer.invoke('reload-config'),
// Action futtatás
executeAction: (action) => ipcRenderer.invoke('execute-action', action),
executeActions: (actions) => ipcRenderer.invoke('execute-actions', actions),
// Clipboard
getClipboardText: () => ipcRenderer.invoke('get-clipboard-text'),
setClipboardText: (text) => ipcRenderer.invoke('set-clipboard-text', text),
// System stats
getSystemStats: () => ipcRenderer.invoke('get-system-stats'),
// Fullscreen
toggleFullscreen: () => ipcRenderer.invoke('toggle-fullscreen'),
// Eredmény események (pl. shell output)
onShellResult: (callback) => {
ipcRenderer.on('shell-result', (event, data) => callback(data));
},
onClipboardResult: (callback) => {
ipcRenderer.on('clipboard-result', (event, data) => callback(data));
},
});
+485
View File
@@ -0,0 +1,485 @@
/* ─── State ────────────────────────────────────────────────────────────────── */
const IS_ELECTRON = typeof window !== 'undefined' && window.electronAPI !== undefined;
let config = { pages: [], gridCols: 5, gridRows: 3 };
let currentPage = 0;
let ws = null;
let wsReconnectTimer = null;
let btnExecuting = {}; // { id: setTimeout }
/* ─── DOM refs ────────────────────────────────────────────────────────────── */
const gridEl = document.getElementById('grid');
const navEl = document.getElementById('nav');
const titleEl = document.getElementById('page-title');
const wsStatusEl = document.getElementById('ws-status');
const pageIndicatorEl = document.getElementById('page-indicator');
const navPrevEl = document.getElementById('nav-prev');
const navNextEl = document.getElementById('nav-next');
/* ─── Config + Monitor page ───────────────────────────────────────────────── */
// A Monitor page index: mindig az utolsó oldal (config.pages.length után hozzáfűzve)
const MONITOR_PAGE_LABEL = '📊';
async function loadConfig() {
try {
if (IS_ELECTRON) {
config = await window.electronAPI.getConfig();
} else {
const res = await fetch('/api/config');
config = await res.json();
}
if (!config.pages || config.pages.length === 0) {
config.pages = [{ title: 'Hello', buttons: [] }];
}
// Virtuális Monitor oldal hozzáfűzése (nem mentjük config.json-ba)
config.pages.push({
title: 'Monitor',
buttons: [],
_isMonitor: true,
});
return true;
} catch (e) {
console.error('Failed to load config:', e);
return false;
}
}
/* ─── WebSocket (böngésző módban) ────────────────────────────────────────── */
function connectWS() {
if (IS_ELECTRON) {
// Electron: nincs szükség WS-re
document.body.classList.add('connected');
wsStatusEl.title = 'Electron app natív';
wsStatusEl.className = 'ws-online';
return;
}
if (ws && ws.readyState === WebSocket.OPEN) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}`);
ws.onopen = () => {
console.log('◉ WebSocket connected');
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
document.body.classList.add('connected');
wsStatusEl.className = 'ws-online';
};
ws.onclose = () => {
console.log('◉ WebSocket disconnected');
document.body.classList.remove('connected');
wsStatusEl.className = 'ws-offline';
ws = null;
wsReconnectTimer = setTimeout(connectWS, 2000);
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'connected') console.log(' ✓', msg.message);
} catch (e) { /* ignore */ }
};
ws.onerror = () => { /* onclose will fire next */ };
}
/* ─── Send press ──────────────────────────────────────────────────────────── */
function sendPress(button) {
const actions = button.actions || [];
if (actions.length === 0) return;
if (IS_ELECTRON) {
// Elektron: natív IPC-n keresztül
window.electronAPI.executeActions(actions).catch(console.error);
} else if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'press',
actions: actions,
id: button.id || button.label
}));
}
// Visual feedback
const btnId = button.id || button.label;
if (btnExecuting[btnId]) clearTimeout(btnExecuting[btnId]);
delete btnExecuting[btnId];
}
/* ─── Render grid (or monitor dashboard) ─────────────────────────────────── */
let monitorInterval = null;
function renderPage(pageIdx) {
const page = config.pages[pageIdx];
if (!page) return;
currentPage = pageIdx;
titleEl.textContent = page.title || '';
// Ha Monitor oldal, rendereld a dashboard-ot
if (page._isMonitor) {
renderMonitorPage();
renderNav();
updateNavArrows();
updatePageIndicator();
return;
}
// Biztonság: ha volt monitor poll, állítsd le
stopMonitorPoll();
// Build grid
gridEl.innerHTML = '';
gridEl.style.gridTemplateColumns = `repeat(${config.gridCols || 5}, 1fr)`;
gridEl.style.gridTemplateRows = `repeat(${config.gridRows || 3}, 1fr)`;
gridEl.classList.remove('drag-scroll');
const buttons = page.buttons || [];
buttons.forEach((btn, i) => {
const el = document.createElement('button');
el.className = 'button';
el.dataset.index = i;
// Color
if (btn.color) el.classList.add(`color-${btn.color}`);
// Icon
if (btn.icon) {
const iconSpan = document.createElement('span');
iconSpan.className = 'icon';
iconSpan.textContent = btn.icon;
el.appendChild(iconSpan);
}
// Label
if (btn.label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'label';
labelSpan.textContent = btn.label;
el.appendChild(labelSpan);
}
// Background image
if (btn.image) {
el.style.backgroundImage = `url(${btn.image})`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
}
// ── Event handlers (multiple redundancies for different touchscreen drivers) ──
function fireAction(e) {
// Log to debug overlay
if (window.debugLog) window.debugLog(e.type, btn.label || '(no label)');
// Visual feedback
el.classList.add('pressed');
setTimeout(() => el.classList.remove('pressed'), 150);
// Send action to server
sendPress(btn);
}
el.addEventListener('pointerdown', fireAction);
el.addEventListener('mousedown', fireAction);
el.addEventListener('click', (e) => { e.preventDefault(); fireAction(e); });
el.addEventListener('touchstart', (e) => {
// Some touchscreens ONLY send touchstart
fireAction(e);
}, { passive: true });
// Colspan / rowspan (via CSS grid)
if (btn.colspan) el.style.gridColumn = `span ${btn.colspan}`;
if (btn.rowspan) el.style.gridRow = `span ${btn.rowspan}`;
// Size classes for easier styling
if (btn.width) el.style.width = btn.width;
if (btn.height) el.style.height = btn.height;
// Empty button (spacer)
if (btn.empty) {
el.style.visibility = 'hidden';
el.style.pointerEvents = 'none';
}
gridEl.appendChild(el);
});
// Navigation dots, arrows, page indicator
renderNav();
updateNavArrows();
updatePageIndicator();
}
/* ─── Monitor dashboard ─────────────────────────────────────────────────────
* Realtime CPU és memória grafikonok. Csak Electron módban működik.
* ──────────────────────────────────────────────────────────────────────────── */
function renderMonitorPage() {
gridEl.innerHTML = '';
gridEl.style.gridTemplateColumns = '1fr';
gridEl.style.gridTemplateRows = '1fr';
gridEl.classList.remove('drag-scroll');
// Container
const dash = document.createElement('div');
dash.id = 'monitor-dashboard';
dash.innerHTML = `
<div class="monitor-section" id="monitor-cpu">
<div class="monitor-title">CPU</div>
<div class="monitor-bar-bg"><div class="monitor-bar monitor-bar-cpu" id="cpu-bar" style="width:0%"></div></div>
<div class="monitor-value" id="cpu-value">—</div>
<div class="monitor-cores" id="cpu-cores"></div>
</div>
<div class="monitor-section" id="monitor-mem">
<div class="monitor-title">MEMORY</div>
<div class="monitor-bar-bg"><div class="monitor-bar monitor-bar-mem" id="mem-bar" style="width:0%"></div></div>
<div class="monitor-value" id="mem-value">—</div>
</div>
<div class="monitor-section" id="monitor-sys">
<div class="monitor-title">SYSTEM</div>
<div class="monitor-line" id="monitor-uptime">Uptime: —</div>
<div class="monitor-line" id="monitor-load">Load: —</div>
<div class="monitor-line" id="monitor-host">Host: —</div>
</div>
`;
gridEl.appendChild(dash);
// Start polling
startMonitorPoll();
}
function stopMonitorPoll() {
if (monitorInterval) {
clearInterval(monitorInterval);
monitorInterval = null;
}
}
function startMonitorPoll() {
stopMonitorPoll();
if (!IS_ELECTRON) {
document.getElementById('monitor-cpu') &&
(document.getElementById('monitor-cpu').innerHTML = '<div class="monitor-title">CPU</div><div style="color:#666;padding:10px">Only available in Electron mode</div>');
return;
}
async function update() {
try {
const stats = await window.electronAPI.getSystemStats();
if (!stats) return;
// CPU
const cpuEl = document.getElementById('cpu-bar');
const cpuVal = document.getElementById('cpu-value');
const cpuCores = document.getElementById('cpu-cores');
if (cpuEl) cpuEl.style.width = Math.min(stats.cpu.usage, 100) + '%';
if (cpuVal) cpuVal.textContent = stats.cpu.usage.toFixed(1) + '%';
if (cpuCores && stats.cpu.perCore) {
let html = '';
stats.cpu.perCore.forEach((core, i) => {
const pct = Math.min(core.usage || 0, 100);
html += `<div class="core-bar-bg"><span class="core-label">C${i}</span><div class="core-bar-fill" style="width:${pct}%"></div><span class="core-pct">${pct}%</span></div>`;
});
cpuCores.innerHTML = html;
}
// Memory
const memEl = document.getElementById('mem-bar');
const memVal = document.getElementById('mem-value');
if (memEl) memEl.style.width = Math.min(stats.memory.usagePercent, 100) + '%';
if (memVal) {
const usedGB = (stats.memory.used / 1024 / 1024 / 1024).toFixed(1);
const totalGB = (stats.memory.total / 1024 / 1024 / 1024).toFixed(1);
const color = stats.memory.usagePercent > 85 ? '#f87171' : stats.memory.usagePercent > 65 ? '#fbbf24' : '#4ade80';
memVal.innerHTML = `${usedGB} GB / ${totalGB} GB <span style="color:${color}">(${stats.memory.usagePercent}%)</span>`;
}
// System info
const uptimeEl = document.getElementById('monitor-uptime');
const loadEl = document.getElementById('monitor-load');
const hostEl = document.getElementById('monitor-host');
if (uptimeEl) {
const days = Math.floor(stats.uptime / 86400);
const hrs = Math.floor((stats.uptime % 86400) / 3600);
const mins = Math.floor((stats.uptime % 3600) / 60);
uptimeEl.textContent = `Uptime: ${days}d ${hrs}h ${mins}m`;
}
if (loadEl) {
loadEl.textContent = `Load: ${stats.loadavg.map(v => v.toFixed(2)).join(', ')}`;
}
if (hostEl) {
hostEl.textContent = `Host: ${stats.hostname}`;
}
} catch (e) {
console.error('Monitor update error:', e);
}
}
update();
monitorInterval = setInterval(update, 2000);
}
function renderNav() {
navEl.innerHTML = '';
config.pages.forEach((page, i) => {
const dot = document.createElement('button');
dot.className = 'nav-dot';
dot.dataset.index = i;
if (i === currentPage) dot.classList.add('active');
function gotoPage(e) {
e.preventDefault();
renderPage(i);
}
dot.addEventListener('click', gotoPage);
dot.addEventListener('touchstart', (e) => {
// For touchscreen drivers that only send touchstart, not click
gotoPage(e);
}, { passive: true });
navEl.appendChild(dot);
});
}
/* ─── Fullscreen toggle ──────────────────────────────────────────────────── */
document.getElementById('fullscreen-btn').addEventListener('click', async (e) => {
e.preventDefault();
if (IS_ELECTRON) {
await window.electronAPI.toggleFullscreen();
} else if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen().catch(() => {});
} else {
await document.exitFullscreen().catch(() => {});
}
});
// Auto-fullscreen Electron indításkor
if (IS_ELECTRON) {
window.electronAPI.toggleFullscreen();
} else {
// Böngésző: első touch-ra fullscreen
document.addEventListener('touchstart', () => {
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
}, { once: true });
}
function updateNavArrows() {
navPrevEl.disabled = currentPage <= 0;
navNextEl.disabled = currentPage >= config.pages.length - 1;
navPrevEl.style.opacity = navPrevEl.disabled ? '0.15' : '0.3';
navNextEl.style.opacity = navNextEl.disabled ? '0.15' : '0.3';
}
function updatePageIndicator() {
pageIndicatorEl.textContent = `${currentPage + 1} / ${config.pages.length}`;
}
// ─── Navigation arrow handlers ──────────────────────────────────────────────
function goToPrevPage(e) {
if (currentPage > 0) renderPage(currentPage - 1);
}
function goToNextPage(e) {
if (currentPage < config.pages.length - 1) renderPage(currentPage + 1);
}
navPrevEl.addEventListener('click', goToPrevPage);
navPrevEl.addEventListener('touchstart', (e) => { goToPrevPage(e); }, { passive: true });
navNextEl.addEventListener('click', goToNextPage);
navNextEl.addEventListener('touchstart', (e) => { goToNextPage(e); }, { passive: true });
/* ─── Swipe gesture for page navigation ────────────────────────────────────
* Egyszerűsített verzió: NEM vár pointermove/touchmove eseményekre.
* Közvetlenül a felengedéskor méri a távolságot a lenyomás pontjától.
* Ez azért kell, mert egyes touchscreen-ek (pl. WCH USB touch) NEM
* küldenek mozgás eseményeket (pointermove/touchmove).
* ──────────────────────────────────────────────────────────────────────────── */
let swipeStartX = 0;
let swipeStartY = 0;
let swipeDownTime = 0;
let swipeTarget = null;
function handleSwipeEnd(endX, endY, target) {
const dx = endX - swipeStartX;
const dy = endY - swipeStartY;
const elapsed = Date.now() - swipeDownTime;
// Nem swipe ha: túl kis távolság (>20px), vagy túl lassú (>500ms),
// vagy nem elég vízszintes, vagy gombról indult
if (Math.abs(dx) < 20 || elapsed > 500) return;
if (Math.abs(dx) < Math.abs(dy) * 1.5) return; // nem elég vízszintes
if (target && target.closest('.button')) return; // gombról indult
if (dx > 0 && currentPage > 0) renderPage(currentPage - 1);
else if (dx < 0 && currentPage < config.pages.length - 1) renderPage(currentPage + 1);
}
// Pointer events minden touchscreen ezt küldi (UPDD driver is)
document.addEventListener('pointerdown', (e) => {
swipeStartX = e.screenX;
swipeStartY = e.screenY;
swipeDownTime = Date.now();
swipeTarget = e.target;
}, { passive: true });
document.addEventListener('pointerup', (e) => {
handleSwipeEnd(e.screenX, e.screenY, swipeTarget);
}, { passive: true });
// Touch events egyes eszközök ezt küldik pointer helyett
document.addEventListener('touchstart', (e) => {
swipeStartX = e.changedTouches[0].screenX;
swipeStartY = e.changedTouches[0].screenY;
swipeDownTime = Date.now();
swipeTarget = e.target;
}, { passive: true });
document.addEventListener('touchend', (e) => {
handleSwipeEnd(
e.changedTouches[0].screenX,
e.changedTouches[0].screenY,
swipeTarget
);
}, { passive: true });
// ← → keyboard nav
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' && currentPage < config.pages.length - 1) renderPage(currentPage + 1);
if (e.key === 'ArrowLeft' && currentPage > 0) renderPage(currentPage - 1);
});
/* ─── Document-level event logger (for troubleshooting touch) ──────────── */
function docLog(e) {
window.debugLog(`📄 ${e.type}`, `target:${e.target?.tagName} pt:${e.pointerType || '-'} btn:${e.which || e.button || 0}`);
}
document.addEventListener('pointerdown', docLog);
document.addEventListener('pointerup', docLog);
document.addEventListener('mousedown', docLog);
document.addEventListener('mouseup', docLog);
document.addEventListener('click', docLog);
document.addEventListener('touchstart', docLog);
document.addEventListener('touchend', docLog);
/* ─── Debug overlay ──────────────────────────────────────────────────────── */
const debugEl = document.getElementById('debug-overlay');
let debugCount = 0;
window.debugLog = function(type, label) {
const now = new Date();
const t = now.toLocaleTimeString();
const entry = `[${t}] ${type.padEnd(15)} ${label || ''}`;
debugEl.innerHTML += `<div>${entry}</div>`;
debugEl.scrollTop = debugEl.scrollHeight;
if (++debugCount > 100) { debugEl.innerHTML = debugEl.innerHTML.split('</div>').slice(-50).join('</div>'); }
};
// Press F2 to toggle debug overlay
document.addEventListener('keydown', (e) => {
if (e.key === 'F2') {
debugEl.style.display = debugEl.style.display === 'none' ? 'block' : 'none';
}
});
/* ─── Init ────────────────────────────────────────────────────────────────── */
(async function init() {
await loadConfig();
renderPage(0);
connectWS();
})();
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>StreamDeck</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Page indicator / header -->
<div id="header">
<span id="ws-status" class="ws-offline"></span>
<span id="page-title"></span>
<button id="fullscreen-btn"></button>
</div>
<!-- Button grid with arrow overlays -->
<div id="grid-wrapper">
<div id="grid"></div>
<button id="nav-prev" class="nav-arrow"></button>
<button id="nav-next" class="nav-arrow"></button>
</div>
<!-- Page navigation dots -->
<div id="nav"></div>
<!-- Page indicator text -->
<div id="page-indicator"></div>
</div>
<div id="debug-overlay" style="display:none; position:fixed; bottom:0; left:0; right:0; background:rgba(0,0,0,0.85); color:#0f0; font:11px monospace; padding:6px; max-height:80px; overflow-y:auto; z-index:999; pointer-events:none;"></div>
<script src="app.js"></script>
</body>
</html>
+359
View File
@@ -0,0 +1,359 @@
/* ─── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #1a1a2e;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* ─── Layout ──────────────────────────────────────────────────────────────── */
#app {
display: flex;
flex-direction: column;
height: 100vh;
padding: 10px;
gap: 8px;
}
#header {
text-align: center;
font-size: 13px;
color: #888;
padding: 2px 0;
flex-shrink: 0;
letter-spacing: 0.5px;
text-transform: uppercase;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
#ws-status {
font-size: 10px;
transition: color 0.3s;
}
#ws-status.ws-online { color: #4ade80; }
#ws-status.ws-offline { color: #f87171; }
#fullscreen-btn {
background: none;
border: 1px solid #555;
color: #888;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
padding: 2px 8px;
line-height: 1.4;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
#fullscreen-btn:active { background: rgba(255,255,255,0.1); }
.button {
position: relative;
border: none;
border-radius: 16px;
background: #16213e;
color: #fff;
font-size: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
transition: all 0.1s ease;
box-shadow: 0 3px 12px rgba(0,0,0,0.4);
line-height: 1.2;
text-align: center;
word-break: break-word;
overflow: hidden;
font-family: inherit;
}
.button .icon {
font-size: 48px;
margin-bottom: 6px;
line-height: 1;
}
.button .label {
font-size: 16px;
opacity: 0.95;
max-width: 100%;
}
/* Pressed state */
.button:active, .button.pressed {
transform: scale(0.92);
filter: brightness(1.3);
}
/* Color variants */
.button.color-blue { background: #0f3460; }
.button.color-green { background: #1b4332; }
.button.color-red { background: #5c1a1a; }
.button.color-purple { background: #3b1f6e; }
.button.color-orange { background: #5c3a1a; }
.button.color-teal { background: #1a4a4a; }
.button.color-pink { background: #5c1a3a; }
.button.color-yellow { background: #5c4a1a; color: #ffe066; }
.button.color-white { background: #2a2a3e; }
.button.color-dark { background: #0d0d1a; }
/* Transparent (image only) */
.button.color-transparent { background: transparent; box-shadow: none; }
.button.color-transparent:active { filter: brightness(1.5); }
/* ─── Grid wrapper with side arrows ──────────────────────────────────────── */
#grid-wrapper {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
#grid {
flex: 1;
display: grid;
gap: 10px;
align-content: stretch;
}
.nav-arrow {
position: absolute;
top: 0; bottom: 0;
width: 56px;
border: none;
background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.35);
font-size: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
padding: 0;
}
.nav-arrow:active { background: rgba(255,255,255,0.15); color: #fff; }
#nav-prev { left: 0; border-radius: 0 10px 10px 0; }
#nav-next { right: 0; border-radius: 10px 0 0 10px; }
/* ─── Navigation dots ─────────────────────────────────────────────────────── */
#nav {
display: flex;
justify-content: center;
gap: 12px;
padding: 8px 0;
flex-shrink: 0;
}
.nav-dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: #444;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
padding: 0;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.nav-dot:active {
background: #666;
transform: scale(0.9);
}
.nav-dot.active {
background: #4a9eff;
border-color: #6bb4ff;
box-shadow: 0 0 10px #4a9eff;
transform: scale(1.15);
}
/* ─── Page indicator text ────────────────────────────────────────────────── */
#page-indicator {
text-align: center;
font-size: 12px;
color: #555;
padding: 2px 0;
flex-shrink: 0;
}
/* ─── Responsive ──────────────────────────────────────────────────────────── */
/* Ultrawide landscape (e.g. 1920×480) — the touchscreen */
@media (min-aspect-ratio: 3/1) {
.button { padding: 10px 14px; }
.button .icon { font-size: 48px; }
.button .label { font-size: 16px; }
#app { padding: 8px 12px; }
}
/* Small portrait (e.g. 1024×768) */
@media (max-aspect-ratio: 4/3) {
.button { border-radius: 20px; }
.button .icon { font-size: 56px; }
.button .label { font-size: 20px; }
}
/* ─── Animations ──────────────────────────────────────────────────────────── */
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.button.executing {
animation: flash 0.3s ease 2;
}
/* ─── Drag scroll (for pages with many rows) ──────────────────────────────── */
#grid.drag-scroll {
overflow-y: auto;
scroll-behavior: smooth;
flex: 1;
}
#grid.drag-scroll::-webkit-scrollbar {
width: 3px;
}
#grid.drag-scroll::-webkit-scrollbar-track {
background: transparent;
}
#grid.drag-scroll::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
/* ─── Monitor dashboard ───────────────────────────────────────────────────── */
#monitor-dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
height: 100%;
padding: 4px;
}
.monitor-section {
background: #16213e;
border-radius: 12px;
padding: 10px 14px;
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.monitor-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 6px;
flex-shrink: 0;
}
.monitor-bar-bg {
height: 20px;
background: #0d1b2a;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
margin-bottom: 4px;
}
.monitor-bar {
height: 100%;
border-radius: 10px;
transition: width 0.6s ease;
}
.monitor-bar-cpu { background: linear-gradient(90deg, #4a9eff, #6bb4ff); }
.monitor-bar-mem { background: linear-gradient(90deg, #4ade80, #6ee7a0); }
.monitor-value {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
flex-shrink: 0;
}
/* Per-core bars */
.monitor-cores {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
justify-content: center;
margin-top: 4px;
}
.core-bar-bg {
height: 14px;
background: #0d1b2a;
border-radius: 7px;
overflow: hidden;
display: flex;
align-items: center;
position: relative;
}
.core-label {
position: absolute;
left: 6px;
font-size: 8px;
color: #aaa;
z-index: 1;
font-weight: 600;
}
.core-bar-fill {
height: 100%;
border-radius: 7px;
background: linear-gradient(90deg, #4a9eff, #7bb8ff);
transition: width 0.6s ease;
}
.core-pct {
position: absolute;
right: 6px;
font-size: 8px;
color: #ddd;
z-index: 1;
}
/* System info section */
#monitor-sys {
grid-column: 1 / 3;
}
.monitor-line {
font-size: 13px;
color: #bbb;
padding: 3px 0;
flex-shrink: 0;
}
/* Ultrawide tuning */
@media (min-aspect-ratio: 3/1) {
#monitor-dashboard { gap: 6px; padding: 2px; }
.monitor-section { padding: 6px 10px; border-radius: 8px; }
.monitor-title { font-size: 10px; }
.monitor-value { font-size: 16px; }
.monitor-bar-bg { height: 16px; }
.core-bar-bg { height: 12px; }
.monitor-line { font-size: 11px; }
}
+238
View File
@@ -0,0 +1,238 @@
const express = require('express');
const WebSocket = require('ws');
const http = require('http');
const path = require('path');
const { exec, spawn } = require('child_process');
const fs = require('fs');
// ─── Config ───────────────────────────────────────────────────────────────────
const PORT = 8090;
const CONFIG_PATH = path.join(__dirname, 'config.json');
let config = { pages: [], gridCols: 5, gridRows: 3 };
function loadConfig() {
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(` ✓ Config loaded: ${config.pages.length} page(s), ${config.gridCols}×${config.gridRows} grid`);
} catch (e) {
console.log(' ! No config.json found, using defaults');
}
}
loadConfig();
// ─── Express server ────────────────────────────────────────────────────────────
const app = express();
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// API endpoint to get config
app.get('/api/config', (req, res) => res.json(config));
// API endpoint to reload config
app.post('/api/reload', (req, res) => {
loadConfig();
res.json({ ok: true, pages: config.pages.length });
});
// API endpoint to run AppleScript
app.post('/api/osascript', (req, res) => {
const { script } = req.body;
if (!script) return res.status(400).json({ error: 'missing script' });
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (err, stdout, stderr) => {
if (err && stderr) console.error(' ✗ osascript error:', stderr.trim());
res.json({ ok: !err, stdout: stdout?.trim(), stderr: stderr?.trim() });
});
});
// API endpoint to run shell commands
app.post('/api/exec', (req, res) => {
const { command } = req.body;
if (!command) return res.status(400).json({ error: 'missing command' });
exec(command, { timeout: 5000 }, (err, stdout, stderr) => {
if (err && stderr) console.error(' ✗ exec error:', stderr.trim());
res.json({ ok: !err, stdout: stdout?.trim(), stderr: stderr?.trim() });
});
});
// API endpoint to open an app
app.post('/api/open', (req, res) => {
const { app, url } = req.body;
if (url) {
exec(`open '${url.replace(/'/g, "'\\''")}'`, (err) => {
res.json({ ok: !err });
});
} else if (app) {
exec(`open -a '${app.replace(/'/g, "'\\''")}'`, (err) => {
res.json({ ok: !err });
});
} else {
res.status(400).json({ error: 'missing app or url' });
}
});
// ─── WebSocket server ──────────────────────────────────────────────────────────
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
function runAction(action, ws) {
if (!action) return;
const { type, value } = action;
console.log(`${type}: ${value?.substring(0, 60)}`);
switch (type) {
case 'keystroke': {
// Simulate keyboard shortcut via AppleScript
// value: "cmd+c", "cmd+shift+4", "cmd+space", etc.
const parts = value.split('+').map(s => s.trim().toLowerCase());
const key = parts.pop();
const specialKeys = {
'cmd': 'command down', 'ctrl': 'control down', 'alt': 'option down',
'shift': 'shift down', 'option': 'option down', 'command': 'command down',
'control': 'control down', 'fn': 'fn down',
};
// Map key names to AppleScript key codes or names
const keyMap = {
'enter': 'return', 'return': 'return', 'tab': 'tab', 'space': 'space',
'escape': 'escape', 'esc': 'escape', 'backspace': 'backspace',
'delete': 'backspace', 'up': 'up', 'down': 'down', 'left': 'left',
'right': 'right', 'home': 'home', 'end': 'end', 'pageup': 'page up',
'pagedown': 'page down',
};
const appleKey = keyMap[key] || key;
const pressedKeys = parts.map(k => specialKeys[k] || k).join(', ');
const script = pressedKeys
? `tell application "System Events" to keystroke "${appleKey}" using {${pressedKeys}}`
: `tell application "System Events" to keystroke "${appleKey}"`;
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (err) => {
if (err) console.error(` ✗ keystroke error: ${err.message}`);
});
break;
}
case 'app': {
exec(`open -a '${(value || '').replace(/'/g, "'\\''")}'`, (err) => {
if (err) console.error(` ✗ open app error: ${err.message}`);
});
break;
}
case 'url': {
exec(`open '${(value || '').replace(/'/g, "'\\''")}'`, (err) => {
if (err) console.error(` ✗ open url error: ${err.message}`);
});
break;
}
case 'shell': {
exec(value, { timeout: 5000 }, (err, stdout, stderr) => {
if (err) console.error(` ✗ shell error: ${err.message}`);
else if (stdout?.trim()) console.log(`${stdout.trim().substring(0, 80)}`);
});
break;
}
case 'osascript': {
exec(`osascript -e '${(value || '').replace(/'/g, "'\\''")}'`, (err, stdout, stderr) => {
if (err && stderr) console.error(` ✗ osascript error: ${stderr.trim()}`);
});
break;
}
case 'volume': {
const vol = parseInt(value);
if (!isNaN(vol)) {
exec(`osascript -e 'set volume output volume ${vol}'`, (err) => {
if (err) console.error(` ✗ volume error: ${err.message}`);
});
}
break;
}
case 'brightness': {
// Requires external tool or AppleScript
exec(`osascript -e 'tell application "System Events"' -e 'repeat 20 times' -e 'key code 107' -e 'end repeat' -e 'end tell'`, () => {});
break;
}
case 'sleep': {
exec('pmset displaysleepnow', (err) => {
if (err) console.error(` ✗ sleep error: ${err.message}`);
});
break;
}
case 'screenshot': {
const script = value === 'window'
? 'tell application "System Events" to keystroke "3" using {command down, shift down}' // full screen
: value === 'area'
? 'tell application "System Events" to keystroke "4" using {command down, shift down}' // area
: 'tell application "System Events" to keystroke "5" using {command down, shift down}'; // screen record
exec(`osascript -e '${script}'`, (err) => {
if (err) console.error(` ✗ screenshot error: ${err.message}`);
});
break;
}
default: {
if (ws) ws.send(JSON.stringify({ type: 'error', message: `Unknown action type: ${type}` }));
}
}
}
wss.on('connection', (ws) => {
console.log(' ◉ Browser connected via WebSocket');
ws.send(JSON.stringify({ type: 'connected', message: 'Stream Deck ready' }));
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
console.log(`${msg.type}${msg.id ? ` [${msg.id}]` : ''}`);
switch (msg.type) {
case 'press':
if (msg.actions) {
// Execute multiple actions sequentially
const executeNext = (index) => {
if (index >= msg.actions.length) return;
const action = msg.actions[index];
const delay = action.delay || 0;
if (delay > 0) {
setTimeout(() => { runAction(action, ws); executeNext(index + 1); }, delay);
} else {
runAction(action, ws);
executeNext(index + 1);
}
};
executeNext(0);
}
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
default:
ws.send(JSON.stringify({ type: 'error', message: `Unknown message type: ${msg.type}` }));
}
} catch (e) {
console.error(' ✗ Invalid message:', e.message);
}
});
ws.on('close', () => console.log(' ◉ Browser disconnected'));
});
// ─── Start ─────────────────────────────────────────────────────────────────────
server.listen(PORT, '0.0.0.0', () => {
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ 🎛 Custom Stream Deck Server ║');
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ Local: http://localhost:${PORT}`);
console.log(`║ Network: http://192.168.1.231:${PORT}`);
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ Pages: ${config.pages.length} Grid: ${config.gridCols}×${config.gridRows}`);
console.log('╚══════════════════════════════════════════════╝');
console.log('');
});
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# StreamDeck — Electron app (touchscreen) / Web server (böngésző)
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
case "${1:-electron}" in
server|node)
pkill -f "node $DIR/server.js" 2>/dev/null
nohup node server.js > /tmp/streamdeck.log 2>&1 &
echo "StreamDeck server started (PID $!)"
echo " → http://localhost:8090"
echo " → Log: /tmp/streamdeck.log"
echo " → Stop: pkill -f \"node $DIR/server.js\""
;;
electron|app|--dev)
echo "Starting StreamDeck Electron app..."
if [ "$1" = "--dev" ]; then
npx electron . --dev
else
npx electron .
fi
;;
*)
echo "Usage: $0 [electron|server|--dev]"
echo " electron (default) Start native macOS app on touchscreen"
echo " server Start web server (browser mode)"
echo " --dev Electron app with DevTools"
exit 1
;;
esac