239 lines
9.2 KiB
JavaScript
239 lines
9.2 KiB
JavaScript
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('');
|
||
});
|