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,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)
|
||||
Reference in New Issue
Block a user