Screencast: cast the desktop to Cast, DLNA and AirPlay receivers
An Omarchy shell plugin: a Python/asyncio daemon that discovers network displays and serves an encoded screen capture for them to pull, plus a Quickshell bar widget and panel to drive it. The screen comes from the xdg-desktop-portal ScreenCast interface (asked for every time, never remembered), goes through GStreamer, and is served from a local HTTP port. Desktop audio is mixed in from the default output's monitor. The container follows the receiver: WebM/VP8 for Cast, MPEG-TS/H.264 for DLNA, HLS for AirPlay video. The stream port has to be reachable from the LAN, and none of these protocols can carry a credential, so the capability is the URL: a fresh random path per session, refused to anything off the LAN, capped at four concurrent readers.
This commit is contained in:
@@ -0,0 +1,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"]
|
||||
Reference in New Issue
Block a user