Files
streamdeck-go/omarchy-plugin/Service.qml
Levi Woodard 2aa4c65e42 Add Omarchy shell plugin for the Stream Deck
QML bar widget + popout panel (dev.woodard.streamdeck) that drives
streamdeck-ctl: key grid shaped by the connected model, per-key editor,
brightness slider, and daemon controls. Themed via qs.Commons.

Makefile gains build-ctl, install-ctl, install-plugin, validate-plugin
and uninstall-plugin targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00

312 lines
11 KiB
QML

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()
}