Files
omarchy-screencast/daemon/screencast/server.py
T
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

510 lines
21 KiB
Python

"""The daemon: device list, session, and the JSON control socket the panel talks to.
Protocol (newline-delimited JSON):
in {"cmd": "cast", "device": "cast:<uuid>"} … see handle()
out {"type": "state", …} pushed on every change
"""
from __future__ import annotations
import asyncio
import contextlib
import fcntl
import json
import os
import shlex
import shutil
import signal
import time
from . import VERSION
from .backends import AirPlayBackend, for_kind
from .backends.base import BackendError
from .capture import PortalError, drop_legacy_token
from .config import PIPELINE_KEYS, Config
from .devices import Device
from .discovery import Discovery
from .session import Session
from .stream import StreamServer
from .util import (firewall_argv, firewall_command, firewall_tool, log, runtime_dir,
terminal_argv)
NO_AGENT = "no polkit agent" # pkexec had nowhere to ask; fall back to a terminal
class Daemon:
def __init__(self) -> None:
self.cfg = Config()
self.discovery = Discovery(on_change=self.push_state)
self.stream = StreamServer(on_client=self._on_client)
self.session = Session(self)
self.backends: dict[str, object] = {}
self.clients: set[asyncio.StreamWriter] = set()
self.error = ""
self.busy = ""
self.pairing = "" # device id a pairing PIN is expected for
self.firewall_blocked = False # a cast failed with nothing on the stream
self._push_handle: asyncio.TimerHandle | None = None
self._cast_task: asyncio.Task | None = None
self._rescan_task: asyncio.Task | None = None
self._stopping = False
# ---- backends ----
def backend_for(self, device: Device):
cls = for_kind(device.kind)
if cls is None:
raise BackendError(f"no backend for {device.kind}")
backend = self.backends.get(device.kind)
if backend is None:
backend = cls(self)
self.backends[device.kind] = backend
return backend
def note_backend_error(self, message: str) -> None:
self.error = message
self.push_state()
# ---- state ----
def build_state(self) -> dict:
devices = sorted(
self.discovery.devices.values(), key=lambda d: (d.kind != "cast", d.name.lower())
)
return {
"type": "state",
"version": VERSION,
"devices": [d.to_json() for d in devices],
"session": self.session.to_json(),
"settings": dict(self.cfg.values),
"error": self.error,
"busy": self.busy,
"pairing": self.pairing,
"capabilities": {
"airplay": AirPlayBackend.available(),
"port": self.stream.port or int(self.cfg["port"]),
},
"firewall": self.firewall_state(),
"time": time.time(),
}
def firewall_state(self) -> dict:
"""What the panel needs to offer "open the port" after a failed cast."""
tool = firewall_tool()
host = self.session.device.host if self.session.device else ""
port = self.stream.port or int(self.cfg["port"])
return {
"tool": tool,
# "blocked": a cast just failed with nothing on the stream.
# "verified": some receiver has fetched it at least once on this
# machine, which is the only proof the port is really open.
"blocked": bool(tool) and self.firewall_blocked,
"verified": bool(self.cfg["portVerified"]),
"port": port,
"command": firewall_command(tool, port, host) if tool else "",
}
async def open_firewall(self, host: str = "") -> dict:
"""Let receivers on this network reach the stream port, once.
Casting is pull-based - the receiver opens the connection to us - so a
default-deny firewall has to be told about the port. Installing does not
need root, so this is where it is asked for: one password dialog before
the first cast, and the rule persists.
"""
tool = firewall_tool(refresh=True)
if not tool:
self.firewall_blocked = False
self.cfg.update({"portVerified": True})
self.push_state()
return {"ok": True, "note": "no firewall is running"}
if not host:
host = self.session.device.host if self.session.device else ""
port = self.stream.port or int(self.cfg["port"])
argv_list = firewall_argv(tool, port, host)
self.busy = f"Opening port {port}…"
self.push_state(now=True)
try:
ok, err = await self._pkexec(argv_list)
if not ok and err == NO_AGENT:
ok, err = await self._sudo_in_terminal(argv_list, port)
finally:
self.busy = ""
self.push_state()
if not ok:
log.warning("open firewall: %s", err)
# The panel only sees state, so a cancelled or ignored prompt has to
# land there too - otherwise the button just looks broken.
self.error = (
f"the port was not opened: {err}" if err != NO_AGENT
else "no way to ask for a password on this machine"
)
self.push_state(now=True)
return {"ok": False, "error": err}
log.info("opened port %d in %s", port, tool)
self.firewall_blocked = False
self.error = ""
# The rule is in: stop warning about a port that is now open. A receiver
# actually reaching us keeps it that way (see _on_client).
self.cfg.update({"portVerified": True})
self.push_state(now=True)
return {"ok": True, "note": f"port {port} is open"}
async def _pkexec(self, argv_list: list[list[str]]) -> tuple[bool, str]:
if shutil.which("pkexec") is None:
return False, NO_AGENT
for argv in argv_list:
exe = shutil.which(argv[0])
if exe is None:
return False, f"{argv[0]} is not installed"
proc = await asyncio.create_subprocess_exec(
"pkexec", exe, *argv[1:],
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), 180)
except asyncio.TimeoutError:
proc.kill()
return False, "the password prompt timed out"
text = out.decode(errors="replace").strip()
# 126 = dismissed or refused, 127 = pkexec had no agent to ask.
if proc.returncode == 127 or "authentication agent" in text.lower():
return False, NO_AGENT
if proc.returncode == 126:
return False, "cancelled"
if proc.returncode != 0:
return False, text.splitlines()[-1] if text else f"failed ({proc.returncode})"
return True, ""
async def _sudo_in_terminal(self, argv_list: list[list[str]], port: int) -> tuple[bool, str]:
"""No polkit agent: run sudo in a terminal window and watch for the marker."""
mark = runtime_dir() / "firewall-ok"
with contextlib.suppress(FileNotFoundError):
mark.unlink()
script = " && ".join(shlex.join(a) for a in argv_list) + f" && : > {shlex.quote(str(mark))}"
inner = (
f"echo 'screencast needs port {port} open so your TV can fetch the stream.'; "
f"echo; sudo sh -c {shlex.quote(script)} "
"|| { echo; echo 'not opened'; read -r _; }"
)
argv = terminal_argv(["sh", "-c", inner])
if argv is None:
return False, "no terminal to ask for a password in; run: " + " && ".join(
"sudo " + shlex.join(a) for a in argv_list)
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL
)
try:
await asyncio.wait_for(proc.wait(), 300)
except asyncio.TimeoutError:
proc.kill()
# The terminal may fork, so trust the marker rather than the exit code.
for _ in range(20):
if mark.exists():
mark.unlink()
return True, ""
await asyncio.sleep(0.5)
return False, "the port was not opened"
def push_state(self, now: bool = False) -> None:
"""Coalesce the bursts discovery and polling produce into one push.
`now` skips the coalescing: anything a click just changed is feedback,
and 80ms of it queued behind the work is 80ms of a dead-looking panel.
"""
if now:
if self._push_handle is not None:
self._push_handle.cancel()
self._push_handle = None
self._push_now()
return
if self._push_handle is not None:
return
loop = asyncio.get_event_loop()
self._push_handle = loop.call_later(0.08, self._push_now)
def _push_now(self) -> None:
self._push_handle = None
if not self.clients:
return
line = (json.dumps(self.build_state()) + "\n").encode()
for writer in list(self.clients):
try:
writer.write(line)
except Exception: # noqa: BLE001
self.clients.discard(writer)
def _on_client(self, peer: str, connected: bool) -> None:
if connected:
self.firewall_blocked = False
if not self.cfg["portVerified"]:
self.cfg.update({"portVerified": True})
self.session.note_client(peer, connected)
# ---- commands ----
def find_device(self, key: str) -> Device:
key = (key or "").strip()
devices = self.discovery.devices
if key in devices:
return devices[key]
lowered = key.lower()
for dev in devices.values():
if dev.name.lower() == lowered or dev.id.lower() == lowered:
return dev
for dev in devices.values():
if lowered and lowered in dev.name.lower():
return dev
raise BackendError(f"no device matching “{key}”")
async def handle(self, msg: dict) -> dict:
cmd = str(msg.get("cmd", ""))
if cmd == "get":
return {"ok": True}
if cmd == "rescan":
if self._rescan_task is None or self._rescan_task.done():
self._rescan_task = asyncio.create_task(self._run_rescan())
return {"ok": True, "started": True}
if cmd == "cast":
device = self.find_device(str(msg.get("device", "")))
self.error = ""
self.busy = f"Connecting to {device.name}…"
self.cfg.update({"lastDevice": device.id})
self.push_state(now=True)
# A firewall we have never been through is a cast that is going to
# fail in forty seconds for a reason we already know. Ask for the
# port up front instead - one dialog, once, and then it is done.
if firewall_tool() and not self.cfg["portVerified"]:
await self.open_firewall(device.host)
self.busy = f"Connecting to {device.name}…"
self.error = ""
self.push_state(now=True)
# Starting can sit for a minute on the portal's screen picker, so it
# runs on its own task: the panel has to stay able to send "stop".
await self._cancel_cast()
self._cast_task = asyncio.create_task(
self._run_cast(device, self.backend_for(device))
)
return {"ok": True, "started": True}
if cmd == "openFirewall":
return await self.open_firewall(str(msg.get("host", "")))
if cmd == "stop":
self.busy = "Stopping…"
self.push_state(now=True)
try:
await self._cancel_cast()
await self.session.stop()
finally:
self.busy = ""
self.push_state(now=True)
return {"ok": True}
if cmd == "set":
changed = self.cfg.update(msg.get("settings") or {})
if changed & {"port"} and self.session.active:
await self.session.stop()
elif changed & PIPELINE_KEYS and self.session.active:
# Quality settings only exist in the running pipeline: restart it.
device, backend = self.session.device, self.session.backend
if device is not None and backend is not None:
await self.session.start(device, backend)
self.push_state()
return {"ok": True, "changed": sorted(changed)}
if cmd in ("volume", "mute", "transport"):
device = (
self.find_device(str(msg["device"])) if msg.get("device") else self.session.device
)
if device is None:
raise BackendError("nothing is being cast")
backend = self.backend_for(device)
if cmd == "volume":
await backend.set_volume(device, float(msg.get("value", 0)))
device.volume = float(msg.get("value", 0))
elif cmd == "mute":
await backend.set_muted(device, bool(msg.get("value")))
device.muted = bool(msg.get("value"))
else:
await backend.transport(device, str(msg.get("action", "")))
self.push_state()
return {"ok": True}
if cmd == "repick":
await self.session.repick_source()
self.push_state()
return {"ok": True}
if cmd == "pair":
device = self.find_device(str(msg.get("device", "")))
backend = self.backend_for(device)
if not hasattr(backend, "pair_begin"):
raise BackendError(f"{device.name} does not need pairing")
self.busy = f"Asking {device.name} for a code…"
self.push_state(now=True)
try:
await backend.pair_begin(device)
finally:
self.busy = ""
self.pairing = device.id
self.push_state()
return {"ok": True}
if cmd == "pairPin":
if not self.pairing:
raise BackendError("no pairing in progress")
device = self.find_device(self.pairing)
backend = self.backend_for(device)
self.busy = f"Pairing with {device.name}…"
self.push_state(now=True)
try:
ok = await backend.pair_pin(str(msg.get("pin", "")))
finally:
self.busy = ""
self.pairing = ""
self.error = "" if ok else f"{device.name} rejected that code"
self.push_state()
return {"ok": ok, "paired": ok}
if cmd == "pairCancel":
for backend in self.backends.values():
if hasattr(backend, "pair_cancel"):
await backend.pair_cancel()
self.pairing = ""
self.push_state()
return {"ok": True}
if cmd == "quit":
asyncio.create_task(self.shutdown())
return {"ok": True}
raise BackendError(f"unknown command “{cmd}”")
async def _run_rescan(self) -> None:
self.busy = "Looking for displays…"
self.push_state(now=True)
try:
await self.discovery.rescan()
finally:
self.busy = ""
self.push_state(now=True)
async def _run_cast(self, device: Device, backend) -> None:
try:
await self.session.start(device, backend)
except asyncio.CancelledError:
await self.session.stop(quiet=True)
raise
finally:
self.busy = ""
self.push_state()
async def _cancel_cast(self) -> None:
task, self._cast_task = self._cast_task, None
if task is not None and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
# ---- control socket ----
async def _serve_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
self.clients.add(writer)
pending: set[asyncio.Task] = set()
try:
writer.write((json.dumps(self.build_state()) + "\n").encode())
while True:
line = await reader.readline()
if not line:
break
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
# Each command runs on its own task. Reading the next line must
# not wait on this one: a rescan takes six seconds and a cast can
# sit on the screen picker for a minute, and a panel whose next
# click is stuck in the socket looks broken.
task = asyncio.create_task(self._run_command(msg, writer))
pending.add(task)
task.add_done_callback(pending.discard)
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
pass # a CLI client that read one line and walked away
finally:
for task in pending:
task.cancel()
self.clients.discard(writer)
with contextlib.suppress(Exception):
writer.close()
async def _run_command(self, msg: dict, writer: asyncio.StreamWriter) -> None:
try:
reply = await self.handle(msg)
except asyncio.CancelledError:
raise
except (BackendError, PortalError) as exc:
reply = {"ok": False, "error": str(exc)}
self.error = str(exc)
except Exception as exc: # noqa: BLE001 - one bad command must not kill the daemon
log.exception("command failed")
reply = {"ok": False, "error": str(exc)}
self.error = str(exc)
reply["type"] = "reply"
reply["cmd"] = msg.get("cmd", "")
with contextlib.suppress(Exception):
writer.write((json.dumps(reply) + "\n").encode())
self._push_now()
# ---- lifecycle ----
async def run(self) -> int:
lock_path = runtime_dir() / "lock"
lock = open(lock_path, "w") # noqa: SIM115 - held for the process lifetime
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
log.error("another screencast-server is already running")
return 3
drop_legacy_token()
lock.write(str(os.getpid()))
lock.flush()
sock_path = runtime_dir() / "ctl.sock"
with contextlib.suppress(FileNotFoundError):
os.unlink(sock_path)
# Create it private rather than chmod-ing after the bind: in the /tmp
# fallback that gap is a window another local user can connect through.
old_umask = os.umask(0o077)
try:
server = await asyncio.start_unix_server(self._serve_client, path=str(sock_path))
finally:
os.umask(old_umask)
os.chmod(sock_path, 0o600)
log.info("screencast-server %s listening on %s", VERSION, sock_path)
await self.discovery.start()
self._done = asyncio.get_running_loop().create_future()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.shutdown()))
try:
await self._done
finally:
server.close()
with contextlib.suppress(Exception):
await server.wait_closed()
with contextlib.suppress(FileNotFoundError):
os.unlink(sock_path)
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
return 0
async def shutdown(self) -> None:
if self._stopping:
return
self._stopping = True
log.info("shutting down")
# zeroconf and pychromecast both run non-daemon threads that can outlive
# a clean shutdown; never let one of them keep the socket (and the lock)
# from being handed to the next instance.
asyncio.get_running_loop().call_later(5, lambda: os._exit(0))
with contextlib.suppress(Exception):
await self._cancel_cast()
with contextlib.suppress(Exception):
await self.session.stop(quiet=True)
for backend in self.backends.values():
with contextlib.suppress(Exception):
await backend.close()
with contextlib.suppress(Exception):
await self.stream.stop()
with contextlib.suppress(Exception):
await self.discovery.stop()
if not self._done.done():
self._done.set_result(None)