From b1a4131de7c8f85a2248d38814fd0899fea00523 Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 11:53:06 +1000 Subject: [PATCH 01/66] feat: implement save and load profile functionality with IPC in Electron app --- electron/main.cjs | 19 +++++++++++++++++++ electron/preload.cjs | 2 ++ src/App.jsx | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/electron/main.cjs b/electron/main.cjs index dfdf036..bdb18ad 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2,6 +2,7 @@ const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') +const fs = require('fs') let mainWindow @@ -166,6 +167,24 @@ async function initStreamDeck() { iconSize: ICON_SIZE ?? 72, }) + // IPC: save / load profile JSON + const profileDir = app.getPath('userData') + const profilePath = path.join(profileDir, 'Default Profile.json') + + ipcMain.handle('profile:save', async (_, data) => { + await fs.promises.mkdir(profileDir, { recursive: true }) + await fs.promises.writeFile(profilePath, JSON.stringify(data, null, 2), 'utf8') + }) + + ipcMain.handle('profile:load', async () => { + try { + const raw = await fs.promises.readFile(profilePath, 'utf8') + return JSON.parse(raw) + } catch { + return null + } + }) + // IPC: renderer sends RGBA pixel data → draw on physical button ipcMain.handle('button:setIcon', async (_, { index, rgbaData }) => { if (!deck) return diff --git a/electron/preload.cjs b/electron/preload.cjs index 6c3592b..a10ffbe 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -9,4 +9,6 @@ contextBridge.exposeInMainWorld('streamDeck', { onSleep: (cb) => ipcRenderer.on('deck:sleep', (_, data) => cb(data)), onWake: (cb) => ipcRenderer.on('deck:wake', (_, data) => cb(data)), setButtonIcon: (index, rgbaData) => ipcRenderer.invoke('button:setIcon', { index, rgbaData }), + saveProfile: (data) => ipcRenderer.invoke('profile:save', data), + loadProfile: () => ipcRenderer.invoke('profile:load'), }) diff --git a/src/App.jsx b/src/App.jsx index c1ac6fb..c7ca2ec 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -382,6 +382,27 @@ export default function App() { setContextMenu(null) } + // Load saved profile once on startup and redraw all hardware buttons + useEffect(() => { + if (!window.streamDeck?.loadProfile) return + window.streamDeck.loadProfile().then(saved => { + if (!saved?.buttons) return + setButtonConfigs(saved.buttons) + Object.entries(saved.buttons).forEach(([idx, cfg]) => { + drawHardwareButton(Number(idx), cfg) + }) + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-save whenever buttonConfigs changes (debounced 500ms) + useEffect(() => { + if (!window.streamDeck?.saveProfile) return + const timer = setTimeout(() => { + window.streamDeck.saveProfile({ name: 'Default Profile', buttons: buttonConfigs }) + }, 500) + return () => clearTimeout(timer) + }, [buttonConfigs]) + useEffect(() => { if (!window.streamDeck) return window.streamDeck.onInfo(info => { From fcf93f3f9680041ce4bdffd9ffb86fab1050f6e7 Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 12:54:42 +1000 Subject: [PATCH 02/66] feat: add hotkey execution functionality and UI components for hotkey recording --- electron/main.cjs | 22 ++++-- electron/preload.cjs | 5 +- src/App.css | 147 +++++++++++++++++++++++++++++++++++++++ src/App.jsx | 159 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 317 insertions(+), 16 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index bdb18ad..00e8b86 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,8 +1,9 @@ 'use strict' const { app, BrowserWindow, ipcMain } = require('electron') -const path = require('path') -const fs = require('fs') +const path = require('path') +const fs = require('fs') +const { spawn } = require('child_process') let mainWindow @@ -143,9 +144,7 @@ async function initStreamDeck() { deck.on('up', async (control) => { if (isSleeping) return - if (control.index !== 0) { - await deck.fillKeyColor(control.index, 0, 0, 0) - } + // Don't blank the button here — the renderer will redraw it with the user's config console.log(`[StreamDeck] KEY UP index=${control.index} row=${control.row} col=${control.column}`) sendToRenderer('deck:up', { index: control.index, row: control.row, column: control.column }) }) @@ -167,6 +166,19 @@ async function initStreamDeck() { iconSize: ICON_SIZE ?? 72, }) + // IPC: execute a hotkey via xdotool (Linux only) + ipcMain.handle('action:hotkey', async (_, { keys }) => { + // Allow only safe xdotool key names: letters, digits, F-keys, modifiers joined by + + if (!keys || !/^[a-zA-Z0-9+_-]+$/.test(keys)) { + return { error: 'Invalid hotkey string' } + } + return new Promise((resolve) => { + const proc = spawn('xdotool', ['key', '--clearmodifiers', '--', keys], { stdio: 'ignore' }) + proc.on('close', (code) => resolve({ code })) + proc.on('error', (err) => resolve({ error: err.message })) + }) + }) + // IPC: save / load profile JSON const profileDir = app.getPath('userData') const profilePath = path.join(profileDir, 'Default Profile.json') diff --git a/electron/preload.cjs b/electron/preload.cjs index a10ffbe..70316be 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -9,6 +9,7 @@ contextBridge.exposeInMainWorld('streamDeck', { onSleep: (cb) => ipcRenderer.on('deck:sleep', (_, data) => cb(data)), onWake: (cb) => ipcRenderer.on('deck:wake', (_, data) => cb(data)), setButtonIcon: (index, rgbaData) => ipcRenderer.invoke('button:setIcon', { index, rgbaData }), - saveProfile: (data) => ipcRenderer.invoke('profile:save', data), - loadProfile: () => ipcRenderer.invoke('profile:load'), + saveProfile: (data) => ipcRenderer.invoke('profile:save', data), + loadProfile: () => ipcRenderer.invoke('profile:load'), + executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), }) diff --git a/src/App.css b/src/App.css index a07f18a..aeea898 100644 --- a/src/App.css +++ b/src/App.css @@ -759,3 +759,150 @@ .context-menu-item.danger:hover { background: #2a1a1a; } + +/* ── Action section ──────────────────────────────────── */ +.assigned-action { + display: flex; + flex-direction: column; + gap: 8px; +} + +.action-chip { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 10px; + background: #1e2a3a; + border: 1px solid #1e4a8a; + border-radius: 7px; + color: #5aabff; + font-size: 12px; + font-weight: 500; +} + +.action-remove { + margin-left: auto; + background: none; + border: none; + color: #3a5a8a; + cursor: pointer; + padding: 2px; + border-radius: 3px; + display: flex; + align-items: center; + transition: color 0.1s; +} + +.action-remove:hover { color: #e05555; } + +.action-type-list { + display: flex; + flex-direction: column; + gap: 2px; + background: #1e1e1e; + border: 1px solid #2e2e2e; + border-radius: 8px; + padding: 4px; +} + +.action-type-item { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 9px; + background: none; + border: none; + border-radius: 5px; + color: #cccccc; + font-size: 12px; + cursor: pointer; + text-align: left; + transition: background 0.1s; +} + +.action-type-item:hover:not(.disabled) { background: #2a2a2a; } + +.action-type-item.disabled { + color: #444; + cursor: default; +} + +.action-type-icon { + width: 18px; + text-align: center; + font-size: 13px; +} + +.action-type-soon { + margin-left: auto; + font-size: 10px; + color: #3a3a3a; + background: #222; + border-radius: 3px; + padding: 1px 5px; +} + +/* ── Hotkey recorder ─────────────────────────────────── */ +.hotkey-editor { + display: flex; + align-items: center; + gap: 6px; +} + +.hotkey-recorder { + flex: 1; + padding: 8px 12px; + background: #181818; + border: 1px solid #2e2e2e; + border-radius: 7px; + cursor: pointer; + outline: none; + transition: border-color 0.15s, background 0.15s; + min-height: 36px; + display: flex; + align-items: center; +} + +.hotkey-recorder:hover, +.hotkey-recorder:focus { border-color: #0082ff; } + +.hotkey-recorder.recording { + border-color: #0082ff; + background: #0d1a2e; + animation: pulse-border 1s ease-in-out infinite; +} + +@keyframes pulse-border { + 0%, 100% { border-color: #0082ff; } + 50% { border-color: #0050aa; } +} + +.hotkey-keys { + font-family: 'SF Mono', 'Cascadia Code', monospace; + font-size: 12px; + color: #5aabff; + letter-spacing: 0.04em; +} + +.hotkey-hint { + font-size: 12px; + color: #3a3a3a; + font-style: italic; +} + +.hotkey-clear { + background: none; + border: 1px solid #2e2e2e; + border-radius: 5px; + color: #555; + cursor: pointer; + padding: 5px 7px; + display: flex; + align-items: center; + transition: color 0.1s, border-color 0.1s; +} + +.hotkey-clear:hover { + color: #e05555; + border-color: #703030; +} diff --git a/src/App.jsx b/src/App.jsx index c7ca2ec..2299e97 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -178,6 +178,139 @@ function ButtonGrid({ rows, cols, selectedKey, pressedKey, onSelectKey, buttonCo } // ─── Properties Panel ─────────────────────────────────────── + +// Convert a browser KeyboardEvent to an xdotool key string, e.g. "ctrl+shift+a" +function toXdotoolKey(e) { + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return null // modifier-only press + const KEY_MAP = { + ' ': 'space', Enter: 'Return', Escape: 'Escape', Tab: 'Tab', + Backspace: 'BackSpace', Delete: 'Delete', Insert: 'Insert', + Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', + ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down', + 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 parts = [] + if (e.ctrlKey) parts.push('ctrl') + if (e.altKey) parts.push('alt') + if (e.shiftKey) parts.push('shift') + if (e.metaKey) parts.push('super') + const keyName = KEY_MAP[e.key] ?? (e.key.length === 1 ? e.key.toLowerCase() : null) + if (!keyName) return null + parts.push(keyName) + return parts.join('+') +} + +// ─── Hotkey Recorder ──────────────────────────────────────── +function HotkeyEditor({ value, onChange }) { + const [recording, setRecording] = useState(false) + + const startRecording = () => setRecording(true) + + const handleKeyDown = (e) => { + if (!recording) return + e.preventDefault() + if (e.key === 'Escape') { setRecording(false); return } + const combo = toXdotoolKey(e) + if (combo) { + onChange(combo) + setRecording(false) + } + } + + return ( +
+
setRecording(false)} + onClick={startRecording} + role="button" + aria-label="Record hotkey" + > + {recording + ? Press a key combination… + : value + ? {value} + : Click to record + } +
+ {value && !recording && ( + + )} +
+ ) +} + +// ─── Action Picker ─────────────────────────────────────────── +const ACTION_TYPE_LABELS = { + hotkey: 'Hotkey', +} + +function ActionSection({ action, onChange }) { + const [picking, setPicking] = useState(false) + + if (action?.type === 'hotkey') { + return ( +
+
+ + + + + Hotkey + +
+ onChange({ action: { type: 'hotkey', keys } })} + /> +
+ ) + } + + return ( +
+ {picking ? ( +
+ {ACTION_CATEGORIES.flatMap(cat => cat.actions).map(a => ( + + ))} +
+ ) : ( + + )} +
+ ) +} + function PropertiesPanel({ keyIndex, onClose, config, onChange, iconSize }) { const fileInputRef = useRef(null) @@ -208,13 +341,7 @@ function PropertiesPanel({ keyIndex, onClose, config, onChange, iconSize }) {
Action - +
@@ -328,6 +455,10 @@ export default function App() { const [iconSize, setIconSize] = useState(72) const [contextMenu, setContextMenu] = useState(null) + // Keep a ref so event handlers registered once can always see latest configs + const buttonConfigsRef = useRef({}) + useEffect(() => { buttonConfigsRef.current = buttonConfigs }, [buttonConfigs]) + // Composite icon + title on canvas → send RGBA to hardware const drawHardwareButton = async (index, config) => { if (!window.streamDeck?.setButtonIcon || !iconSize) return @@ -409,8 +540,18 @@ export default function App() { setDevice(info) if (info.iconSize) setIconSize(info.iconSize) }) - window.streamDeck.onKeyDown(({ index }) => setPressedKey(index)) - window.streamDeck.onKeyUp(({ index }) => setPressedKey(p => p === index ? null : p)) + window.streamDeck.onKeyDown(({ index }) => { + setPressedKey(index) + const action = buttonConfigsRef.current[index]?.action + if (action?.type === 'hotkey' && action.keys) { + window.streamDeck.executeHotkey(action.keys) + } + }) + window.streamDeck.onKeyUp(({ index }) => { + setPressedKey(p => p === index ? null : p) + // Redraw the hardware button to restore the user's icon after the press-flash + drawHardwareButton(index, buttonConfigsRef.current[index]) + }) window.streamDeck.onSleep(() => setSleeping(true)) window.streamDeck.onWake(() => setSleeping(false)) }, []) From 8d558b7c91e73b289b64eb6836be56b58fa1a380 Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 16:05:01 +1000 Subject: [PATCH 03/66] feat: add functionality to open applications and browse for files via IPC --- electron/main.cjs | 41 ++++++++++++++++- electron/preload.cjs | 14 +++--- src/App.css | 84 ++++++++++++++++++++++++++++++++++ src/App.jsx | 105 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 225 insertions(+), 19 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index 00e8b86..cdd5cad 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,6 +1,6 @@ 'use strict' -const { app, BrowserWindow, ipcMain } = require('electron') +const { app, BrowserWindow, ipcMain, dialog } = require('electron') const path = require('path') const fs = require('fs') const { spawn } = require('child_process') @@ -179,6 +179,45 @@ async function initStreamDeck() { }) }) + // IPC: open application via xdg-open or direct binary + ipcMain.handle('action:open-app', async (_, { target, mode }) => { + if (!target?.trim()) return { error: 'No target specified' } + const safeTarget = target.trim() + let cmd, args + if (mode === 'direct') { + // Split so "flatpak run com.obsproject.Studio" → cmd=flatpak args=[run, ...] + const parts = safeTarget.split(/\s+/) + cmd = parts[0] + args = parts.slice(1) + } else if (mode === 'xdg-open') { + cmd = 'xdg-open' + args = [safeTarget] + } else { + // default: gtk-launch — resolves .desktop IDs including Flatpak apps + cmd = 'gtk-launch' + args = [safeTarget] + } + console.log('[open-app] spawning:', cmd, args) + return new Promise((resolve) => { + const proc = spawn(cmd, args, { detached: true, stdio: 'ignore', env: process.env }) + proc.unref() + proc.once('spawn', () => resolve({ code: 0 })) + proc.once('error', (err) => { + console.error('[open-app] error:', err.message) + resolve({ error: err.message }) + }) + }) + }) + + // IPC: open native file-picker so renderer can browse for a binary + ipcMain.handle('dialog:open-file', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Select Application', + properties: ['openFile'], + }) + return result.canceled ? null : result.filePaths[0] + }) + // IPC: save / load profile JSON const profileDir = app.getPath('userData') const profilePath = path.join(profileDir, 'Default Profile.json') diff --git a/electron/preload.cjs b/electron/preload.cjs index 70316be..d40531a 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -3,13 +3,15 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('streamDeck', { - onInfo: (cb) => ipcRenderer.on('deck:info', (_, data) => cb(data)), - onKeyDown: (cb) => ipcRenderer.on('deck:down', (_, data) => cb(data)), - onKeyUp: (cb) => ipcRenderer.on('deck:up', (_, data) => cb(data)), - onSleep: (cb) => ipcRenderer.on('deck:sleep', (_, data) => cb(data)), - onWake: (cb) => ipcRenderer.on('deck:wake', (_, data) => cb(data)), + onInfo: (cb) => { const wrap = (_, d) => cb(d); ipcRenderer.on('deck:info', wrap); return () => ipcRenderer.removeListener('deck:info', wrap) }, + onKeyDown: (cb) => { const wrap = (_, d) => cb(d); ipcRenderer.on('deck:down', wrap); return () => ipcRenderer.removeListener('deck:down', wrap) }, + onKeyUp: (cb) => { const wrap = (_, d) => cb(d); ipcRenderer.on('deck:up', wrap); return () => ipcRenderer.removeListener('deck:up', wrap) }, + onSleep: (cb) => { const wrap = (_, d) => cb(d); ipcRenderer.on('deck:sleep', wrap); return () => ipcRenderer.removeListener('deck:sleep', wrap) }, + onWake: (cb) => { const wrap = (_, d) => cb(d); ipcRenderer.on('deck:wake', wrap); return () => ipcRenderer.removeListener('deck:wake', wrap) }, setButtonIcon: (index, rgbaData) => ipcRenderer.invoke('button:setIcon', { index, rgbaData }), saveProfile: (data) => ipcRenderer.invoke('profile:save', data), loadProfile: () => ipcRenderer.invoke('profile:load'), - executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), + executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), + openApplication: (target, mode) => ipcRenderer.invoke('action:open-app', { target, mode }), + browseForFile: () => ipcRenderer.invoke('dialog:open-file'), }) diff --git a/src/App.css b/src/App.css index aeea898..1709576 100644 --- a/src/App.css +++ b/src/App.css @@ -906,3 +906,87 @@ color: #e05555; border-color: #703030; } + +/* ── Open App editor ─────────────────────────────────── */ +.open-app-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.open-app-input-row { + display: flex; + gap: 6px; + align-items: center; +} + +.open-app-input-row .prop-input { + flex: 1; +} + +.browse-btn { + flex-shrink: 0; + padding: 6px 8px; + background: #1e1e1e; + border: 1px solid #2e2e2e; + border-radius: 6px; + color: #666; + cursor: pointer; + display: flex; + align-items: center; + transition: color 0.1s, border-color 0.1s; +} + +.browse-btn:hover { + color: #aaa; + border-color: #444; +} + +.open-app-mode-toggle { + display: none; /* replaced by open-app-modes */ +} + +.open-app-modes { + display: flex; + flex-direction: column; + gap: 3px; +} + +.open-app-mode-option { + display: flex; + align-items: baseline; + gap: 7px; + padding: 5px 8px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + color: #666; + transition: background 0.1s; +} + +.open-app-mode-option:hover { background: #1e1e1e; } + +.open-app-mode-option.active { + background: #1a2030; + color: #aaa; +} + +.open-app-mode-option input[type="radio"] { + accent-color: #0082ff; + cursor: pointer; + flex-shrink: 0; +} + +.open-app-mode-label { + font-weight: 500; + color: #888; + min-width: 68px; +} + +.open-app-mode-option.active .open-app-mode-label { color: #5aabff; } + +.open-app-mode-hint { + font-size: 10px; + color: #444; + font-style: italic; +} diff --git a/src/App.jsx b/src/App.jsx index 2299e97..29b9700 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -247,14 +247,63 @@ function HotkeyEditor({ value, onChange }) { ) } -// ─── Action Picker ─────────────────────────────────────────── -const ACTION_TYPE_LABELS = { - hotkey: 'Hotkey', +// ─── Open App Editor ──────────────────────────────────────── +function OpenAppEditor({ target, mode, onChange }) { + const browseFile = async () => { + const file = await window.streamDeck?.browseForFile() + if (file) onChange({ target: file }) + } + + return ( +
+
+ onChange({ target: e.target.value })} + /> + +
+
+ {[ + { value: 'gtk-launch', label: 'App ID', hint: 'Recommended — works for Flatpak & native' }, + { value: 'xdg-open', label: 'xdg-open', hint: 'Open files / URLs with default handler' }, + { value: 'direct', label: 'Command', hint: 'Run a binary or shell command directly' }, + ].map(opt => ( + + ))} +
+
+ ) } +// ─── Action Picker ─────────────────────────────────────────── +const ENABLED_ACTIONS = new Set(['hotkey', 'open-app']) + function ActionSection({ action, onChange }) { const [picking, setPicking] = useState(false) + // ── assigned: hotkey ── if (action?.type === 'hotkey') { return (
@@ -278,6 +327,32 @@ function ActionSection({ action, onChange }) { ) } + // ── assigned: open-app ── + if (action?.type === 'open-app') { + return ( +
+
+ + + + + Open Application + +
+ onChange({ action: { type: 'open-app', ...action, ...updates } })} + /> +
+ ) + } + + // ── unassigned ── return (
{picking ? ( @@ -285,16 +360,19 @@ function ActionSection({ action, onChange }) { {ACTION_CATEGORIES.flatMap(cat => cat.actions).map(a => ( ))}
@@ -536,24 +614,27 @@ export default function App() { useEffect(() => { if (!window.streamDeck) return - window.streamDeck.onInfo(info => { + const offInfo = window.streamDeck.onInfo(info => { setDevice(info) if (info.iconSize) setIconSize(info.iconSize) }) - window.streamDeck.onKeyDown(({ index }) => { + const offDown = window.streamDeck.onKeyDown(({ index }) => { setPressedKey(index) const action = buttonConfigsRef.current[index]?.action if (action?.type === 'hotkey' && action.keys) { window.streamDeck.executeHotkey(action.keys) + } else if (action?.type === 'open-app' && action.target) { + window.streamDeck.openApplication(action.target, action.mode ?? 'gtk-launch') } }) - window.streamDeck.onKeyUp(({ index }) => { + const offUp = window.streamDeck.onKeyUp(({ index }) => { setPressedKey(p => p === index ? null : p) // Redraw the hardware button to restore the user's icon after the press-flash drawHardwareButton(index, buttonConfigsRef.current[index]) }) - window.streamDeck.onSleep(() => setSleeping(true)) - window.streamDeck.onWake(() => setSleeping(false)) + const offSleep = window.streamDeck.onSleep(() => setSleeping(true)) + const offWake = window.streamDeck.onWake(() => setSleeping(false)) + return () => { offInfo(); offDown(); offUp(); offSleep(); offWake() } }, []) const rows = device?.rows ?? 3 From 751b31f62a0c1237426277bbc28b04eafeb5d52a Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 16:56:32 +1000 Subject: [PATCH 04/66] feat: implement icon library functionality with directory browsing and icon loading --- electron/main.cjs | 125 +++++++++++++++++++++----- electron/preload.cjs | 8 +- src/App.css | 205 ++++++++++++++++++++++++++++++++++++++++++- src/App.jsx | 182 +++++++++++++++++++++++++++++++++++++- 4 files changed, 488 insertions(+), 32 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index cdd5cad..e2a5613 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,11 +1,36 @@ 'use strict' -const { app, BrowserWindow, ipcMain, dialog } = require('electron') +const { app, BrowserWindow, ipcMain, dialog, protocol, net } = require('electron') const path = require('path') const fs = require('fs') const { spawn } = require('child_process') let mainWindow +let libraryDir = null + +// Recursively walk a directory and collect image file paths +function scanDir(dirPath, maxFiles = 5000) { + const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg']) + const results = [] + function walk(dir, category) { + let entries + try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + if (results.length >= maxFiles) return + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full, entry.name) + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase() + if (IMAGE_EXTS.has(ext)) { + results.push({ name: path.basename(entry.name, ext), category, path: full }) + } + } + } + } + walk(dirPath, '') + return results +} function sendToRenderer(channel, data) { if (mainWindow && !mainWindow.isDestroyed()) { @@ -93,24 +118,15 @@ async function initStreamDeck() { return buf } - // Restore all buttons to their awake state. - // Button 0 shows a green circle (sleep-toggle indicator). - // All other buttons go dark. + // Restore brightness when waking — renderer redraws icons from profile async function drawAwakeState() { await deck.setBrightness(100) - if (ICON_SIZE) { - await deck.fillKeyBuffer(0, circleBuffer(0, 200, 80), { format: 'rgb' }) - } else { - await deck.fillKeyColor(0, 0, 200, 80) - } - for (let i = 1; i < buttonControls.length; i++) { - await deck.fillKeyColor(i, 0, 0, 0) - } } - // Draw initial test pattern — proves hardware image drawing works + // Start with a clean panel; renderer will draw icons once the profile loads + await deck.clearPanel() await drawAwakeState() - console.log('[StreamDeck] Test image drawn — button 0 (green circle) = sleep toggle') + console.log('[StreamDeck] Device ready') deck.on('down', async (control) => { if (isSleeping) { @@ -122,16 +138,6 @@ async function initStreamDeck() { return } - if (control.index === 0) { - // Button 0 = sleep toggle - isSleeping = true - await deck.clearPanel() - await deck.setBrightness(0) - sendToRenderer('deck:sleep', {}) - console.log('[StreamDeck] Sleep') - return - } - // Flash the pressed button blue if (ICON_SIZE) { await deck.fillKeyBuffer(control.index, circleBuffer(0, 130, 255), { format: 'rgb' }) @@ -209,6 +215,22 @@ async function initStreamDeck() { }) }) + // IPC: sleep / wake toggle — triggered by renderer when a sleep-toggle action fires + ipcMain.handle('action:sleep-toggle', async () => { + if (isSleeping) { + isSleeping = false + await drawAwakeState() + sendToRenderer('deck:wake', {}) + console.log('[StreamDeck] Wake (action)') + } else { + isSleeping = true + await deck.clearPanel() + await deck.setBrightness(0) + sendToRenderer('deck:sleep', {}) + console.log('[StreamDeck] Sleep (action)') + } + }) + // IPC: open native file-picker so renderer can browse for a binary ipcMain.handle('dialog:open-file', async () => { const result = await dialog.showOpenDialog(mainWindow, { @@ -260,8 +282,63 @@ async function initStreamDeck() { } app.whenReady().then(async () => { + // Serve local icon library files via a custom protocol to avoid CORS issues in dev + protocol.handle('iconlib', (request) => { + const encoded = request.url.slice('iconlib://'.length) + const filePath = decodeURIComponent(encoded) + const resolved = path.resolve(filePath) + const safeBase = libraryDir ? path.resolve(libraryDir) : null + // Security: only serve files within the chosen library directory + if (!safeBase || (!resolved.startsWith(safeBase + path.sep) && resolved !== safeBase)) { + return new Response('Forbidden', { status: 403 }) + } + const ext = path.extname(resolved).toLowerCase() + if (!['.png', '.jpg', '.jpeg', '.gif', '.svg'].includes(ext)) { + return new Response('Forbidden', { status: 403 }) + } + return net.fetch('file://' + resolved) + }) + createWindow() await initStreamDeck() + + // IPC: icon library — open folder picker + ipcMain.handle('icons:browse-dir', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Select Icon Library Folder', + properties: ['openDirectory'], + }) + return result.canceled ? null : result.filePaths[0] + }) + + // IPC: icon library — scan folder, return [{name, category, path}] + ipcMain.handle('icons:scan-dir', async (_, { dirPath }) => { + if (!dirPath) return { error: 'No path given' } + const resolved = path.resolve(dirPath) + libraryDir = resolved + const icons = scanDir(resolved) + return { icons } + }) + + // IPC: icon library — read one icon file, return base64 dataUrl + ipcMain.handle('icons:load-file', async (_, { filePath }) => { + if (!filePath) return { error: 'No path' } + const resolved = path.resolve(filePath) + const safeBase = libraryDir ? path.resolve(libraryDir) : null + if (!safeBase || (!resolved.startsWith(safeBase + path.sep) && resolved !== safeBase)) { + return { error: 'Path not in library' } + } + const ext = path.extname(resolved).toLowerCase() + const ALLOWED = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg']) + if (!ALLOWED.has(ext)) return { error: 'Not an image' } + try { + const data = fs.readFileSync(resolved) + const mime = { '.svg': 'image/svg+xml', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' }[ext] ?? 'image/png' + return { dataUrl: `data:${mime};base64,${data.toString('base64')}` } + } catch (err) { + return { error: err.message } + } + }) }) app.on('window-all-closed', () => { diff --git a/electron/preload.cjs b/electron/preload.cjs index d40531a..422785f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -11,7 +11,11 @@ contextBridge.exposeInMainWorld('streamDeck', { setButtonIcon: (index, rgbaData) => ipcRenderer.invoke('button:setIcon', { index, rgbaData }), saveProfile: (data) => ipcRenderer.invoke('profile:save', data), loadProfile: () => ipcRenderer.invoke('profile:load'), - executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), - openApplication: (target, mode) => ipcRenderer.invoke('action:open-app', { target, mode }), + executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), + openApplication: (target, mode) => ipcRenderer.invoke('action:open-app', { target, mode }), + sleepToggle: () => ipcRenderer.invoke('action:sleep-toggle'), browseForFile: () => ipcRenderer.invoke('dialog:open-file'), + browseIconDir: () => ipcRenderer.invoke('icons:browse-dir'), + scanIconDir: (dirPath) => ipcRenderer.invoke('icons:scan-dir', { dirPath }), + loadIconFile: (filePath) => ipcRenderer.invoke('icons:load-file', { filePath }), }) diff --git a/src/App.css b/src/App.css index 1709576..20bb32a 100644 --- a/src/App.css +++ b/src/App.css @@ -707,14 +707,14 @@ } .icon-remove-btn { - margin-top: 6px; - padding: 4px 10px; + padding: 5px 10px; background: none; border: 1px solid #2e2e2e; border-radius: 5px; color: #666; font-size: 11px; cursor: pointer; + white-space: nowrap; transition: color 0.1s, border-color 0.1s; } @@ -990,3 +990,204 @@ color: #444; font-style: italic; } + +.action-hint { + font-size: 11px; + color: #3d3d3d; + margin: 4px 0 0; + line-height: 1.4; +} + +/* ─── Icon Library Modal ─────────────────────────────────── */ + +.icon-action-row { + display: flex; + gap: 6px; + margin-top: 6px; + align-items: center; +} + +.icon-lib-open-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + background: #2a2a2a; + border: 1px solid #333; + border-radius: 5px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex: 1; +} +.icon-lib-open-btn:hover { background: #333; color: #bbb; } + +.icon-lib-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-lib-modal { + background: #1c1c1c; + border: 1px solid #333; + border-radius: 10px; + width: 680px; + max-width: 96vw; + height: 520px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 64px rgba(0,0,0,0.7); +} + +.icon-lib-header { + padding: 14px 16px 10px; + border-bottom: 1px solid #2a2a2a; + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.icon-lib-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.icon-lib-title { + font-size: 13px; + font-weight: 600; + color: #ccc; + flex: 1; +} + +.icon-lib-folder-btn { + font-size: 11px; + color: #5aabff; + background: none; + border: none; + cursor: pointer; + padding: 0; +} +.icon-lib-folder-btn:hover { color: #82c5ff; } + +.icon-lib-close { + background: none; + border: none; + color: #555; + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; +} +.icon-lib-close:hover { color: #aaa; } + +.icon-lib-search { + width: 100%; + background: #141414; + border: 1px solid #2d2d2d; + border-radius: 5px; + color: #ccc; + font-size: 12px; + padding: 6px 10px; + outline: none; + box-sizing: border-box; +} +.icon-lib-search:focus { border-color: #0082ff; } + +.icon-lib-cats { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.icon-lib-cat { + padding: 3px 9px; + border-radius: 12px; + font-size: 11px; + background: #252525; + border: 1px solid #333; + color: #666; + cursor: pointer; + white-space: nowrap; + transition: background 0.1s, color 0.1s; +} +.icon-lib-cat:hover { background: #2f2f2f; color: #aaa; } +.icon-lib-cat.active { background: #0a2a4a; border-color: #0082ff; color: #5aabff; } + +.icon-lib-body { + flex: 1; + overflow-y: auto; + padding: 10px; +} + +.icon-lib-status { + text-align: center; + color: #444; + font-size: 12px; + padding: 40px 0; +} + +.icon-lib-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 20px; + text-align: center; + color: #555; + font-size: 12px; +} +.icon-lib-empty p { margin: 0; } +.icon-lib-hint { color: #3a3a3a; font-size: 11px; line-height: 1.5; } + +.icon-lib-choose-btn { + margin-top: 8px; + padding: 7px 18px; + background: #0a2a4a; + border: 1px solid #0082ff; + border-radius: 6px; + color: #5aabff; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} +.icon-lib-choose-btn:hover { background: #0d3560; } + +.icon-lib-grid { + display: grid; + grid-template-columns: repeat(auto-fill, 60px); + gap: 4px; +} + +.lib-icon-item { + width: 60px; + height: 60px; + background: #222; + border: 1px solid transparent; + border-radius: 6px; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s, border-color 0.1s; + overflow: hidden; +} +.lib-icon-item:hover { background: #2a2a2a; border-color: #0082ff; } + +.lib-icon-thumb { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + border-radius: 3px; +} diff --git a/src/App.jsx b/src/App.jsx index 29b9700..ddeb415 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,6 +17,7 @@ const ACTION_CATEGORIES = [ id: 'streamdeck', name: 'Stream Deck', actions: [ + { id: 'sleep-toggle', name: 'Sleep', icon: '☽' }, { id: 'switch-profile', name: 'Switch Profile', icon: '⇄' }, { id: 'back-folder', name: 'Back to Folder', icon: '↩' }, { id: 'create-folder', name: 'Create Folder', icon: '▣' }, @@ -177,6 +178,132 @@ function ButtonGrid({ rows, cols, selectedKey, pressedKey, onSelectKey, buttonCo ) } +// ─── Icon Library Modal ───────────────────────────────────── +function IconLibraryModal({ onSelect, onClose }) { + const [icons, setIcons] = useState([]) + const [query, setQuery] = useState('') + const [category, setCategory] = useState('all') + const [loading, setLoading] = useState(false) + const [libraryPath, setLibraryPath] = useState(() => localStorage.getItem('iconLibraryPath') ?? '') + + useEffect(() => { + if (libraryPath) scan(libraryPath) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const scan = async (dirPath) => { + setLoading(true) + const result = await window.streamDeck?.scanIconDir(dirPath) + setLoading(false) + if (result?.icons) setIcons(result.icons) + } + + const browse = async () => { + const dir = await window.streamDeck?.browseIconDir() + if (!dir) return + localStorage.setItem('iconLibraryPath', dir) + setLibraryPath(dir) + scan(dir) + } + + const handleSelect = async (icon) => { + const result = await window.streamDeck?.loadIconFile(icon.path) + if (result?.dataUrl) onSelect(result.dataUrl) + } + + const categories = ['all', ...new Set(icons.map(i => i.category).filter(Boolean))] + const filtered = icons.filter(icon => { + const q = query.toLowerCase() + return (!q || icon.name.toLowerCase().includes(q)) && + (category === 'all' || icon.category === category) + }) + + return ( +
+
e.stopPropagation()}> +
+
+ Icon Library + + +
+ {icons.length > 0 && ( + setQuery(e.target.value)} + autoFocus + /> + )} + {categories.length > 1 && ( +
+ {categories.slice(0, 24).map(cat => ( + + ))} +
+ )} +
+ +
+ {loading &&
Scanning folder…
} + + {!loading && !libraryPath && ( +
+ + + +

No folder selected

+

+ Extract an icon pack and point here to the folder.
+ Works with any PNG, JPG, SVG or GIF files. +

+ +
+ )} + + {!loading && libraryPath && icons.length === 0 && ( +
No image files found in this folder.
+ )} + + {!loading && filtered.length > 0 && ( +
+ {filtered.map((icon, i) => ( + + ))} +
+ )} +
+
+
+ ) +} + // ─── Properties Panel ─────────────────────────────────────── // Convert a browser KeyboardEvent to an xdotool key string, e.g. "ctrl+shift+a" @@ -298,11 +425,31 @@ function OpenAppEditor({ target, mode, onChange }) { } // ─── Action Picker ─────────────────────────────────────────── -const ENABLED_ACTIONS = new Set(['hotkey', 'open-app']) +const ENABLED_ACTIONS = new Set(['hotkey', 'open-app', 'sleep-toggle']) function ActionSection({ action, onChange }) { const [picking, setPicking] = useState(false) + // ── assigned: sleep-toggle ── + if (action?.type === 'sleep-toggle') { + return ( +
+
+ + + + Sleep + +
+

Puts the deck to sleep. Any button press wakes it.

+
+ ) + } + // ── assigned: hotkey ── if (action?.type === 'hotkey') { return ( @@ -365,6 +512,8 @@ function ActionSection({ action, onChange }) { if (!ENABLED_ACTIONS.has(a.id)) return const defaults = a.id === 'hotkey' ? { type: 'hotkey', keys: '' } + : a.id === 'sleep-toggle' + ? { type: 'sleep-toggle' } : { type: 'open-app', target: '', mode: 'gtk-launch' } onChange({ action: defaults }) setPicking(false) @@ -391,6 +540,7 @@ function ActionSection({ action, onChange }) { function PropertiesPanel({ keyIndex, onClose, config, onChange, iconSize }) { const fileInputRef = useRef(null) + const [showLibrary, setShowLibrary] = useState(false) const handleIconSelect = (e) => { const file = e.target.files[0] @@ -437,9 +587,17 @@ function PropertiesPanel({ keyIndex, onClose, config, onChange, iconSize }) {
)}
- {config?.iconDataUrl && ( - - )} +
+ + {config?.iconDataUrl && ( + + )} +
+ {showLibrary && ( + { onChange({ iconDataUrl: dataUrl }); setShowLibrary(false) }} + onClose={() => setShowLibrary(false)} + /> + )}
@@ -625,6 +789,8 @@ export default function App() { window.streamDeck.executeHotkey(action.keys) } else if (action?.type === 'open-app' && action.target) { window.streamDeck.openApplication(action.target, action.mode ?? 'gtk-launch') + } else if (action?.type === 'sleep-toggle') { + window.streamDeck.sleepToggle() } }) const offUp = window.streamDeck.onKeyUp(({ index }) => { @@ -637,6 +803,14 @@ export default function App() { return () => { offInfo(); offDown(); offUp(); offSleep(); offWake() } }, []) + // When waking, re-draw every hardware button with the stored config + useEffect(() => { + if (sleeping) return + Object.entries(buttonConfigsRef.current).forEach(([idx, cfg]) => { + drawHardwareButton(Number(idx), cfg) + }) + }, [sleeping]) // eslint-disable-line react-hooks/exhaustive-deps + const rows = device?.rows ?? 3 const cols = device?.cols ?? 5 const deviceName = device From 2dd13999bb8208a0c92f1c311e49be2745be032e Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 17:02:52 +1000 Subject: [PATCH 05/66] feat: add functionality to open URLs in the default browser via IPC --- electron/main.cjs | 16 ++++++++++++++++ electron/preload.cjs | 1 + src/App.jsx | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/electron/main.cjs b/electron/main.cjs index e2a5613..e4fb1cf 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -215,6 +215,22 @@ async function initStreamDeck() { }) }) + // IPC: open a URL in the default browser + ipcMain.handle('action:open-url', async (_, { url }) => { + if (!url?.trim()) return { error: 'No URL specified' } + const safe = url.trim() + // Only allow http/https/ftp — block file:// and other schemes + if (!/^https?:\/\//i.test(safe) && !/^ftp:\/\//i.test(safe)) { + return { error: 'Only http/https/ftp URLs are allowed' } + } + return new Promise((resolve) => { + const proc = spawn('xdg-open', [safe], { detached: true, stdio: 'ignore', env: process.env }) + proc.unref() + proc.once('spawn', () => resolve({ code: 0 })) + proc.once('error', (err) => resolve({ error: err.message })) + }) + }) + // IPC: sleep / wake toggle — triggered by renderer when a sleep-toggle action fires ipcMain.handle('action:sleep-toggle', async () => { if (isSleeping) { diff --git a/electron/preload.cjs b/electron/preload.cjs index 422785f..df0d5a3 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld('streamDeck', { loadProfile: () => ipcRenderer.invoke('profile:load'), executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), openApplication: (target, mode) => ipcRenderer.invoke('action:open-app', { target, mode }), + openUrl: (url) => ipcRenderer.invoke('action:open-url', { url }), sleepToggle: () => ipcRenderer.invoke('action:sleep-toggle'), browseForFile: () => ipcRenderer.invoke('dialog:open-file'), browseIconDir: () => ipcRenderer.invoke('icons:browse-dir'), diff --git a/src/App.jsx b/src/App.jsx index ddeb415..3e0e9ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -425,7 +425,7 @@ function OpenAppEditor({ target, mode, onChange }) { } // ─── Action Picker ─────────────────────────────────────────── -const ENABLED_ACTIONS = new Set(['hotkey', 'open-app', 'sleep-toggle']) +const ENABLED_ACTIONS = new Set(['hotkey', 'open-app', 'open-url', 'sleep-toggle']) function ActionSection({ action, onChange }) { const [picking, setPicking] = useState(false) @@ -450,6 +450,33 @@ function ActionSection({ action, onChange }) { ) } + // ── assigned: open-url ── + if (action?.type === 'open-url') { + return ( +
+
+ + + + + Open URL + +
+ onChange({ action: { type: 'open-url', url: e.target.value } })} + /> +
+ ) + } + // ── assigned: hotkey ── if (action?.type === 'hotkey') { return ( @@ -514,6 +541,8 @@ function ActionSection({ action, onChange }) { ? { type: 'hotkey', keys: '' } : a.id === 'sleep-toggle' ? { type: 'sleep-toggle' } + : a.id === 'open-url' + ? { type: 'open-url', url: '' } : { type: 'open-app', target: '', mode: 'gtk-launch' } onChange({ action: defaults }) setPicking(false) @@ -789,6 +818,8 @@ export default function App() { window.streamDeck.executeHotkey(action.keys) } else if (action?.type === 'open-app' && action.target) { window.streamDeck.openApplication(action.target, action.mode ?? 'gtk-launch') + } else if (action?.type === 'open-url' && action.url) { + window.streamDeck.openUrl(action.url) } else if (action?.type === 'sleep-toggle') { window.streamDeck.sleepToggle() } From 568b3f71a715b6e10c3264f06e2ab63cecb286d5 Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 19 May 2026 17:23:09 +1000 Subject: [PATCH 06/66] feat: add 'run command' action to execute arbitrary shell commands via IPC --- electron/main.cjs | 15 +++++++++++++++ electron/preload.cjs | 1 + src/App.css | 8 ++++++++ src/App.jsx | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/electron/main.cjs b/electron/main.cjs index e4fb1cf..712f61b 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -231,6 +231,21 @@ async function initStreamDeck() { }) }) + // IPC: run an arbitrary shell command via bash -c + ipcMain.handle('action:run-cmd', async (_, { command }) => { + if (!command?.trim()) return { error: 'No command specified' } + return new Promise((resolve) => { + const proc = spawn('bash', ['-c', command.trim()], { + detached: true, + stdio: 'ignore', + env: process.env, + }) + proc.unref() + proc.once('spawn', () => resolve({ code: 0 })) + proc.once('error', (err) => resolve({ error: err.message })) + }) + }) + // IPC: sleep / wake toggle — triggered by renderer when a sleep-toggle action fires ipcMain.handle('action:sleep-toggle', async () => { if (isSleeping) { diff --git a/electron/preload.cjs b/electron/preload.cjs index df0d5a3..96dc981 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('streamDeck', { executeHotkey: (keys) => ipcRenderer.invoke('action:hotkey', { keys }), openApplication: (target, mode) => ipcRenderer.invoke('action:open-app', { target, mode }), openUrl: (url) => ipcRenderer.invoke('action:open-url', { url }), + runCommand: (command) => ipcRenderer.invoke('action:run-cmd', { command }), sleepToggle: () => ipcRenderer.invoke('action:sleep-toggle'), browseForFile: () => ipcRenderer.invoke('dialog:open-file'), browseIconDir: () => ipcRenderer.invoke('icons:browse-dir'), diff --git a/src/App.css b/src/App.css index 20bb32a..424da12 100644 --- a/src/App.css +++ b/src/App.css @@ -599,6 +599,14 @@ color: #3a3a3a; } +.run-cmd-input { + font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + font-size: 11px; + line-height: 1.6; + resize: vertical; + min-height: 60px; +} + .prop-color-row { display: flex; align-items: center; diff --git a/src/App.jsx b/src/App.jsx index 3e0e9ce..988ae14 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -425,7 +425,7 @@ function OpenAppEditor({ target, mode, onChange }) { } // ─── Action Picker ─────────────────────────────────────────── -const ENABLED_ACTIONS = new Set(['hotkey', 'open-app', 'open-url', 'sleep-toggle']) +const ENABLED_ACTIONS = new Set(['hotkey', 'open-app', 'open-url', 'run-cmd', 'sleep-toggle']) function ActionSection({ action, onChange }) { const [picking, setPicking] = useState(false) @@ -450,6 +450,35 @@ function ActionSection({ action, onChange }) { ) } + // ── assigned: run-cmd ── + if (action?.type === 'run-cmd') { + return ( +
+
+ + + + + + Run Command + +
+