commit 1ed57291f908e31229c3bce5b8c65ff59f5ced3a Author: Alan Silva Date: Sat Aug 29 19:57:54 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c03aa5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +*.log +scripts/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2673a28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alan Silva + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c080386 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Screencast + +An [Omarchy](https://omarchy.org) shell plugin that finds the displays on your +network and casts this desktop to them, with the controls in the bar. + +Google Cast (Chromecast, Google TV, Nest), DLNA/UPnP TVs, and AirPlay video. +The screen is captured through the `xdg-desktop-portal` ScreenCast interface, +encoded with GStreamer, and served from a local HTTP port that the receiver +pulls from. Desktop audio is mixed in from the default output's monitor. + +> **Status: early. Looking for testers.** It works here on Cast; DLNA and +> AirPlay have had far less exercise. Bug reports welcome. + +## Install + +```bash +omarchy plugin add https://github.com/alanfortlink/omarchy-screencast --enable +``` + +On first run the plugin builds a private virtualenv under +`~/.local/lib/screencast` for its daemon. That needs no password. It does need +these Arch packages, and it will tell you in the panel if any are missing: + +``` +gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-pipewire +gst-libav xdg-desktop-portal xdg-desktop-portal-hyprland +``` + +Remove with `omarchy plugin remove io.github.alanfortlink.screencast`. + +## Using it + +Click the cast icon (󰄘), click a display. The portal asks which screen to +share every time — the choice is deliberately never remembered. While casting +you get stop, pause, change-screen, a live preview and the receiver's volume. +Quality, bitrate, desktop audio and pointer visibility are at the bottom of the +panel. + +Keys while the panel is open: `↑`/`↓` and `Enter` to cast, `r` rescan, `s` stop, +`p` pause, `Esc` close. + +For a keybinding: + +``` +bind = SUPER SHIFT, C, exec, omarchy-shell io.github.alanfortlink.screencast cast "TV name" +bind = SUPER SHIFT, X, exec, omarchy-shell io.github.alanfortlink.screencast stop +``` + +The same from a terminal: `screencast-server devices | cast "TV name" | status | +stop | pair "TV name"`. + +Settings live in `~/.config/screencast/config.json` (`fps`, `maxHeight`, +`bitrate`, `encoder`, `audio`, `cursor`, `port`, `autoReconnect`) and the panel +writes the same file. + +## Two things to know + +**It is not low latency.** Cast, DLNA and AirPlay video are *pull* protocols: +you hand the device a URL and it buffers. Expect one to three seconds, same as +casting a tab from a phone. Fine for a film, a talk or a dashboard; wrong for +playing a game on the TV. Low-latency mirroring means Miracast or real AirPlay +mirroring, neither of which has a working Linux sender. + +**The receiver connects to you**, so a default-deny inbound firewall — which +Omarchy sets up — silently drops it. The panel notices and offers a button that +opens the port for your subnet only, through one polkit password dialog. +Nothing to type. + +AirPlay here is AirPlay *video*, not mirroring (mirroring needs Apple's +FairPlay handshake). Apple TVs and most AirPlay 2 TVs accept it; some want +pairing first, via the **Pair** button on the row. Support comes from `pyatv`, +which is installed best-effort — if it fails, Cast and DLNA carry on. + +## Troubleshooting + +* **Nothing found.** mDNS and SSDP do not cross subnets, VPNs, or the client + isolation some routers apply to Wi-Fi. Compare with + `avahi-browse -rt _googlecast._tcp`. +* **The TV spins forever.** Almost always the firewall above. +* **Stutter.** Lower the bitrate or resolution; Wi-Fi is usually the limit. + Cast is the one path that encodes VP8 on the CPU. +* **Logs.** `~/.local/state/screencast/daemon.log`, and + `~/.cache/screencast/install.log` for the install. + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/daemon/requirements-optional.txt b/daemon/requirements-optional.txt new file mode 100644 index 0000000..8f2930e --- /dev/null +++ b/daemon/requirements-optional.txt @@ -0,0 +1,3 @@ +# AirPlay support. Optional: pyatv is heavy and lags new Python releases, so the +# installer treats a failure here as "AirPlay unavailable", not a broken install. +pyatv>=0.14 diff --git a/daemon/requirements.txt b/daemon/requirements.txt new file mode 100644 index 0000000..b8a06a4 --- /dev/null +++ b/daemon/requirements.txt @@ -0,0 +1,5 @@ +# Runtime dependencies of screencast-server (installed into ~/.local/lib/screencast/venv). +aiohttp>=3.9 +zeroconf>=0.130 +pychromecast>=13 +dbus-fast>=2.21 diff --git a/daemon/screencast/__init__.py b/daemon/screencast/__init__.py new file mode 100644 index 0000000..f67487b --- /dev/null +++ b/daemon/screencast/__init__.py @@ -0,0 +1,3 @@ +"""screencast-server: discover network displays and cast this desktop to them.""" + +VERSION = "0.1.0" diff --git a/daemon/screencast/__main__.py b/daemon/screencast/__main__.py new file mode 100644 index 0000000..7daab8d --- /dev/null +++ b/daemon/screencast/__main__.py @@ -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 "" start casting the screen + screencast-server stop | status + screencast-server pair "" 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") diff --git a/daemon/screencast/backends/__init__.py b/daemon/screencast/backends/__init__.py new file mode 100644 index 0000000..0fa324f --- /dev/null +++ b/daemon/screencast/backends/__init__.py @@ -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) diff --git a/daemon/screencast/backends/airplay.py b/daemon/screencast/backends/airplay.py new file mode 100644 index 0000000..aecfef1 --- /dev/null +++ b/daemon/screencast/backends/airplay.py @@ -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)) diff --git a/daemon/screencast/backends/base.py b/daemon/screencast/backends/base.py new file mode 100644 index 0000000..1c5e81d --- /dev/null +++ b/daemon/screencast/backends/base.py @@ -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 diff --git a/daemon/screencast/backends/cast.py b/daemon/screencast/backends/cast.py new file mode 100644 index 0000000..c243346 --- /dev/null +++ b/daemon/screencast/backends/cast.py @@ -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() diff --git a/daemon/screencast/backends/dlna.py b/daemon/screencast/backends/dlna.py new file mode 100644 index 0000000..ccadb9d --- /dev/null +++ b/daemon/screencast/backends/dlna.py @@ -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 = ( + '' + '{body}' +) + +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 ( + '' + '' + f"{escape(title)}" + "object.item.videoItem.movie" + f'{escape(url)}' + "" + ) + + +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'' + "".join( + f"<{k}>{escape(str(v))}" for k, v in args.items() + ) + f"" + 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 "" diff --git a/daemon/screencast/capture.py b/daemon/screencast/capture.py new file mode 100644 index 0000000..47c63d8 --- /dev/null +++ b/daemon/screencast/capture.py @@ -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 diff --git a/daemon/screencast/config.py b/daemon/screencast/config.py new file mode 100644 index 0000000..2ba462d --- /dev/null +++ b/daemon/screencast/config.py @@ -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 diff --git a/daemon/screencast/devices.py b/daemon/screencast/devices.py new file mode 100644 index 0000000..d5e70e6 --- /dev/null +++ b/daemon/screencast/devices.py @@ -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, + } diff --git a/daemon/screencast/discovery.py b/daemon/screencast/discovery.py new file mode 100644 index 0000000..e86caad --- /dev/null +++ b/daemon/screencast/discovery.py @@ -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"] diff --git a/daemon/screencast/server.py b/daemon/screencast/server.py new file mode 100644 index 0000000..f36edd6 --- /dev/null +++ b/daemon/screencast/server.py @@ -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:"} … 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) diff --git a/daemon/screencast/session.py b/daemon/screencast/session.py new file mode 100644 index 0000000..64797d3 --- /dev/null +++ b/daemon/screencast/session.py @@ -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) diff --git a/daemon/screencast/stream.py b/daemon/screencast/stream.py new file mode 100644 index 0000000..aa44ba4 --- /dev/null +++ b/daemon/screencast/stream.py @@ -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 diff --git a/daemon/screencast/util.py b/daemon/screencast/util.py new file mode 100644 index 0000000..cec064f --- /dev/null +++ b/daemon/screencast/util.py @@ -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] = {} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..99ac54d --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Install the Screencast plugin for the current user. +# ./install.sh install the daemon venv, link + enable the shell plugin +# ./install.sh --no-root same, but never ask for a password (skips missing packages) +# ./install.sh --uninstall +# +# Nothing here needs root except installing missing Arch packages, and even that +# is skipped with --no-root: the daemon runs entirely as you. +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd) +LIB=$HOME/.local/lib/screencast +BIN=$HOME/.local/bin +CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/screencast +STATE=${XDG_STATE_HOME:-$HOME/.local/state}/screencast +ID=io.github.alanfortlink.screencast +PLUGIN=$HOME/.config/omarchy/plugins/$ID +MODE=${1:-} +LOG=$CACHE/install.log + +if [[ $MODE == --uninstall ]]; then + omarchy-plugin-disable "$ID" >/dev/null 2>&1 || true + # The daemon is a plain user process; ask it to quit, then remove its files. + "$LIB/screencast-server" stop >/dev/null 2>&1 || true + rm -rf "$LIB" "$CACHE" "$STATE" "$BIN/screencast-server" + [[ -L $PLUGIN ]] && rm -f "$PLUGIN" # dev symlink only + echo "uninstalled (settings left in ~/.config/screencast/config.json). Now run: omarchy plugin remove $ID" + echo "note: if you ever let it open the stream port, that firewall rule is still there." + exit 0 +fi + +# Runtime dependencies, all from the Arch repos. +missing=() +for p in python gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-pipewire gst-libav libpipewire xdg-desktop-portal; do + pacman -Q "$p" >/dev/null 2>&1 || missing+=("$p") +done +# Something has to answer org.freedesktop.portal.ScreenCast. +if ! pacman -Q xdg-desktop-portal-hyprland >/dev/null 2>&1 && ! pacman -Q xdg-desktop-portal-wlr >/dev/null 2>&1; then + missing+=(xdg-desktop-portal-hyprland) +fi +if ((${#missing[@]})); then + if [[ $MODE == --no-root ]]; then + : # reported at the end, where the panel can see it + else + echo "› installing missing packages: ${missing[*]} (password prompt)" + if sudo -n true 2>/dev/null; then sudo pacman -S --needed --noconfirm "${missing[@]}" + else pkexec pacman -S --needed --noconfirm "${missing[@]}"; fi + fi +fi + +mkdir -p "$CACHE" "$LIB" "$BIN" +echo "› creating the Python environment in $LIB/venv (log: $LOG)" +{ + python3 -m venv --upgrade-deps "$LIB/venv" + "$LIB/venv/bin/pip" install --upgrade -r "$HERE/daemon/requirements.txt" +} >"$LOG" 2>&1 || { tail -n 15 "$LOG" >&2; echo "error: could not build the environment; log: $LOG" >&2; exit 1; } + +# AirPlay is optional: pyatv is a big dependency tree and lags new Python +# releases, and everything else works without it. +if "$LIB/venv/bin/pip" install --upgrade -r "$HERE/daemon/requirements-optional.txt" >>"$LOG" 2>&1; then + echo " AirPlay support installed (pyatv)" +else + echo " note: pyatv did not install — Cast and DLNA still work, AirPlay will not (see $LOG)" +fi + +echo "› installing the daemon" +rm -rf "$LIB/screencast" +cp -r "$HERE/daemon/screencast" "$LIB/screencast" +find "$LIB/screencast" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true +cat > "$LIB/screencast-server" < "$LIB/installed-commit" 2>/dev/null || date +%s > "$LIB/installed-commit" + +echo "› installing the shell plugin" +mkdir -p "$(dirname "$PLUGIN")" +if [[ $(readlink -f "$PLUGIN" 2>/dev/null) != "$(readlink -f "$HERE")" ]]; then + if [[ -L $PLUGIN || ! -e $PLUGIN ]]; then + ln -sfn "$HERE" "$PLUGIN" # dev mode; a real checkout comes from `omarchy plugin add` + omarchy-shell -q shell rescanPlugins 2>/dev/null || true + fi +fi +if command -v omarchy-shell >/dev/null && omarchy-shell -q shell ping >/dev/null 2>&1; then + enabled() { omarchy-plugin-list 2>/dev/null | awk -v id="$ID" '$1 == id && $2 == "enabled" { found = 1 } END { exit !found }'; } + for _ in 1 2 3 4 5; do + if enabled; then break; fi + if omarchy-plugin-enable "$ID" right >/dev/null 2>&1; then echo " enabled $ID"; break; fi + sleep 1 + done + enabled || echo " note: not enabled yet; run: omarchy plugin enable $ID" +else + echo " note: omarchy-shell is not running; enable later with: omarchy plugin enable $ID" +fi + +# The receiver pulls the stream from us, so the host firewall has to let it in. +# Installing stays root-free: the panel offers to open the port (via polkit) the +# first time a cast is actually blocked by it. +if [[ $(systemctl is-active ufw 2>/dev/null) == active || $(systemctl is-active firewalld 2>/dev/null) == active ]]; then + echo "› note: this machine runs a firewall. Receivers fetch the stream from us, so" + echo " the panel has an \"Open port\" button: one password dialog, once, and the" + echo " rule stays. Nothing to type here." +fi + +echo "done. The cast icon appears in the bar (if not: omarchy-shell shell rescanPlugins)." +echo "From a terminal: screencast-server devices | cast \"\" | stop" + +# Missing system packages are the one thing this cannot fix without root, so say +# so last: the plugin shows the final line of this log verbatim. +if ((${#missing[@]})) && [[ $MODE == --no-root ]]; then + echo "MISSING: sudo pacman -S --needed ${missing[*]}" +fi diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..107fed1 --- /dev/null +++ b/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "id": "io.github.alanfortlink.screencast", + "name": "Screencast", + "version": "0.1.0", + "author": "alanfortlink", + "license": "MIT", + "description": "Find the displays on your network — Google Cast, AirPlay and DLNA — and cast this desktop to them, with volume, pause and stop from the bar.", + "kinds": [ + "service", + "bar-widget" + ], + "keepLoaded": true, + "entryPoints": { + "service": "plugin/Service.qml", + "barWidget": "plugin/Panel.qml" + }, + "barWidget": { + "displayName": "Screencast", + "description": "Cast the screen to a TV, Chromecast or speaker on the network; the icon lights up while casting.", + "category": "Media", + "allowMultiple": false, + "defaultSection": "right", + "defaults": { + "alwaysShow": true + }, + "schema": [ + { + "key": "alwaysShow", + "type": "boolean", + "label": "Show the icon even when nothing is being cast", + "defaultValue": true + } + ] + } +} diff --git a/plugin/Panel.qml b/plugin/Panel.qml new file mode 100644 index 0000000..4143ec0 --- /dev/null +++ b/plugin/Panel.qml @@ -0,0 +1,999 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +// Bar icon + popup panel for Screencast: the receivers found on the network, +// what is being cast right now, and the handful of knobs that decide what goes +// over the wire. All the state lives in the screencast-server daemon (see +// Service.qml); this file renders it and forwards what the user does. +Panel { + id: root + moduleName: "io.github.alanfortlink.screencast" + ipcTarget: "io.github.alanfortlink.screencast" + // manageIpc: false so this panel can own the single IpcHandler the target + // permits — needed for the cast/stop methods below. + manageIpc: false + + readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("io.github.alanfortlink.screencast") : null + readonly property bool connected: !!svc && svc.connected + readonly property bool installed: !!svc && svc.installed + readonly property var devices: svc ? svc.devices : [] + readonly property var session: svc ? svc.session : ({}) + readonly property var s: svc ? svc.settings : ({}) + readonly property bool casting: !!svc && svc.casting + readonly property bool starting: !!svc && svc.sessionState === "starting" + // The click that has not been answered yet, so the row can react to it now. + readonly property string pendingId: svc ? svc.pendingId : "" + readonly property var castDevice: svc ? svc.castDevice : null + readonly property bool alwaysShow: setting("alwaysShow", true) + // Anything the daemon is currently doing on our behalf, so every control that + // triggers it can show that it was heard. + readonly property bool workingBusy: !!svc && svc.acting + // "Casting but nothing fetched yet" counts as work in progress: that wait is + // where a blocked port shows up, and silence there is what looks broken. + readonly property bool working: starting || workingBusy || (!!svc && svc.setupBusy) + || (casting && !!svc && !svc.live) + // Only cast work belongs on the bar icon: a routine rescan every time the + // panel opens must not make the bar look like something is happening. + readonly property bool castWorking: starting || (!!svc && svc.pendingId !== "") + || (casting && !!svc && !svc.live) + + // A start goes through the portal, the encoder, the receiver, and then the + // wait for that receiver to actually fetch the stream. Say which one it is. + function phaseText() { + if (!svc) return "" + var to = svc.castingTo || "the receiver" + if (!starting && !casting && svc.activity !== "") return svc.activity + switch (svc.sessionPhase) { + case "screen": return "Choose the screen to share…" + case "encoder": return "Starting the encoder…" + case "connect": return "Handing the stream to " + to + "…" + case "waiting": return "Waiting for " + to + " to start playing…" + } + if (starting) return "Connecting to " + to + "…" + if (casting && !svc.live) return "Waiting for " + to + " to start playing…" + return svc.activity + } + + readonly property color fg: bar ? bar.foreground : Color.foreground + readonly property color dim: Qt.darker(fg, 1.45) + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + + // md-cast / md-cast_connected: the bar icon says at a glance whether the + // screen is going somewhere. + readonly property string castGlyph: "󰄘" + readonly property string castConnectedGlyph: "󰄙" + readonly property string tvGlyph: "󰔂" + readonly property string speakerGlyph: "󱀞" + readonly property string monitorGlyph: "󰍹" + + // Frame-based rather than a rotating glyph: it stays legible at caption size + // and does not depend on the icon font having a spinner. + component Spinner: Text { + property bool spinning: true + property int frame: 0 + readonly property string frames: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + text: frames.charAt(frame) + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + opacity: spinning ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + Timer { + interval: 90 + repeat: true + running: parent.spinning && root.opened + onTriggered: parent.frame = (parent.frame + 1) % parent.frames.length + } + } + + // The preview file is overwritten in place, so the Image needs a changing + // source to reload it. Ticks only while the panel is actually on screen. + property int previewTick: 0 + Timer { + interval: 450 + repeat: true + running: root.opened && root.casting + onTriggered: root.previewTick++ + } + + // Ticks only while something is in flight; drives the "(12s)" counter. + property int elapsedTick: 0 + Timer { + interval: 1000 + repeat: true + running: root.working && root.opened + onTriggered: root.elapsedTick++ + } + function elapsedSuffix() { + if (!svc || !svc.sessionSince || !(starting || (casting && !svc.live))) return "" + var tick = elapsedTick // referenced so this binding re-runs every second + var secs = Math.floor(Date.now() / 1000 - svc.sessionSince) + 0 * tick + return secs >= 3 ? " " + secs + "s" : "" + } + + function pairingThis(dev) { + if (!svc || !dev) return false + return svc.activity.indexOf("Asking " + dev.name) === 0 || svc.pairing === dev.id + } + + function deviceGlyph(dev) { + if (!dev) return tvGlyph + return dev.audioOnly ? speakerGlyph : tvGlyph + } + + function statusLine() { + if (!svc) return "starting…" + if (!installed) return "not installed yet" + if (!connected) return svc.daemonError !== "" ? svc.daemonError : "starting the caster…" + if (svc.sessionState === "error") return svc.sessionError || "cast failed" + if (starting) return "setting up…" + if (svc.activity !== "") return svc.activity + if (casting) { + var res = session.width > 0 ? session.width + "×" + session.height : "" + var bits = [res, String(session.encoder || "").toUpperCase(), (s.fps || 30) + " fps"] + if (svc.paused) bits.push("paused") + return bits.filter(function(b) { return b !== "" }).join(" · ") + } + var n = devices.length + return n === 0 ? "looking for displays…" : n + (n === 1 ? " display found" : " displays found") + } + + function deviceSubtitle(dev) { + if (dev.id === pendingId && !casting) return svc.activity !== "" ? svc.activity : "connecting…" + if (dev.id === (session.deviceId || "") && casting) + return starting ? "connecting…" : (svc.paused ? "paused" : "casting" + (session.clients > 0 ? "" : " · waiting for the receiver")) + if (dev.kind === "airplay" && svc && !svc.airplayAvailable) return "AirPlay · needs pyatv" + var bits = [dev.kindLabel] + if (dev.app !== "") bits.push(dev.app) + else if (dev.status === "busy") bits.push("in use") + else if (dev.status === "away") bits.push("not answering") + if (dev.error !== "") bits.push(dev.error) + return bits.join(" · ") + } + + // ---- actions ---- + function castTo(dev) { + if (!svc || !dev) return + if (dev.id === svc.pendingId && !casting) return // already asked, still waiting + if (dev.id === (session.deviceId || "") && casting) { svc.stop(); return } + svc.cast(dev.id) + } + function quality() { + var h = s.maxHeight || 1080, f = s.fps || 30 + return h + "p" + f + } + function setQuality(value) { + var m = String(value).match(/^(\d+)p(\d+)$/) + if (!m || !svc) return + svc.send({ cmd: "set", settings: { maxHeight: parseInt(m[1]), fps: parseInt(m[2]) } }) + } + + Connections { + target: root.svc + // A cast killed by the firewall leaves exactly one thing to do. Put the + // panel (and its button) on screen rather than leave a notification that + // explains the problem but cannot act on it. + function onFirewallBlockedChanged() { + if (root.svc && root.svc.firewallBlocked && !root.opened) root.open() + } + } + + IpcHandler { + target: root.ipcTarget + function open() { root.open() } + function close() { root.close() } + function toggle() { root.toggle() } + // omarchy-shell io.github.alanfortlink.screencast cast "TV name" + function cast(device: string): string { + if (!root.svc) return "no service" + if (device === "") { + var last = root.s.lastDevice || "" + if (last === "") return "no device given" + root.svc.cast(last) + return "ok" + } + root.svc.cast(device) + return "ok" + } + function stop(): string { if (root.svc) root.svc.stop(); return "ok" } + } + + // ---- keyboard cursor over the device list ---- + property bool cursorActive: false + property int selectedIndex: 0 + function moveCursor(dx, dy) { + cursorActive = true + if (dy === 0 || devices.length === 0) return + selectedIndex = Math.max(0, Math.min(devices.length - 1, selectedIndex + dy)) + } + function activateCursor() { + if (!cursorActive || selectedIndex >= devices.length) return + castTo(devices[selectedIndex]) + } + + visible: alwaysShow || casting + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.casting ? root.castConnectedGlyph : root.castGlyph + active: root.casting + // Setting up a cast takes seconds (portal, encoder, receiver): pulse so the + // bar shows the click landed, even with the panel closed. + opacity: root.castWorking ? barPulse.value : 1 + QtObject { + id: barPulse + property real value: 1 + NumberAnimation on value { + running: root.castWorking + from: 1; to: 0.35; duration: 700 + loops: Animation.Infinite + easing.type: Easing.InOutSine + onRunningChanged: if (!running) barPulse.value = 1 + } + } + useActiveColor: true + activeColor: root.svc && root.svc.sessionState === "error" ? Color.urgent : Color.accent + tooltipText: root.casting ? ("Casting to " + root.svc.castingTo + " · right-click: stop") + : "Cast this screen" + onPressed: function(b) { + if (b === Qt.RightButton && root.casting && root.svc) root.svc.stop() + else root.toggle() + } + } + + onOpenedChanged: if (opened && svc) { svc.refresh(); svc.rescan() } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(400)) + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(620)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } + root.moveCursor(dx, dy) + } + onActivateRequested: root.activateCursor() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(t) { + if (t === "r" || t === "R") { if (root.svc) root.svc.rescan() } + else if (t === "s" || t === "S") { if (root.svc) root.svc.stop() } + else if (t === "p" || t === "P") { if (root.svc && root.casting) root.svc.transport(root.svc.paused ? "play" : "pause") } + } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: column + width: panelFlick.width + spacing: Style.space(12) + + PanelHero { + id: hero + width: parent.width + title: root.casting ? root.svc.castingTo : "Screencast" + meta: root.statusLine() + foreground: root.fg + fontFamily: root.fontFamily + iconComponent: Component { + Text { + textFormat: Text.PlainText + text: root.casting ? root.castConnectedGlyph : root.castGlyph + color: root.casting ? Color.accent : root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.display + } + } + } + + // ---------- what the daemon is doing right now ---------- + Row { + width: parent.width + spacing: Style.space(8) + visible: root.working && root.phaseText() !== "" + + Spinner { + anchors.verticalCenter: parent.verticalCenter + spinning: root.working + color: Color.accent + } + Text { + textFormat: Text.PlainText + width: parent.width - Style.space(30) + anchors.verticalCenter: parent.verticalCenter + text: root.phaseText() + root.elapsedSuffix() + color: root.fg + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + + // ---------- the port receivers fetch from ---------- + // Opening it needs root, which installing deliberately does not, so it + // is asked for here: a warning once a cast has proved it necessary, a + // quiet note before that. + Column { + width: parent.width + spacing: Style.space(6) + visible: !!root.svc && root.svc.firewallTool !== "" + && (root.svc.firewallBlocked || !root.svc.firewallVerified) + + Row { + width: parent.width + spacing: Style.space(8) + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: "󰞀" + color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.icon + } + Text { + textFormat: Text.PlainText + width: parent.width - Style.space(34) + anchors.verticalCenter: parent.verticalCenter + text: !root.svc ? "" + : root.svc.firewallBlocked + ? ("Nothing reached us on port " + root.svc.firewallPort + ". " + + root.svc.firewallTool + " blocks incoming connections, and receivers fetch the stream from this machine.") + : ("Receivers fetch the stream from this machine, and " + + root.svc.firewallTool + " blocks incoming connections. Port " + + root.svc.firewallPort + " has to be open for a cast to play.") + color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + Row { + spacing: Style.space(8) + + Button { + text: root.svc ? "Open port " + root.svc.firewallPort : "Open the port" + iconText: "󰌾" + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + enabled: !!root.svc && root.svc.activity.indexOf("Opening port") !== 0 + onClicked: if (root.svc) root.svc.openFirewall() + } + Spinner { + anchors.verticalCenter: parent.verticalCenter + visible: !!root.svc && root.svc.activity.indexOf("Opening port") === 0 + spinning: visible + color: Color.accent + } + } + + Text { + textFormat: Text.PlainText + width: parent.width + text: "Asks for your password once, allows only this network, and stays." + color: root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // ---------- not installed / install failed ---------- + Column { + width: parent.width + spacing: Style.space(6) + visible: !!root.svc && (!root.installed || root.svc.setupOutput !== "" || root.svc.busyText !== "") + + Button { + visible: !!root.svc && !root.installed && !root.svc.setupBusy + text: "Install the caster" + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: if (root.svc) root.svc.install() + } + Text { + textFormat: Text.PlainText + width: parent.width + visible: !!root.svc && (root.svc.busyText !== "" || root.svc.setupOutput !== "") + text: root.svc ? (root.svc.busyText !== "" ? root.svc.busyText : root.svc.setupOutput) : "" + color: root.svc && root.svc.setupOutput !== "" ? root.urgent : root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // ---------- what is being cast ---------- + Column { + width: parent.width + spacing: Style.space(10) + visible: root.casting || (!!root.svc && root.svc.sessionState === "error") + + PanelSectionHeader { + text: root.casting ? "CASTING" : "LAST CAST" + foreground: root.fg + fontFamily: root.fontFamily + } + + // ---------- what the receiver is being sent ---------- + // Tapped off the encoder itself rather than re-grabbed, so it shows + // the screen that is really going out, at the size it goes out. + Rectangle { + id: preview + width: parent.width + height: Math.round(width * (root.svc && root.svc.sessionHeight > 0 + ? root.svc.sessionHeight / root.svc.sessionWidth + : 9 / 16)) + visible: root.casting + radius: Style.cornerRadius + clip: true + color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.05) + border.width: 1 + border.color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.14) + + Image { + id: previewImage + anchors.fill: parent + anchors.margins: 1 + fillMode: Image.PreserveAspectFit + cache: false + asynchronous: true + smooth: true + source: root.svc && root.svc.sessionPreview !== "" + ? "file://" + root.svc.sessionPreview + "?t=" + root.previewTick + : "" + // Never blank between reloads: hold the last good frame. + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + // Before the first frame lands: a quiet breathing placeholder + // rather than an empty hole. + Column { + anchors.centerIn: parent + spacing: Style.space(6) + visible: previewImage.status !== Image.Ready + opacity: waitPulse.value + Text { + textFormat: Text.PlainText + anchors.horizontalCenter: parent.horizontalCenter + text: root.monitorGlyph + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.display + } + Text { + textFormat: Text.PlainText + anchors.horizontalCenter: parent.horizontalCenter + text: root.starting ? "getting the picture…" : "no picture yet" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + QtObject { + id: waitPulse + property real value: 1 + NumberAnimation on value { + running: previewImage.status !== Image.Ready && root.opened + from: 1; to: 0.4; duration: 1100 + loops: Animation.Infinite + easing.type: Easing.InOutSine + } + } + } + + // A live badge in the corner: red dot while the receiver is + // actually pulling, hollow while it is only being offered. + Row { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Style.space(8) + spacing: Style.space(5) + visible: previewImage.status === Image.Ready + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Style.space(7); height: width; radius: width / 2 + color: root.svc && root.svc.live ? root.urgent : "transparent" + border.width: root.svc && root.svc.live ? 0 : 1 + border.color: root.fg + opacity: root.svc && root.svc.live ? livePulse2.value : 0.7 + QtObject { + id: livePulse2 + property real value: 1 + NumberAnimation on value { + running: !!root.svc && root.svc.live && root.opened + from: 1; to: 0.35; duration: 1200 + loops: Animation.Infinite + easing.type: Easing.InOutSine + } + } + } + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: root.svc && root.svc.live ? "LIVE" : "OFFERED" + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + } + + // Which screen, bottom-left, over a scrim so it stays readable + // whatever is on the picture. + Rectangle { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: Style.space(6) + visible: previewImage.status === Image.Ready && sourceLabel.text !== "" + width: sourceLabel.width + Style.space(12) + height: sourceLabel.height + Style.space(6) + radius: Style.space(4) + color: Qt.rgba(0, 0, 0, 0.45) + Text { + textFormat: Text.PlainText + id: sourceLabel + anchors.centerIn: parent + text: root.svc ? root.svc.sessionSource : "" + color: "#ffffff" + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } + + Text { + textFormat: Text.PlainText + width: parent.width + visible: !!root.svc && root.svc.sessionState === "error" + text: root.svc ? root.svc.sessionError : "" + color: root.urgent + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Row { + width: parent.width + spacing: Style.space(8) + visible: root.casting + + Button { + readonly property bool stopping: !!root.svc && root.svc.stopping + text: stopping ? "Stopping…" : "Stop" + iconText: stopping ? "󰔟" : "󰖱" + iconSpinning: stopping + enabled: !stopping + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: if (root.svc) root.svc.stop() + } + Button { + text: root.svc && root.svc.paused ? "Resume" : "Pause" + iconText: root.svc && root.svc.paused ? "󰐊" : "󰏤" // play / pause + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: if (root.svc) root.svc.transport(root.svc.paused ? "play" : "pause") + } + Button { + readonly property bool repicking: !!root.svc + && root.svc.activity.indexOf("Asking which screen") === 0 + text: repicking ? "Choose a screen…" : "Change screen" + iconText: root.monitorGlyph + iconSpinning: repicking + enabled: !repicking + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: if (root.svc) root.svc.repickSource() + } + } + + // Receiver volume, when the protocol has one. + Row { + width: parent.width + spacing: Style.space(8) + visible: root.casting && !!root.castDevice && root.castDevice.volume >= 0 + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: root.castDevice && root.castDevice.muted ? "󰖁" : "󰕾" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.icon + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.svc && root.castDevice) root.svc.setMuted(!root.castDevice.muted) + } + } + PanelSlider { + width: parent.width - Style.space(40) + anchors.verticalCenter: parent.verticalCenter + bar: root.bar + minimum: 0 + maximum: 1 + step: 0.02 + value: root.castDevice ? root.castDevice.volume : 0 + onMoved: function(v) { if (root.svc) root.svc.setVolume(v) } + } + } + + Text { + textFormat: Text.PlainText + width: parent.width + visible: root.casting + text: { + var src = session.source || "" + var bits = [] + if (src !== "") bits.push("Sharing " + src) + bits.push((s.audio ? "with" : "without") + " desktop audio") + if (session.clients > 0) bits.push(session.clients + " receiver" + (session.clients > 1 ? "s" : "") + " connected") + return bits.join(" · ") + } + color: root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + PanelSeparator { visible: root.casting; foreground: root.fg } + + // ---------- the network ---------- + Column { + width: parent.width + spacing: Style.space(8) + + Item { + width: parent.width + implicitHeight: sectionLabel.implicitHeight + + PanelSectionHeader { + id: sectionLabel + text: "DISPLAYS ON THE NETWORK" + foreground: root.fg + fontFamily: root.fontFamily + } + Text { + textFormat: Text.PlainText + id: rescanIcon + readonly property bool scanning: !!root.svc && root.svc.activity.indexOf("Looking for") === 0 + anchors.right: parent.right + anchors.verticalCenter: sectionLabel.verticalCenter + text: "󰑐" // md-refresh + color: rescanIcon.scanning ? Color.accent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + RotationAnimation on rotation { + running: rescanIcon.scanning + loops: Animation.Infinite + from: 0; to: 360; duration: 900 + } + MouseArea { + anchors.fill: parent + anchors.margins: -Style.space(6) + cursorShape: Qt.PointingHandCursor + onClicked: if (root.svc) root.svc.rescan() + ToolTip.visible: containsMouse + } + } + } + + Text { + textFormat: Text.PlainText + width: parent.width + visible: root.devices.length === 0 + text: root.connected ? "Nothing found yet. Cast devices announce themselves over mDNS; make sure this machine is on the same network (and not only on a VPN)." + : "Waiting for the caster to start." + color: root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Column { + id: deviceColumn + width: parent.width + spacing: Style.space(4) + + Repeater { + model: root.devices + DeviceRow { + required property var modelData + required property int index + width: deviceColumn.width + dev: modelData + rowIndex: index + } + } + } + } + + PanelSeparator { foreground: root.fg } + + // ---------- what gets sent ---------- + Column { + width: parent.width + spacing: Style.space(8) + + PanelSectionHeader { + text: "QUALITY" + foreground: root.fg + fontFamily: root.fontFamily + } + + Dropdown { + width: parent.width + label: "Resolution" + fontFamily: root.fontFamily + value: root.quality() + options: [ + { value: "720p30", label: "720p · 30 fps" }, + { value: "1080p30", label: "1080p · 30 fps" }, + { value: "1080p60", label: "1080p · 60 fps" }, + { value: "1440p30", label: "1440p · 30 fps" }, + { value: "2160p30", label: "4K · 30 fps" } + ] + onChanged: function(v) { root.setQuality(v) } + } + + Row { + width: parent.width + spacing: Style.space(8) + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + width: Style.space(70) + text: "Bitrate" + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + PanelSlider { + id: bitrateSlider + width: parent.width - Style.space(130) + anchors.verticalCenter: parent.verticalCenter + bar: root.bar + minimum: 1 + maximum: 25 + step: 1 + integer: true + value: (root.s.bitrate || 8000) / 1000 + onReleased: function(v) { if (root.svc) root.svc.setSetting("bitrate", Math.round(v) * 1000) } + } + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: Math.round(bitrateSlider.liveValue) + " Mb/s" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + Toggle { + width: parent.width + label: "Desktop audio" + description: "Send what the speakers are playing along with the picture" + checked: !!root.s.audio + foreground: root.fg + fontFamily: root.fontFamily + onClicked: if (root.svc) root.svc.setSetting("audio", !root.s.audio) + } + + Toggle { + width: parent.width + label: "Show the pointer" + checked: !!root.s.cursor + foreground: root.fg + fontFamily: root.fontFamily + onClicked: if (root.svc) root.svc.setSetting("cursor", !root.s.cursor) + } + + } + + Text { + textFormat: Text.PlainText + width: parent.width + visible: !!root.svc && root.svc.error !== "" && root.svc.sessionState !== "error" + text: root.svc ? root.svc.error : "" + color: root.urgent + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } + } + } + + // One receiver: click to cast, or to stop when it is the one being cast to. + component DeviceRow: CursorSurface { + id: row + property var dev: null + property int rowIndex: 0 + readonly property bool isCurrent: !!dev && ((dev.id === (root.session.deviceId || "") && root.casting) + || dev.id === root.pendingId) + // Waiting on us, not yet on the receiver: the row spins from the click on. + readonly property bool isPending: !!dev && (dev.id === root.pendingId || (isCurrent && root.starting)) + readonly property bool disabled: !!dev && dev.kind === "airplay" && !!root.svc && !root.svc.airplayAvailable + + hasCursor: root.cursorActive && root.selectedIndex === rowIndex + current: isCurrent + foreground: root.fg + implicitHeight: rowLayout.implicitHeight + Style.spacing.rowPaddingX + opacity: disabled ? 0.55 : 1.0 + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: row.disabled ? Qt.ArrowCursor : Qt.PointingHandCursor + onEntered: { root.cursorActive = true; root.selectedIndex = row.rowIndex } + onClicked: { + if (row.disabled) return + root.castTo(row.dev) + } + } + + RowLayout { + id: rowLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(10) + anchors.rightMargin: Style.space(10) + spacing: Style.space(10) + + Text { + textFormat: Text.PlainText + text: root.deviceGlyph(row.dev) + color: row.isCurrent ? Color.accent : root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.heading + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + textFormat: Text.PlainText + Layout.fillWidth: true + text: row.dev ? row.dev.name : "" + color: root.fg + elide: Text.ElideRight + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + Text { + textFormat: Text.PlainText + Layout.fillWidth: true + text: row.dev ? root.deviceSubtitle(row.dev) : "" + color: root.dim + elide: Text.ElideRight + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // AirPlay receivers usually want to be paired once before they accept + // anything; the button is only in the way until then. + Button { + visible: !!row.dev && row.dev.kind === "airplay" && !row.disabled && !row.isCurrent + && (row.hasCursor || (!!root.svc && root.svc.pairing === row.dev.id)) + text: root.pairingThis(row.dev) ? "Asking…" : "Pair" + iconText: root.pairingThis(row.dev) ? "󰔟" : "" + iconSpinning: root.pairingThis(row.dev) + enabled: !root.pairingThis(row.dev) + foreground: root.dim + fontFamily: root.fontFamily + fontSize: Style.font.caption + onClicked: if (root.svc) root.svc.pair(row.dev.id) + Layout.alignment: Qt.AlignVCenter + } + + // Starting: a spinner. Live: a breathing dot. Casting but nothing being + // fetched yet: a dim dot. Otherwise nothing — the whole row is the button. + Spinner { + Layout.alignment: Qt.AlignVCenter + visible: row.isPending + spinning: visible + color: Color.accent + font.pixelSize: Style.font.icon + } + Text { + textFormat: Text.PlainText + id: liveDot + Layout.alignment: Qt.AlignVCenter + visible: row.isCurrent && !row.isPending + text: root.svc && root.svc.live ? "󰄙" : "󰔟" // cast_connected / timer-outline + color: root.svc && root.svc.live ? Color.accent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.icon + opacity: root.svc && root.svc.live ? 1 : livePulse.value + QtObject { + id: livePulse + property real value: 1 + NumberAnimation on value { + running: liveDot.visible && !(root.svc && root.svc.live) + from: 1; to: 0.4; duration: 900 + loops: Animation.Infinite + easing.type: Easing.InOutSine + } + } + } + Text { + textFormat: Text.PlainText + Layout.alignment: Qt.AlignVCenter + visible: row.isCurrent && !row.isPending + text: "󰖱" // md-stop + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.icon + } + } + + // AirPlay receivers that want a PIN: the daemon asks, the code is typed here. + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: Style.space(10) + spacing: Style.space(6) + visible: !!root.svc && root.svc.pairing !== "" && !!row.dev && root.svc.pairing === row.dev.id + + TextField { + id: pinField + width: Style.space(90) + placeholderText: "PIN" + foreground: root.fg + onAccepted: if (root.svc) { root.svc.pairPin(text); text = "" } + } + Button { + readonly property bool checking: !!root.svc && root.svc.activity.indexOf("Pairing with") === 0 + text: checking ? "Checking…" : "Pair" + iconText: checking ? "󰔟" : "" + iconSpinning: checking + enabled: !checking + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: if (root.svc) { root.svc.pairPin(pinField.text); pinField.text = "" } + } + } + } +} diff --git a/plugin/Service.qml b/plugin/Service.qml new file mode 100644 index 0000000..2b2561d --- /dev/null +++ b/plugin/Service.qml @@ -0,0 +1,338 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// Headless service: owns the screencast-server daemon (starts it, restarts it +// if it dies) and keeps a control connection to it. Everything the panel shows +// comes from the daemon's state pushes; everything the panel does goes through +// send(). +Item { + id: root + + property var shell: null + property var manifest: null + + // ---- daemon state (mirrors the JSON pushed by screencast-server) ---- + property var state: ({}) + readonly property bool connected: sockConnected + readonly property var devices: state.devices || [] + readonly property var session: state.session || ({}) + readonly property var settings: state.settings || ({}) + readonly property var capabilities: state.capabilities || ({}) + readonly property string error: state.error || "" + readonly property string busy: state.busy || "" + readonly property string pairing: state.pairing || "" + // Receivers pull the stream from us, so a default-deny firewall silently + // stops every cast. The daemon sets `blocked` once a cast fails that way. + readonly property var firewall: state.firewall || ({}) + readonly property bool firewallBlocked: !!firewall.blocked + readonly property string firewallCommand: firewall.command || "" + readonly property string firewallTool: firewall.tool || "" + readonly property bool firewallVerified: firewall.verified !== false + readonly property int firewallPort: firewall.port || 8011 + readonly property int streamPort: capabilities.port || 8011 + readonly property bool airplayAvailable: capabilities.airplay !== false + + // "idle" | "starting" | "streaming" | "stopping" | "error" + readonly property string sessionState: session.state || "idle" + readonly property bool casting: sessionState === "starting" || sessionState === "streaming" + readonly property bool live: sessionState === "streaming" && (session.clients || 0) > 0 + readonly property string castingTo: session.deviceName || "" + readonly property string castingId: session.deviceId || "" + readonly property string sessionError: session.error || "" + // What a "starting" session is waiting on, so the panel can say so. + readonly property string sessionPhase: session.phase || "" + // A JPEG of the screen actually being encoded, refreshed a few times a second + // by the daemon. Empty until the first frame has been written. + readonly property string sessionPreview: session.preview || "" + readonly property real sessionPreviewAt: session.previewAt || 0 + readonly property string sessionSource: session.source || "" + readonly property int sessionWidth: session.width || 0 + readonly property int sessionHeight: session.height || 0 + readonly property real sessionSince: session.since || 0 + readonly property string playerState: session.playerState || "" + readonly property bool paused: playerState === "paused" + readonly property bool stopping: sessionState === "stopping" + + function deviceById(id) { + var list = devices + for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i] + return null + } + readonly property var castDevice: deviceById(castingId) + + // ---- what we are optimistically claiming, before the daemon answers ---- + // Every round trip, however short, is a gap where a click looks ignored. The + // panel says what it just asked for straight away and the daemon's own `busy` + // takes over as soon as the first state arrives. + property string pendingLabel: "" + property string pendingId: "" // the device the pending action is about + property real pendingAt: 0 + readonly property string activity: pendingLabel !== "" ? pendingLabel : busy + readonly property bool acting: activity !== "" + + function expect(label, id) { + pendingLabel = label + pendingId = id || "" + pendingAt = Date.now() + pendingTimer.restart() + } + function clearPending() { + pendingLabel = "" + pendingId = "" + pendingTimer.stop() + } + // The daemon confirms by putting something in `busy` (it pushes that before + // it starts the work). This only has to fire when it never answers at all. + Timer { id: pendingTimer; interval: 8000; repeat: false; onTriggered: root.clearPending() } + onBusyChanged: if (busy !== "") clearPending() + + // ---- commands ---- + function send(obj) { + if (!sockConnected) return false + sock.write(JSON.stringify(obj) + "\n") + sock.flush() + return true + } + function refresh() { return send({ cmd: "get" }) } + function rescan() { + expect("Looking for displays…", "") + return send({ cmd: "rescan" }) + } + function cast(deviceId) { + var dev = deviceById(deviceId) + expect("Connecting to " + (dev ? dev.name : "the display") + "…", deviceId) + return send({ cmd: "cast", device: deviceId }) + } + function stop() { + expect("Stopping…", castingId) + return send({ cmd: "stop" }) + } + function setSetting(key, value) { var p = {}; p[key] = value; return send({ cmd: "set", settings: p }) } + function setVolume(v) { return send({ cmd: "volume", value: v }) } + function setMuted(v) { return send({ cmd: "mute", value: !!v }) } + function transport(action) { return send({ cmd: "transport", action: action }) } + // Restart the cast so the portal asks which screen to share again. + function repickSource() { + expect("Asking which screen to share…", castingId) + return send({ cmd: "repick" }) + } + function pair(deviceId) { + var dev = deviceById(deviceId) + expect("Asking " + (dev ? dev.name : "the display") + " for a code…", deviceId) + return send({ cmd: "pair", device: deviceId }) + } + function pairPin(pin) { + expect("Checking the code…", pairing) + return send({ cmd: "pairPin", pin: String(pin) }) + } + function pairCancel() { clearPending(); return send({ cmd: "pairCancel" }) } + function openFirewall() { + expect("Opening port " + firewallPort + "…", "") + return send({ cmd: "openFirewall" }) + } + + // Notification bodies are parsed as markup by most daemons, and both of these + // carry device names and receiver error text off the network. + function plain(text) { + return String(text).replace(/[<>&]/g, " ").slice(0, 200) + } + + function notify(title, body) { + if (notifyProc.running) return + title = plain(title); body = plain(body) + notifyProc.command = ["sh", "-c", + 'if command -v omarchy-notification-send >/dev/null 2>&1; then exec omarchy-notification-send "$1" "$2"; fi; exec notify-send "$1" "$2"', + "screencast-notify", title, body] + notifyProc.running = true + } + Process { id: notifyProc } + + // ---- paths ---- + readonly property string runtimeDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screencast" + readonly property string socketPath: runtimeDir + "/ctl.sock" + readonly property string homeDir: Quickshell.env("HOME") || "" + readonly property string libDir: homeDir + "/.local/lib/screencast" + readonly property string daemonBinary: libDir + "/screencast-server" + readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (homeDir + "/.cache")) + "/screencast" + readonly property string installLog: cacheDir + "/install.log" + // The plugin checkout (this file lives in /plugin/). + readonly property string repoDir: decodeURIComponent(String(Qt.resolvedUrl("..")).replace(/^file:\/\//, "").replace(/\/$/, "")) + + property bool installed: false + property string setupOutput: "" // only what the user must act on + property string busyText: "" // transient progress, disappears on its own + property string daemonLog: "" + property string daemonError: "" + property int restarts: 0 + readonly property bool setupBusy: installProc.running + + // ---- install / update ---- + // First run after `omarchy plugin add` (and "Reinstall" later): build the + // virtualenv under ~/.local/lib and install the daemon into it. No password: + // nothing here touches the system. + function install() { + if (installProc.running) return + setupOutput = "" + if (busyText === "") busyText = "Installing the caster…" + daemonError = "" + installProc.command = ["sh", "-c", + 'mkdir -p "$(dirname "$2")"; cd "$1" && ./install.sh --no-root >"$2" 2>&1; rc=$?; tail -n 4 "$2"; exit $rc', + "screencast-install", repoDir, installLog] + installProc.running = true + } + Process { + id: installProc + property string tailText: "" + stdout: StdioCollector { onStreamFinished: installProc.tailText = String(text).trim() } + onExited: function(code) { + root.busyText = "" + if (code !== 0) { + root.setupOutput = "Install failed (" + code + "). Log: " + root.installLog + "\n" + tailText + } else { + // Everything the installer can do without a password is done; system + // packages are not one of them, so pass that one line straight through. + const missing = /^MISSING: (.*)$/m.exec(tailText) + root.setupOutput = missing + ? "Some system packages are missing, so capture will fail. Install them:\n" + missing[1] + : "" + restartTimer.restart() + } + tailText = "" + probeProc.running = true + } + } + // After `omarchy plugin update` the shell reloads this plugin: rebuild + // silently when the checkout has moved past what install.sh last installed. + Process { + id: updateCheckProc + command: ["sh", "-c", + 'a=$(git -C "$1" rev-parse HEAD 2>/dev/null) || exit 0; b=$(cat "$2/installed-commit" 2>/dev/null); ' + + '[ -x "$2/screencast-server" ] || exit 0; [ -n "$a" ] && [ "$a" != "$b" ] && exit 3; exit 0', + "screencast-updatecheck", root.repoDir, root.libDir] + onExited: function(code) { if (code === 3) { root.busyText = "Updating…"; root.install() } } + } + Process { + id: probeProc + command: ["sh", "-c", 'test -x "$1"', "probe", root.daemonBinary] + onExited: function(code) { + root.installed = code === 0 + if (root.installed && !daemon.running) restartTimer.restart() + } + } + // While not installed, keep looking: an install interrupted by a shell reload + // finishes on its own and we pick the daemon up. + Timer { + interval: 5000 + repeat: true + running: !root.installed && !installProc.running && !probeProc.running + onTriggered: probeProc.running = true + } + + // ---- daemon lifecycle ---- + Process { + id: daemon + command: [root.daemonBinary, "run"] + running: false + stderr: SplitParser { + onRead: function(line) { + var l = root.daemonLog + line + "\n" + if (l.length > 4000) l = l.slice(l.length - 4000) + root.daemonLog = l + } + } + onExited: function(code, status) { + root.state = ({}) + if (code === 127) { root.installed = false; probeProc.running = true; return } + root.restarts += 1 + if (code !== 3 && root.restarts >= 3) + root.daemonError = "the caster keeps exiting (code " + code + ") — reinstall it or check the log" + restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock + restartTimer.restart() + } + } + + // Set while we wait for an orphaned daemon (left by a previous shell) to quit. + property bool orphanQuit: false + Timer { + id: restartTimer + interval: 1000 + repeat: false + onTriggered: { + if (!root.installed) { probeProc.running = true; return } + if (daemon.running) { daemon.signal(15); return } + if (root.sockConnected) { root.send({ cmd: "quit" }); root.orphanQuit = true; interval = 5000; restart(); return } + daemon.running = true + } + } + Timer { interval: 60000; running: daemon.running; repeat: false; onTriggered: root.restarts = 0 } + + // ---- control connection ---- + // Quickshell's Socket cannot recover from a refused connection, so recreate + // the object for every attempt. + property var sock: null + readonly property bool sockConnected: sock ? sock.connected === true : false + property string lastSessionState: "idle" + + Component { + id: sockComp + Socket { + path: root.socketPath + connected: true + parser: SplitParser { + onRead: function(line) { + try { + var msg = JSON.parse(line) + if (msg && msg.type === "state") { + root.state = msg + if (root.daemonError !== "") root.daemonError = "" + // Any state the daemon produced after the click is its answer to + // it; the optimistic label has done its job. + if (root.pendingLabel !== "" && Date.now() - root.pendingAt > 250) + root.clearPending() + root.noteSession() + } + } catch (e) { /* a reply we do not care about */ } + } + } + onConnectionStateChanged: { + root.sockConnectedChanged() + if (!connected) { + root.state = ({}) + if (root.orphanQuit) { root.orphanQuit = false; restartTimer.interval = 1000; restartTimer.restart() } + reconnectTimer.restart() + } + } + onError: function(err) { reconnectTimer.restart() } + } + } + function connectSocket() { + if (sock) { sock.destroy(); sock = null } + sock = sockComp.createObject(root) + sockConnectedChanged() + } + Timer { id: reconnectTimer; interval: 800; repeat: false; onTriggered: if (!root.sockConnected) root.connectSocket() } + Timer { interval: 3000; running: !root.sockConnected; repeat: true; onTriggered: if (!root.sockConnected) root.connectSocket() } + + // A cast that fails while the panel is closed would otherwise be invisible. + function noteSession() { + var now = sessionState + if (now === lastSessionState) return + var was = lastSessionState + lastSessionState = now + if (now === "error" && sessionError !== "") + notify("Screencast", firewallBlocked + ? "Port " + firewallPort + " is closed — press “Open port " + firewallPort + "” in the panel" + : sessionError) + else if (now === "streaming" && was !== "streaming") notify("Screencast", "Casting to " + castingTo) + } + + Component.onCompleted: { + probeProc.running = true + connectSocket() + updateCheckProc.running = true + } + Component.onDestruction: { + if (daemon.running) daemon.signal(15) + } +}