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:
2026-08-29 19:57:54 +01:00
commit 1ed57291f9
24 changed files with 5086 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
"""Small helpers shared by the daemon: logging, paths, local addresses, subprocess."""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import json
import logging
import logging.handlers
import os
import shutil
import stat
import socket
import subprocess
from pathlib import Path
log = logging.getLogger("screencast")
_firewall_tool: str | None = None
def setup_logging(verbose: bool = False) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname).1s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
# The shell owns the daemon's stderr and keeps only the last few lines in a
# QML property, so also keep a real log on disk: when a cast misbehaves this
# file is the only account of what the encoder and the receiver did.
try:
handler = logging.handlers.RotatingFileHandler(
state_dir() / "daemon.log", maxBytes=512_000, backupCount=1
)
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname).1s %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
logging.getLogger().addHandler(handler)
except OSError as exc:
log.warning("no log file: %s", exc)
# These three are chatty at DEBUG and none of it is ours.
for noisy in ("zeroconf", "pychromecast", "aiohttp.access", "dbus_fast"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def firewall_tool(refresh: bool = False) -> str:
"""Which host firewall is running, if any ("ufw", "firewalld" or "").
A receiver fetches the stream from us, so the host firewall has to let it
in. Omarchy installs ufw with `default deny incoming`, which drops the TV's
SYN silently: the TV shows a spinner and we never see a client at all.
Reading the rules needs root, so all we can tell is which firewall is up.
"""
global _firewall_tool
if _firewall_tool is not None and not refresh:
return _firewall_tool
_firewall_tool = ""
for unit in ("ufw", "firewalld"):
try:
out = subprocess.run(["systemctl", "is-active", unit],
capture_output=True, text=True, timeout=3)
except Exception: # noqa: BLE001
continue
if out.stdout.strip() == "active":
_firewall_tool = unit
break
return _firewall_tool
def firewall_argv(tool: str, port: int, host: str = "") -> list[list[str]]:
"""The privileged command(s) that open the stream port to the local network."""
if tool == "ufw":
return [["ufw", "allow", "from", _subnet_of(host), "to", "any",
"port", str(port), "proto", "tcp", "comment", "screencast"]]
if tool == "firewalld":
# Scoped to the LAN like the ufw rule above: --add-port alone would open
# this port to every network the machine ever joins, permanently.
rule = (f'rule family="ipv{4 if ":" not in _subnet_of(host) else 6}" '
f'source address="{_subnet_of(host)}" '
f'port port="{port}" protocol="tcp" accept')
return [["firewall-cmd", f"--add-rich-rule={rule}", "--permanent"],
["firewall-cmd", "--reload"]]
return []
def firewall_command(tool: str, port: int, host: str = "") -> str:
"""The same thing as one line a person can paste into a terminal."""
parts = [" ".join(["sudo", *argv]) for argv in firewall_argv(tool, port, host)]
return " && ".join(parts)
def terminal_argv(command: list[str]) -> list[str] | None:
"""Run `command` in the user's terminal, for the times pkexec cannot ask.
Not every desktop runs a polkit authentication agent - Omarchy does not
install one - and without an agent pkexec has nowhere to prompt. A terminal
window with sudo in it needs nothing installed and shows the user exactly
what is being run.
"""
for candidate in (os.environ.get("TERMINAL"), "xdg-terminal-exec", "ghostty",
"alacritty", "kitty", "foot", "wezterm", "xterm"):
if not candidate:
continue
exe = shutil.which(candidate)
if exe is None:
continue
name = Path(exe).name
if name in ("xdg-terminal-exec", "foot", "kitty", "ghostty"):
return [exe, *command] # these take the command as plain arguments
if name == "wezterm":
return [exe, "start", "--", *command]
return [exe, "-e", *command] # alacritty, xterm and the rest
return None
def firewall_hint(port: int, host: str = "") -> str:
tool = firewall_tool()
if not tool:
return ""
return (f"{tool} is blocking port {port} — receivers cannot fetch the stream "
f"until it is opened")
def _subnet_of(host: str) -> str:
"""The /24 the receiver lives on, so the opened port stays on the LAN."""
# `host` comes from a discovery announcement, which anyone on the wire can
# send: a device claiming a public address would otherwise pick the subnet
# in a rule we are about to install as root. Only a private address gets to
# choose; otherwise ask the kernel which address the default route leaves
# from, which is the LAN the receivers actually live on.
try:
if host and not ipaddress.ip_address(host).is_private:
host = ""
except ValueError:
host = ""
try:
addr = ipaddress.ip_address(local_ip_for(host or "1.1.1.1"))
except ValueError:
return "192.168.0.0/16"
if not addr.is_private:
return "192.168.0.0/16"
if isinstance(addr, ipaddress.IPv4Address):
return str(ipaddress.ip_network(f"{addr}/24", strict=False))
return str(ipaddress.ip_network(f"{addr}/64", strict=False))
def runtime_dir() -> Path:
"""Where the control socket, the preview frame and HLS segments live.
Without XDG_RUNTIME_DIR this falls back to /tmp, which is shared and
predictable: another local user could pre-create the directory, or plant a
symlink where the preview frame is about to be written. So refuse anything
we do not own, and keep it to ourselves.
"""
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
p = Path(base) / "screencast"
p.mkdir(parents=True, exist_ok=True, mode=0o700)
st = p.lstat()
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid():
raise RuntimeError(f"{p} is not ours — refusing to use it")
if st.st_mode & 0o077:
p.chmod(0o700)
return p
def config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
p = Path(base) / "screencast"
p.mkdir(parents=True, exist_ok=True)
return p
def state_dir() -> Path:
"""Logs and, for AirPlay, the receiver pairing credentials — so 0700."""
base = os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state")
p = Path(base) / "screencast"
p.mkdir(parents=True, exist_ok=True, mode=0o700)
if p.stat().st_mode & 0o077:
p.chmod(0o700)
return p
def read_json(path: Path, default):
try:
return json.loads(path.read_text())
except Exception:
return default
def write_json(path: Path, data) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(path)
def local_ip_for(host: str) -> str:
"""The address a receiver at `host` would see us as.
The machine can sit on several networks at once (several NICs, a VPN), so
the stream URL must carry the address that routes back to that particular
device: let the kernel pick it.
"""
fam = socket.AF_INET
try:
if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
fam = socket.AF_INET6
except ValueError:
pass
s = socket.socket(fam, socket.SOCK_DGRAM)
try:
s.connect((host, 9))
ip = s.getsockname()[0]
except OSError:
ip = "127.0.0.1"
finally:
s.close()
return ip
def url_host(ip: str) -> str:
return f"[{ip}]" if ":" in ip else ip
async def run_cmd(*args: str, timeout: float = 5.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "", "timeout"
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
def have(binary: str) -> bool:
from shutil import which
return which(binary) is not None
async def port_open(host: str, port: int, timeout: float = 2.0) -> bool:
"""Is anything listening there? Distinguishes "asleep" from "not running"."""
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
except Exception: # noqa: BLE001
return False
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
return True
def gst_has(element: str) -> bool:
"""Is this GStreamer element installed? (cached; a plugin set never changes mid-run)"""
if element in _gst_cache:
return _gst_cache[element]
ok = False
try:
ok = subprocess.run(
["gst-inspect-1.0", "--exists", element], timeout=10
).returncode == 0
except Exception:
ok = False
_gst_cache[element] = ok
return ok
_gst_cache: dict[str, bool] = {}