import QtQuick import Quickshell import Quickshell.Io import "Model.js" as Model // Service owns every conversation with streamdeck-ctl. The panel binds to the // properties here and calls the functions; it never spawns a process itself. Item { id: root property var settings: ({}) // Set by the panel. While the popout is closed only the bar icon consumes // status, so polling relaxes to at most once a minute. property bool panelOpen: false // Full snapshot from `streamdeck-ctl status`, normalised by Model.parseStatus. property var status: Model.EMPTY_STATUS property bool loaded: false property bool refreshing: false property string lastError: "" property string actionStatus: "" // True once we have looked for the binary and failed — the panel shows install // guidance instead of controls in that state. property bool ctlMissing: false property string ctlResolved: "" readonly property bool busy: statusProcess.running || actionProcess.running readonly property bool connected: status.device.connected === true readonly property bool daemonActive: status.daemon.active === true // Brightness is echoed optimistically so the slider tracks the drag instead of // snapping back on the next poll, which lands up to refreshIntervalSec later. property int pendingBrightness: -1 readonly property int brightness: pendingBrightness >= 0 ? pendingBrightness : (Number(status.brightness) || 0) readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 10, 2, 600) readonly property string configPath: String(setting("configPath", "") || "") function setting(name, fallback) { var value = settings ? settings[name] : undefined return value === undefined || value === null || value === "" ? fallback : value } function intSetting(name, fallback, min, max) { var n = parseInt(String(setting(name, fallback)), 10) if (!isFinite(n)) n = fallback return Math.max(min, Math.min(max, n)) } // ── locating streamdeck-ctl ──────────────────────────────────────────────── // // The shell inherits a minimal PATH under systemd, so ~/.local/bin (Linux) and // ~/go/bin (macOS, and Go's default install target) are checked explicitly // before falling back to a PATH lookup. readonly property string home: Quickshell.env("HOME") readonly property var candidatePaths: [ String(setting("ctlPath", "") || ""), home + "/.local/bin/streamdeck-ctl", home + "/go/bin/streamdeck-ctl", "/usr/local/bin/streamdeck-ctl" ] function resolveCtl() { if (locateProcess.running) return locateProcess.command = ["sh", "-c", locateScript()] locateProcess.running = true } // A changed ctlPath setting (or HOME, in theory) must invalidate the cached // location — without this, ctlResolved is sticky for the life of the shell. onCandidatePathsChanged: { ctlResolved = "" ctlMissing = false resolveCtl() } function locateScript() { var checks = [] for (var i = 0; i < candidatePaths.length; i++) { var path = candidatePaths[i] if (!path) continue checks.push('[ -x ' + shellQuote(path) + ' ] && { printf %s ' + shellQuote(path) + '; exit 0; }') } checks.push('command -v streamdeck-ctl 2>/dev/null || true') return checks.join("\n") } function shellQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'" } // baseArgs prefixes every invocation, carrying the -config override when the // widget was pointed at a non-default config. function baseArgs() { var args = [] if (configPath !== "") args.push("-config", configPath) return args } // ── reading ──────────────────────────────────────────────────────────────── function refresh() { if (ctlResolved === "") { if (!locateProcess.running) resolveCtl() return } if (statusProcess.running) return refreshing = true statusProcess.command = [ctlResolved].concat(baseArgs()).concat(["status"]) statusProcess.running = true } // ── writing ──────────────────────────────────────────────────────────────── function setBrightness(value) { var clamped = Math.max(0, Math.min(100, Math.round(value))) pendingBrightness = clamped brightnessDebounce.restart() } function toggleDaemon() { runAction(["daemon", "toggle"], daemonActive ? "Stopping daemon…" : "Starting daemon…") } function restartDaemon() { runAction(["daemon", "restart"], "Restarting daemon…") } function pressKey(index) { runAction(["key", "press", String(index)], "") } function clearKey(index) { runAction(["key", "clear", String(index)], "Cleared key " + index) } // saveKey takes the flag list Model.editArguments built. Returning early on an // empty list keeps a no-op "Save" from rewriting the config file. function saveKey(index, flags) { if (!flags || flags.length === 0) { actionStatus = "No changes" actionStatusTimer.restart() return } runAction(["key", "set", String(index)].concat(flags), "Saved key " + index) } // Writes are queued, not dropped: a save clicked while another action is in // flight must still land. Brightness entries coalesce to the newest value so // a slider drag queues one write, not a backlog of intermediate positions. property var actionQueue: [] function runAction(args, message) { if (ctlResolved === "") { lastError = "streamdeck-ctl not found"; return } if (args[0] === "brightness") { actionQueue = actionQueue.filter(function(entry) { return entry.args[0] !== "brightness" }) } actionQueue.push({ args: args, message: message }) pumpActions() } function pumpActions() { if (actionProcess.running || actionQueue.length === 0) return var next = actionQueue.shift() actionProcess.pendingMessage = next.message actionProcess.command = [ctlResolved].concat(baseArgs()).concat(next.args) actionProcess.running = true } function openConfig() { var path = status.configPath || (home + "/.config/streamdeck-go/config.yaml") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", path]) } function openLogs() { Quickshell.execDetached(["uwsm-app", "--", "omarchy-launch-floating-terminal-with-presentation", "journalctl", "--user", "-u", "streamdeck-go.service", "-f", "-n", "200"]) } function openIcons() { var dir = status.iconsDir || (home + "/.config/streamdeck-go/icons") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", dir]) } // ── processes ────────────────────────────────────────────────────────────── Process { id: locateProcess running: false command: [] stdout: StdioCollector { id: locateStdout; waitForEnd: true } onExited: function(exitCode) { var found = String(locateStdout.text || "").trim().split("\n")[0].trim() if (found !== "") { root.ctlResolved = found root.ctlMissing = false root.refresh() } else { root.ctlMissing = true root.loaded = true } } } Process { id: statusProcess running: false command: [] stdout: StdioCollector { id: statusStdout; waitForEnd: true } stderr: StdioCollector { id: statusStderr; waitForEnd: true } onExited: function(exitCode) { root.refreshing = false root.loaded = true // A failure with empty stdout is spawn-shaped (the CLI always emits JSON, // even on error) — the binary may have been moved or removed. Drop the // cached location so the next tick re-probes. if (exitCode !== 0 && String(statusStdout.text || "").trim() === "") { root.ctlResolved = "" root.lastError = "streamdeck-ctl failed to run" return } var parsed = Model.parseStatus(statusStdout.text) if (parsed.ok === false && parsed.error) { root.lastError = parsed.error return } root.status = parsed root.lastError = parsed.configError ? parsed.configError : (exitCode !== 0 ? String(statusStderr.text || "").trim() : "") // The poll caught up with the optimistic value — stop overriding it. if (root.pendingBrightness >= 0 && Number(parsed.brightness) === root.pendingBrightness) { root.pendingBrightness = -1 } } } Process { id: actionProcess property string pendingMessage: "" running: false command: [] stdout: StdioCollector { id: actionStdout; waitForEnd: true } stderr: StdioCollector { id: actionStderr; waitForEnd: true } onExited: function(exitCode) { if (exitCode === 0) { root.lastError = "" if (actionProcess.pendingMessage !== "") { root.actionStatus = actionProcess.pendingMessage actionStatusTimer.restart() } } else { var message = String(actionStderr.text || actionStdout.text || "").trim() root.lastError = Model.truncate(message || "streamdeck-ctl failed", 160) root.actionStatus = "" // A failed brightness write must release the optimistic value, or the // slider shows a level the deck never reached — forever, since no poll // will ever confirm it. root.pendingBrightness = -1 } root.pumpActions() // Daemon transitions take a moment to settle; re-poll shortly after rather // than waiting for the next scheduled refresh. settleTimer.restart() root.refresh() } } // ── timers ───────────────────────────────────────────────────────────────── Timer { id: refreshTimer // The bar icon doesn't need panel-grade freshness; the panel refreshes // explicitly on open, so a slow background cadence costs nothing visible. interval: (root.panelOpen ? root.refreshIntervalSec : Math.max(root.refreshIntervalSec, 60)) * 1000 repeat: true running: true triggeredOnStart: true onTriggered: root.refresh() } Timer { // Dragging the slider would otherwise rewrite config.yaml on every pixel, // and each write triggers a daemon reload. Coalesce to the last value. id: brightnessDebounce interval: 180 repeat: false onTriggered: { if (root.pendingBrightness < 0) return root.runAction(["brightness", String(root.pendingBrightness)], "") } } Timer { id: settleTimer interval: 1200 repeat: false onTriggered: root.refresh() } Timer { id: actionStatusTimer interval: 2400 repeat: false onTriggered: root.actionStatus = "" } Component.onCompleted: resolveCtl() }