Screencast: cast the desktop to Cast, DLNA and AirPlay receivers

An Omarchy shell plugin: a Python/asyncio daemon that discovers network
displays and serves an encoded screen capture for them to pull, plus a
Quickshell bar widget and panel to drive it.

The screen comes from the xdg-desktop-portal ScreenCast interface (asked
for every time, never remembered), goes through GStreamer, and is served
from a local HTTP port. Desktop audio is mixed in from the default
output's monitor. The container follows the receiver: WebM/VP8 for Cast,
MPEG-TS/H.264 for DLNA, HLS for AirPlay video.

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