Initial commit: StreamDeck Electron app
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
config.json
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+1722
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user