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.
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user