1ed57291f9
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.
268 lines
9.4 KiB
Python
268 lines
9.4 KiB
Python
"""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] = {}
|