Files
alan 1ed57291f9 Screencast: cast the desktop to Cast, DLNA and AirPlay receivers
An Omarchy shell plugin: a Python/asyncio daemon that discovers network
displays and serves an encoded screen capture for them to pull, plus a
Quickshell bar widget and panel to drive it.

The screen comes from the xdg-desktop-portal ScreenCast interface (asked
for every time, never remembered), goes through GStreamer, and is served
from a local HTTP port. Desktop audio is mixed in from the default
output's monitor. The container follows the receiver: WebM/VP8 for Cast,
MPEG-TS/H.264 for DLNA, HLS for AirPlay video.

The stream port has to be reachable from the LAN, and none of these
protocols can carry a credential, so the capability is the URL: a fresh
random path per session, refused to anything off the LAN, capped at four
concurrent readers.
2026-08-29 19:57:54 +01:00

339 lines
14 KiB
QML

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 <repo>/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)
}
}