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
This commit is contained in:
311
omarchy-plugin/Service.qml
Normal file
311
omarchy-plugin/Service.qml
Normal file
@@ -0,0 +1,311 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user