import QtQuick import Quickshell import Quickshell.Io // Headless service: owns the screencast-server daemon (starts it, restarts it // if it dies) and keeps a control connection to it. Everything the panel shows // comes from the daemon's state pushes; everything the panel does goes through // send(). Item { id: root property var shell: null property var manifest: null // ---- daemon state (mirrors the JSON pushed by screencast-server) ---- property var state: ({}) readonly property bool connected: sockConnected readonly property var devices: state.devices || [] readonly property var session: state.session || ({}) readonly property var settings: state.settings || ({}) readonly property var capabilities: state.capabilities || ({}) readonly property string error: state.error || "" readonly property string busy: state.busy || "" readonly property string pairing: state.pairing || "" // Receivers pull the stream from us, so a default-deny firewall silently // stops every cast. The daemon sets `blocked` once a cast fails that way. readonly property var firewall: state.firewall || ({}) readonly property bool firewallBlocked: !!firewall.blocked readonly property string firewallCommand: firewall.command || "" readonly property string firewallTool: firewall.tool || "" readonly property bool firewallVerified: firewall.verified !== false readonly property int firewallPort: firewall.port || 8011 readonly property int streamPort: capabilities.port || 8011 readonly property bool airplayAvailable: capabilities.airplay !== false // "idle" | "starting" | "streaming" | "stopping" | "error" readonly property string sessionState: session.state || "idle" readonly property bool casting: sessionState === "starting" || sessionState === "streaming" readonly property bool live: sessionState === "streaming" && (session.clients || 0) > 0 readonly property string castingTo: session.deviceName || "" readonly property string castingId: session.deviceId || "" readonly property string sessionError: session.error || "" // What a "starting" session is waiting on, so the panel can say so. readonly property string sessionPhase: session.phase || "" // A JPEG of the screen actually being encoded, refreshed a few times a second // by the daemon. Empty until the first frame has been written. readonly property string sessionPreview: session.preview || "" readonly property real sessionPreviewAt: session.previewAt || 0 readonly property string sessionSource: session.source || "" readonly property int sessionWidth: session.width || 0 readonly property int sessionHeight: session.height || 0 readonly property real sessionSince: session.since || 0 readonly property string playerState: session.playerState || "" readonly property bool paused: playerState === "paused" readonly property bool stopping: sessionState === "stopping" function deviceById(id) { var list = devices for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i] return null } readonly property var castDevice: deviceById(castingId) // ---- what we are optimistically claiming, before the daemon answers ---- // Every round trip, however short, is a gap where a click looks ignored. The // panel says what it just asked for straight away and the daemon's own `busy` // takes over as soon as the first state arrives. property string pendingLabel: "" property string pendingId: "" // the device the pending action is about property real pendingAt: 0 readonly property string activity: pendingLabel !== "" ? pendingLabel : busy readonly property bool acting: activity !== "" function expect(label, id) { pendingLabel = label pendingId = id || "" pendingAt = Date.now() pendingTimer.restart() } function clearPending() { pendingLabel = "" pendingId = "" pendingTimer.stop() } // The daemon confirms by putting something in `busy` (it pushes that before // it starts the work). This only has to fire when it never answers at all. Timer { id: pendingTimer; interval: 8000; repeat: false; onTriggered: root.clearPending() } onBusyChanged: if (busy !== "") clearPending() // ---- commands ---- function send(obj) { if (!sockConnected) return false sock.write(JSON.stringify(obj) + "\n") sock.flush() return true } function refresh() { return send({ cmd: "get" }) } function rescan() { expect("Looking for displays…", "") return send({ cmd: "rescan" }) } function cast(deviceId) { var dev = deviceById(deviceId) expect("Connecting to " + (dev ? dev.name : "the display") + "…", deviceId) return send({ cmd: "cast", device: deviceId }) } function stop() { expect("Stopping…", castingId) return send({ cmd: "stop" }) } function setSetting(key, value) { var p = {}; p[key] = value; return send({ cmd: "set", settings: p }) } function setVolume(v) { return send({ cmd: "volume", value: v }) } function setMuted(v) { return send({ cmd: "mute", value: !!v }) } function transport(action) { return send({ cmd: "transport", action: action }) } // Restart the cast so the portal asks which screen to share again. function repickSource() { expect("Asking which screen to share…", castingId) return send({ cmd: "repick" }) } function pair(deviceId) { var dev = deviceById(deviceId) expect("Asking " + (dev ? dev.name : "the display") + " for a code…", deviceId) return send({ cmd: "pair", device: deviceId }) } function pairPin(pin) { expect("Checking the code…", pairing) return send({ cmd: "pairPin", pin: String(pin) }) } function pairCancel() { clearPending(); return send({ cmd: "pairCancel" }) } function openFirewall() { expect("Opening port " + firewallPort + "…", "") return send({ cmd: "openFirewall" }) } // Notification bodies are parsed as markup by most daemons, and both of these // carry device names and receiver error text off the network. function plain(text) { return String(text).replace(/[<>&]/g, " ").slice(0, 200) } function notify(title, body) { if (notifyProc.running) return title = plain(title); body = plain(body) notifyProc.command = ["sh", "-c", 'if command -v omarchy-notification-send >/dev/null 2>&1; then exec omarchy-notification-send "$1" "$2"; fi; exec notify-send "$1" "$2"', "screencast-notify", title, body] notifyProc.running = true } Process { id: notifyProc } // ---- paths ---- readonly property string runtimeDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screencast" readonly property string socketPath: runtimeDir + "/ctl.sock" readonly property string homeDir: Quickshell.env("HOME") || "" readonly property string libDir: homeDir + "/.local/lib/screencast" readonly property string daemonBinary: libDir + "/screencast-server" readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (homeDir + "/.cache")) + "/screencast" readonly property string installLog: cacheDir + "/install.log" // The plugin checkout (this file lives in /plugin/). readonly property string repoDir: decodeURIComponent(String(Qt.resolvedUrl("..")).replace(/^file:\/\//, "").replace(/\/$/, "")) property bool installed: false property string setupOutput: "" // only what the user must act on property string busyText: "" // transient progress, disappears on its own property string daemonLog: "" property string daemonError: "" property int restarts: 0 readonly property bool setupBusy: installProc.running // ---- install / update ---- // First run after `omarchy plugin add` (and "Reinstall" later): build the // virtualenv under ~/.local/lib and install the daemon into it. No password: // nothing here touches the system. function install() { if (installProc.running) return setupOutput = "" if (busyText === "") busyText = "Installing the caster…" daemonError = "" installProc.command = ["sh", "-c", 'mkdir -p "$(dirname "$2")"; cd "$1" && ./install.sh --no-root >"$2" 2>&1; rc=$?; tail -n 4 "$2"; exit $rc', "screencast-install", repoDir, installLog] installProc.running = true } Process { id: installProc property string tailText: "" stdout: StdioCollector { onStreamFinished: installProc.tailText = String(text).trim() } onExited: function(code) { root.busyText = "" if (code !== 0) { root.setupOutput = "Install failed (" + code + "). Log: " + root.installLog + "\n" + tailText } else { // Everything the installer can do without a password is done; system // packages are not one of them, so pass that one line straight through. const missing = /^MISSING: (.*)$/m.exec(tailText) root.setupOutput = missing ? "Some system packages are missing, so capture will fail. Install them:\n" + missing[1] : "" restartTimer.restart() } tailText = "" probeProc.running = true } } // After `omarchy plugin update` the shell reloads this plugin: rebuild // silently when the checkout has moved past what install.sh last installed. Process { id: updateCheckProc command: ["sh", "-c", 'a=$(git -C "$1" rev-parse HEAD 2>/dev/null) || exit 0; b=$(cat "$2/installed-commit" 2>/dev/null); ' + '[ -x "$2/screencast-server" ] || exit 0; [ -n "$a" ] && [ "$a" != "$b" ] && exit 3; exit 0', "screencast-updatecheck", root.repoDir, root.libDir] onExited: function(code) { if (code === 3) { root.busyText = "Updating…"; root.install() } } } Process { id: probeProc command: ["sh", "-c", 'test -x "$1"', "probe", root.daemonBinary] onExited: function(code) { root.installed = code === 0 if (root.installed && !daemon.running) restartTimer.restart() } } // While not installed, keep looking: an install interrupted by a shell reload // finishes on its own and we pick the daemon up. Timer { interval: 5000 repeat: true running: !root.installed && !installProc.running && !probeProc.running onTriggered: probeProc.running = true } // ---- daemon lifecycle ---- Process { id: daemon command: [root.daemonBinary, "run"] running: false stderr: SplitParser { onRead: function(line) { var l = root.daemonLog + line + "\n" if (l.length > 4000) l = l.slice(l.length - 4000) root.daemonLog = l } } onExited: function(code, status) { root.state = ({}) if (code === 127) { root.installed = false; probeProc.running = true; return } root.restarts += 1 if (code !== 3 && root.restarts >= 3) root.daemonError = "the caster keeps exiting (code " + code + ") — reinstall it or check the log" restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock restartTimer.restart() } } // Set while we wait for an orphaned daemon (left by a previous shell) to quit. property bool orphanQuit: false Timer { id: restartTimer interval: 1000 repeat: false onTriggered: { if (!root.installed) { probeProc.running = true; return } if (daemon.running) { daemon.signal(15); return } if (root.sockConnected) { root.send({ cmd: "quit" }); root.orphanQuit = true; interval = 5000; restart(); return } daemon.running = true } } Timer { interval: 60000; running: daemon.running; repeat: false; onTriggered: root.restarts = 0 } // ---- control connection ---- // Quickshell's Socket cannot recover from a refused connection, so recreate // the object for every attempt. property var sock: null readonly property bool sockConnected: sock ? sock.connected === true : false property string lastSessionState: "idle" Component { id: sockComp Socket { path: root.socketPath connected: true parser: SplitParser { onRead: function(line) { try { var msg = JSON.parse(line) if (msg && msg.type === "state") { root.state = msg if (root.daemonError !== "") root.daemonError = "" // Any state the daemon produced after the click is its answer to // it; the optimistic label has done its job. if (root.pendingLabel !== "" && Date.now() - root.pendingAt > 250) root.clearPending() root.noteSession() } } catch (e) { /* a reply we do not care about */ } } } onConnectionStateChanged: { root.sockConnectedChanged() if (!connected) { root.state = ({}) if (root.orphanQuit) { root.orphanQuit = false; restartTimer.interval = 1000; restartTimer.restart() } reconnectTimer.restart() } } onError: function(err) { reconnectTimer.restart() } } } function connectSocket() { if (sock) { sock.destroy(); sock = null } sock = sockComp.createObject(root) sockConnectedChanged() } Timer { id: reconnectTimer; interval: 800; repeat: false; onTriggered: if (!root.sockConnected) root.connectSocket() } Timer { interval: 3000; running: !root.sockConnected; repeat: true; onTriggered: if (!root.sockConnected) root.connectSocket() } // A cast that fails while the panel is closed would otherwise be invisible. function noteSession() { var now = sessionState if (now === lastSessionState) return var was = lastSessionState lastSessionState = now if (now === "error" && sessionError !== "") notify("Screencast", firewallBlocked ? "Port " + firewallPort + " is closed — press “Open port " + firewallPort + "” in the panel" : sessionError) else if (now === "streaming" && was !== "streaming") notify("Screencast", "Casting to " + castingTo) } Component.onCompleted: { probeProc.running = true connectSocket() updateCheckProc.running = true } Component.onDestruction: { if (daemon.running) daemon.signal(15) } }