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
+3
View File
@@ -0,0 +1,3 @@
"""screencast-server: discover network displays and cast this desktop to them."""
VERSION = "0.1.0"
+230
View File
@@ -0,0 +1,230 @@
"""Command line entry point.
screencast-server run the daemon the shell plugin starts
screencast-server devices list what is on the network
screencast-server cast "<name>" start casting the screen
screencast-server stop | status
screencast-server pair "<name>" AirPlay PIN pairing
screencast-server set fps=30 bitrate=8000
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from . import VERSION
from .server import Daemon
from .util import log, runtime_dir, setup_logging
async def _call(msg: dict, *, wait_state: bool = True, timeout: float = 200.0) -> dict:
"""Send one command to the running daemon and return its reply."""
path = runtime_dir() / "ctl.sock"
try:
reader, writer = await asyncio.open_unix_connection(str(path))
except (FileNotFoundError, ConnectionRefusedError):
print("screencast-server is not running (start it with: screencast-server run)", file=sys.stderr)
raise SystemExit(4)
state: dict = {}
writer.write((json.dumps(msg) + "\n").encode())
await writer.drain()
deadline = asyncio.get_running_loop().time() + timeout
try:
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
line = await asyncio.wait_for(reader.readline(), remaining)
if not line:
break
data = json.loads(line)
if data.get("type") == "state":
state = data
if msg.get("cmd") == "get" and wait_state:
break
elif data.get("type") == "reply":
data["state"] = state
return data
except asyncio.TimeoutError:
pass
finally:
writer.close()
return {"ok": True, "state": state}
def _print_devices(state: dict) -> None:
devices = state.get("devices") or []
if not devices:
print("no receivers found yet")
return
width = max(len(d["name"]) for d in devices)
for dev in devices:
extra = f" · {dev['app']}" if dev.get("app") else ""
print(f"{dev['name']:<{width}} {dev['kindLabel']:<12} {dev['host']:<15} {dev['id']}{extra}")
def _print_status(state: dict) -> None:
session = state.get("session") or {}
if session.get("state") in (None, "", "idle"):
print("idle")
else:
print(
f"{session['state']}: {session.get('deviceName', '')} "
f"({session.get('width')}x{session.get('height')} {session.get('encoder')}/"
f"{session.get('container')}, {session.get('clients')} client(s))"
)
if session.get("url"):
print(f" {session['url']}")
if session.get("error"):
print(f"error: {session['error']}")
elif state.get("error"):
print(f"error: {state['error']}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="screencast-server", description=__doc__)
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("--version", action="version", version=VERSION)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("run", help="run the daemon")
sub.add_parser("devices", help="list receivers")
sub.add_parser("status", help="what is being cast")
sub.add_parser("stop", help="stop casting")
sub.add_parser("rescan", help="scan the network again")
sub.add_parser("open-firewall", help="let receivers reach the stream port (asks for a password)")
p_cast = sub.add_parser("cast", help="cast the screen to a receiver")
p_cast.add_argument("device", help="device id, or part of its name")
p_vol = sub.add_parser("volume", help="set the receiver volume (0-100)")
p_vol.add_argument("percent", type=float)
p_pair = sub.add_parser("pair", help="pair with an AirPlay receiver")
p_pair.add_argument("device")
p_set = sub.add_parser("set", help="change settings, e.g. fps=30 audio=false")
p_set.add_argument("assignments", nargs="+")
args = parser.parse_args(argv)
setup_logging(args.verbose)
if args.command == "run":
try:
code = asyncio.run(Daemon().run())
except KeyboardInterrupt:
code = 0
# Same reason as the timer in Daemon.shutdown: library threads would
# otherwise hold the process open after everything is closed.
sys.stdout.flush()
sys.stderr.flush()
os._exit(code)
if args.command == "devices":
state = asyncio.run(_call({"cmd": "rescan"}, timeout=8)).get("state") or {}
_print_devices(state)
return 0
if args.command == "status":
_print_status(asyncio.run(_call({"cmd": "get"}, timeout=8)).get("state") or {})
return 0
if args.command == "stop":
asyncio.run(_call({"cmd": "stop"}, timeout=20))
return 0
if args.command == "rescan":
_print_devices(asyncio.run(_call({"cmd": "rescan"}, timeout=10)).get("state") or {})
return 0
if args.command == "open-firewall":
reply = asyncio.run(_call({"cmd": "openFirewall"}, timeout=200))
if reply.get("ok"):
print(reply.get("note") or "the stream port is open")
return 0
print(reply.get("error") or "could not open the port", file=sys.stderr)
return 1
if args.command == "cast":
state = asyncio.run(_cast(args.device))
_print_status(state)
return 0 if (state.get("session") or {}).get("state") == "streaming" else 1
if args.command == "volume":
reply = asyncio.run(_call({"cmd": "volume", "value": args.percent / 100.0}, timeout=15))
return 0 if reply.get("ok") else 1
if args.command == "pair":
return _pair(args.device)
if args.command == "set":
patch: dict = {}
for item in args.assignments:
if "=" not in item:
print(f"expected key=value, got “{item}", file=sys.stderr)
return 2
key, value = item.split("=", 1)
if value.lower() in ("true", "false"):
patch[key] = value.lower() == "true"
else:
try:
patch[key] = int(value)
except ValueError:
patch[key] = value
reply = asyncio.run(_call({"cmd": "set", "settings": patch}, timeout=15))
print(json.dumps((reply.get("state") or {}).get("settings", {}), indent=2))
return 0
return 2
async def _cast(device: str) -> dict:
"""Start the cast, then follow the state pushes until it settles."""
path = runtime_dir() / "ctl.sock"
try:
reader, writer = await asyncio.open_unix_connection(str(path))
except (FileNotFoundError, ConnectionRefusedError):
print("screencast-server is not running", file=sys.stderr)
raise SystemExit(4)
writer.write((json.dumps({"cmd": "cast", "device": device}) + "\n").encode())
await writer.drain()
state: dict = {}
# The daemon pushes its state on connect, before it has seen the command:
# that first one says "idle" and must not end the wait.
first = True
deadline = asyncio.get_running_loop().time() + 300
try:
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
line = await asyncio.wait_for(reader.readline(), remaining)
if not line:
break
data = json.loads(line)
if data.get("type") == "reply" and not data.get("ok"):
print(f"error: {data.get('error')}", file=sys.stderr)
break
if data.get("type") != "state":
continue
state = data
if first:
first = False
continue
session_state = (data.get("session") or {}).get("state", "")
if session_state in ("streaming", "error", "idle") and state.get("busy", "") == "":
break
except asyncio.TimeoutError:
pass
finally:
writer.close()
return state
def _pair(device: str) -> int:
reply = asyncio.run(_call({"cmd": "pair", "device": device}, timeout=30))
if not reply.get("ok"):
print(f"error: {reply.get('error')}", file=sys.stderr)
return 1
pin = input("PIN shown on the receiver: ").strip()
reply = asyncio.run(_call({"cmd": "pairPin", "pin": pin}, timeout=30))
if not reply.get("ok"):
print(f"error: {reply.get('error') or 'pairing failed'}", file=sys.stderr)
return 1
print("paired")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except BrokenPipeError:
log.debug("stdout closed")
+10
View File
@@ -0,0 +1,10 @@
from .base import Backend, BackendError
from .cast import CastBackend
from .dlna import DlnaBackend
from .airplay import AirPlayBackend
__all__ = ["Backend", "BackendError", "CastBackend", "DlnaBackend", "AirPlayBackend", "for_kind"]
def for_kind(kind: str):
return {"cast": CastBackend, "dlna": DlnaBackend, "airplay": AirPlayBackend}.get(kind)
+247
View File
@@ -0,0 +1,247 @@
"""AirPlay, through pyatv.
Caveat worth knowing: AirPlay *mirroring* (the low-latency screen protocol Macs
and iPhones use) is closed and has no open sender implementation on Linux. What
does work is AirPlay video playback — the receiver is handed a URL and plays it
— so the screen goes out as a live HLS stream. That means several seconds of
delay, and a receiver that refuses live HLS refuses the cast.
pyatv is optional: without it this backend reports itself unavailable instead of
taking the whole daemon down.
"""
from __future__ import annotations
import asyncio
import contextlib
from ..devices import Device
from ..util import log, port_open, state_dir
from .base import Backend, BackendError
INSTALL_HINT = "AirPlay needs pyatv: ~/.local/lib/screencast/venv/bin/pip install pyatv"
async def _pingable(host: str) -> bool:
proc = await asyncio.create_subprocess_exec(
"ping", "-c1", "-W1", host,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
return await proc.wait() == 0
class AirPlayBackend(Backend):
kind = "airplay"
containers = ("hls",)
progressive = False
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._atv = None
self._atv_id = ""
self._play_task: asyncio.Task | None = None
self._storage = None
self._pairing = None
self._pairing_device: Device | None = None
# ---- pyatv plumbing ----
@staticmethod
def available() -> bool:
try:
import pyatv # noqa: F401
except Exception: # noqa: BLE001
return False
return True
async def _storage_obj(self):
if self._storage is None:
from pyatv.storage.file_storage import FileStorage
path = state_dir() / "airplay.json"
self._storage = FileStorage(str(path), asyncio.get_running_loop())
await self._storage.load()
# pyatv writes this with a plain open(); it holds the credentials
# that let anyone play to the user's receivers.
with contextlib.suppress(OSError):
path.touch()
path.chmod(0o600)
return self._storage
async def _config(self, device: Device):
"""Find the receiver again, and be specific about why when we cannot.
Televisions keep announcing `_airplay._tcp` over mDNS while the receiver
itself is switched off, so "it is in the list" says nothing about whether
it will talk to us. Check the port first: a refused connection is a
setting the user can change, and no amount of scanning will fix it.
"""
import pyatv
loop = asyncio.get_running_loop()
storage = await self._storage_obj()
port = device.port or 7000
if not await port_open(device.host, port):
reachable = await port_open(device.host, 80, 1.0) or await _pingable(device.host)
log.warning("airplay: %s has nothing listening on %d", device.name, port)
raise BackendError(
f"{device.name} is on the network but its AirPlay receiver is off — "
"turn AirPlay on in the TV's settings"
if reachable else
f"{device.name} is not answering — switch it on and try again"
)
# Unicast first (fast, and exact); some receivers only answer multicast.
for attempt, timeout in enumerate((5, 8), start=1):
confs = await pyatv.scan(loop, hosts=[device.host], timeout=timeout, storage=storage)
if confs:
if attempt > 1:
log.info("airplay: %s answered on attempt %d", device.name, attempt)
return confs[0]
log.warning("airplay: %s did not answer a unicast scan (%ds)", device.name, timeout)
confs = [c for c in await pyatv.scan(loop, timeout=8, storage=storage)
if str(c.address) == device.host]
if confs:
log.info("airplay: %s only answered the multicast scan", device.name)
return confs[0]
raise BackendError(
f"{device.name} accepts connections but did not identify itself — "
"check that AirPlay is on and the TV is awake"
)
async def _connect(self, device: Device):
if self._atv is not None and self._atv_id == device.id:
return self._atv
if self._atv is not None:
await self.stop(device) # a connection to a different receiver
if not self.available():
raise BackendError(INSTALL_HINT)
import pyatv
conf = await self._config(device)
try:
self._atv = await pyatv.connect(
conf, asyncio.get_running_loop(), storage=await self._storage_obj()
)
except Exception as exc: # noqa: BLE001
log.warning("airplay connect to %s failed: %s", device.name, exc)
if any(w in str(exc).lower() for w in ("auth", "credential", "pair")):
raise BackendError(f"{device.name} needs pairing first") from exc
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
self._atv_id = device.id
log.info("airplay: connected to %s", device.name)
return self._atv
# ---- casting ----
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
atv = await self._connect(device)
async def run():
try:
await atv.stream.play_url(url)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.warning("airplay playback ended: %s", exc)
self.daemon.note_backend_error(f"{device.name}: {exc}")
# play_url only returns when playback ends, so it cannot be awaited here.
self._play_task = asyncio.create_task(run())
await asyncio.sleep(0.5)
if self._play_task.done():
exc = self._play_task.exception()
if exc is not None:
raise BackendError(f"{device.name} refused the stream: {exc}")
log.info("airplay: %s playing %s", device.name, url)
async def stop(self, device: Device) -> None:
if self._play_task is not None:
self._play_task.cancel()
self._play_task = None
atv, self._atv, self._atv_id = self._atv, None, ""
if atv is not None:
try:
await atv.remote_control.stop()
except Exception: # noqa: BLE001
pass
atv.close()
async def set_volume(self, device: Device, volume: float) -> None:
atv = await self._connect(device)
await atv.audio.set_volume(max(0.0, min(1.0, volume)) * 100.0)
async def transport(self, device: Device, action: str) -> None:
atv = await self._connect(device)
rc = atv.remote_control
fn = {"play": rc.play, "pause": rc.pause, "stop": rc.stop}.get(action)
if fn is None:
raise BackendError(f"unknown transport action {action}")
await fn()
async def poll(self, device: Device) -> dict:
atv = self._atv
if atv is None:
return {}
out: dict = {"connected": True}
try:
playing = await atv.metadata.playing()
out["playerState"] = str(playing.device_state).split(".")[-1].lower()
out["app"] = playing.title or ""
except Exception: # noqa: BLE001
pass
try:
out["volume"] = float(atv.audio.volume) / 100.0
except Exception: # noqa: BLE001
pass
return out
# ---- pairing (PIN shown on the TV, typed into the panel) ----
async def pair_begin(self, device: Device) -> None:
if not self.available():
raise BackendError(INSTALL_HINT)
import pyatv
from pyatv.const import Protocol
await self.pair_cancel()
conf = await self._config(device)
self._pairing = await pyatv.pair(
conf, Protocol.AirPlay, asyncio.get_running_loop(), storage=await self._storage_obj()
)
await self._pairing.begin()
self._pairing_device = device
log.info("airplay: pairing with %s (%s)", device.name,
"PIN on the TV" if self._pairing.device_provides_pin else "no PIN needed")
if not self._pairing.device_provides_pin:
# Some receivers want *us* to show a PIN; nothing to type in that case.
await self._pairing.finish()
async def pair_pin(self, pin: str) -> bool:
if self._pairing is None:
raise BackendError("no pairing in progress")
name = self._pairing_device.name if self._pairing_device else "the receiver"
self._pairing.pin(pin)
try:
await self._pairing.finish()
except Exception as exc: # noqa: BLE001
log.warning("airplay pairing with %s failed: %s", name, exc)
await self.pair_cancel()
raise BackendError(f"{name} rejected the code: {exc}") from exc
ok = bool(self._pairing.has_paired)
log.info("airplay: pairing with %s %s", name, "succeeded" if ok else "was refused")
if ok and self._storage is not None:
await self._storage.save()
# Pairing leaves its own connection behind; drop ours so the next cast
# reconnects with the credentials we just stored.
self._atv, self._atv_id = None, ""
await self.pair_cancel()
return ok
async def pair_cancel(self) -> None:
pairing, self._pairing = self._pairing, None
self._pairing_device = None
if pairing is not None:
try:
await pairing.close()
except Exception: # noqa: BLE001
pass
async def close(self) -> None:
await self.pair_cancel()
if self._atv is not None or self._play_task is not None:
await self.stop(Device(id="", kind="airplay", name="", host="", port=0))
+41
View File
@@ -0,0 +1,41 @@
"""What every receiver protocol has to provide."""
from __future__ import annotations
from ..devices import Device
class BackendError(Exception):
pass
class Backend:
kind = ""
#: Containers this protocol can be handed, best first.
containers: tuple[str, ...] = ("webm",)
#: Does it pull a plain HTTP stream (True) or an HLS playlist (False)?
progressive = True
def __init__(self, daemon) -> None:
self.daemon = daemon
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
raise NotImplementedError
async def stop(self, device: Device) -> None:
raise NotImplementedError
async def set_volume(self, device: Device, volume: float) -> None:
raise BackendError("this receiver has no volume control")
async def set_muted(self, device: Device, muted: bool) -> None:
raise BackendError("this receiver has no mute control")
async def transport(self, device: Device, action: str) -> None:
raise BackendError("this receiver has no transport control")
async def poll(self, device: Device) -> dict:
"""Live status of the session: {state, app, volume, muted, error}."""
return {}
async def close(self) -> None:
return None
+136
View File
@@ -0,0 +1,136 @@
"""Google Cast.
pychromecast is a threaded, blocking library; every call into it runs in a
worker thread and its status callbacks are marshalled back onto the loop.
"""
from __future__ import annotations
import asyncio
import functools
from ..devices import Device
from ..util import log
from .base import Backend, BackendError
CONTENT_TYPES = {"webm": "video/webm", "mp4": "video/mp4", "matroska": "video/x-matroska",
"mpegts": "video/mp2t"}
class CastBackend(Backend):
kind = "cast"
# WebM/VP8 only. The receiver plays a WebM live stream from any cluster,
# with no index and no seeking. GStreamer's mp4mux "streamable" output is
# not that: a client joining after the first fragment cannot start it (the
# TV sits on a spinner, and ffprobe hangs the same way), and the CMAF muxer
# that would fix it lives in gst-plugins-rs, which is not a dependency worth
# taking for a second-best path.
containers = ("webm",)
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._casts: dict[str, object] = {}
async def _cast(self, device: Device):
cast = self._casts.get(device.id)
if cast is not None and getattr(cast, "socket_client", None) is not None:
if not cast.socket_client.stop.is_set():
return cast
self._casts.pop(device.id, None)
info = device.extra.get("cast_info")
if info is None:
raise BackendError("the device is no longer on the network")
zconf = self.daemon.discovery.zeroconf
def connect():
import pychromecast
cast = pychromecast.get_chromecast_from_cast_info(info, zconf, tries=1, timeout=5)
cast.wait(timeout=12)
return cast
try:
cast = await asyncio.to_thread(connect)
except Exception as exc: # noqa: BLE001 - anything from the socket layer
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
self._casts[device.id] = cast
return cast
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
cast = await self._cast(device)
ctype = CONTENT_TYPES.get(container, "video/webm")
def start():
mc = cast.media_controller
mc.play_media(
url, ctype, title=title, stream_type="LIVE", autoplay=True,
metadata={"metadataType": 0, "title": title},
)
mc.block_until_active(timeout=20)
try:
await asyncio.to_thread(start)
except Exception as exc: # noqa: BLE001
raise BackendError(f"{device.name} refused the stream: {exc}") from exc
log.info("cast: %s playing %s (%s)", device.name, url, ctype)
async def stop(self, device: Device) -> None:
cast = self._casts.get(device.id)
if cast is None:
return
def finish():
try:
cast.media_controller.stop()
except Exception: # noqa: BLE001
pass
try:
cast.quit_app()
except Exception: # noqa: BLE001
pass
cast.disconnect()
await asyncio.to_thread(finish)
self._casts.pop(device.id, None)
async def set_volume(self, device: Device, volume: float) -> None:
cast = await self._cast(device)
await asyncio.to_thread(
functools.partial(cast.socket_client.receiver_controller.set_volume, max(0.0, min(1.0, volume)))
)
async def set_muted(self, device: Device, muted: bool) -> None:
cast = await self._cast(device)
await asyncio.to_thread(
functools.partial(cast.socket_client.receiver_controller.set_volume_muted, bool(muted))
)
async def transport(self, device: Device, action: str) -> None:
cast = await self._cast(device)
mc = cast.media_controller
fn = {"play": mc.play, "pause": mc.pause, "stop": mc.stop}.get(action)
if fn is None:
raise BackendError(f"unknown transport action {action}")
await asyncio.to_thread(fn)
async def poll(self, device: Device) -> dict:
cast = self._casts.get(device.id)
if cast is None:
return {}
status = cast.socket_client.receiver_controller.status
media = cast.media_controller.status
state = (media.player_state or "").lower() if media else ""
return {
"app": (status.display_name if status else "") or "",
"volume": float(status.volume_level) if status else -1.0,
"muted": bool(status.volume_muted) if status else False,
"playerState": state,
"connected": not cast.socket_client.stop.is_set(),
}
async def close(self) -> None:
for cast in list(self._casts.values()):
try:
await asyncio.to_thread(cast.disconnect)
except Exception: # noqa: BLE001
pass
self._casts.clear()
+163
View File
@@ -0,0 +1,163 @@
"""DLNA / UPnP MediaRenderer.
A renderer is told to fetch a URL (SetAVTransportURI + Play) and polled for what
it is doing. Most TVs handle a live MPEG-TS stream this way; some refuse
anything without a duration, which shows up as a Play error.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from html import escape
import aiohttp
from ..devices import Device
from ..util import log
from .base import Backend, BackendError
SOAP_ENV = (
'<?xml version="1.0" encoding="utf-8"?>'
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"'
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>{body}</s:Body></s:Envelope>'
)
PROTOCOL_INFO = {
"mpegts": "http-get:*:video/mp2t:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
"mp4": "http-get:*:video/mp4:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
"webm": "http-get:*:video/webm:*",
}
def didl(url: str, title: str, container: str) -> str:
return (
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"'
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
' xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
'<item id="0" parentID="-1" restricted="1">'
f"<dc:title>{escape(title)}</dc:title>"
"<upnp:class>object.item.videoItem.movie</upnp:class>"
f'<res protocolInfo="{PROTOCOL_INFO.get(container, PROTOCOL_INFO["mpegts"])}">{escape(url)}</res>'
"</item></DIDL-Lite>"
)
class DlnaBackend(Backend):
kind = "dlna"
# MPEG-TS: a renderer can start on any packet, which is what a live stream
# needs (see the note in cast.py about fragmented MP4).
containers = ("mpegts",)
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._session: aiohttp.ClientSession | None = None
async def _http(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
return self._session
async def _soap(self, device: Device, service: str, action: str, args: dict) -> dict:
control = device.extra.get("control") or {}
url = control.get(service)
stype = control.get(f"{service}_type")
if not url or not stype:
raise BackendError(f"{device.name} has no {service} service")
# stype comes out of the device's own description XML.
body = f'<u:{action} xmlns:u="{escape(stype, quote=True)}">' + "".join(
f"<{k}>{escape(str(v))}</{k}>" for k, v in args.items()
) + f"</u:{action}>"
payload = SOAP_ENV.format(body=body).encode()
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": f'"{stype}#{action}"',
"Connection": "close",
}
session = await self._http()
try:
async with session.post(url, data=payload, headers=headers) as resp:
text = (await resp.content.read(256 << 10)).decode("utf-8", "replace")
if resp.status >= 400:
raise BackendError(f"{action} failed: {_fault(text) or resp.status}")
except aiohttp.ClientError as exc:
raise BackendError(f"{device.name} did not answer: {exc}") from exc
return _soap_values(text)
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
await self._soap(device, "avtransport", "SetAVTransportURI",
{"InstanceID": 0, "CurrentURI": url,
"CurrentURIMetaData": didl(url, title, container)})
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
log.info("dlna: %s playing %s", device.name, url)
async def stop(self, device: Device) -> None:
try:
await self._soap(device, "avtransport", "Stop", {"InstanceID": 0})
except BackendError as exc:
log.debug("dlna stop: %s", exc)
async def transport(self, device: Device, action: str) -> None:
if action == "pause":
await self._soap(device, "avtransport", "Pause", {"InstanceID": 0})
elif action == "play":
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
elif action == "stop":
await self.stop(device)
else:
raise BackendError(f"unknown transport action {action}")
async def set_volume(self, device: Device, volume: float) -> None:
await self._soap(device, "rendering", "SetVolume",
{"InstanceID": 0, "Channel": "Master",
"DesiredVolume": int(max(0.0, min(1.0, volume)) * 100)})
async def set_muted(self, device: Device, muted: bool) -> None:
await self._soap(device, "rendering", "SetMute",
{"InstanceID": 0, "Channel": "Master", "DesiredMute": 1 if muted else 0})
async def poll(self, device: Device) -> dict:
out: dict = {}
try:
info = await self._soap(device, "avtransport", "GetTransportInfo", {"InstanceID": 0})
state = (info.get("CurrentTransportState") or "").lower()
out["playerState"] = {"playing": "playing", "paused_playback": "paused",
"stopped": "idle", "transitioning": "buffering"}.get(state, state)
out["connected"] = True
except BackendError:
out["connected"] = False
if (device.extra.get("control") or {}).get("rendering"):
try:
vol = await self._soap(device, "rendering", "GetVolume",
{"InstanceID": 0, "Channel": "Master"})
out["volume"] = int(vol.get("CurrentVolume", 0)) / 100.0
except BackendError:
pass
return out
async def close(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
def _soap_values(text: str) -> dict:
try:
root = ET.fromstring(text)
except ET.ParseError:
return {}
values = {}
for body in root.iter():
if body.tag.endswith("Body"):
for resp in body:
for child in resp:
values[child.tag.split("}")[-1]] = child.text or ""
return values
def _fault(text: str) -> str:
try:
root = ET.fromstring(text)
except ET.ParseError:
return ""
for el in root.iter():
if el.tag.endswith("errorDescription"):
return el.text or ""
return ""
+635
View File
@@ -0,0 +1,635 @@
"""Screen capture and encoding.
Wayland gives no direct screen access, so the picture comes from the
xdg-desktop-portal ScreenCast interface: the portal asks which screen to share -
every time, deliberately, since nothing here is worth sharing by accident - and
hands back a PipeWire node and a fd for it. GStreamer reads that node, encodes,
and writes the muxed stream to a pipe the HTTP server fans out to the receiver.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import re
import secrets
import time
from dbus_fast import BusType, Message, MessageType, Variant
from dbus_fast.aio import MessageBus
from .util import gst_has, log, runtime_dir, state_dir
JPEG_START = b"\xff\xd8\xff"
JPEG_END = b"\xff\xd9"
PORTAL = "org.freedesktop.portal.Desktop"
PORTAL_PATH = "/org/freedesktop/portal/desktop"
SCREENCAST = "org.freedesktop.portal.ScreenCast"
SOURCE_MONITOR = 1
SOURCE_WINDOW = 2
CURSOR_HIDDEN, CURSOR_EMBEDDED = 1, 2
PERSIST_NONE = 0 # never remember a screen: every cast asks again
class PortalError(Exception):
pass
class PortalStream:
def __init__(self, node_id: int, fd: int, props: dict) -> None:
self.node_id = node_id
self.fd = fd
self.props = props
size = props.get("size")
self.width, self.height = (int(size[0]), int(size[1])) if size else (0, 0)
self.source_type = int(props.get("source_type", SOURCE_MONITOR))
self.name = str(props.get("id", "")) or ("Window" if self.source_type == SOURCE_WINDOW else "Screen")
class Portal:
"""One ScreenCast session. The bus connection must outlive the cast."""
def __init__(self) -> None:
self.bus: MessageBus | None = None
self.session: str | None = None
self._pending: dict[str, asyncio.Future] = {}
async def _connect(self) -> MessageBus:
if self.bus is None or self.bus._disconnected: # noqa: SLF001 - no public flag
self.bus = await MessageBus(bus_type=BusType.SESSION, negotiate_unix_fd=True).connect()
await self.bus.call(
Message(
destination="org.freedesktop.DBus",
path="/org/freedesktop/DBus",
interface="org.freedesktop.DBus",
member="AddMatch",
signature="s",
body=[
"type='signal',interface='org.freedesktop.portal.Request',member='Response'"
],
)
)
self.bus.add_message_handler(self._on_signal)
return self.bus
def _on_signal(self, msg: Message):
if msg.message_type is not MessageType.SIGNAL or msg.member != "Response":
return
fut = self._pending.pop(msg.path, None)
if fut and not fut.done():
code, results = msg.body[0], msg.body[1]
fut.set_result((int(code), {k: v.value for k, v in results.items()}))
def _request_path(self, token: str) -> str:
sender = self.bus.unique_name[1:].replace(".", "_")
return f"{PORTAL_PATH}/request/{sender}/{token}"
async def _call(self, member: str, signature: str, body: list, options: dict, timeout: float):
"""Call a portal method and wait for the Request.Response it answers with."""
token = "sc" + secrets.token_hex(8)
options = dict(options)
options["handle_token"] = Variant("s", token)
path = self._request_path(token)
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[path] = fut
reply = await self.bus.call(
Message(
destination=PORTAL,
path=PORTAL_PATH,
interface=SCREENCAST,
member=member,
signature=signature,
body=body + [options],
)
)
if reply.message_type is MessageType.ERROR:
self._pending.pop(path, None)
raise PortalError(f"{member}: {reply.body[0] if reply.body else reply.error_name}")
try:
code, results = await asyncio.wait_for(fut, timeout)
except asyncio.TimeoutError as exc:
self._pending.pop(path, None)
raise PortalError("no screen was picked (the share dialog was left unanswered)") from exc
if code == 1:
raise PortalError("no screen was picked (the share dialog was dismissed)")
if code != 0:
raise PortalError(f"{member}: portal returned {code}")
return results
async def open(self, *, cursor: bool, allow_windows: bool = True) -> PortalStream:
"""Start a capture session. The portal always asks which screen to share."""
await self._connect()
stoken = "sc" + secrets.token_hex(8)
results = await self._call(
"CreateSession", "a{sv}", [], {"session_handle_token": Variant("s", stoken)}, 20
)
self.session = results.get("session_handle") or f"{PORTAL_PATH}/session/{self.bus.unique_name[1:].replace('.', '_')}/{stoken}"
types = SOURCE_MONITOR | (SOURCE_WINDOW if allow_windows else 0)
opts = {
"types": Variant("u", types),
"multiple": Variant("b", False),
"cursor_mode": Variant("u", CURSOR_EMBEDDED if cursor else CURSOR_HIDDEN),
"persist_mode": Variant("u", PERSIST_NONE),
}
# xdg-desktop-portal-hyprland shows its share picker during
# SelectSources, not Start, so this is the call that sits waiting for a
# human. Other portals prompt on Start; both get room.
await self._call("SelectSources", "oa{sv}", [self.session], opts, 180)
results = await self._call("Start", "osa{sv}", [self.session, ""], {}, 180)
streams = results.get("streams") or []
if not streams:
raise PortalError("the portal returned no stream")
node_id, props = streams[0][0], {k: v.value if isinstance(v, Variant) else v
for k, v in streams[0][1].items()}
reply = await self.bus.call(
Message(
destination=PORTAL,
path=PORTAL_PATH,
interface=SCREENCAST,
member="OpenPipeWireRemote",
signature="oa{sv}",
body=[self.session, {}],
)
)
if reply.message_type is MessageType.ERROR:
raise PortalError(f"OpenPipeWireRemote: {reply.error_name}")
fd = reply.unix_fds[reply.body[0]]
return PortalStream(int(node_id), fd, props)
async def close(self) -> None:
if self.bus is not None and self.session:
try:
await self.bus.call(
Message(
destination=PORTAL,
path=self.session,
interface="org.freedesktop.portal.Session",
member="Close",
)
)
except Exception: # noqa: BLE001 - the session may already be gone
pass
self.session = None
if self.bus is not None:
self.bus.disconnect()
self.bus = None
def drop_legacy_token() -> None:
"""Earlier versions remembered the shared screen; delete what they stored."""
with contextlib.suppress(OSError):
(state_dir() / "restore-token.json").unlink()
def pick_encoder(preference: str, container: str) -> str:
"""Resolve "auto" against what this machine's GStreamer actually has."""
if container == "webm":
wanted = ["vp8", "vp9"] if preference in ("auto", "vp8", "nvenc", "vaapi", "x264") else [preference]
for enc in wanted:
if gst_has({"vp8": "vp8enc", "vp9": "vp9enc"}[enc]):
return enc
raise PortalError("no VP8/VP9 encoder (install gst-plugins-good)")
order = {
"auto": ["nvenc", "vaapi", "x264", "openh264"],
"nvenc": ["nvenc", "vaapi", "x264", "openh264"],
"vaapi": ["vaapi", "nvenc", "x264", "openh264"],
"x264": ["x264", "openh264", "nvenc", "vaapi"],
}.get(preference, ["nvenc", "vaapi", "x264", "openh264"])
elements = {"nvenc": "nvh264enc", "vaapi": "vah264enc", "x264": "x264enc", "openh264": "openh264enc"}
for enc in order:
if gst_has(elements[enc]):
return enc
raise PortalError("no H.264 encoder (install gst-plugins-ugly or gst-plugin-va)")
def video_encoder_chain(encoder: str, bitrate_kbps: int, fps: int, *,
gpu_scale: bool = False) -> list[str]:
# Half a second between keyframes. It costs a little bitrate and buys two
# things that matter more here: a receiver can start on the next keyframe
# instead of waiting a whole second, and the stream can be cut at one when a
# slow receiver has to be skipped forward (see Fanout).
gop = max(15, fps // 2)
if encoder == "nvenc":
upload = [] if gpu_scale else [
"cudaupload", "!", "cudaconvertscale", "!", "video/x-raw(memory:CUDAMemory),format=NV12", "!",
]
return upload + [
"nvh264enc", "name=venc", f"bitrate={bitrate_kbps}", f"gop-size={gop}",
"rc-mode=cbr", "preset=low-latency-hq", "zerolatency=true", "!",
"h264parse", "config-interval=-1",
]
if encoder == "vaapi":
return [
"vah264enc", "name=venc", f"bitrate={bitrate_kbps}", f"key-int-max={gop}",
"rate-control=cbr", "!", "h264parse", "config-interval=-1",
]
if encoder == "x264":
return [
"x264enc", "name=venc", f"bitrate={bitrate_kbps}", "tune=zerolatency",
"speed-preset=veryfast", f"key-int-max={gop}", "!", "h264parse", "config-interval=-1",
]
if encoder == "openh264":
return [
"openh264enc", "name=venc", f"bitrate={bitrate_kbps * 1000}", f"gop-size={gop}", "!",
"h264parse", "config-interval=-1",
]
if encoder == "vp8":
return [
"vp8enc", "name=venc", "deadline=1", "cpu-used=6", "threads=8", "end-usage=cbr",
f"target-bitrate={bitrate_kbps * 1000}", f"keyframe-max-dist={gop}", "error-resilient=1",
]
if encoder == "vp9":
return [
"vp9enc", "name=venc", "deadline=1", "cpu-used=8", "threads=8", "end-usage=cbr",
f"target-bitrate={bitrate_kbps * 1000}", f"keyframe-max-dist={gop}",
]
raise PortalError(f"unknown encoder {encoder}")
def audio_chain(container: str) -> list[str]:
if container == "webm":
return ["opusenc", "bitrate=128000", "!", "queue"]
if gst_has("avenc_aac"):
return ["avenc_aac", "bitrate=128000", "!", "aacparse", "!", "queue"]
if gst_has("fdkaacenc"):
return ["fdkaacenc", "bitrate=128000", "!", "aacparse", "!", "queue"]
return ["opusenc", "bitrate=128000", "!", "queue"]
def muxer(container: str) -> list[str]:
if container == "webm":
# Just under the keyframe interval, so matroskamux closes each cluster on
# a keyframe: that is what makes a cluster a safe place to cut.
return ["webmmux", "name=mux", "streamable=true", "min-cluster-duration=400000000"]
if container == "mp4":
return ["mp4mux", "name=mux", "streamable=true", "fragment-duration=200",
"faststart=false", "presentation-time=0"]
if container == "mpegts":
return ["mpegtsmux", "name=mux", "alignment=7"]
if container == "matroska":
return ["matroskamux", "name=mux", "streamable=true"]
raise PortalError(f"unknown container {container}")
def preview_size(width: int, height: int, target: int = 480) -> tuple[int, int]:
"""Preview dimensions: `target` wide at the source aspect, both even."""
if width <= 0 or height <= 0:
return target, (target * 9 // 16 // 2) * 2
h = max(2, round(height * target / width))
return (target // 2) * 2, (h // 2) * 2
def build_pipeline(stream: PortalStream, *, container: str, encoder: str, fps: int,
max_height: int, bitrate_kbps: int, audio_node: str | None,
hls_dir: str | None = None, gpu_scale: bool = False,
preview_fd: int | None = None) -> list[str]:
"""The gst-launch argv for one session (fd 1 carries the muxed stream)."""
args = ["gst-launch-1.0", "-q"]
# Scale first, pace second: the pacing element repeats frames, and repeating
# a 1080p frame is far cheaper than repeating a 4K one.
args += [
"pipewiresrc", f"fd={stream.fd}", f"path={stream.node_id}", "do-timestamp=true",
"keepalive-time=1000", "resend-last=true", "!",
"queue", "max-size-time=100000000", "leaky=downstream", "!",
]
out_w, out_h = output_size(stream.width, stream.height, max_height)
size_caps = f",width={out_w},height={out_h}" if out_w and out_h else f",height=[16,{max_height}]"
if gpu_scale:
# Colour conversion and scaling on the GPU: a 4K screen is otherwise the
# most expensive thing in the pipeline, ahead of the encoder itself.
args += [
"cudaupload", "!", "cudaconvertscale", "!",
f"video/x-raw(memory:CUDAMemory),format=NV12{size_caps},pixel-aspect-ratio=1/1", "!",
]
if encoder not in ("nvenc",):
args += ["cudadownload", "!", "videoconvert", "!", "video/x-raw,format=I420", "!"]
else:
args += [
"videoconvert", "!", "videoscale", "method=lanczos", "!",
f"video/x-raw{size_caps},pixel-aspect-ratio=1/1", "!",
]
on_gpu = gpu_scale and encoder == "nvenc"
# A Wayland screen only produces a frame when something on it changes, and
# videorate can only duplicate once the *next* frame arrives: on a desk left
# alone the encoder simply stops, the muxer stops, and the receiver sits on a
# spinner waiting for data that is not coming. A compositor is clock-driven
# like audiomixer - it emits the last frame again on schedule - so the stream
# keeps its framerate no matter how still the screen is.
mixer = "cudacompositor" if (on_gpu and gst_has("cudacompositor")) else "compositor"
if mixer == "compositor" and on_gpu:
args += ["cudadownload", "!", "videoconvert", "!", "video/x-raw,format=I420", "!"]
on_gpu = False
# Pin the size here, not just the framerate. A compositor places its input
# at 0,0 and does not scale it, so anything downstream that renegotiates a
# smaller size (the preview branch's videoscale will happily propose its
# own 480x270 back through the tee) does not shrink the picture - it crops
# it to the top-left corner and encodes that.
size_pin = f",width={out_w},height={out_h}" if out_w and out_h else ""
rate_caps = (("video/x-raw(memory:CUDAMemory)" if on_gpu else "video/x-raw")
+ f"{size_pin},framerate={fps}/1")
args += [
mixer, "name=vmix", "latency=60000000", "start-time-selection=first",
# A pad property, not an element one: repeat the last frame for as long
# as the screen stays still instead of falling back to black.
"sink_0::max-last-buffer-repeat=-1",
"!", rate_caps, "!",
"queue", "max-size-time=100000000", "leaky=downstream", "!",
]
if preview_fd is not None:
# A second, deliberately cheap branch: a few frames a second, small, and
# leaky, so the panel can show what is actually going out without the
# preview ever being able to hold the encoder up.
pw, ph = preview_size(out_w, out_h)
args += ["tee", "name=vt",
"vt.", "!", "queue", "max-size-buffers=1", "leaky=downstream", "!"]
if on_gpu:
args += ["cudadownload", "!"]
args += [
"videorate", "drop-only=true", "!", "video/x-raw,framerate=2/1", "!",
"videoconvert", "!", "videoscale", "!", f"video/x-raw,width={pw},height={ph}", "!",
"jpegenc", "quality=65", "!", "fdsink", f"fd={preview_fd}", "sync=false",
"vt.", "!", "queue", "max-size-time=100000000", "leaky=downstream", "!",
]
args += video_encoder_chain(encoder, bitrate_kbps, fps, gpu_scale=on_gpu)
# hlssink2 does its own muxing and takes the elementary streams on request
# pads; everything else goes through one muxer into the pipe.
hls = hls_dir is not None
if hls and not gst_safe(hls_dir):
raise RuntimeError(f"runtime directory {hls_dir!r} cannot go in a pipeline")
args += ["!", "queue", "!", "hls.video" if hls else "mux."]
if audio_node:
# The monitor of an idle sink can go quiet for minutes at a time (a
# Bluetooth headset suspends outright), and a muxer with a declared but
# silent audio track is exactly what leaves a receiver on a spinner:
# players wait for that track's first packet. Mixing the monitor with a
# live silence source keeps audio flowing whatever the speakers do.
rate_caps = "audio/x-raw,format=S16LE,rate=48000,channels=2,layout=interleaved"
args += [
"audiomixer", "name=amix", "latency=60000000", "start-time-selection=first", "!",
rate_caps, "!", "audioconvert", "!",
]
args += audio_chain(container)
args += ["!", "hls.audio" if hls else "mux."]
args += [
"pipewiresrc", f"target-object={audio_node}",
"stream-properties=p,stream.capture.sink=true", "do-timestamp=true", "!",
"audio/x-raw", "!", "audioconvert", "!", "audioresample", "!", rate_caps, "!",
"queue", "max-size-time=100000000", "leaky=downstream", "!", "amix.",
"audiotestsrc", "is-live=true", "wave=silence", "!", rate_caps, "!",
"queue", "max-size-time=100000000", "leaky=downstream", "!", "amix.",
]
if hls:
args += ["hlssink2", "name=hls", f"location={hls_dir}/segment%05d.ts",
f"playlist-location={hls_dir}/index.m3u8", "target-duration=2", "max-files=6",
"playlist-length=4"]
else:
args += muxer(container) + ["!", "fdsink", "fd=1", "sync=false"]
return args
def output_size(width: int, height: int, max_height: int) -> tuple[int, int]:
"""The size to encode at: capped to max_height, aspect kept, both even.
Encoders want even dimensions, and the scalers want a fixed size — an
open-ended caps range makes cudaconvertscale fail to negotiate at all.
"""
if width <= 0 or height <= 0:
return 0, 0
if height > max_height:
width = round(width * max_height / height)
height = max_height
return (width // 2) * 2, (height // 2) * 2
class Capture:
"""Portal session + gst process, feeding a Fanout."""
def __init__(self, on_stopped=None) -> None:
self.portal: Portal | None = None
self.proc: asyncio.subprocess.Process | None = None
self.stream: PortalStream | None = None
self.encoder = ""
self.container = ""
self.error = ""
self.on_stopped = on_stopped
# A small JPEG of what is actually going out, refreshed a couple of
# times a second, so the panel can show the screen it is sharing.
self.preview_path = runtime_dir() / "preview.jpg"
self.preview_at = 0.0
self._tasks: list[asyncio.Task] = []
self._stderr = ""
@property
def running(self) -> bool:
return self.proc is not None and self.proc.returncode is None
@property
def size(self) -> tuple[int, int]:
return (self.stream.width, self.stream.height) if self.stream else (0, 0)
@property
def source_name(self) -> str:
return self.stream.name if self.stream else ""
async def start(self, *, container: str, cfg, fanout,
hls_dir: str | None = None, on_phase=None) -> None:
await self.stop()
self.error = ""
self.container = container
self.encoder = pick_encoder(str(cfg["encoder"]), container)
self.portal = Portal()
self.stream = await self.portal.open(cursor=bool(cfg["cursor"]))
if on_phase:
on_phase("encoder")
audio_node = default_sink_node() if cfg["audio"] else None
preview_r, preview_w = os.pipe()
argv = build_pipeline(
self.stream, container=container, encoder=self.encoder, fps=int(cfg["fps"]),
max_height=int(cfg["maxHeight"]), bitrate_kbps=int(cfg["bitrate"]),
audio_node=audio_node, hls_dir=hls_dir, gpu_scale=gst_has("cudaconvertscale"),
preview_fd=preview_w,
)
log.info("capture: %dx%d %s/%s @%sfps%s", self.stream.width, self.stream.height,
self.encoder, container, cfg["fps"], " +audio" if audio_node else "")
log.debug("gst: %s", " ".join(argv))
self.proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE if not hls_dir else asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
pass_fds=(self.stream.fd, preview_w),
)
os.close(self.stream.fd) # the child owns it now
self.stream.fd = -1
os.close(preview_w) # ...and the write end of the preview pipe
self._tasks.append(asyncio.create_task(self._pump_preview(preview_r)))
if not hls_dir:
self._tasks.append(asyncio.create_task(self._pump(fanout)))
self._tasks.append(asyncio.create_task(self._watch_stderr()))
self._tasks.append(asyncio.create_task(self._wait()))
async def _pump(self, fanout) -> None:
assert self.proc and self.proc.stdout
try:
while True:
chunk = await self.proc.stdout.read(65536)
if not chunk:
break
fanout.feed(chunk)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("capture pump ended: %s", exc)
async def _pump_preview(self, fd: int) -> None:
"""JPEGs off the preview branch, published one whole frame at a time.
The panel reads the file while gst keeps writing, so each frame is
written beside it and renamed over the top - never half a picture.
"""
loop = asyncio.get_running_loop()
reader = asyncio.StreamReader()
try:
await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(fd, "rb", 0))
except Exception as exc: # noqa: BLE001
log.debug("preview pipe: %s", exc)
return
buf = b""
try:
while True:
chunk = await reader.read(65536)
if not chunk:
break
buf += chunk
while True:
end = buf.find(JPEG_END)
if end < 0:
break
frame, buf = buf[: end + 2], buf[end + 2 :]
start = frame.rfind(JPEG_START)
if start > 0:
frame = frame[start:]
if len(frame) > 1024:
self._publish_preview(frame)
if len(buf) > 1 << 20: # never a whole frame: give up on it
buf = b""
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("preview ended: %s", exc)
def _publish_preview(self, frame: bytes) -> None:
tmp = self.preview_path.with_suffix(".tmp")
try:
tmp.write_bytes(frame)
os.replace(tmp, self.preview_path)
self.preview_at = time.time()
except OSError as exc:
log.debug("preview write: %s", exc)
def clear_preview(self) -> None:
self.preview_at = 0.0
with contextlib.suppress(OSError):
self.preview_path.unlink()
async def _watch_stderr(self) -> None:
assert self.proc and self.proc.stderr
while True:
line = await self.proc.stderr.readline()
if not line:
break
text = line.decode(errors="replace").rstrip()
if not text:
continue
self._stderr = (self._stderr + "\n" + text)[-2000:]
log.warning("gst: %s", text)
if "ERROR" in text and not self.error:
# gst-launch keeps running after a negotiation failure; stop it
# so the session reports the problem instead of hanging.
self.error = _clean_error(text)
proc = self.proc
if proc is not None and proc.returncode is None:
proc.terminate()
async def _wait(self) -> None:
assert self.proc
code = await self.proc.wait()
if code not in (0, -15) and not self.error:
self.error = _first_error(self._stderr) or f"the encoder exited ({code})"
log.info("capture stopped (%s)", code)
if self.on_stopped:
self.on_stopped(code, self.error)
async def stop(self) -> None:
proc, self.proc = self.proc, None
for t in self._tasks:
t.cancel()
self._tasks = []
if proc is not None and proc.returncode is None:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), 3)
except asyncio.TimeoutError:
proc.kill()
if self.stream is not None and self.stream.fd >= 0:
try:
os.close(self.stream.fd)
except OSError:
pass
self.stream = None
self.clear_preview()
if self.portal is not None:
await self.portal.close()
self.portal = None
def _first_error(text: str) -> str:
for line in text.splitlines():
if "ERROR" in line:
return _clean_error(line)
return ""
def _clean_error(line: str) -> str:
""""ERROR: from element /GstPipeline:…/GstFoo:foo0: msg""foo0: msg"."""
msg = line.split("ERROR", 1)[1].strip(" :")
if msg.startswith("from element"):
msg = msg[len("from element"):].strip()
msg = msg.rsplit("/", 1)[-1]
return msg[:200]
# gst-launch-1.0 joins its argv back into one string and re-parses it, so a
# value containing a space, '!' or ',' does not stay a value - it becomes more
# pipeline. Everything else we pass is a literal or a clamped int; these two are
# not, so they get checked.
GST_SAFE = re.compile(r"^[A-Za-z0-9._:@/+-]+$")
def gst_safe(value: str) -> bool:
return bool(GST_SAFE.match(value))
def default_sink_node() -> str | None:
"""node.name of the default audio output, captured in monitor mode."""
import subprocess
for cmd in (["pactl", "get-default-sink"],):
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
name = out.stdout.strip()
if out.returncode == 0 and name and name != "@DEFAULT_SINK@":
if not gst_safe(name):
log.warning("audio: sink name %r cannot go in a pipeline", name)
return None
return name
except Exception: # noqa: BLE001
continue
return None
+73
View File
@@ -0,0 +1,73 @@
"""User settings, persisted to ~/.config/screencast/config.json."""
from __future__ import annotations
from .util import config_dir, read_json, write_json
DEFAULTS = {
"fps": 30,
"maxHeight": 1080, # the capture is scaled down to this before encoding
"bitrate": 8000, # kbit/s
"encoder": "auto", # auto | nvenc | vaapi | x264 | vp8 | vp9
"audio": True, # mix desktop audio into the stream
"cursor": True, # draw the pointer into the capture
"port": 8011, # local HTTP server the receiver pulls the stream from
"lastDevice": "", # id of the device cast to last, for the "cast again" default
"autoReconnect": True, # restart the pipeline if the receiver drops the stream
"portVerified": False, # set once a receiver has actually fetched the stream
}
# Settings that only take effect on the next session (changing them mid-cast
# restarts the pipeline instead of being ignored).
PIPELINE_KEYS = {"fps", "maxHeight", "bitrate", "encoder", "audio", "cursor"}
class Config:
def __init__(self) -> None:
self.path = config_dir() / "config.json"
self.values = dict(DEFAULTS)
self.values.update(read_json(self.path, {}) or {})
def __getitem__(self, key):
return self.values.get(key, DEFAULTS.get(key))
def get(self, key, default=None):
return self.values.get(key, DEFAULTS.get(key, default))
def update(self, patch: dict) -> set[str]:
"""Apply a patch, returning the keys that actually changed."""
changed = set()
for key, value in (patch or {}).items():
if key not in DEFAULTS:
continue
want = _coerce(key, value)
if want is None or self.values.get(key) == want:
continue
self.values[key] = want
changed.add(key)
if changed:
self.save()
return changed
def save(self) -> None:
write_json(self.path, self.values)
def _coerce(key, value):
ref = DEFAULTS[key]
try:
if isinstance(ref, bool):
return bool(value)
if isinstance(ref, int):
v = int(value)
if key == "fps":
return max(5, min(60, v))
if key == "maxHeight":
return max(360, min(2160, v))
if key == "bitrate":
return max(500, min(50000, v))
if key == "port":
return max(1024, min(65535, v))
return v
return str(value)
except (TypeError, ValueError):
return None
+60
View File
@@ -0,0 +1,60 @@
"""What the panel sees as a "display": one receiver, whatever protocol it speaks."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
CAST, AIRPLAY, DLNA = "cast", "airplay", "dlna"
KIND_LABEL = {CAST: "Google Cast", AIRPLAY: "AirPlay", DLNA: "DLNA"}
@dataclass
class Device:
id: str
kind: str
name: str
host: str
port: int
model: str = ""
manufacturer: str = ""
# Protocol-specific handles: cast_info for Cast, control URLs for DLNA,
# the mDNS TXT record for AirPlay.
extra: dict = field(default_factory=dict)
seen: float = field(default_factory=time.time)
# Live status, filled in by the backend that owns the device.
status: str = "" # "" | idle | busy | casting | connecting | error
app: str = "" # what it is showing now ("Netflix", "Backdrop", …)
volume: float = -1.0 # -1 when unknown
muted: bool = False
error: str = ""
@property
def video(self) -> bool:
"""Can it show a picture? Speaker groups and speakers cannot."""
if self.kind == CAST:
return self.extra.get("cast_type") in (None, "cast", "group") and not self.audio_only
return True
@property
def audio_only(self) -> bool:
if self.kind == CAST:
return self.extra.get("cast_type") in ("audio", "group")
return False
def to_json(self) -> dict:
return {
"id": self.id,
"kind": self.kind,
"kindLabel": KIND_LABEL.get(self.kind, self.kind),
"name": self.name,
"model": self.model,
"host": self.host,
"port": self.port,
"status": self.status,
"app": self.app,
"volume": self.volume,
"muted": self.muted,
"audioOnly": self.audio_only,
"error": self.error,
}
+412
View File
@@ -0,0 +1,412 @@
"""Finding receivers on the network.
Three protocols, three mechanisms, one device list:
* Google Cast — mDNS `_googlecast._tcp`, browsed by pychromecast.
* AirPlay — mDNS `_airplay._tcp`.
* DLNA — SSDP `MediaRenderer`, then the device description XML for its
control URLs.
"""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import socket
import time
import xml.etree.ElementTree as ET
from urllib.parse import urljoin, urlsplit
import aiohttp
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
from .devices import AIRPLAY, CAST, DLNA, Device
# Caps on things a hostile or broken LAN can hand us. Every one of these is a
# number an attacker would otherwise get to choose.
MAX_SSDP_REPLIES = 32 # description URLs fetched per scan
DESCRIPTION_LIMIT = 256 << 10 # bytes of device XML we are willing to parse
MAX_DEVICES = 64 # entries in the device table
def _is_lan(host: str) -> bool:
"""True for an address a receiver could plausibly have."""
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip.is_private or ip.is_loopback or ip.is_link_local
from .util import log
AIRPLAY_TYPE = "_airplay._tcp.local."
GOOGLECAST_TYPE = "_googlecast._tcp.local."
SSDP_ADDR, SSDP_PORT = "239.255.255.250", 1900
SSDP_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
STALE_AFTER = 180.0 # a device unseen this long drops off the list
AWAY_GRACE = 60.0 # ...but a withdrawn announcement gets this long to come back
class Discovery:
def __init__(self, on_change) -> None:
self.devices: dict[str, Device] = {}
self.on_change = on_change
self.zeroconf: Zeroconf | None = None
self.cast_browser = None
self._airplay_browser: ServiceBrowser | None = None
self._cast_txt_browser: ServiceBrowser | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ssdp_task: asyncio.Task | None = None
self._reap_task: asyncio.Task | None = None
self._session: aiohttp.ClientSession | None = None
# ---- lifecycle ----
async def start(self) -> None:
self._loop = asyncio.get_running_loop()
self.zeroconf = Zeroconf()
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=6), headers={"User-Agent": "screencast/1.0"}
)
self._start_cast()
self._airplay_browser = ServiceBrowser(
self.zeroconf, [AIRPLAY_TYPE], handlers=[self._airplay_change]
)
# Cast devices announce what they are showing in their TXT record, so the
# list can say "Netflix" or "idle" without opening a connection to each.
self._cast_txt_browser = ServiceBrowser(
self.zeroconf, [GOOGLECAST_TYPE], handlers=[self._cast_txt_change]
)
self._ssdp_task = asyncio.create_task(self._ssdp_loop())
self._reap_task = asyncio.create_task(self._reap_loop())
def _start_cast(self) -> None:
try:
from pychromecast.discovery import CastBrowser, SimpleCastListener
except Exception as exc: # noqa: BLE001
log.warning("Google Cast discovery unavailable: %s", exc)
return
listener = SimpleCastListener(
add_callback=self._cast_seen, update_callback=self._cast_seen,
remove_callback=self._cast_gone,
)
self.cast_browser = CastBrowser(listener, self.zeroconf)
self.cast_browser.start_discovery()
async def stop(self) -> None:
for task in (self._ssdp_task, self._reap_task):
if task:
task.cancel()
if self.cast_browser is not None:
try:
self.cast_browser.stop_discovery()
except Exception: # noqa: BLE001
pass
for browser in (self._airplay_browser, self._cast_txt_browser):
if browser is not None:
browser.cancel()
if self.zeroconf is not None:
# close() does blocking network I/O; keep it off the event loop.
zc, self.zeroconf = self.zeroconf, None
with contextlib.suppress(Exception):
await asyncio.wait_for(asyncio.to_thread(zc.close), 3)
if self._session is not None:
await self._session.close()
async def rescan(self) -> None:
"""Ask again now instead of waiting for the next announcement.
The long-lived browsers are left alone (stopping pychromecast's browser
tears down the shared Zeroconf instance with it); a throwaway browser
over the same types is enough to put fresh queries on the wire.
"""
if self.zeroconf is not None:
probe = ServiceBrowser(self.zeroconf, [GOOGLECAST_TYPE, AIRPLAY_TYPE],
handlers=[self._probe_change])
asyncio.get_running_loop().call_later(4, probe.cancel)
await self._ssdp_search()
def _probe_change(self, zeroconf: Zeroconf, service_type: str, name: str,
state_change: ServiceStateChange) -> None:
"""A manual scan only needs to make devices answer; the real browsers record them."""
return
# ---- Google Cast (callbacks arrive on a zeroconf thread) ----
def _cast_seen(self, uuid, service) -> None:
if self._loop:
self._loop.call_soon_threadsafe(self._cast_seen_main, uuid)
def _cast_gone(self, uuid, service, cast_info) -> None:
if self._loop:
self._loop.call_soon_threadsafe(self._drop, f"cast:{uuid}")
def _cast_seen_main(self, uuid) -> None:
info = (self.cast_browser.devices or {}).get(uuid) if self.cast_browser else None
if info is None:
return
dev = self._upsert(
Device(
id=f"cast:{uuid}",
kind=CAST,
name=info.friendly_name or str(uuid),
host=info.host,
port=info.port or 8009,
model=info.model_name or "",
manufacturer=info.manufacturer or "",
extra={"uuid": str(uuid), "cast_type": info.cast_type},
)
)
dev.extra["cast_info"] = info
def _cast_txt_change(self, zeroconf: Zeroconf, service_type: str, name: str,
state_change: ServiceStateChange) -> None:
if self._loop is None or state_change is ServiceStateChange.Removed:
return
info = zeroconf.get_service_info(service_type, name, timeout=2000)
if info is None:
return
txt = {
k.decode(errors="replace"): (v or b"").decode(errors="replace")
for k, v in (info.properties or {}).items()
}
raw = txt.get("id", "")
if len(raw) != 32:
return
uuid = f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
self._loop.call_soon_threadsafe(self._cast_txt_main, uuid, txt)
def _cast_txt_main(self, uuid: str, txt: dict) -> None:
dev = self.devices.get(f"cast:{uuid}")
if dev is None:
return
app = txt.get("rs", "").strip()
# st: 0 = nothing running, anything else = an app has the screen.
status = "idle" if txt.get("st", "0") == "0" else "busy"
if (dev.app, dev.status) != (app, status):
dev.app, dev.status = app, status
self.on_change()
# ---- AirPlay ----
def _airplay_change(self, zeroconf: Zeroconf, service_type: str, name: str,
state_change: ServiceStateChange) -> None:
if self._loop is None:
return
if state_change is ServiceStateChange.Removed:
# TVs withdraw and re-publish their AirPlay record constantly, and a
# row that disappears under the pointer is worse than a stale one:
# mark it away and let the reaper drop it if it stays gone.
self._loop.call_soon_threadsafe(self._mark_away, f"airplay:{name}")
return
info = zeroconf.get_service_info(service_type, name, timeout=2000)
if info is None:
return
addrs = info.parsed_scoped_addresses() if hasattr(info, "parsed_scoped_addresses") else []
ipv4 = next((a for a in addrs if ":" not in a), None) or (addrs[0] if addrs else "")
if not ipv4:
return
txt = {
k.decode(errors="replace"): (v or b"").decode(errors="replace")
for k, v in (info.properties or {}).items()
}
friendly = name.split(".")[0].replace("\\032", " ")
self._loop.call_soon_threadsafe(
self._upsert,
Device(
id=f"airplay:{name}",
kind=AIRPLAY,
name=txt.get("name") or friendly,
host=ipv4,
port=info.port or 7000,
model=txt.get("model", ""),
manufacturer="Apple" if txt.get("model", "").startswith(("Mac", "Apple")) else "",
extra={"txt": txt, "service": name},
),
)
# ---- DLNA ----
async def _ssdp_loop(self) -> None:
while True:
try:
await self._ssdp_search()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("ssdp: %s", exc)
await asyncio.sleep(60)
async def _ssdp_search(self) -> None:
"""M-SEARCH from every local address; renderers answer by unicast."""
msg = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
'MAN: "ssdp:discover"\r\n'
"MX: 2\r\n"
f"ST: {SSDP_TARGET}\r\n\r\n"
).encode()
locations: dict[str, str] = {} # description URL -> the address that sent it
for local_ip in _local_ipv4s():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
try:
sock.bind((local_ip, 0))
sock.setblocking(False)
loop = asyncio.get_running_loop()
await loop.sock_sendto(sock, msg, (SSDP_ADDR, SSDP_PORT))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
try:
data, src = await asyncio.wait_for(
loop.sock_recvfrom(sock, 4096), max(0.2, deadline - time.monotonic())
)
except (asyncio.TimeoutError, OSError):
break
loc = _header(data.decode(errors="replace"), "location")
if loc and len(locations) < MAX_SSDP_REPLIES:
locations.setdefault(loc, src[0])
except OSError as exc:
log.debug("ssdp on %s: %s", local_ip, exc)
finally:
sock.close()
for loc, sender in locations.items():
await self._add_dlna(loc, sender)
async def _add_dlna(self, location: str, sender: str) -> None:
assert self._session is not None
# An SSDP reply is an unauthenticated UDP packet from anyone on the wire,
# and we are about to fetch the URL inside it as this user. Only fetch it
# back from the host that sent it: otherwise the packet chooses the
# target, and "http://127.0.0.1:9091/..." is a request we would make for
# a stranger.
host = urlsplit(location).hostname or ""
if host != sender or not _is_lan(host):
log.info("ssdp: ignoring %s announced by %s", location, sender)
return
try:
async with self._session.get(location) as resp:
# No renderer needs a megabyte to describe itself; an attacker
# would happily send us gigabytes, or an entity bomb.
raw = await resp.content.read(DESCRIPTION_LIMIT)
except Exception as exc: # noqa: BLE001
log.debug("dlna description %s: %s", location, exc)
return
xml = raw.decode("utf-8", errors="replace")
try:
root = ET.fromstring(xml)
except ET.ParseError:
return
ns = {"u": "urn:schemas-upnp-org:device-1-0"}
dev_el = root.find("u:device", ns)
if dev_el is None:
return
udn = (dev_el.findtext("u:UDN", "", ns) or location).strip()
name = (dev_el.findtext("u:friendlyName", "", ns) or "DLNA renderer").strip()
model = (dev_el.findtext("u:modelName", "", ns) or "").strip()
maker = (dev_el.findtext("u:manufacturer", "", ns) or "").strip()
base = root.findtext("u:URLBase", "", ns) or location
control: dict[str, str] = {}
for svc in dev_el.iter("{urn:schemas-upnp-org:device-1-0}service"):
stype = svc.findtext("u:serviceType", "", ns)
curl = svc.findtext("u:controlURL", "", ns)
if not stype or not curl:
continue
target = urljoin(base, curl)
# URLBase and controlURL are the device's own words. An absolute URL
# here would point our POSTs anywhere it likes.
if urlsplit(target).hostname != host:
log.info("dlna: %s points its control URL at %s, ignoring", location, target)
continue
if "AVTransport" in stype:
control["avtransport"] = target
control["avtransport_type"] = stype
elif "RenderingControl" in stype:
control["rendering"] = target
control["rendering_type"] = stype
if "avtransport" not in control:
return # can be discovered but cannot be told to play anything
self._upsert(
Device(
id=f"dlna:{udn}",
kind=DLNA,
name=name,
host=host,
port=int(location.split("/")[2].split(":")[1]) if ":" in location.split("/")[2] else 80,
model=model,
manufacturer=maker,
extra={"control": control, "location": location},
)
)
# ---- list bookkeeping ----
def _upsert(self, dev: Device) -> Device:
old = self.devices.get(dev.id)
if old is not None:
old.seen = time.time()
changed = (old.name, old.host, old.port) != (dev.name, dev.host, dev.port)
if old.status == "away":
old.status, changed = "", True
old.name, old.host, old.port = dev.name, dev.host, dev.port
old.model = dev.model or old.model
old.extra.update(dev.extra)
if changed:
self.on_change()
return old
if len(self.devices) >= MAX_DEVICES:
# Announcements are free to send and we push the whole list to the
# shell on every change; a flood must not become the shell's problem.
log.warning("device list full (%d), ignoring %s", MAX_DEVICES, dev.id)
return dev
self.devices[dev.id] = dev
log.info("found %s: %s (%s)", dev.kind, dev.name, dev.host)
self.on_change()
return dev
def _mark_away(self, device_id: str) -> None:
dev = self.devices.get(device_id)
if dev is None or dev.status == "away":
return
dev.status = "away"
dev.seen = time.time() - (STALE_AFTER - AWAY_GRACE)
log.info("away %s", device_id)
self.on_change()
def _drop(self, device_id: str) -> None:
if self.devices.pop(device_id, None) is not None:
log.info("lost %s", device_id)
self.on_change()
async def _reap_loop(self) -> None:
while True:
await asyncio.sleep(15)
now = time.time()
for dev_id, dev in list(self.devices.items()):
# Cast devices are kept by their browser; the rest age out —
# AirPlay only once its away grace has run down too.
if dev.kind in (DLNA, AIRPLAY) and now - dev.seen > STALE_AFTER:
self._drop(dev_id)
def _header(text: str, name: str) -> str:
for line in text.splitlines():
if line.lower().startswith(name + ":"):
return line.split(":", 1)[1].strip()
return ""
def _local_ipv4s() -> list[str]:
"""Every non-loopback IPv4 we can send from (several NICs, VPNs, …)."""
out: list[str] = []
try:
import ifaddr
for adapter in ifaddr.get_adapters():
for ip in adapter.ips:
if ip.is_IPv4 and not str(ip.ip).startswith("127."):
out.append(str(ip.ip))
except Exception: # noqa: BLE001
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 9))
out.append(s.getsockname()[0])
except OSError:
pass
finally:
s.close()
return out or ["0.0.0.0"]
+509
View File
@@ -0,0 +1,509 @@
"""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)
+307
View File
@@ -0,0 +1,307 @@
"""One cast: capture → local HTTP stream → receiver, and the watching of it."""
from __future__ import annotations
import asyncio
import shutil
import time
from .backends.base import BackendError
from .capture import Capture, PortalError, pick_encoder
from .devices import Device
from .stream import Fanout
from .util import firewall_hint, local_ip_for, log, runtime_dir, url_host
IDLE, STARTING, STREAMING, STOPPING, ERROR = "idle", "starting", "streaming", "stopping", "error"
def choose_container(backend, cfg) -> str:
"""The best container this receiver and this machine can agree on."""
preference = str(cfg["encoder"])
for container in backend.containers:
if container == "hls":
return "hls"
if preference in ("vp8", "vp9") and container != "webm":
continue
try:
pick_encoder(preference, container)
return container
except PortalError:
continue
# Nothing matched the preference; fall back to whatever the machine has.
for container in backend.containers:
try:
pick_encoder("auto", container)
return container
except PortalError:
continue
raise PortalError("no usable encoder for this receiver")
class Session:
def __init__(self, daemon) -> None:
self.daemon = daemon
self.state = IDLE
self.phase = "" # what "starting" is waiting on, for the panel
self.device: Device | None = None
self.backend = None
self.container = ""
self.encoder = ""
self.url = ""
self.error = ""
self.since = 0.0
self.player_state = ""
self.clients = 0
self.last_client = 0.0
self.capture = Capture(on_stopped=self._capture_stopped)
self.fanout: Fanout | None = None
self.hls_dir = None
self._watchdog: asyncio.Task | None = None
self._reconnects = 0
# ---- reporting ----
@property
def active(self) -> bool:
return self.state in (STARTING, STREAMING)
def to_json(self) -> dict:
width, height = self.capture.size
return {
"state": self.state,
"phase": self.phase,
"deviceId": self.device.id if self.device else "",
"deviceName": self.device.name if self.device else "",
"deviceKind": self.device.kind if self.device else "",
"since": self.since,
"url": self.url,
"container": self.container,
"encoder": self.encoder,
"source": self.capture.source_name,
"preview": str(self.capture.preview_path) if self.capture.preview_at else "",
"previewAt": self.capture.preview_at,
"width": width,
"height": height,
"clients": self.clients,
"playerState": self.player_state,
"bytes": self.fanout.bytes_out if self.fanout else 0,
# How far behind the receiver is letting itself fall, in bytes.
"backlog": self.fanout.backlog if self.fanout else 0,
"dropped": self.fanout.dropped if self.fanout else 0,
"error": self.error,
}
# ---- start / stop ----
async def start(self, device: Device, backend) -> None:
await self.stop()
cfg = self.daemon.cfg
self.device, self.backend = device, backend
self.state, self.error, self.since = STARTING, "", time.time()
self.phase = "screen" # the portal is asking which screen to share
self.player_state, self.clients, self._reconnects = "", 0, 0
self.daemon.push_state(now=True)
try:
self.container = choose_container(backend, cfg)
hls_dir = None
if self.container == "hls":
hls_dir = runtime_dir() / "hls"
shutil.rmtree(hls_dir, ignore_errors=True)
hls_dir.mkdir(parents=True, exist_ok=True)
self.hls_dir = hls_dir
self.daemon.stream.hls_dir = hls_dir
self.fanout = None
else:
self.fanout = Fanout(self.container, int(cfg["bitrate"]))
self.daemon.stream.fanout = self.fanout
await self.daemon.stream.start(int(cfg["port"]))
await self.capture.start(
container="mpegts" if self.container == "hls" else self.container,
cfg=cfg, fanout=self.fanout,
hls_dir=str(hls_dir) if hls_dir else None,
on_phase=self._set_phase,
)
self.encoder = self.capture.encoder
ip = local_ip_for(device.host)
# A fresh token per session is both the access control and the reason
# a receiver cannot carry its old buffer - and the delay in it - into
# the new stream: to it, this is a URL it has never seen.
self.daemon.stream.new_token()
path = (self.daemon.stream.hls_path() if self.container == "hls"
else self.daemon.stream.url_path(self.container))
self.url = f"http://{url_host(ip)}:{self.daemon.stream.port}{path}"
if self.container == "hls":
await self._await_playlist()
self._set_phase("connect")
await backend.play(device, self.url, container=self.container, title="Screencast")
except asyncio.CancelledError:
await self._teardown()
self.state, self.device, self.backend = IDLE, None, None
self.daemon.push_state(now=True)
raise
except (PortalError, BackendError) as exc:
await self.fail(str(exc))
return
except Exception as exc: # noqa: BLE001 - never leave a half-started cast behind
log.exception("cast failed")
await self.fail(str(exc))
return
self.state = STREAMING
# Handed over, but nothing is on screen until the receiver fetches it.
self.phase = "live" if self.clients else "waiting"
self.daemon.push_state(now=True)
self._watchdog = asyncio.create_task(self._watch())
def _set_phase(self, phase: str) -> None:
self.phase = phase
self.daemon.push_state(now=True)
async def _await_playlist(self) -> None:
"""AirPlay fetches the playlist immediately; give hlssink2 time to write one."""
assert self.hls_dir is not None
for _ in range(100):
if (self.hls_dir / "index.m3u8").exists():
await asyncio.sleep(1.0) # one full segment, so the receiver has something to play
return
await asyncio.sleep(0.1)
raise PortalError("the encoder produced no HLS playlist")
async def fail(self, message: str) -> None:
log.warning("cast failed: %s", message)
device, backend = self.device, self.backend
await self._teardown()
self.device, self.backend = device, backend
self.state, self.error, self.phase = ERROR, message, ""
self.daemon.push_state(now=True)
async def stop(self, *, quiet: bool = False) -> None:
if self.state == IDLE and self.device is None:
return
self.state = STOPPING
if not quiet:
self.daemon.push_state(now=True)
device, backend = self.device, self.backend
if backend is not None and device is not None:
try:
await backend.stop(device)
except Exception as exc: # noqa: BLE001
log.debug("backend stop: %s", exc)
await self._teardown()
self.state, self.error, self.phase = IDLE, "", ""
self.device, self.backend = None, None
if not quiet:
self.daemon.push_state(now=True)
async def _teardown(self) -> None:
# The watchdog is usually the one calling this (through fail()), and a
# task that cancels itself never gets to finish the job: the session
# would stay "streaming" with a dead encoder behind it, which is exactly
# what a receiver stuck on a spinner looks like from here.
watchdog, self._watchdog = self._watchdog, None
if watchdog is not None and watchdog is not asyncio.current_task():
watchdog.cancel()
await self.capture.stop()
if self.fanout is not None:
self.fanout.close()
self.fanout = None
self.daemon.stream.fanout = None
self.daemon.stream.hls_dir = None
if self.hls_dir is not None:
shutil.rmtree(self.hls_dir, ignore_errors=True)
self.hls_dir = None
self.url = ""
self.clients = 0
# ---- live ----
def note_client(self, peer: str, connected: bool) -> None:
if self.fanout is None and self.hls_dir is None:
return
self.clients = self.fanout.clients if self.fanout else self.clients + (1 if connected else -1)
self.clients = max(0, self.clients)
if connected:
self.last_client = time.time()
self._reconnects = 0
if self.state == STREAMING:
self.phase = "live" if self.clients else "waiting"
self.daemon.push_state(now=True)
def _capture_stopped(self, code: int, error: str) -> None:
if not self.active:
return
asyncio.create_task(self.fail(error or f"screen capture stopped ({code})"))
async def _watch(self) -> None:
"""Poll the receiver, and notice when the picture stops arriving."""
last_bytes, last_progress = -1, time.time()
ticks = 0
while self.active:
await asyncio.sleep(2)
ticks += 1
if self.device is None or self.backend is None:
return
# A client that connected and then went quiet (a half-open socket, a
# receiver that gave up without closing) used to keep the session
# looking healthy forever, so check the encoder itself rather than
# trusting the client count.
if not self.capture.running:
await self.fail(self.capture.error or "the screen capture stopped")
return
if self.fanout is not None:
if self.fanout.bytes_out != last_bytes:
last_bytes, last_progress = self.fanout.bytes_out, time.time()
elif time.time() - last_progress > 15:
await self.fail("the encoder stopped producing video")
return
try:
status = await self.backend.poll(self.device)
except Exception as exc: # noqa: BLE001
log.debug("poll: %s", exc)
status = {}
if status:
self.player_state = status.get("playerState", self.player_state)
if "volume" in status and status["volume"] is not None:
self.device.volume = float(status["volume"])
if "muted" in status:
self.device.muted = bool(status["muted"])
self.device.app = status.get("app", self.device.app)
if status.get("connected") is False:
await self.fail(f"{self.device.name} dropped the connection")
return
# A receiver that never fetched the stream, or that let go of it, is
# not casting — retry the play once before giving up on it.
stale = self.fanout is not None and self.clients == 0 and time.time() - self.since > 12
if stale and time.time() - self.last_client > 12:
if bool(self.daemon.cfg["autoReconnect"]) and self._reconnects < 2:
self._reconnects += 1
log.info("no receiver on the stream; retrying play (%d)", self._reconnects)
try:
await self.backend.play(self.device, self.url,
container=self.container, title="Screencast")
self.last_client = time.time()
except BackendError as exc:
await self.fail(str(exc))
return
else:
hint = firewall_hint(self.daemon.stream.port, self.device.host)
reason = f"{self.device.name} never picked up the stream"
if hint:
# The panel turns this into an "Open port" button.
self.daemon.firewall_blocked = True
await self.fail(f"{reason}{hint}" if hint else reason)
return
# How much of the delay is ours: bytes the receiver has not taken
# yet, and bytes we skipped past because it never would have.
if self.fanout is not None and ticks % 8 == 0 and self.clients:
rate = self.fanout.bytes_out / max(1e-3, time.time() - self.fanout.started)
log.info("stream: %.1f KiB queued (%.1fs behind), %.0f KiB skipped, %.0f KiB/s",
self.fanout.backlog / 1024, self.fanout.backlog / max(1.0, rate),
self.fanout.dropped / 1024, rate / 1024)
self.daemon.push_state(now=True)
# ---- source ----
async def repick_source(self) -> None:
"""Restart the cast so the portal asks which screen to share again."""
if self.active and self.device is not None and self.backend is not None:
await self.start(self.device, self.backend)
+385
View File
@@ -0,0 +1,385 @@
"""The local HTTP server the receivers pull the screen stream from.
Receivers (Cast, DLNA, AirPlay) do not accept a push: they are handed a URL and
fetch it themselves. So the encoder writes one live byte stream and this fans it
out to whoever connected, plus serves the HLS directory AirPlay needs.
"""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import secrets
import socket
import time
from collections import deque
from pathlib import Path
from aiohttp import web
from .util import log
# A late client cannot start mid-container, so every stream keeps the bytes the
# encoder wrote before the first media chunk (EBML head + tracks for WebM, the
# moov/init for fMP4, PAT/PMT for MPEG-TS) and replays them on connect.
CLUSTER_MARK = b"\x1f\x43\xb6\x75" # WebM Cluster id
MOOF_MARK = b"moof" # fragmented MP4 fragment box
HEADER_LIMIT = 1 << 20 # never hoard more than 1 MiB as "header"
MIN_BACKLOG = 192 << 10 # never hold less than this, whatever the bitrate
BACKLOG_SECONDS = 1.5 # ...and never much more than this much screen
# (a few keyframe-aligned clusters: the drop unit)
class Reader:
"""One receiver's view of the stream: a short, self-trimming backlog.
A receiver pulls at playback speed, so anything we let pile up here is
latency we hand it and never get back — and a live stream that falls behind
never catches up on its own. Keeping under a second of screen queued, and
skipping forward when it does not drain, is what keeps the picture close to
now rather than however far behind it drifted an hour ago.
"""
def __init__(self, max_bytes: int) -> None:
self.items: deque[tuple[bytes, bool]] = deque()
self.queued = 0
self.dropped = 0
self.max_bytes = max_bytes
self.closed = False
self._wake = asyncio.Event()
def push(self, data: bytes, at_boundary: bool) -> None:
self.items.append((data, at_boundary))
self.queued += len(data)
if self.queued > self.max_bytes:
self._skip_forward()
self._wake.set()
def _skip_forward(self) -> None:
while self.queued > self.max_bytes and len(self.items) > 1:
data, _ = self.items.popleft()
self.queued -= len(data)
self.dropped += len(data)
# Resume on a container boundary: dropping into the middle of a cluster
# hands the receiver's demuxer a torn one.
while len(self.items) > 1 and not self.items[0][1]:
data, _ = self.items.popleft()
self.queued -= len(data)
self.dropped += len(data)
def close(self) -> None:
self.closed = True
self._wake.set()
async def get(self) -> bytes | None:
while not self.items:
if self.closed:
return None
self._wake.clear()
await self._wake.wait()
data, _ = self.items.popleft()
self.queued -= len(data)
return data
class Fanout:
"""One encoder byte stream, many HTTP readers."""
def __init__(self, container: str, bitrate_kbps: int = 5000) -> None:
self.container = container
self.header = b""
self.header_done = False
self.bytes_out = 0
self.started = time.time()
self.max_bytes = max(MIN_BACKLOG, int(bitrate_kbps * 1000 / 8 * BACKLOG_SECONDS))
# Where this container can be cut without tearing a unit in half. MPEG-TS
# has no such mark - it resynchronises on its own - so it is cut anywhere.
self._mark = (CLUSTER_MARK if container in ("webm", "matroska")
else MOOF_MARK if container == "mp4" else None)
self._carry = b"" # bytes held back in case a mark straddles a chunk
self._at_boundary = True
self._readers: set[Reader] = set()
self._closed = False
@property
def clients(self) -> int:
return len(self._readers)
@property
def backlog(self) -> int:
return max((r.queued for r in self._readers), default=0)
@property
def dropped(self) -> int:
return max((r.dropped for r in self._readers), default=0)
def feed(self, chunk: bytes) -> None:
if self._closed or not chunk:
return
self.bytes_out += len(chunk)
if not self.header_done:
chunk = self._grow_header(chunk)
if not self.header_done or not chunk:
return
for data, at_boundary in self._cut(chunk):
for reader in list(self._readers):
reader.push(data, at_boundary)
def _cut(self, chunk: bytes) -> list[tuple[bytes, bool]]:
"""Split so that every piece starting a new container unit says so."""
if self._mark is None:
return [(chunk, True)]
buf = self._carry + chunk
width = len(self._mark)
pieces: list[tuple[bytes, bool]] = []
start = i = 0
while True:
idx = buf.find(self._mark, i)
if idx < 0:
break
if idx > start:
pieces.append((buf[start:idx], self._at_boundary))
self._at_boundary = False
start, i = idx, idx + width
self._at_boundary = True
tail = buf[start:]
hold = min(len(tail), width - 1)
body, self._carry = tail[: len(tail) - hold], tail[len(tail) - hold :]
if body:
pieces.append((body, self._at_boundary))
self._at_boundary = False
return pieces
def _grow_header(self, chunk: bytes) -> bytes:
"""Collect the container header; return whatever followed it."""
buf = self.header + chunk
mark = CLUSTER_MARK if self.container in ("webm", "matroska") else MOOF_MARK
idx = buf.find(mark, max(0, len(self.header) - 4))
if self.container == "mpegts":
# TS repeats its tables; the first packets are enough to start on.
if len(buf) >= 32768:
self.header, self.header_done = buf[:32768], True
return buf[32768:]
elif idx > 0:
self.header, self.header_done = buf[:idx], True
return buf[idx:]
if len(buf) >= HEADER_LIMIT:
self.header, self.header_done = buf[:HEADER_LIMIT], True
return buf[HEADER_LIMIT:]
self.header = buf
return b""
def subscribe(self) -> Reader:
reader = Reader(self.max_bytes)
self._readers.add(reader)
return reader
def unsubscribe(self, reader: Reader) -> None:
self._readers.discard(reader)
reader.close()
def close(self) -> None:
self._closed = True
for reader in list(self._readers):
reader.close()
self._readers.clear()
CONTENT_TYPES = {
"webm": "video/webm",
"matroska": "video/x-matroska",
"mp4": "video/mp4",
"mpegts": "video/mp2t",
}
PATHS = {"webm": "/live.webm", "mp4": "/live.mp4", "mpegts": "/live.ts", "matroska": "/live.mkv"}
# One receiver pulls one stream; a couple spare for a retry that has not timed
# out yet. Anything past this is either a bug or someone else's curiosity, and
# each reader costs a socket plus its share of the backlog.
MAX_CLIENTS = 4
def _is_lan(peer: str | None) -> bool:
"""Receivers live on the LAN. Anything routed in from outside it is not one."""
if not peer:
return False
try:
ip = ipaddress.ip_address(peer.split("%", 1)[0])
except ValueError:
return False
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
return ip.is_private or ip.is_loopback or ip.is_link_local
class StreamServer:
"""aiohttp server exposing whatever the current session is encoding.
`on_client` lets the session know a receiver actually connected (or that the
last one went away) — that is the only reliable signal that casting started.
"""
def __init__(self, on_client=None) -> None:
self.port = 0
self.fanout: Fanout | None = None
self.hls_dir: Path | None = None
self.on_client = on_client
# The stream port has to be reachable from the LAN or no receiver can
# pull it, and none of these protocols can carry a credential. So the
# capability lives in the URL: a fresh unguessable path per session,
# which is the only thing standing between a shared screen and everyone
# else on the network.
self.token = secrets.token_urlsafe(16)
self._runner: web.AppRunner | None = None
self._app = web.Application()
self._app.add_routes(
[
web.get("/", self._index),
web.get("/status", self._status),
web.options("/{token}/live.{ext}", self._preflight),
web.route("*", "/{token}/live.{ext}", self._live),
web.route("*", "/{token}/hls/{name}", self._hls),
]
)
def new_token(self) -> str:
"""Rotate the stream path. Every session gets its own; the old one dies."""
self.token = secrets.token_urlsafe(16)
return self.token
def _authorized(self, request: web.Request) -> bool:
return (
_is_lan(request.remote)
and secrets.compare_digest(request.match_info.get("token", ""), self.token)
)
async def start(self, port: int) -> None:
if self._runner is not None and self.port == port:
return
await self.stop()
self._runner = web.AppRunner(self._app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", port, reuse_address=True)
await site.start()
self.port = port
log.info("http stream server on 0.0.0.0:%d", port)
async def stop(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
self.port = 0
def url_path(self, container: str) -> str:
return f"/{self.token}" + PATHS.get(container, "/live.webm")
def hls_path(self) -> str:
return f"/{self.token}/hls/index.m3u8"
async def _index(self, request: web.Request) -> web.Response:
return web.Response(text="screencast\n")
async def _status(self, request: web.Request) -> web.Response:
# Whether this desktop is casting is nobody else's business.
if request.remote not in ("127.0.0.1", "::1"):
raise web.HTTPNotFound()
f = self.fanout
return web.json_response(
{
"streaming": f is not None,
"container": f.container if f else "",
"clients": f.clients if f else 0,
"bytes": f.bytes_out if f else 0,
}
)
async def _preflight(self, request: web.Request) -> web.Response:
return web.Response(
status=204,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
async def _hls(self, request: web.Request) -> web.StreamResponse:
if not self._authorized(request):
raise web.HTTPNotFound()
name = request.match_info["name"]
if self.hls_dir is None or "/" in name or name.startswith("."):
raise web.HTTPNotFound()
path = self.hls_dir / name
if not path.is_file():
raise web.HTTPNotFound()
ctype = "application/vnd.apple.mpegurl" if name.endswith(".m3u8") else "video/mp2t"
return web.FileResponse(
path,
headers={"Content-Type": ctype, "Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*"},
)
async def _live(self, request: web.Request) -> web.StreamResponse:
if not self._authorized(request):
raise web.HTTPNotFound()
f = self.fanout
if f is None:
raise web.HTTPServiceUnavailable(text="nothing is being cast")
if request.method != "HEAD" and f.clients >= MAX_CLIENTS:
log.warning("stream: refusing %s, already %d clients", request.remote, f.clients)
raise web.HTTPServiceUnavailable(text="too many clients")
headers = {
"Content-Type": CONTENT_TYPES.get(f.container, "application/octet-stream"),
# The Cast receiver is a web page playing our URL through the media
# stack: without these it never gets past "loading". Wide-open CORS
# is only safe because the path itself is the secret - a page that
# cannot guess the token cannot read the stream.
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Expose-Headers": "*",
"Cache-Control": "no-cache, no-store",
"Pragma": "no-cache",
# DLNA renderers refuse a stream that does not say it is one.
"transferMode.dlna.org": "Streaming",
"contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;"
"DLNA.ORG_FLAGS=8D500000000000000000000000000000",
"Server": "Linux/1.0 UPnP/1.0 screencast/1.0",
"Accept-Ranges": "none",
}
resp = web.StreamResponse(status=200, headers=headers)
resp.enable_chunked_encoding()
await resp.prepare(request)
if request.method == "HEAD":
return resp
reader = f.subscribe()
peer = request.remote or "?"
log.info("stream client %s connected (%s)", peer, f.container)
if self.on_client:
self.on_client(peer, True)
# Nagle would hold a small write back waiting for company; on a live
# stream that is latency for nothing.
with contextlib.suppress(Exception):
request.transport.get_extra_info("socket").setsockopt(
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
with contextlib.suppress(Exception):
request.transport.set_write_buffer_limits(high=128 << 10)
try:
if f.header:
await resp.write(f.header)
while True:
chunk = await reader.get()
if chunk is None:
break
await resp.write(chunk)
except (ConnectionResetError, asyncio.CancelledError):
pass
except Exception as exc: # noqa: BLE001 - a dropped receiver must not kill the server
log.debug("stream client %s error: %s", peer, exc)
finally:
f.unsubscribe(reader)
log.info("stream client %s gone", peer)
if self.on_client:
self.on_client(peer, False)
return resp
+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] = {}