1ed57291f9
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.
339 lines
14 KiB
QML
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)
|
|
}
|
|
}
|