From 6e424ef9776c7bf0a7ffba512c0623fde7e166fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 24 Jun 2026 12:13:20 -0600 Subject: [PATCH 1/4] feat(core): add Chromecast and AirPlay casting service New cast manager in the core daemon: mDNS discovery of both Google Cast (_googlecast._tcp) and AirPlay (_airplay._tcp) devices, connect, live playback status, media casting with transport controls, and screen mirroring. Exposed over JSON-RPC and wired into the server/router. Discovery prefers avahi via D-Bus when it is running (no parallel mDNS stack, so it avoids UDP 5353 contention) and otherwise falls back to a self healing zeroconf/go-chromecast browser that re-browses periodically. AirPlay devices are keyed by their stable mDNS instance name rather than the flaky deviceid TXT. A preferred device auto connects when discovered. Screen capture and encode run in dms-cast-helper, a cgo gated go-gst program (VA-API DMA-BUF import, I420 chroma, PTS restamp) kept separate so the core stays CGO free. Chromecast mirrors via HLS served from core; AirPlay mirrors via the external doubletake process. Co-Authored-By: Claude Opus 4.8 --- Makefile | 11 +- core/Makefile | 20 +- core/cmd/dms-cast-helper/main.go | 167 ++++ core/cmd/dms-cast-helper/main_nocgo.go | 17 + core/go.mod | 16 + core/go.sum | 68 +- core/internal/server/chromecast/airplay.go | 119 +++ .../server/chromecast/airplay_mirror.go | 110 +++ core/internal/server/chromecast/avahi_dbus.go | 191 ++++ core/internal/server/chromecast/config.go | 54 ++ core/internal/server/chromecast/handlers.go | 157 ++++ core/internal/server/chromecast/manager.go | 814 ++++++++++++++++++ .../server/chromecast/manager_test.go | 756 ++++++++++++++++ core/internal/server/chromecast/screencast.go | 239 +++++ .../server/chromecast/screencast_portal.go | 187 ++++ core/internal/server/chromecast/types.go | 42 + core/internal/server/router.go | 10 + core/internal/server/server.go | 58 ++ 18 files changed, 3033 insertions(+), 3 deletions(-) create mode 100644 core/cmd/dms-cast-helper/main.go create mode 100644 core/cmd/dms-cast-helper/main_nocgo.go create mode 100644 core/internal/server/chromecast/airplay.go create mode 100644 core/internal/server/chromecast/airplay_mirror.go create mode 100644 core/internal/server/chromecast/avahi_dbus.go create mode 100644 core/internal/server/chromecast/config.go create mode 100644 core/internal/server/chromecast/handlers.go create mode 100644 core/internal/server/chromecast/manager.go create mode 100644 core/internal/server/chromecast/manager_test.go create mode 100644 core/internal/server/chromecast/screencast.go create mode 100644 core/internal/server/chromecast/screencast_portal.go create mode 100644 core/internal/server/chromecast/types.go diff --git a/Makefile b/Makefile index f8c64cbe4..ccfbc37c4 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms ASSETS_DIR=assets APPLICATIONS_DIR=$(DATA_DIR)/applications -.PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help +.PHONY: all build cast-helper clean lint-qml install install-bin install-cast-helper install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help all: build @@ -27,6 +27,11 @@ build: @$(MAKE) -C $(CORE_DIR) build @echo "Build complete" +# Opt-in: builds the go-gst screen-capture helper for the cast screen-mirror +# feature (needs CGO + GStreamer dev packages). The main dms build stays CGO-free. +cast-helper: + @$(MAKE) -C $(CORE_DIR) cast-helper + clean: @echo "Cleaning build artifacts..." @$(MAKE) -C $(CORE_DIR) clean @@ -41,6 +46,10 @@ install-bin: @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @echo "Binary installed" +# Install the cast helper alongside dms (dms locates it in the same directory). +install-cast-helper: + @$(MAKE) -C $(CORE_DIR) install-cast-helper PREFIX=$(PREFIX) + install-shell: @echo "Installing shell files to $(SHELL_INSTALL_DIR)..." @mkdir -p $(SHELL_INSTALL_DIR) diff --git a/core/Makefile b/core/Makefile index cad9b95ec..d4cba4a36 100644 --- a/core/Makefile +++ b/core/Makefile @@ -1,7 +1,9 @@ BINARY_NAME=dms BINARY_NAME_INSTALL=dankinstall +CAST_HELPER=dms-cast-helper SOURCE_DIR=cmd/dms SOURCE_DIR_INSTALL=cmd/dankinstall +SOURCE_DIR_CAST=cmd/dms-cast-helper BUILD_DIR=bin PREFIX ?= /usr/local INSTALL_DIR=$(PREFIX)/bin @@ -22,7 +24,7 @@ BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUI # Architecture to build for dist target (amd64, arm64, or all) ARCH ?= all -.PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help +.PHONY: all build dankinstall cast-helper dist clean install install-all install-dankinstall install-cast-helper uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help # Default target all: build @@ -40,6 +42,16 @@ dankinstall: CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)" +# Build the go-gst screen-capture helper used by the cast screen-mirror feature. +# Opt-in (not part of `make build`) because it needs CGO + GStreamer dev packages +# (gstreamer-1.0, gst-plugins-base, plus plugins-good/bad/ugly + pipewire at +# runtime). The main dms binary stays CGO-free and spawns this when present. +cast-helper: + @echo "Building $(CAST_HELPER) (requires CGO + GStreamer dev)..." + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=1 $(GO) build -tags casthelper -ldflags='-s -w' -o $(BUILD_DIR)/$(CAST_HELPER) ./$(SOURCE_DIR_CAST) + @echo "Build complete: $(BUILD_DIR)/$(CAST_HELPER)" + # Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support) dist: ifeq ($(ARCH),all) @@ -68,6 +80,12 @@ install: @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @echo "Installation complete" +# Install the cast helper next to dms (dms finds it via the same directory). +install-cast-helper: + @echo "Installing $(CAST_HELPER) to $(INSTALL_DIR)..." + @install -D -m 755 $(BUILD_DIR)/$(CAST_HELPER) $(INSTALL_DIR)/$(CAST_HELPER) + @echo "Installation complete" + install-all: @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) diff --git a/core/cmd/dms-cast-helper/main.go b/core/cmd/dms-cast-helper/main.go new file mode 100644 index 000000000..6904ca3a7 --- /dev/null +++ b/core/cmd/dms-cast-helper/main.go @@ -0,0 +1,167 @@ +//go:build casthelper + +// Command dms-cast-helper captures the screen with GStreamer (the go-gst +// library — no gst-launch CLI) and either writes an HLS stream (for Chromecast) +// or an H.264 byte-stream to stdout (for AirPlay/doubletake). +// +// The screen source is the xdg-desktop-portal PipeWire remote: the parent +// negotiates the portal and passes the remote fd as fd 3 plus the node id via +// -node. With -test the source is a synthetic pattern (no portal needed), which +// makes the pipeline verifiable offline. +// +// Built only with CGO (it links GStreamer); the package has a no-cgo stub so +// `CGO_ENABLED=0 go build ./...` over the core stays green. +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/gst/app" +) + +const portalFD = 3 // the PipeWire remote fd the parent passes via ExtraFiles + +func main() { + mode := flag.String("mode", "h264", "output mode: h264 (stdout) | hls") + out := flag.String("out", "", "HLS output directory (mode=hls)") + node := flag.Int("node", 0, "PipeWire node id from the portal") + fps := flag.Int("fps", 30, "framerate") + bitrate := flag.Int("bitrate", 4147, "x264 bitrate kbps") + width := flag.Int("width", 1920, "scaled width") + height := flag.Int("height", 1080, "scaled height") + test := flag.Bool("test", false, "use a synthetic source instead of the portal (debug)") + flag.Parse() + + gst.Init(nil) + + desc, err := buildPipeline(*mode, *out, *node, *fps, *bitrate, *width, *height, *test) + if err != nil { + fmt.Fprintln(os.Stderr, "dms-cast-helper:", err) + os.Exit(2) + } + + pipeline, err := gst.NewPipelineFromString(desc) + if err != nil { + fmt.Fprintln(os.Stderr, "pipeline:", err) + os.Exit(1) + } + + // Rewrite PTS to a monotonic timeline on the encoder input: the portal + // delivers pts=0 on every buffer, which breaks HLS segmentation (hlssink + // cuts segments by timestamp) and gives the encoder no temporal reference. + if err := restampEncoderInput(pipeline, *fps); err != nil { + fmt.Fprintln(os.Stderr, "restamp:", err) + os.Exit(1) + } + + if *mode == "h264" { + if err := wireH264Stdout(pipeline); err != nil { + fmt.Fprintln(os.Stderr, "appsink:", err) + os.Exit(1) + } + } + + if err := pipeline.SetState(gst.StatePlaying); err != nil { + fmt.Fprintln(os.Stderr, "set playing:", err) + os.Exit(1) + } + runUntilDone(pipeline) +} + +// buildPipeline assembles the GStreamer pipeline description. +func buildPipeline(mode, out string, node, fps, bitrate, width, height int, test bool) (string, error) { + var src string + if test { + // Synthetic source — already system-memory, no DMA-BUF import needed. + src = fmt.Sprintf("videotestsrc is-live=true pattern=18 ! video/x-raw,width=%d,height=%d,framerate=%d/1,format=I420 ! videoconvert", width, height, fps) + } else { + // Portal PipeWire source: VA-import the DMA-BUF, scale, and force 4:2:0 + // (I420). RGB screens would otherwise yield High 4:4:4 Predictive, which + // consumer TV/Cast decoders can't decode -> black. + src = fmt.Sprintf("pipewiresrc fd=%d path=%d do-timestamp=true ! vapostproc ! video/x-raw,width=%d,height=%d,format=I420 ! videoconvert", portalFD, node, width, height) + } + + enc := fmt.Sprintf("x264enc name=enc tune=zerolatency speed-preset=superfast bitrate=%d key-int-max=%d byte-stream=true ! h264parse config-interval=-1", bitrate, fps) + + switch mode { + case "h264": + return src + " ! " + enc + " ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=sink sync=false max-buffers=8 drop=false", nil + case "hls": + if out == "" { + return "", fmt.Errorf("mode=hls requires -out ") + } + hls := fmt.Sprintf("mpegtsmux ! hlssink location=%s/segment%%05d.ts playlist-location=%s/stream.m3u8 target-duration=1 max-files=10 playlist-length=5", out, out) + return src + " ! " + enc + " ! " + hls, nil + default: + return "", fmt.Errorf("unknown -mode %q (want h264|hls)", mode) + } +} + +// restampEncoderInput adds a pad probe on the encoder's sink that rewrites each +// buffer's PTS/duration to a monotonic fps timeline. +func restampEncoderInput(pipeline *gst.Pipeline, fps int) error { + enc, err := pipeline.GetElementByName("enc") + if err != nil { + return err + } + sinkPad := enc.GetStaticPad("sink") + frameDur := uint64(time.Second) / uint64(fps) + var frame uint64 + sinkPad.AddProbe(gst.PadProbeTypeBuffer, func(self *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { + buf := info.GetBuffer() + if buf == nil { + return gst.PadProbeOK + } + buf.SetPresentationTimestamp(gst.ClockTime(frame * frameDur)) + buf.SetDuration(gst.ClockTime(frameDur)) + frame++ + return gst.PadProbeOK + }) + return nil +} + +// wireH264Stdout streams appsink buffers (raw H.264 byte-stream) to stdout. +func wireH264Stdout(pipeline *gst.Pipeline) error { + elem, err := pipeline.GetElementByName("sink") + if err != nil { + return err + } + sink := app.SinkFromElement(elem) + sink.SetCallbacks(&app.SinkCallbacks{ + NewSampleFunc: func(s *app.Sink) gst.FlowReturn { + sample := s.PullSample() + if sample == nil { + return gst.FlowEOS + } + if _, err := os.Stdout.Write(sample.GetBuffer().Bytes()); err != nil { + return gst.FlowError + } + return gst.FlowOK + }, + }) + return nil +} + +// runUntilDone blocks on the bus until EOS or error. +func runUntilDone(pipeline *gst.Pipeline) { + bus := pipeline.GetBus() + for { + msg := bus.TimedPop(gst.ClockTime(uint64(gst.ClockTimeNone))) + if msg == nil { + continue + } + switch msg.Type() { + case gst.MessageEOS: + pipeline.SetState(gst.StateNull) + return + case gst.MessageError: + fmt.Fprintln(os.Stderr, "gst error:", msg.ParseError().Error()) + pipeline.SetState(gst.StateNull) + os.Exit(1) + } + } +} diff --git a/core/cmd/dms-cast-helper/main_nocgo.go b/core/cmd/dms-cast-helper/main_nocgo.go new file mode 100644 index 000000000..a67afe477 --- /dev/null +++ b/core/cmd/dms-cast-helper/main_nocgo.go @@ -0,0 +1,17 @@ +//go:build !casthelper + +// Stub built unless the `casthelper` tag is set, so a plain `go build ./...` / +// `go test ./...` over the core stays green without GStreamer dev packages (and +// without enabling CGO). The real GStreamer helper is built by `make +// cast-helper`, which passes `-tags casthelper` with CGO_ENABLED=1. +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "dms-cast-helper was built without GStreamer support; build it with `make cast-helper` (needs CGO + GStreamer dev packages)") + os.Exit(2) +} diff --git a/core/go.mod b/core/go.mod index 6b16688ee..412a47964 100644 --- a/core/go.mod +++ b/core/go.mod @@ -10,12 +10,15 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v1.0.0 github.com/fsnotify/fsnotify v1.10.1 + github.com/go-gst/go-gst v1.4.0 github.com/godbus/dbus/v5 v5.2.2 + github.com/grandcat/zeroconf v1.0.0 github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 github.com/pilebones/go-udev v0.9.1 github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/vishen/go-chromecast v0.3.4 github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/writer/standard v1.3.0 github.com/yuin/goldmark v1.8.2 @@ -32,6 +35,8 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/akutz/memconn v0.1.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect @@ -44,29 +49,40 @@ require ( github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect + github.com/go-gst/go-glib v1.4.0 // indirect github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/jsimonetti/rtnetlink v1.4.2 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattn/go-pointer v0.0.1 // indirect github.com/mdlayher/netlink v1.11.1 // indirect github.com/mdlayher/socket v0.6.0 // indirect + github.com/miekg/dns v1.1.62 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) require ( diff --git a/core/go.sum b/core/go.sum index e1bb4ebd9..fdadd7aeb 100644 --- a/core/go.sum +++ b/core/go.sum @@ -24,6 +24,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -91,6 +95,10 @@ github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsd github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-gst/go-glib v1.4.0 h1:FB2uVfB0uqz7/M6EaDdWWlBZRQpvFAbWfL7drdw8lAE= +github.com/go-gst/go-glib v1.4.0/go.mod h1:GUIpWmkxQ1/eL+FYSjKpLDyTZx6Vgd9nNXt8dA31d5M= +github.com/go-gst/go-gst v1.4.0 h1:EikB43u4c3wc8d2RzlFRSfIGIXYzDy6Zls2vJqrG2BU= +github.com/go-gst/go-gst v1.4.0/go.mod h1:p8TLGtOxJLcrp6PCkTPdnanwWBxPZvYiHDbuSuwgO3c= github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o= github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= @@ -98,6 +106,8 @@ github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -105,6 +115,10 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -117,9 +131,12 @@ github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5G github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -132,12 +149,19 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= +github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0= github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -164,6 +188,8 @@ github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4Xl github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -182,6 +208,8 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8 github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/vishen/go-chromecast v0.3.4 h1:ELRwOoNaxwIsKCXuxCXXku92+H/qKLj3a6ZN6LDD+H8= +github.com/vishen/go-chromecast v0.3.4/go.mod h1:9ht6970KP5YmO0WpJJPMfInai2HA5w+q+UWId3QLxBc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -192,6 +220,8 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= @@ -204,32 +234,68 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/core/internal/server/chromecast/airplay.go b/core/internal/server/chromecast/airplay.go new file mode 100644 index 000000000..ce8bd9d89 --- /dev/null +++ b/core/internal/server/chromecast/airplay.go @@ -0,0 +1,119 @@ +package chromecast + +import ( + "context" + "fmt" + "strings" + + "github.com/grandcat/zeroconf" +) + +// airplayDiscoverFunc browses for AirPlay devices. Overridable in tests. +var airplayDiscoverFunc = discoverAirplay + +// discoverAirplay browses _airplay._tcp and returns a channel of normalized +// AirPlay devices. The channel is closed when ctx is cancelled. +func discoverAirplay(ctx context.Context) (<-chan Device, error) { + resolver, err := zeroconf.NewResolver(nil) + if err != nil { + return nil, fmt.Errorf("airplay resolver: %w", err) + } + + entries := make(chan *zeroconf.ServiceEntry, 8) + if err := resolver.Browse(ctx, "_airplay._tcp", "local.", entries); err != nil { + return nil, fmt.Errorf("airplay browse: %w", err) + } + + out := make(chan Device, 8) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case entry, ok := <-entries: + if !ok { + return + } + dev := airplayEntryToDevice(entry) + if dev.Host == "" { + continue + } + select { + case out <- dev: + case <-ctx.Done(): + return + } + } + } + }() + return out, nil +} + +// unescapeDNS decodes DNS presentation-format escapes (\DDD decimal byte +// escapes and \X literal escapes) that zeroconf leaves in instance names, e.g. +// "Geoffrey\226\128\153s\ MacBook" -> "Geoffrey’s MacBook". +func unescapeDNS(s string) string { + if !strings.Contains(s, "\\") { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + continue + } + if i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]) { + b.WriteByte(byte((int(s[i+1]-'0')*100 + int(s[i+2]-'0')*10 + int(s[i+3]-'0')))) + i += 3 + continue + } + b.WriteByte(s[i+1]) + i++ + } + return b.String() +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +// airplayEntryToDevice normalizes a zeroconf _airplay._tcp entry into a Device. +func airplayEntryToDevice(entry *zeroconf.ServiceEntry) Device { + dev := Device{ + Name: unescapeDNS(entry.Instance), + Port: entry.Port, + Protocol: ProtocolAirplay, + } + if len(entry.AddrIPv4) > 0 { + dev.Host = entry.AddrIPv4[0].String() + } + // AirPlay TXT records carry model (and a deviceid we deliberately do NOT key + // on — see below). + for _, txt := range entry.Text { + k, v, ok := strings.Cut(txt, "=") + if !ok { + continue + } + if strings.EqualFold(k, "model") { + dev.Model = v + } + } + + // Key on the mDNS instance name, not the deviceid TXT or host:port. The + // instance comes from the PTR/SRV records and is present in every resolved + // browse response; the deviceid TXT is frequently dropped under mDNS port + // contention (avahi + other listeners share 5353), which previously made the + // same device flip between its MAC id and a host:port fallback across + // browses — breaking favorite/auto-reconnect matching and de-duplication. + switch { + case entry.Instance != "": + dev.ID = "airplay:" + unescapeDNS(entry.Instance) + case entry.HostName != "": + dev.ID = "airplay:" + strings.TrimSuffix(entry.HostName, ".") + default: + dev.ID = fmt.Sprintf("airplay:%s:%d", dev.Host, dev.Port) + } + if dev.Name == "" { + dev.Name = strings.TrimPrefix(dev.ID, "airplay:") + } + return dev +} diff --git a/core/internal/server/chromecast/airplay_mirror.go b/core/internal/server/chromecast/airplay_mirror.go new file mode 100644 index 000000000..030eb5299 --- /dev/null +++ b/core/internal/server/chromecast/airplay_mirror.go @@ -0,0 +1,110 @@ +package chromecast + +import ( + "errors" + "fmt" + "os" + "os/exec" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +// defaultPortRange is the fixed UDP/TCP window doubletake confines its AirPlay +// back-channel ports to, so users can open exactly this range in their firewall. +const defaultPortRange = "60000-60010" + +// doubletakePath resolves the doubletake binary (the AirPlay 2 protocol sender). +// doubletake is a separate GPLv3 process, never linked into the MIT core. +func doubletakePath() string { + if p := os.Getenv("DMS_DOUBLETAKE"); p != "" { + return p + } + return "doubletake" +} + +func portRange() string { + if r := os.Getenv("DMS_CAST_PORT_RANGE"); r != "" { + return r + } + return defaultPortRange +} + +// buildDoubletakeCmd builds the doubletake mirror invocation for host. +// doubletake captures via the go-gst library itself (see the fork at +// github.com/domenkozar/doubletake), so no capture hook is needed here. +// Overridable in tests. +var buildDoubletakeCmd = func(host string) *exec.Cmd { + return exec.Command(doubletakePath(), + "-target", host, + "-port-range", portRange(), + "-no-audio", + ) +} + +// airplayMirror manages the doubletake subprocess that mirrors the screen to an +// AirPlay 2 receiver. +type airplayMirror struct { + mu sync.Mutex + cmd *exec.Cmd + running bool +} + +// start launches doubletake mirroring to host. onExit fires if the process ends +// on its own (not via stop), so the manager can clear connection state. +func (a *airplayMirror) start(host string, onExit func()) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.running { + return fmt.Errorf("airplay mirror already running") + } + + cmd := buildDoubletakeCmd(host) + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("AirPlay mirroring requires 'doubletake' — install it (or set DMS_DOUBLETAKE)") + } + return fmt.Errorf("start doubletake: %w", err) + } + a.cmd = cmd + a.running = true + log.Infof("[Cast] AirPlay mirror started -> %s", host) + + go func() { + _ = cmd.Wait() + a.mu.Lock() + unexpected := a.running // still true => we didn't stop() it + a.running = false + a.cmd = nil + a.mu.Unlock() + if unexpected { + log.Warn("[Cast] AirPlay mirror exited unexpectedly") + if onExit != nil { + onExit() + } + } + }() + return nil +} + +// stop terminates the doubletake subprocess. +func (a *airplayMirror) stop() { + a.mu.Lock() + defer a.mu.Unlock() + if !a.running { + return + } + a.running = false + if a.cmd != nil && a.cmd.Process != nil { + _ = a.cmd.Process.Kill() + } + a.cmd = nil + log.Info("[Cast] AirPlay mirror stopped") +} + +func (a *airplayMirror) isRunning() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.running +} diff --git a/core/internal/server/chromecast/avahi_dbus.go b/core/internal/server/chromecast/avahi_dbus.go new file mode 100644 index 000000000..d61865992 --- /dev/null +++ b/core/internal/server/chromecast/avahi_dbus.go @@ -0,0 +1,191 @@ +package chromecast + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/godbus/dbus/v5" +) + +// Avahi D-Bus constants (see avahi-common/defs.h). +const ( + avahiIfaceUnspec = int32(-1) // AVAHI_IF_UNSPEC + avahiProtoInet = int32(0) // AVAHI_PROTO_INET (IPv4) + avahiServerRunning = int32(2) // AVAHI_SERVER_RUNNING +) + +const ( + avahiDest = "org.freedesktop.Avahi" + avahiServerIface = "org.freedesktop.Avahi.Server" + avahiBrowserIface = "org.freedesktop.Avahi.ServiceBrowser" + avahiItemNewSignal = "org.freedesktop.Avahi.ServiceBrowser.ItemNew" +) + +// avahiAvailableFunc reports whether the Avahi daemon is usable. Overridable in +// tests so they never touch the system bus. +var avahiAvailableFunc = avahiAvailable + +// avahiBrowseFunc browses one service type via Avahi. Overridable in tests. +var avahiBrowseFunc = browseAvahi + +// avahiAvailable reports whether avahi-daemon is running and reachable on the +// system bus. When it is, we browse through it instead of running our own mDNS +// stack, which avoids contending for UDP port 5353 (avahi already owns it). +func avahiAvailable() bool { + conn, err := dbus.SystemBus() + if err != nil { + return false + } + var state int32 + err = conn.Object(avahiDest, "/").Call(avahiServerIface+".GetState", 0).Store(&state) + return err == nil && state == avahiServerRunning +} + +// browseAvahi asks Avahi to browse serviceType and returns a channel of +// normalized Devices (tagged with proto). It uses a private system-bus +// connection so its signal stream is isolated; the connection, the browser, and +// the channel are all torn down when ctx is cancelled. +func browseAvahi(ctx context.Context, serviceType string, proto string) (<-chan Device, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("avahi system bus: %w", err) + } + + server := conn.Object(avahiDest, "/") + var browserPath dbus.ObjectPath + if err := server.Call(avahiServerIface+".ServiceBrowserNew", 0, + avahiIfaceUnspec, avahiProtoInet, serviceType, "", uint32(0)).Store(&browserPath); err != nil { + conn.Close() + return nil, fmt.Errorf("avahi ServiceBrowserNew(%s): %w", serviceType, err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(browserPath), + dbus.WithMatchInterface(avahiBrowserIface), + ); err != nil { + conn.Close() + return nil, fmt.Errorf("avahi match: %w", err) + } + + sig := make(chan *dbus.Signal, 32) + conn.Signal(sig) + + out := make(chan Device, 8) + var resolves sync.WaitGroup + go func() { + // Defers run LIFO: wait for in-flight resolves, then free the browser and + // close the bus while they no longer use it, then close the channel. + defer close(out) + defer conn.Close() + defer conn.Object(avahiDest, browserPath).Call(avahiBrowserIface+".Free", 0) + defer resolves.Wait() + + for { + select { + case <-ctx.Done(): + return + case s, ok := <-sig: + if !ok { + return + } + if s.Path != browserPath || s.Name != avahiItemNewSignal { + continue + } + // ItemNew(i iface, i proto, s name, s type, s domain, u flags) + if len(s.Body) < 5 { + continue + } + iface, _ := s.Body[0].(int32) + protocol, _ := s.Body[1].(int32) + name, _ := s.Body[2].(string) + stype, _ := s.Body[3].(string) + domain, _ := s.Body[4].(string) + + // Resolve off the browse loop so one slow/failed resolve doesn't + // stall the others, and retry: under mDNS port contention (e.g. + // Chrome's 5353 socket) a single resolve often loses its response. + resolves.Add(1) + go func() { + defer resolves.Done() + dev, ok := resolveAvahiRetry(ctx, server, iface, protocol, name, stype, domain, proto) + if !ok { + return + } + select { + case out <- dev: + case <-ctx.Done(): + } + }() + } + } + }() + return out, nil +} + +// avahiResolveAttempts bounds how many times a browsed item is re-resolved +// before giving up for this announcement (it is retried on the next re-announce). +const avahiResolveAttempts = 3 + +// resolveAvahiRetry resolves an item, retrying a few times because a single +// mDNS resolve response is easily lost when other listeners share port 5353. +func resolveAvahiRetry(ctx context.Context, server dbus.BusObject, iface, protocol int32, name, stype, domain, proto string) (Device, bool) { + for attempt := 0; attempt < avahiResolveAttempts; attempt++ { + if dev, ok := resolveAvahi(server, iface, protocol, name, stype, domain, proto); ok { + return dev, true + } + if ctx.Err() != nil { + return Device{}, false + } + } + return Device{}, false +} + +// resolveAvahi resolves a browsed item to an address + TXT and maps it to a +// Device. Avahi returns names already unescaped, so no DNS-escape decoding is +// needed here (unlike the raw zeroconf path). +func resolveAvahi(server dbus.BusObject, iface, protocol int32, name, stype, domain string, proto string) (Device, bool) { + var ( + rIface, rProto int32 + rName, rType, rDomain string + rHost string + rAproto int32 + rAddress string + rPort uint16 + rTxt [][]byte + rFlags uint32 + ) + err := server.Call(avahiServerIface+".ResolveService", 0, + iface, protocol, name, stype, domain, avahiProtoInet, uint32(0)).Store( + &rIface, &rProto, &rName, &rType, &rDomain, &rHost, &rAproto, &rAddress, &rPort, &rTxt, &rFlags) + if err != nil { + return Device{}, false // device vanished between browse and resolve + } + + txt := make(map[string]string, len(rTxt)) + for _, b := range rTxt { + k, v, ok := strings.Cut(string(b), "=") + if ok { + txt[strings.ToLower(k)] = v + } + } + + dev := Device{Host: rAddress, Port: int(rPort), Protocol: proto} + switch proto { + case ProtocolChromecast: + // Cast TXT carries the device UUID (id), friendly name (fn), model (md). + dev.ID = txt["id"] + dev.Name = txt["fn"] + dev.Model = txt["md"] + case ProtocolAirplay: + // Key on the instance name, consistent with the zeroconf path. + dev.ID = "airplay:" + rName + dev.Name = rName + dev.Model = txt["model"] + } + if dev.Name == "" { + dev.Name = rName + } + return dev, true +} diff --git a/core/internal/server/chromecast/config.go b/core/internal/server/chromecast/config.go new file mode 100644 index 000000000..89931d1b7 --- /dev/null +++ b/core/internal/server/chromecast/config.go @@ -0,0 +1,54 @@ +package chromecast + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +// Config persists user preferences for the chromecast service. +type Config struct { + // PreferredID is the stable ID of the device to auto-reconnect to. Empty + // means no preferred device. + PreferredID string `json:"preferredId"` + // PreferredName is kept for display only (the ID is authoritative). + PreferredName string `json:"preferredName"` +} + +// configPathFunc resolves the config file path. Overridable in tests. +var configPathFunc = func() (string, error) { + return filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "castsettings.json"), nil +} + +func loadConfig() Config { + var cfg Config + path, err := configPathFunc() + if err != nil { + return cfg + } + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{} + } + return cfg +} + +func saveConfig(cfg Config) error { + path, err := configPathFunc() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/core/internal/server/chromecast/handlers.go b/core/internal/server/chromecast/handlers.go new file mode 100644 index 000000000..629428604 --- /dev/null +++ b/core/internal/server/chromecast/handlers.go @@ -0,0 +1,157 @@ +package chromecast + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +// HandleRequest routes an IPC request to the appropriate handler. +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "chromecast.getState": + handleGetState(conn, req, manager) + case "chromecast.startDiscovery": + handleStartDiscovery(conn, req, manager) + case "chromecast.stopDiscovery": + handleStopDiscovery(conn, req, manager) + case "chromecast.connect": + handleConnect(conn, req, manager) + case "chromecast.disconnect": + handleDisconnect(conn, req, manager) + case "chromecast.cast": + handleCast(conn, req, manager) + case "chromecast.play": + handleControl(conn, req, manager.Play, "playing") + case "chromecast.pause": + handleControl(conn, req, manager.Pause, "paused") + case "chromecast.stop": + handleControl(conn, req, manager.StopPlayback, "stopped") + case "chromecast.seek": + handleSeek(conn, req, manager) + case "chromecast.setVolume": + handleSetVolume(conn, req, manager) + case "chromecast.setMuted": + handleSetMuted(conn, req, manager) + case "chromecast.castScreen": + handleCastScreen(conn, req, manager) + case "chromecast.stopScreen": + handleStopScreen(conn, req, manager) + case "chromecast.setPreferred": + handleSetPreferred(conn, req, manager) + case "chromecast.clearPreferred": + manager.ClearPreferred() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "preference cleared"}) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetState(conn net.Conn, req models.Request, manager *Manager) { + models.Respond(conn, req.ID, manager.GetState()) +} + +func handleStartDiscovery(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.StartDiscovery(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery started"}) +} + +func handleStopDiscovery(conn net.Conn, req models.Request, manager *Manager) { + manager.StopDiscovery() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery stopped"}) +} + +func handleConnect(conn net.Conn, req models.Request, manager *Manager) { + id := models.GetOr(req, "id", "") + if id == "" { + models.RespondError(conn, req.ID, "missing device id") + return + } + if err := manager.Connect(id); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"}) +} + +func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) { + manager.Disconnect() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleCast(conn net.Conn, req models.Request, manager *Manager) { + url := models.GetOr(req, "url", "") + if url == "" { + models.RespondError(conn, req.ID, "missing url") + return + } + contentType := models.GetOr(req, "contentType", "") + if err := manager.Cast(url, contentType); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "casting"}) +} + +// handleControl wraps a no-argument transport action. +func handleControl(conn net.Conn, req models.Request, action func() error, okMsg string) { + if err := action(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: okMsg}) +} + +func handleSeek(conn net.Conn, req models.Request, manager *Manager) { + pos := models.GetOr(req, "position", float64(0)) + if err := manager.Seek(pos); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "seeked"}) +} + +func handleSetVolume(conn net.Conn, req models.Request, manager *Manager) { + level := models.GetOr(req, "level", float64(0)) + if err := manager.SetVolume(level); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "volume set"}) +} + +func handleSetMuted(conn net.Conn, req models.Request, manager *Manager) { + muted := models.GetOr(req, "muted", false) + if err := manager.SetMuted(muted); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "mute set"}) +} + +func handleCastScreen(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.CastScreen(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "screen casting"}) +} + +func handleStopScreen(conn net.Conn, req models.Request, manager *Manager) { + manager.StopScreen() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "screen stopped"}) +} + +func handleSetPreferred(conn net.Conn, req models.Request, manager *Manager) { + id := models.GetOr(req, "id", "") + if id == "" { + models.RespondError(conn, req.ID, "missing device id") + return + } + manager.SetPreferred(id) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "preference saved"}) +} diff --git a/core/internal/server/chromecast/manager.go b/core/internal/server/chromecast/manager.go new file mode 100644 index 000000000..339835c07 --- /dev/null +++ b/core/internal/server/chromecast/manager.go @@ -0,0 +1,814 @@ +package chromecast + +import ( + "context" + "errors" + "fmt" + "net" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + castapp "github.com/vishen/go-chromecast/application" + "github.com/vishen/go-chromecast/cast" + castdns "github.com/vishen/go-chromecast/dns" +) + +// pollInterval is how often the connected device is polled for playback status. +// The Cast protocol does not push media progress, so we refresh on a timer and +// broadcast only when something actually changes. +const pollInterval = time.Second + +// discoverFunc abstracts mDNS discovery so the manager can be unit-tested +// without a real LAN. Defaults to the go-chromecast implementation. +var discoverFunc = func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + return castdns.DiscoverCastDNSEntries(ctx, iface) +} + +// castApp is the slice of the go-chromecast Application API the manager uses. +// It exists so connection logic can be unit-tested with a fake. +type castApp interface { + Start(addr string, port int) error + Update() error + Status() (*cast.Application, *cast.Media, *cast.Volume) + Load(filenameOrURL string, startTime int, contentType string, transcode, detach, forceDetach bool) error + Unpause() error + Pause() error + StopMedia() error + SeekToTime(value float32) error + SetVolume(value float32) error + SetMuted(value bool) error + Close(stopMedia bool) error +} + +// errNotConnected is returned by control actions when no device is connected. +var errNotConnected = errors.New("not connected to a device") + +// newAppFunc constructs a cast application. Overridable in tests. +var newAppFunc = func() castApp { + return castapp.NewApplication() +} + +// Manager discovers Cast devices via mDNS and broadcasts device-list changes +// to subscribers. Discovery is explicitly started/stopped by clients so the +// mDNS browser only runs while a UI is interested in the results. +type Manager struct { + mu sync.RWMutex + devices map[string]Device + discovering bool + discoverCancel context.CancelFunc + discoverGen uint64 // bumped on every (re)start/stop so a stale wg-drain can't clear a newer scan + + // connMu serializes connection lifecycle transitions (Connect/Disconnect) + // so two overlapping attempts can't both install an app or undo each other. + connMu sync.Mutex + // appMu serializes every call into the go-chromecast Application, which is + // not concurrent-safe; the poll loop and IPC control actions both call it. + appMu sync.Mutex + + // Connection state (guarded by mu; blocking network calls are never made + // while the lock is held). + app castApp + connCancel context.CancelFunc + connected bool + autoConnecting bool // guards against overlapping auto-reconnect attempts + suppressAutoReconnect bool // set when the user explicitly disconnects, so a re-announce doesn't auto-reconnect + activeDevice Device + playback *Playback + screencasting bool + preferredID string + + screen *screenStreamer + airplay *airplayMirror + subscribers syncmap.Map[string, chan State] + sendMu sync.Mutex // serializes broadcast sends vs. subscriber-channel closes + closed atomic.Bool +} + +// callApp runs a single Application call while holding appMu. The go-chromecast +// Application mutates a shared requestID/resultChanMap without locking, so the +// poll loop and concurrent IPC control actions must not call into it at once +// (doing so races and can crash the process with "concurrent map writes"). +func (m *Manager) callApp(app castApp, fn func(castApp) error) error { + m.appMu.Lock() + defer m.appMu.Unlock() + return fn(app) +} + +// statusOf reads the Application's cached status under appMu (Status() reads +// fields Update() writes, so it must be serialized with the other app calls). +func (m *Manager) statusOf(app castApp) (*cast.Application, *cast.Media, *cast.Volume) { + m.appMu.Lock() + defer m.appMu.Unlock() + return app.Status() +} + +// NewManager creates a chromecast manager. Discovery does not start until +// StartDiscovery is called. +func NewManager() *Manager { + cfg := loadConfig() + return &Manager{ + devices: make(map[string]Device), + screen: &screenStreamer{}, + airplay: &airplayMirror{}, + preferredID: cfg.PreferredID, + } +} + +// rebrowseInterval paces the self-healing re-browse on the built-in mDNS +// fallback: every interval the resolver is recreated so a browser that missed +// responses (port 5353 is shared with other mDNS listeners) gets another chance. +const rebrowseInterval = 10 * time.Second + +// StartDiscovery begins discovery for both Chromecast (_googlecast._tcp) and +// AirPlay (_airplay._tcp) devices. It is a no-op if discovery is already +// running. Newly seen devices from either protocol are merged into one list and +// broadcast to subscribers. When avahi-daemon is available, browsing goes +// through it (no parallel mDNS stack, no port contention); otherwise the +// built-in zeroconf/go-chromecast browsers run with periodic self-healing. +func (m *Manager) StartDiscovery() error { + m.mu.Lock() + if m.discovering { + m.mu.Unlock() + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + m.discoverCancel = cancel + m.discovering = true + m.discoverGen++ + gen := m.discoverGen + m.devices = make(map[string]Device) // fresh list per scan + m.mu.Unlock() + + sources := m.discoverySources(ctx) + + log.Info("[Cast] Discovery started") + m.broadcast() + + var wg sync.WaitGroup + for _, src := range sources { + wg.Add(1) + go func(ch <-chan Device) { + defer wg.Done() + for dev := range ch { + m.upsertDevice(dev) + } + }(src) + } + + // Mark discovery stopped once every source has ended — but only if this is + // still the active scan. A StopDiscovery + StartDiscovery can replace this + // session while its sources are still draining; the gen check stops that + // stale drain from clearing the newer scan's discovering flag. + go func() { + wg.Wait() + m.mu.Lock() + stale := m.discoverGen != gen + if !stale { + m.discovering = false + m.discoverCancel = nil + } + m.mu.Unlock() + if !stale { + log.Info("[Cast] Discovery stopped") + m.broadcast() + } + }() + + return nil +} + +// discoverySources picks the discovery backend and returns one Device channel +// per protocol. Prefers avahi; falls back to the built-in self-healing mDNS +// browsers. Errors only if no source could be started at all. +func (m *Manager) discoverySources(ctx context.Context) []<-chan Device { + useAvahi := avahiAvailableFunc() + if useAvahi { + log.Info("[Cast] Using avahi for mDNS discovery") + } + // One source per protocol, each choosing avahi or its built-in browser + // independently — so a single avahi browse failure degrades only that + // protocol to its fallback instead of silently dropping it. + cc := m.protocolSource(ctx, useAvahi, "_googlecast._tcp", ProtocolChromecast, func(c context.Context) (<-chan Device, error) { + return castEntriesToDevices(discoverFunc(c, nil)) + }) + ap := m.protocolSource(ctx, useAvahi, "_airplay._tcp", ProtocolAirplay, func(c context.Context) (<-chan Device, error) { + return airplayDiscoverFunc(c) + }) + return []<-chan Device{cc, ap} +} + +// protocolSource returns the device channel for one protocol: avahi when it is +// available and its browse starts, otherwise the built-in self-healing browser. +func (m *Manager) protocolSource(ctx context.Context, useAvahi bool, serviceType, proto string, builtin func(context.Context) (<-chan Device, error)) <-chan Device { + if useAvahi { + ch, err := avahiBrowseFunc(ctx, serviceType, proto) + if err == nil { + return ch + } + log.Warnf("[Cast] avahi browse %s failed (%v); falling back to built-in mDNS", serviceType, err) + } + return m.selfHealBrowse(ctx, builtin) +} + +// selfHealBrowse repeatedly runs mk with a fresh per-cycle context so a browser +// that missed mDNS responses re-queries on the next cycle. Devices are forwarded +// as they arrive; the loop ends when ctx is cancelled. +func (m *Manager) selfHealBrowse(ctx context.Context, mk func(context.Context) (<-chan Device, error)) <-chan Device { + out := make(chan Device, 8) + go func() { + defer close(out) + for ctx.Err() == nil { + cycleCtx, cancel := context.WithTimeout(ctx, rebrowseInterval) + if ch, err := mk(cycleCtx); err == nil { + for dev := range ch { + select { + case out <- dev: + case <-ctx.Done(): + cancel() + return + } + } + } + <-cycleCtx.Done() // pace re-browses and honor cancellation + cancel() + } + }() + return out +} + +// castEntriesToDevices adapts the go-chromecast discovery channel to Devices. +func castEntriesToDevices(in <-chan castdns.CastEntry, err error) (<-chan Device, error) { + if err != nil { + return nil, err + } + out := make(chan Device, 8) + go func() { + defer close(out) + for e := range in { + out <- Device{ + ID: e.UUID, + Name: e.DeviceName, + Model: e.Device, + Host: e.GetAddr(), + Port: e.Port, + Protocol: ProtocolChromecast, + } + } + }() + return out, nil +} + +// upsertDevice merges a discovered device into the list, assigns a stable key, +// broadcasts, and auto-reconnects to the preferred device when it appears. +func (m *Manager) upsertDevice(dev Device) { + key := dev.ID + if key == "" { + key = fmt.Sprintf("%s:%d", dev.Host, dev.Port) + } + dev.ID = key + if dev.Name == "" { + dev.Name = key + } + + m.mu.Lock() + existing, known := m.devices[key] + changed := !known || existing != dev + m.devices[key] = dev + // Auto-reconnect to the preferred device when it (re)appears, for either + // protocol — Connect dispatches Chromecast vs AirPlay (which starts the + // mirror). For AirPlay this auto-starts a screen mirror; the screen-share + // portal only prompts on the first grant (the restore token auto-grants + // after), so it isn't intrusive on later reconnects. autoConnecting prevents + // overlapping attempts from rapid/duplicate discovery events. + shouldReconnect := m.preferredID == key && !m.connected && !m.autoConnecting && !m.suppressAutoReconnect + if shouldReconnect { + m.autoConnecting = true + } + m.mu.Unlock() + // Discovery re-announces and the self-heal re-browse re-emit unchanged + // devices on a loop; only snapshot/sort/broadcast when something actually + // changed to avoid re-pushing an identical list to every subscriber. + if changed { + log.Debugf("[Cast] Discovered %s %s (%s) at %s:%d", dev.Protocol, dev.Name, dev.Model, dev.Host, dev.Port) + m.broadcast() + } + + if shouldReconnect { + log.Infof("[Cast] Auto-reconnecting to preferred device %s", dev.Name) + go func(id string) { + if err := m.Connect(id); err != nil { + log.Warnf("[Cast] Auto-reconnect failed: %v", err) + } + m.mu.Lock() + m.autoConnecting = false + m.mu.Unlock() + }(key) + } +} + +// StopDiscovery cancels an in-flight mDNS browse. Safe to call when not +// discovering. +func (m *Manager) StopDiscovery() { + m.mu.Lock() + cancel := m.discoverCancel + m.discoverCancel = nil + // Clear synchronously (don't wait for the async source drain) so an + // immediate StartDiscovery isn't a no-op, and bump the generation so the + // in-flight drain goroutine won't touch the next scan's state. + wasDiscovering := m.discovering + m.discovering = false + m.discoverGen++ + m.mu.Unlock() + + if cancel != nil { + cancel() + } + // The stale drain goroutine won't broadcast (its gen no longer matches), so + // publish the stopped state here. + if wasDiscovering { + m.broadcast() + } +} + +// Connect opens a control connection to the device with the given ID and +// begins polling it for playback status. Any existing connection is dropped +// first. The device must have been seen by discovery. +func (m *Manager) Connect(id string) error { + m.connMu.Lock() + defer m.connMu.Unlock() + return m.connectLocked(id) +} + +// connectLocked performs the connection. Callers hold connMu so connect and +// disconnect transitions never overlap (which previously let two concurrent +// connects both install an app, leaking the first connection and its poll loop). +func (m *Manager) connectLocked(id string) error { + m.mu.Lock() + // An explicit connect re-engages auto-reconnect for future re-announces. + m.suppressAutoReconnect = false + dev, ok := m.devices[id] + hasSession := m.connected || m.app != nil + m.mu.Unlock() + + if !ok { + return fmt.Errorf("unknown device: %s", id) + } + + // AirPlay devices use the mirroring path (doubletake), not the Cast protocol. + if dev.Protocol == ProtocolAirplay { + return m.connectAirplayLocked(dev) + } + + // Drop any existing session first — a Chromecast app OR an AirPlay mirror. + // m.app is nil for AirPlay, so the connected check is what catches it. + if hasSession { + m.disconnectLocked() + } + + app := newAppFunc() + if err := m.callApp(app, func(a castApp) error { return a.Start(dev.Host, dev.Port) }); err != nil { + return fmt.Errorf("connect to %s: %w", dev.Name, err) + } + + ctx, cancel := context.WithCancel(context.Background()) + m.mu.Lock() + m.app = app + m.connCancel = cancel + m.connected = true + m.activeDevice = dev + m.playback = nil + m.mu.Unlock() + + log.Infof("[Chromecast] Connected to %s (%s:%d)", dev.Name, dev.Host, dev.Port) + m.broadcast() + + // Prime playback state, then poll for changes until disconnected. + if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil { + log.Warnf("[Chromecast] Initial status update failed: %v", err) + } + m.refreshPlayback(app) + + go m.pollLoop(ctx, app) + return nil +} + +// connectAirplayLocked starts mirroring the screen to an AirPlay 2 device via +// doubletake. For AirPlay, connecting and mirroring are the same action. +// Callers hold connMu. +func (m *Manager) connectAirplayLocked(dev Device) error { + m.disconnectLocked() // drop any existing connection first + + onExit := func() { + m.mu.Lock() + m.connected = false + m.activeDevice = Device{} + m.screencasting = false + m.mu.Unlock() + m.broadcast() + } + if err := m.airplay.start(dev.Host, onExit); err != nil { + return err + } + + m.mu.Lock() + m.connected = true + m.activeDevice = dev + m.screencasting = true // AirPlay connect == screen mirroring + m.mu.Unlock() + log.Infof("[Cast] Connected to AirPlay device %s (%s)", dev.Name, dev.Host) + m.broadcast() + return nil +} + +// Disconnect closes the active control connection, if any, and stops any +// in-progress screen mirroring. This is the user-initiated path, so it suppresses +// auto-reconnect until the next explicit connect/preference change. +func (m *Manager) Disconnect() { + m.connMu.Lock() + defer m.connMu.Unlock() + m.mu.Lock() + m.suppressAutoReconnect = true + m.mu.Unlock() + m.disconnectLocked() +} + +// disconnectLocked tears down the active session (Cast app and/or AirPlay +// mirror) and clears connection state. Callers hold connMu. +func (m *Manager) disconnectLocked() { + m.screen.stop() + m.airplay.stop() + + m.mu.Lock() + cancel := m.connCancel + app := m.app + m.app = nil + m.connCancel = nil + m.connected = false + m.activeDevice = Device{} + m.playback = nil + m.screencasting = false + m.mu.Unlock() + + if cancel != nil { + cancel() + } + if app != nil { + if err := m.callApp(app, func(a castApp) error { return a.Close(false) }); err != nil { + log.Debugf("[Chromecast] Error closing connection: %v", err) + } + log.Info("[Chromecast] Disconnected") + } + m.broadcast() +} + +// SetPreferred records a device as the auto-reconnect target and persists it. +// An empty id clears the preference. +func (m *Manager) SetPreferred(id string) { + m.mu.Lock() + m.preferredID = id + // Choosing a preferred device is an opt-in to auto-connect, so lift any + // suppression left by an earlier explicit disconnect. + m.suppressAutoReconnect = false + name := "" + if dev, ok := m.devices[id]; ok { + name = dev.Name + } + m.mu.Unlock() + + if err := saveConfig(Config{PreferredID: id, PreferredName: name}); err != nil { + log.Warnf("[Chromecast] Failed to persist preferred device: %v", err) + } + m.broadcast() +} + +// ClearPreferred removes the auto-reconnect preference. +func (m *Manager) ClearPreferred() { + m.SetPreferred("") +} + +// startupScanWindow bounds how long the boot-time reconnect scan runs. +const startupScanWindow = 30 * time.Second + +// StartupReconnect runs a bounded discovery scan if a preferred device is set, +// so the shell can reconnect to it without any UI being open. The scan stops +// itself after startupScanWindow; the discovery loop auto-connects if the +// preferred device shows up in the meantime. +func (m *Manager) StartupReconnect() { + m.mu.RLock() + preferred := m.preferredID + m.mu.RUnlock() + if preferred == "" { + return + } + + log.Infof("[Chromecast] Scanning for preferred device for up to %s", startupScanWindow) + if err := m.StartDiscovery(); err != nil { + log.Warnf("[Chromecast] Startup scan failed to start: %v", err) + return + } + time.AfterFunc(startupScanWindow, func() { + // Only stop if we never connected; an active connection means a UI or + // the auto-reconnect already took over. + m.mu.RLock() + connected := m.connected + m.mu.RUnlock() + if !connected { + m.StopDiscovery() + } + }) +} + +// currentApp returns the active connection or errNotConnected. +func (m *Manager) currentApp() (castApp, error) { + m.mu.RLock() + app := m.app + m.mu.RUnlock() + if app == nil { + return nil, errNotConnected + } + return app, nil +} + +// Cast loads a media URL (or local file path) on the connected device. An empty +// contentType lets the library infer it from the extension. +func (m *Manager) Cast(url, contentType string) error { + app, err := m.currentApp() + if err != nil { + return err + } + if err := m.callApp(app, func(a castApp) error { return a.Load(url, 0, contentType, false, false, false) }); err != nil { + return fmt.Errorf("load media: %w", err) + } + m.afterControl(app) + return nil +} + +// CastScreen mirrors the local screen to the connected device by capturing it +// to an HLS stream and casting that stream's URL. This is the buffered "laggy +// mirror" path — expect multi-second latency, not real-time mirroring. +func (m *Manager) CastScreen() error { + m.mu.RLock() + app := m.app + host := m.activeDevice.Host + m.mu.RUnlock() + if app == nil { + return errNotConnected + } + + ip, err := outboundIPFunc(host) + if err != nil { + return fmt.Errorf("determine local address: %w", err) + } + + // onExit fires if the capture helper dies on its own, so the UI doesn't get + // stuck showing "Mirroring" with a dead/zombie helper. + onExit := func() { + m.mu.Lock() + wasCasting := m.screencasting + m.screencasting = false + m.mu.Unlock() + if wasCasting { + log.Warn("[Cast] Screen capture helper exited unexpectedly") + m.broadcast() + } + } + + url, err := m.screen.start(ip, onExit) + if err != nil { + return err + } + + if err := m.callApp(app, func(a castApp) error { return a.Load(url, 0, hlsContentType, false, false, false) }); err != nil { + m.screen.stop() + return fmt.Errorf("cast screen stream: %w", err) + } + + m.mu.Lock() + m.screencasting = true + m.mu.Unlock() + + m.broadcast() + m.afterControl(app) + return nil +} + +// StopScreen stops screen mirroring (the capture pipeline and HLS server). It +// leaves the device connection intact. +func (m *Manager) StopScreen() { + m.screen.stop() + m.mu.Lock() + wasCasting := m.screencasting + m.screencasting = false + app := m.app + m.mu.Unlock() + + if app != nil { + _ = m.callApp(app, func(a castApp) error { return a.StopMedia() }) + } + if wasCasting { + m.broadcast() + } +} + +// control runs a transport action against the connected app and refreshes state. +func (m *Manager) control(fn func(castApp) error) error { + app, err := m.currentApp() + if err != nil { + return err + } + if err := m.callApp(app, fn); err != nil { + return err + } + m.afterControl(app) + return nil +} + +// afterControl refreshes status after a state-changing action so subscribers +// see the result without waiting for the next poll tick. +func (m *Manager) afterControl(app castApp) { + if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil { + log.Debugf("[Chromecast] Post-action status update failed: %v", err) + } + m.refreshPlayback(app) +} + +func (m *Manager) Play() error { return m.control(func(a castApp) error { return a.Unpause() }) } +func (m *Manager) Pause() error { return m.control(func(a castApp) error { return a.Pause() }) } +func (m *Manager) StopPlayback() error { + return m.control(func(a castApp) error { return a.StopMedia() }) +} +func (m *Manager) Seek(seconds float64) error { + return m.control(func(a castApp) error { return a.SeekToTime(float32(seconds)) }) +} +func (m *Manager) SetVolume(level float64) error { + return m.control(func(a castApp) error { return a.SetVolume(float32(level)) }) +} +func (m *Manager) SetMuted(muted bool) error { + return m.control(func(a castApp) error { return a.SetMuted(muted) }) +} + +// pollLoop refreshes playback status on a timer until the context is cancelled. +func (m *Manager) pollLoop(ctx context.Context, app castApp) { + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil { + log.Debugf("[Chromecast] Status update failed: %v", err) + continue + } + m.refreshPlayback(app) + } + } +} + +// refreshPlayback reads the app's cached status and broadcasts if it changed. +func (m *Manager) refreshPlayback(app castApp) { + pb := buildPlayback(m.statusOf(app)) + + m.mu.Lock() + // Ignore late refreshes from a connection we've already dropped. + if m.app != app { + m.mu.Unlock() + return + } + changed := !playbackEqual(m.playback, pb) + m.playback = pb + m.mu.Unlock() + + if changed { + m.broadcast() + } +} + +// buildPlayback maps the go-chromecast status into our Playback model, or nil +// when nothing is loaded. +func buildPlayback(app *cast.Application, media *cast.Media, vol *cast.Volume) *Playback { + if app == nil && media == nil { + return nil + } + pb := &Playback{} + if app != nil { + pb.AppName = app.DisplayName + if pb.AppName == "" { + pb.AppName = app.StatusText + } + } + if media != nil { + pb.State = media.PlayerState + pb.CurrentTime = float64(media.CurrentTime) + pb.Duration = float64(media.Media.Duration) + pb.Title = media.Media.Metadata.Title + pb.Subtitle = media.Media.Metadata.Subtitle + pb.Artist = media.Media.Metadata.Artist + } + if vol != nil { + pb.Volume = float64(vol.Level) + pb.Muted = vol.Muted + } + return pb +} + +func playbackEqual(a, b *Playback) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// GetState returns a snapshot of the current discovery state and device list. +func (m *Manager) GetState() State { + m.mu.RLock() + defer m.mu.RUnlock() + return m.snapshot() +} + +// snapshot builds a State value. Caller must hold at least a read lock. +func (m *Manager) snapshot() State { + devices := make([]Device, 0, len(m.devices)) + for _, d := range m.devices { + devices = append(devices, d) + } + // Stable ordering so the UI list doesn't reshuffle between updates. + sort.Slice(devices, func(i, j int) bool { + if devices[i].Name != devices[j].Name { + return devices[i].Name < devices[j].Name + } + return devices[i].ID < devices[j].ID + }) + + state := State{ + Discovering: m.discovering, + Devices: devices, + Connected: m.connected, + Playback: m.playback, + Screencasting: m.screencasting, + PreferredID: m.preferredID, + } + if m.connected { + dev := m.activeDevice + state.ActiveDevice = &dev + } + return state +} + +// Subscribe creates a buffered channel for the given client ID. +func (m *Manager) Subscribe(clientID string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(clientID, ch) + return ch +} + +// Unsubscribe removes and closes the subscriber channel. +func (m *Manager) Unsubscribe(clientID string) { + if val, ok := m.subscribers.LoadAndDelete(clientID); ok { + // Serialize with broadcast so a concurrent send can't hit a closed channel. + m.sendMu.Lock() + close(val) + m.sendMu.Unlock() + } +} + +func (m *Manager) broadcast() { + if m.closed.Load() { + return + } + m.mu.RLock() + state := m.snapshot() + m.mu.RUnlock() + + m.sendMu.Lock() + defer m.sendMu.Unlock() + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +// Close stops discovery, drops any active connection, and closes all +// subscriber channels. +func (m *Manager) Close() { + m.closed.Store(true) + m.StopDiscovery() + m.Disconnect() + + m.sendMu.Lock() + defer m.sendMu.Unlock() + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} diff --git a/core/internal/server/chromecast/manager_test.go b/core/internal/server/chromecast/manager_test.go new file mode 100644 index 000000000..9fe538695 --- /dev/null +++ b/core/internal/server/chromecast/manager_test.go @@ -0,0 +1,756 @@ +package chromecast + +import ( + "context" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/grandcat/zeroconf" + "github.com/vishen/go-chromecast/cast" + castdns "github.com/vishen/go-chromecast/dns" +) + +// blockingCatCmd returns a `cat` command that genuinely blocks (its stdin is a +// pipe whose write end stays open until test cleanup), so the subprocess stays +// alive for the test instead of reading EOF from /dev/null and exiting at once. +// Stand-in for the cast helper / doubletake; no sleeps, no timing. +func blockingCatCmd(t *testing.T) *exec.Cmd { + t.Helper() + pr, pw, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + t.Cleanup(func() { + pw.Close() + pr.Close() + }) + c := exec.Command("cat") + c.Stdin = pr + return c +} + +// withDiscoverFunc swaps the package discovery function for the duration of a +// test and restores it afterwards. +func withDiscoverFunc(t *testing.T, fn func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error)) { + t.Helper() + prev := discoverFunc + discoverFunc = fn + t.Cleanup(func() { discoverFunc = prev }) +} + +// TestMain disables real AirPlay (zeroconf) discovery for all tests by default; +// airplay-specific tests override airplayDiscoverFunc. +func TestMain(m *testing.M) { + // Never touch the real system bus / mDNS in tests; drive discovery through + // the overridable fakes only. + avahiAvailableFunc = func() bool { return false } + airplayDiscoverFunc = func(ctx context.Context) (<-chan Device, error) { + ch := make(chan Device) + go func() { <-ctx.Done(); close(ch) }() + return ch, nil + } + discoverFunc = func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + ch := make(chan castdns.CastEntry) + go func() { <-ctx.Done(); close(ch) }() + return ch, nil + } + os.Exit(m.Run()) +} + +func withAirplayDiscoverFunc(t *testing.T, fn func(ctx context.Context) (<-chan Device, error)) { + t.Helper() + prev := airplayDiscoverFunc + airplayDiscoverFunc = fn + t.Cleanup(func() { airplayDiscoverFunc = prev }) +} + +// emitOnce returns a discovery func that emits the given devices then stays open +// until ctx is cancelled. +func emitOnceAirplay(devs ...Device) func(ctx context.Context) (<-chan Device, error) { + return func(ctx context.Context) (<-chan Device, error) { + out := make(chan Device, len(devs)) + for _, d := range devs { + out <- d + } + go func() { <-ctx.Done(); close(out) }() + return out, nil + } +} + +func emitOnceChromecast(entries ...castdns.CastEntry) func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + return func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry, len(entries)) + for _, e := range entries { + out <- e + } + go func() { <-ctx.Done(); close(out) }() + return out, nil + } +} + +func TestDiscoversBothProtocols(t *testing.T) { + withDiscoverFunc(t, emitOnceChromecast(castdns.CastEntry{ + UUID: "cc1", DeviceName: "Living Room", Device: "Chromecast", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5), + })) + withAirplayDiscoverFunc(t, emitOnceAirplay(Device{ + ID: "ap1", Name: "TV de la sala", Model: "Hisense", Host: "192.168.1.6", Port: 7000, Protocol: ProtocolAirplay, + })) + + m := NewManager() + defer m.Close() + sub := m.Subscribe("test") + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + + got := waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 }) + + byProto := map[string]Device{} + for _, d := range got.Devices { + byProto[d.Protocol] = d + } + if byProto[ProtocolChromecast].ID != "cc1" { + t.Errorf("missing/incorrect chromecast device: %+v", byProto) + } + if byProto[ProtocolAirplay].ID != "ap1" || byProto[ProtocolAirplay].Port != 7000 { + t.Errorf("missing/incorrect airplay device: %+v", byProto) + } +} + +func TestConnectAirplayStartsMirror(t *testing.T) { + prev := buildDoubletakeCmd + buildDoubletakeCmd = func(host string) *exec.Cmd { return blockingCatCmd(t) } + defer func() { buildDoubletakeCmd = prev }() + + withDiscoverFunc(t, emitOnceChromecast()) + withAirplayDiscoverFunc(t, emitOnceAirplay(Device{ + ID: "ap1", Name: "TV de la sala", Host: "192.168.4.33", Port: 7000, Protocol: ProtocolAirplay, + })) + + m := NewManager() + defer m.Close() + sub := m.Subscribe("test") + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + waitForState(t, sub, func(s State) bool { return len(s.Devices) == 1 }) + + if err := m.Connect("ap1"); err != nil { + t.Fatalf("Connect(airplay): %v", err) + } + got := waitForState(t, sub, func(s State) bool { return s.Connected && s.Screencasting }) + if got.ActiveDevice == nil || got.ActiveDevice.Protocol != ProtocolAirplay { + t.Fatalf("expected active airplay device, got %+v", got.ActiveDevice) + } + if !m.airplay.isRunning() { + t.Fatal("expected airplay mirror running") + } + + m.Disconnect() + got = waitForState(t, sub, func(s State) bool { return !s.Connected }) + if m.airplay.isRunning() { + t.Fatal("expected airplay mirror stopped") + } + if got.Screencasting { + t.Fatal("expected screencasting cleared on disconnect") + } +} + +func TestAirplayEntryNormalization(t *testing.T) { + dev := airplayEntryToDevice(&zeroconf.ServiceEntry{ + ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"}, + HostName: "LinuxTV.local.", + Port: 7000, + AddrIPv4: []net.IP{net.IPv4(192, 168, 4, 33)}, + Text: []string{"deviceid=AA:BB:CC:DD:EE:FF", "model=55A6QU"}, + }) + if dev.Protocol != ProtocolAirplay || dev.ID != "airplay:TV de la sala" || dev.Model != "55A6QU" || dev.Host != "192.168.4.33" { + t.Fatalf("unexpected normalization: %+v", dev) + } +} + +// The same device must keep the same ID across browses even when the deviceid +// TXT and/or the A record are dropped under mDNS port contention — the id is +// derived from the always-present instance name, not the flaky TXT or host:port. +func TestAirplayEntryStableIDWithoutTXT(t *testing.T) { + full := airplayEntryToDevice(&zeroconf.ServiceEntry{ + ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"}, + HostName: "LinuxTV.local.", + Port: 7000, + AddrIPv4: []net.IP{net.IPv4(192, 168, 4, 33)}, + Text: []string{"deviceid=AA:BB:CC:DD:EE:FF", "model=55A6QU"}, + }) + // A later browse that lost the TXT record and the resolved address. + partial := airplayEntryToDevice(&zeroconf.ServiceEntry{ + ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"}, + HostName: "LinuxTV.local.", + Port: 7000, + }) + if partial.ID != full.ID { + t.Fatalf("id flipped across browses: full=%q partial=%q", full.ID, partial.ID) + } +} + +func TestUnescapeDNS(t *testing.T) { + cases := map[string]string{ + `TV de la sala`: "TV de la sala", + `Geoffrey\226\128\153s\ MacBook`: "Geoffrey’s MacBook", + `Mac\ Pro\ \(2\)`: "Mac Pro (2)", + } + for in, want := range cases { + if got := unescapeDNS(in); got != want { + t.Errorf("unescapeDNS(%q) = %q, want %q", in, got, want) + } + } +} + +// withNewAppFunc swaps the cast-application constructor for the test duration. +func withNewAppFunc(t *testing.T, fn func() castApp) { + t.Helper() + prev := newAppFunc + newAppFunc = fn + t.Cleanup(func() { newAppFunc = prev }) +} + +// fakeApp is a test double for the go-chromecast Application. +type fakeApp struct { + mu sync.Mutex + startErr error + started bool + closed bool + app *cast.Application + media *cast.Media + vol *cast.Volume + + loadedURL string + loadedType string + unpaused bool + paused bool + mediaStopped bool + seekedTo float32 + volumeSet float32 + muteSet bool +} + +func (f *fakeApp) Start(addr string, port int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.started = true + return f.startErr +} + +func (f *fakeApp) Update() error { return nil } + +func (f *fakeApp) Status() (*cast.Application, *cast.Media, *cast.Volume) { + f.mu.Lock() + defer f.mu.Unlock() + return f.app, f.media, f.vol +} + +func (f *fakeApp) Load(url string, startTime int, contentType string, transcode, detach, forceDetach bool) error { + f.mu.Lock() + defer f.mu.Unlock() + f.loadedURL = url + f.loadedType = contentType + return nil +} + +func (f *fakeApp) Unpause() error { f.mu.Lock(); defer f.mu.Unlock(); f.unpaused = true; return nil } +func (f *fakeApp) Pause() error { f.mu.Lock(); defer f.mu.Unlock(); f.paused = true; return nil } +func (f *fakeApp) StopMedia() error { + f.mu.Lock() + defer f.mu.Unlock() + f.mediaStopped = true + return nil +} +func (f *fakeApp) SeekToTime(v float32) error { + f.mu.Lock() + defer f.mu.Unlock() + f.seekedTo = v + return nil +} +func (f *fakeApp) SetVolume(v float32) error { + f.mu.Lock() + defer f.mu.Unlock() + f.volumeSet = v + return nil +} +func (f *fakeApp) SetMuted(v bool) error { f.mu.Lock(); defer f.mu.Unlock(); f.muteSet = v; return nil } + +func (f *fakeApp) Close(stopMedia bool) error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil +} + +// discoverOneDevice installs a discovery func that emits a single device and +// then blocks until cancelled, and starts discovery on the manager. +func discoverOneDevice(t *testing.T, m *Manager, entry castdns.CastEntry) { + t.Helper() + emit := make(chan castdns.CastEntry, 1) + emit <- entry + withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case e := <-emit: + out <- e + } + } + }() + return out, nil + }) + sub := m.Subscribe("discover-helper") + defer m.Unsubscribe("discover-helper") + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + waitForState(t, sub, func(s State) bool { return len(s.Devices) == 1 }) +} + +// waitForState reads states from ch until pred is satisfied or it times out. +func waitForState(t *testing.T, ch chan State, pred func(State) bool) State { + t.Helper() + timeout := time.After(2 * time.Second) + for { + select { + case s := <-ch: + if pred(s) { + return s + } + case <-timeout: + t.Fatal("timed out waiting for expected state") + } + } +} + +func TestStartDiscoveryBroadcastsDevices(t *testing.T) { + entries := make(chan castdns.CastEntry, 2) + withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case e, ok := <-entries: + if !ok { + return + } + out <- e + } + } + }() + return out, nil + }) + + m := NewManager() + defer m.Close() + + sub := m.Subscribe("test") + + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + + // First broadcast is the empty in-progress state. + waitForState(t, sub, func(s State) bool { return s.Discovering && len(s.Devices) == 0 }) + + entries <- castdns.CastEntry{UUID: "uuid-b", DeviceName: "Bedroom", Device: "Chromecast", Port: 8009} + entries <- castdns.CastEntry{UUID: "uuid-a", DeviceName: "Attic", Device: "Google Nest", Port: 8009} + + got := waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 }) + + // snapshot must be sorted by Name for stable UI ordering. + if got.Devices[0].Name != "Attic" || got.Devices[1].Name != "Bedroom" { + t.Fatalf("devices not sorted by name: %+v", got.Devices) + } + if !got.Discovering { + t.Fatal("expected Discovering=true while scan is active") + } +} + +func TestStartDiscoveryIsIdempotent(t *testing.T) { + withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry) + go func() { + <-ctx.Done() + close(out) + }() + return out, nil + }) + + m := NewManager() + defer m.Close() + + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + if err := m.StartDiscovery(); err != nil { + t.Fatalf("second StartDiscovery should be a no-op, got: %v", err) + } + if !m.GetState().Discovering { + t.Fatal("expected Discovering=true") + } +} + +func TestStopDiscoveryEndsScan(t *testing.T) { + withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry) + go func() { + <-ctx.Done() + close(out) + }() + return out, nil + }) + + m := NewManager() + defer m.Close() + + sub := m.Subscribe("test") + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + waitForState(t, sub, func(s State) bool { return s.Discovering }) + + m.StopDiscovery() + waitForState(t, sub, func(s State) bool { return !s.Discovering }) +} + +// A device without a UUID must still be tracked (keyed by host:port) rather +// than dropped or collapsed with other UUID-less devices. +func TestDeviceWithoutUUIDIsKeyedByHostPort(t *testing.T) { + entries := make(chan castdns.CastEntry, 2) + withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) { + out := make(chan castdns.CastEntry) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case e, ok := <-entries: + if !ok { + return + } + out <- e + } + } + }() + return out, nil + }) + + m := NewManager() + defer m.Close() + sub := m.Subscribe("test") + if err := m.StartDiscovery(); err != nil { + t.Fatalf("StartDiscovery: %v", err) + } + + entries <- castdns.CastEntry{AddrV4: net.IPv4(192, 168, 1, 10), Port: 8009} + entries <- castdns.CastEntry{AddrV4: net.IPv4(192, 168, 1, 11), Port: 8009} + + waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 }) +} + +func TestConnectBroadcastsPlayback(t *testing.T) { + fake := &fakeApp{ + app: &cast.Application{DisplayName: "Default Media Receiver"}, + media: &cast.Media{PlayerState: "PLAYING", CurrentTime: 12, Media: cast.MediaItem{Duration: 200, Metadata: cast.MediaMetadata{Title: "Song"}}}, + vol: &cast.Volume{Level: 0.5, Muted: false}, + } + withNewAppFunc(t, func() castApp { return fake }) + + m := NewManager() + defer m.Close() + discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Living Room", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)}) + + sub := m.Subscribe("test") + if err := m.Connect("dev1"); err != nil { + t.Fatalf("Connect: %v", err) + } + + got := waitForState(t, sub, func(s State) bool { + return s.Connected && s.Playback != nil && s.Playback.State == "PLAYING" + }) + if !fake.started { + t.Fatal("expected Start to be called on the app") + } + if got.ActiveDevice == nil || got.ActiveDevice.ID != "dev1" { + t.Fatalf("expected active device dev1, got %+v", got.ActiveDevice) + } + if got.Playback.Title != "Song" || got.Playback.Duration != 200 || got.Playback.Volume != 0.5 { + t.Fatalf("unexpected playback: %+v", got.Playback) + } +} + +// connectFake wires a discovered device + fake app and connects to it, +// returning the manager and fake for assertions. +func connectFake(t *testing.T, fake *fakeApp) *Manager { + t.Helper() + withNewAppFunc(t, func() castApp { return fake }) + m := NewManager() + t.Cleanup(m.Close) + discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "TV", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)}) + if err := m.Connect("dev1"); err != nil { + t.Fatalf("Connect: %v", err) + } + return m +} + +func TestCastLoadsMedia(t *testing.T) { + fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}} + m := connectFake(t, fake) + + if err := m.Cast("http://example.com/v.mp4", "video/mp4"); err != nil { + t.Fatalf("Cast: %v", err) + } + if fake.loadedURL != "http://example.com/v.mp4" || fake.loadedType != "video/mp4" { + t.Fatalf("unexpected load: url=%q type=%q", fake.loadedURL, fake.loadedType) + } +} + +func TestTransportControlsCallThrough(t *testing.T) { + fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}} + m := connectFake(t, fake) + + if err := m.Play(); err != nil || !fake.unpaused { + t.Fatalf("Play: err=%v unpaused=%v", err, fake.unpaused) + } + if err := m.Pause(); err != nil || !fake.paused { + t.Fatalf("Pause: err=%v paused=%v", err, fake.paused) + } + if err := m.Seek(42.5); err != nil || fake.seekedTo != 42.5 { + t.Fatalf("Seek: err=%v seekedTo=%v", err, fake.seekedTo) + } + if err := m.SetVolume(0.25); err != nil || fake.volumeSet != 0.25 { + t.Fatalf("SetVolume: err=%v volumeSet=%v", err, fake.volumeSet) + } + if err := m.SetMuted(true); err != nil || !fake.muteSet { + t.Fatalf("SetMuted: err=%v muteSet=%v", err, fake.muteSet) + } + if err := m.StopPlayback(); err != nil || !fake.mediaStopped { + t.Fatalf("StopPlayback: err=%v mediaStopped=%v", err, fake.mediaStopped) + } +} + +func TestControlsErrorWhenNotConnected(t *testing.T) { + m := NewManager() + defer m.Close() + if err := m.Cast("http://x/y.mp4", ""); err != errNotConnected { + t.Fatalf("expected errNotConnected, got %v", err) + } + if err := m.Play(); err != errNotConnected { + t.Fatalf("expected errNotConnected from Play, got %v", err) + } +} + +// withStubCapture stubs the capture helper with `cat` (blocks on stdin, no +// timing), a fixed local IP, and a fake portal session so tests never touch the +// network, GStreamer, or the real screen-share dialog. +func withStubCapture(t *testing.T) { + t.Helper() + prevCmd := buildHelperCmd + prevIP := outboundIPFunc + prevPortal := requestScreencast + buildHelperCmd = func(outDir string, nodeID uint32) *exec.Cmd { return blockingCatCmd(t) } + outboundIPFunc = func(host string) (string, error) { return "127.0.0.1", nil } + requestScreencast = func(ctx context.Context) (*PortalSession, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + w.Close() + return &PortalSession{Fd: r, NodeID: 7}, nil + } + t.Cleanup(func() { + buildHelperCmd = prevCmd + outboundIPFunc = prevIP + requestScreencast = prevPortal + }) +} + +func TestCastScreenStartsCaptureAndCasts(t *testing.T) { + withStubCapture(t) + fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}} + m := connectFake(t, fake) + + sub := m.Subscribe("test") + if err := m.CastScreen(); err != nil { + t.Fatalf("CastScreen: %v", err) + } + + if !strings.HasSuffix(fake.loadedURL, "/"+hlsPlaylist) { + t.Fatalf("expected HLS playlist URL, got %q", fake.loadedURL) + } + if fake.loadedType != hlsContentType { + t.Fatalf("expected HLS content type, got %q", fake.loadedType) + } + waitForState(t, sub, func(s State) bool { return s.Screencasting }) + if !m.screen.isRunning() { + t.Fatal("expected screen streamer to be running") + } + + m.StopScreen() + waitForState(t, sub, func(s State) bool { return !s.Screencasting }) + if m.screen.isRunning() { + t.Fatal("expected screen streamer to be stopped") + } +} + +func TestCastScreenRequiresConnection(t *testing.T) { + withStubCapture(t) + m := NewManager() + defer m.Close() + if err := m.CastScreen(); err != errNotConnected { + t.Fatalf("expected errNotConnected, got %v", err) + } +} + +func TestScreenStreamerServesHLSDir(t *testing.T) { + withStubCapture(t) + + s := &screenStreamer{} + url, err := s.start("127.0.0.1", nil) + if err != nil { + t.Fatalf("start: %v", err) + } + defer s.stop() + + // Drop a file into the served directory and fetch it back. + if err := os.WriteFile(filepath.Join(s.dir, hlsPlaylist), []byte("#EXTM3U\n"), 0o644); err != nil { + t.Fatalf("write playlist: %v", err) + } + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "#EXTM3U") { + t.Fatalf("unexpected response: status=%d body=%q", resp.StatusCode, string(body)) + } +} + +// withTempConfig points config persistence at a temp file for the test. +func withTempConfig(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), "castsettings.json") + prev := configPathFunc + configPathFunc = func() (string, error) { return path, nil } + t.Cleanup(func() { configPathFunc = prev }) +} + +func TestSetPreferredPersistsAndReloads(t *testing.T) { + withTempConfig(t) + + m := NewManager() + m.SetPreferred("dev-42") + if m.GetState().PreferredID != "dev-42" { + t.Fatalf("expected preferred dev-42, got %q", m.GetState().PreferredID) + } + + // A fresh manager must load the persisted preference. + m2 := NewManager() + if m2.GetState().PreferredID != "dev-42" { + t.Fatalf("preference not reloaded, got %q", m2.GetState().PreferredID) + } + + m2.ClearPreferred() + m3 := NewManager() + if m3.GetState().PreferredID != "" { + t.Fatalf("expected cleared preference, got %q", m3.GetState().PreferredID) + } +} + +func TestAutoReconnectsToPreferredDevice(t *testing.T) { + withTempConfig(t) + fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}} + withNewAppFunc(t, func() castApp { return fake }) + + m := NewManager() + defer m.Close() + m.SetPreferred("dev1") + + sub := m.Subscribe("test") + // Discover the preferred device; the manager should auto-connect. + discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Living Room", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)}) + + got := waitForState(t, sub, func(s State) bool { return s.Connected }) + if got.ActiveDevice == nil || got.ActiveDevice.ID != "dev1" { + t.Fatalf("expected auto-connect to dev1, got %+v", got.ActiveDevice) + } + if !fake.started { + t.Fatal("expected Start to be called during auto-reconnect") + } +} + +func TestAutoConnectsToPreferredAirplayDevice(t *testing.T) { + withTempConfig(t) + prev := buildDoubletakeCmd + buildDoubletakeCmd = func(host string) *exec.Cmd { return blockingCatCmd(t) } + defer func() { buildDoubletakeCmd = prev }() + + m := NewManager() + defer m.Close() + m.SetPreferred("airplay:Living Room TV") + + sub := m.Subscribe("test") + // The preferred AirPlay device appears: auto-connect must start the mirror. + m.upsertDevice(Device{ID: "airplay:Living Room TV", Name: "Living Room TV", Host: "192.168.4.40", Port: 7000, Protocol: ProtocolAirplay}) + + got := waitForState(t, sub, func(s State) bool { return s.Connected && s.Screencasting }) + if got.ActiveDevice == nil || got.ActiveDevice.ID != "airplay:Living Room TV" { + t.Fatalf("expected auto-connect to preferred airplay device, got %+v", got.ActiveDevice) + } + if !m.airplay.isRunning() { + t.Fatal("expected airplay mirror running after auto-connect") + } +} + +func TestConnectUnknownDeviceErrors(t *testing.T) { + withNewAppFunc(t, func() castApp { return &fakeApp{} }) + m := NewManager() + defer m.Close() + if err := m.Connect("nope"); err == nil { + t.Fatal("expected error connecting to unknown device") + } +} + +func TestDisconnectClearsStateAndClosesApp(t *testing.T) { + fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}} + withNewAppFunc(t, func() castApp { return fake }) + + m := NewManager() + defer m.Close() + discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Kitchen", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 6)}) + + sub := m.Subscribe("test") + if err := m.Connect("dev1"); err != nil { + t.Fatalf("Connect: %v", err) + } + waitForState(t, sub, func(s State) bool { return s.Connected }) + + m.Disconnect() + got := waitForState(t, sub, func(s State) bool { return !s.Connected }) + if got.ActiveDevice != nil || got.Playback != nil { + t.Fatalf("expected cleared state, got %+v", got) + } + if !fake.closed { + t.Fatal("expected Close to be called on the app") + } +} diff --git a/core/internal/server/chromecast/screencast.go b/core/internal/server/chromecast/screencast.go new file mode 100644 index 000000000..fed541d7a --- /dev/null +++ b/core/internal/server/chromecast/screencast.go @@ -0,0 +1,239 @@ +package chromecast + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +// hlsPlaylist is the HLS playlist filename served to the Cast device. +const hlsPlaylist = "stream.m3u8" + +// hlsContentType is the MIME type for an HLS playlist. +const hlsContentType = "application/x-mpegURL" + +// helperPath resolves the dms-cast-helper binary (the go-gst capture program). +// Order: DMS_CAST_HELPER env, then next to the dms executable, then PATH. +func helperPath() string { + if p := os.Getenv("DMS_CAST_HELPER"); p != "" { + return p + } + if exe, err := os.Executable(); err == nil { + cand := filepath.Join(filepath.Dir(exe), "dms-cast-helper") + if _, err := os.Stat(cand); err == nil { + return cand + } + } + return "dms-cast-helper" +} + +// buildHelperCmd builds the go-gst capture helper invocation for HLS output. +// A package var so tests can stub it. +var buildHelperCmd = func(outDir string, nodeID uint32) *exec.Cmd { + return exec.Command(helperPath(), + "-mode", "hls", + "-out", outDir, + "-node", fmt.Sprintf("%d", nodeID), + ) +} + +// outboundIPFunc resolves the local interface address that targetHost would +// connect back to. Overridable in tests. +var outboundIPFunc = outboundIP + +func outboundIP(targetHost string) (string, error) { + // A UDP "connection" sends no packets; it just selects the route/interface + // the kernel would use to reach targetHost, exposing the local address the + // Cast device can reach us on. + conn, err := net.Dial("udp", net.JoinHostPort(targetHost, "8009")) + if err != nil { + return "", err + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil +} + +// screenStreamer captures the screen to HLS and serves it over HTTP for a Cast +// device to pull. This is the buffered-player "laggy mirror" path: the Cast +// media receiver pre-buffers segments, so expect multi-second latency. It is +// not real-time mirroring (that needs Google's closed protocol). +// portalTimeout bounds how long we wait for the user to approve the screen-share +// dialog before giving up. +const portalTimeout = 90 * time.Second + +type screenStreamer struct { + mu sync.Mutex + dir string + cmd *exec.Cmd + server *http.Server + portal *PortalSession + running bool +} + +// noListFS wraps an http.FileSystem to disable directory listings, so the HLS +// segment filenames can't be enumerated by an unauthenticated client. +type noListFS struct{ fs http.FileSystem } + +func (n noListFS) Open(name string) (http.File, error) { + f, err := n.fs.Open(name) + if err != nil { + return nil, err + } + return noListFile{f}, nil +} + +type noListFile struct{ http.File } + +func (noListFile) Readdir(int) ([]os.FileInfo, error) { return nil, nil } + +// randToken returns an unguessable URL path segment for the HLS server, so a +// LAN host can't reach the screen capture just by scanning the port. +func randToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// start negotiates the screen-share portal, runs the go-gst capture helper to +// produce HLS, serves it over HTTP, and returns the playlist URL reachable from +// reachableIP. The HTTP server is bound to reachableIP only, serves under an +// unguessable path token with directory listing off, and onExit fires if the +// capture helper exits on its own. The portal step pops the screen-share dialog. +func (s *screenStreamer) start(reachableIP string, onExit func()) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return "", fmt.Errorf("screencast already running") + } + + token, err := randToken() + if err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), portalTimeout) + defer cancel() + portal, err := requestScreencast(ctx) + if err != nil { + return "", fmt.Errorf("screencast portal: %w", err) + } + + dir, err := os.MkdirTemp("", "dms-cast-screen-") + if err != nil { + portal.Close() + return "", err + } + + // Bind to the single LAN-facing interface the cast device reaches us on, + // not 0.0.0.0, so the capture isn't offered on every interface. + listener, err := net.Listen("tcp", net.JoinHostPort(reachableIP, "0")) + if err != nil { + os.RemoveAll(dir) + portal.Close() + return "", err + } + port := listener.Addr().(*net.TCPAddr).Port + + // Serve under a random path token (so a port scan can't guess the URL) with + // directory listing disabled. Still unauthenticated, but unguessable. + prefix := "/" + token + "/" + mux := http.NewServeMux() + mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(noListFS{http.Dir(dir)}))) + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Warnf("[Cast] HLS server error: %v", err) + } + }() + + cmd := buildHelperCmd(dir, portal.NodeID) + cmd.ExtraFiles = []*os.File{portal.Fd} // PipeWire remote fd -> child fd 3 + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + srv.Close() + os.RemoveAll(dir) + portal.Close() + return "", fmt.Errorf("start capture helper: %w", err) + } + + s.dir = dir + s.cmd = cmd + s.server = srv + s.portal = portal + s.running = true + + // Supervise the helper: if it exits on its own (crash, portal revoked), + // tear down and notify so the manager clears screencasting instead of being + // stuck on a dead/zombie helper. Mirrors airplayMirror's exit watcher. + go func() { + _ = cmd.Wait() + s.mu.Lock() + unexpected := s.running && s.cmd == cmd + if unexpected { + s.teardownLocked() // process already reaped by Wait above + } + s.mu.Unlock() + if unexpected && onExit != nil { + onExit() + } + }() + + url := fmt.Sprintf("http://%s:%d%s%s", reachableIP, port, prefix, hlsPlaylist) + log.Infof("[Cast] Screencast HLS at %s (dir %s)", url, dir) + return url, nil +} + +// stop kills the capture helper and tears down the HTTP server, temp directory, +// and portal. +func (s *screenStreamer) stop() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running { + return + } + // Only Kill here; the supervisor goroutine started in start() is the sole + // caller of cmd.Wait() and will reap the process (calling Wait from two + // goroutines races). It sees running=false below and skips its own teardown. + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + s.teardownLocked() +} + +// teardownLocked releases the HTTP server, temp directory, and portal and +// resets state. The caller holds s.mu and must already have reaped the helper. +func (s *screenStreamer) teardownLocked() { + if s.server != nil { + _ = s.server.Close() + } + if s.portal != nil { + s.portal.Close() + } + if s.dir != "" { + _ = os.RemoveAll(s.dir) + } + s.cmd = nil + s.server = nil + s.portal = nil + s.dir = "" + s.running = false + log.Info("[Cast] Screencast stopped") +} + +func (s *screenStreamer) isRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} diff --git a/core/internal/server/chromecast/screencast_portal.go b/core/internal/server/chromecast/screencast_portal.go new file mode 100644 index 000000000..33cbfce45 --- /dev/null +++ b/core/internal/server/chromecast/screencast_portal.go @@ -0,0 +1,187 @@ +package chromecast + +import ( + "context" + "fmt" + "os" + "strings" + "sync/atomic" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +const ( + portalBus = "org.freedesktop.portal.Desktop" + portalObjPath = "/org/freedesktop/portal/desktop" + scIface = "org.freedesktop.portal.ScreenCast" + reqIface = "org.freedesktop.portal.Request" +) + +var portalTokenSeq atomic.Uint64 + +// PortalSession is an open xdg-desktop-portal ScreenCast session plus the +// PipeWire remote it produced. +type PortalSession struct { + conn *dbus.Conn + session dbus.ObjectPath + NodeID uint32 + Fd *os.File +} + +// Close releases the PipeWire fd, closes the portal session, and the bus conn. +func (p *PortalSession) Close() { + if p == nil { + return + } + if p.Fd != nil { + p.Fd.Close() + } + if p.conn != nil { + if p.session != "" { + p.conn.Object(portalBus, p.session).Call("org.freedesktop.portal.Session.Close", 0) + } + p.conn.Close() + } +} + +// requestScreencast negotiates a ScreenCast session via xdg-desktop-portal and +// returns the granted PipeWire node id + remote fd. It pops the system +// screen-share dialog. Pure Go (godbus) — no cgo, no CLI. +// +// Overridable in tests. +var requestScreencast = func(ctx context.Context) (*PortalSession, error) { + conn, err := dbus.ConnectSessionBus() + if err != nil { + return nil, fmt.Errorf("session bus: %w", err) + } + ps := &PortalSession{conn: conn} + + // SENDER token for request/session object paths: unique name minus the + // leading ':' with '.' -> '_'. + sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_") + portal := conn.Object(portalBus, portalObjPath) + + // 1) CreateSession + sessTok := nextToken("ses") + res, err := portalRequest(ctx, conn, sender, portal, scIface+".CreateSession", + map[string]dbus.Variant{ + "handle_token": dbus.MakeVariant(nextToken("req")), + "session_handle_token": dbus.MakeVariant(sessTok), + }) + if err != nil { + conn.Close() + return nil, fmt.Errorf("CreateSession: %w", err) + } + sh, ok := dbusutil.Get[string](res, "session_handle") + if !ok { + conn.Close() + return nil, fmt.Errorf("CreateSession: no session_handle") + } + ps.session = dbus.ObjectPath(sh) + + // 2) SelectSources: monitor, single, embedded cursor. + if _, err := portalRequest(ctx, conn, sender, portal, scIface+".SelectSources", + ps.session, + map[string]dbus.Variant{ + "handle_token": dbus.MakeVariant(nextToken("req")), + "types": dbus.MakeVariant(uint32(1)), // 1 = MONITOR + "multiple": dbus.MakeVariant(false), + "cursor_mode": dbus.MakeVariant(uint32(2)), // 2 = EMBEDDED + }); err != nil { + ps.Close() + return nil, fmt.Errorf("SelectSources: %w", err) + } + + // 3) Start (pops the dialog). + startRes, err := portalRequest(ctx, conn, sender, portal, scIface+".Start", + ps.session, "", + map[string]dbus.Variant{"handle_token": dbus.MakeVariant(nextToken("req"))}) + if err != nil { + ps.Close() + return nil, fmt.Errorf("Start: %w", err) + } + nodeID, err := firstStreamNode(startRes) + if err != nil { + ps.Close() + return nil, err + } + ps.NodeID = nodeID + + // 4) OpenPipeWireRemote — synchronous, returns the fd directly (not a Request). + var fd dbus.UnixFD + if err := portal.CallWithContext(ctx, scIface+".OpenPipeWireRemote", 0, + ps.session, map[string]dbus.Variant{}).Store(&fd); err != nil { + ps.Close() + return nil, fmt.Errorf("OpenPipeWireRemote: %w", err) + } + ps.Fd = os.NewFile(uintptr(fd), "pipewire-remote") + return ps, nil +} + +func nextToken(prefix string) string { + return fmt.Sprintf("dms_%s_%d", prefix, portalTokenSeq.Add(1)) +} + +// portalRequest invokes a portal method whose last argument is the options map +// (carrying handle_token) and which returns a Request object path, then waits +// for that Request's Response signal and returns its results. +func portalRequest(ctx context.Context, conn *dbus.Conn, sender string, portal dbus.BusObject, method string, args ...any) (map[string]dbus.Variant, error) { + // The handle_token is in the options map (last arg); derive the request path. + opts, _ := args[len(args)-1].(map[string]dbus.Variant) + token := opts["handle_token"].Value().(string) + reqPath := dbus.ObjectPath(fmt.Sprintf("%s/request/%s/%s", portalObjPath, sender, token)) + + // Subscribe BEFORE calling to avoid missing a fast response. + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(reqPath), + dbus.WithMatchInterface(reqIface), + dbus.WithMatchMember("Response"), + ); err != nil { + return nil, err + } + defer conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(reqPath), + dbus.WithMatchInterface(reqIface), + dbus.WithMatchMember("Response"), + ) + sigCh := make(chan *dbus.Signal, 4) + conn.Signal(sigCh) + defer conn.RemoveSignal(sigCh) + + var returned dbus.ObjectPath + if err := portal.CallWithContext(ctx, method, 0, args...).Store(&returned); err != nil { + return nil, err + } + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case sig := <-sigCh: + if sig.Path != reqPath || len(sig.Body) < 2 { + continue + } + code, _ := sig.Body[0].(uint32) + results, _ := sig.Body[1].(map[string]dbus.Variant) + if code != 0 { + return nil, fmt.Errorf("portal request rejected (response=%d)", code) + } + return results, nil + } + } +} + +// firstStreamNode extracts the PipeWire node id from a Start response. The +// "streams" result is a()(ua{sv}): array of (node_id uint32, props). +func firstStreamNode(res map[string]dbus.Variant) (uint32, error) { + streams, ok := dbusutil.Get[[][]any](res, "streams") + if !ok || len(streams) == 0 { + return 0, fmt.Errorf("Start: empty/invalid streams") + } + nodeID, ok := streams[0][0].(uint32) + if !ok { + return 0, fmt.Errorf("Start: stream node id not a uint32") + } + return nodeID, nil +} diff --git a/core/internal/server/chromecast/types.go b/core/internal/server/chromecast/types.go new file mode 100644 index 000000000..3b2ed698a --- /dev/null +++ b/core/internal/server/chromecast/types.go @@ -0,0 +1,42 @@ +package chromecast + +// Protocol identifies how a device is reached. +const ( + ProtocolChromecast = "chromecast" // Google Cast (_googlecast._tcp) + ProtocolAirplay = "airplay" // AirPlay 2 (_airplay._tcp) +) + +// Device is a Cast-compatible device discovered on the LAN (Chromecast or +// AirPlay). +type Device struct { + ID string `json:"id"` // stable identifier: device UUID/id, or host:port when none is advertised + Name string `json:"name"` // friendly name + Model string `json:"model"` // device model, e.g. "Chromecast Ultra" or "Hisense ..." + Host string `json:"host"` // IPv4 address used to connect + Port int `json:"port"` + Protocol string `json:"protocol"` // ProtocolChromecast | ProtocolAirplay +} + +// Playback describes what the connected device is currently playing. +type Playback struct { + State string `json:"state"` // PLAYING, PAUSED, BUFFERING, IDLE + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Artist string `json:"artist"` + AppName string `json:"appName"` // receiver app, e.g. "Default Media Receiver" + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + Volume float64 `json:"volume"` // 0.0–1.0 + Muted bool `json:"muted"` +} + +// State is the full chromecast service state pushed to subscribers. +type State struct { + Discovering bool `json:"discovering"` + Devices []Device `json:"devices"` + Connected bool `json:"connected"` + ActiveDevice *Device `json:"activeDevice,omitempty"` + Playback *Playback `json:"playback,omitempty"` + Screencasting bool `json:"screencasting"` + PreferredID string `json:"preferredId"` +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index df7fee2ef..6dbcdc878 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -8,6 +8,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/chromecast" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" @@ -124,6 +125,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "chromecast.") { + if chromecastManager == nil { + models.RespondError(conn, req.ID, "chromecast manager not initialized") + return + } + chromecast.HandleRequest(conn, req, chromecastManager) + return + } + if strings.HasPrefix(req.Method, "brightness.") { if brightnessManager == nil { models.RespondError(conn, req.ID, "brightness manager not initialized") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index c239f24be..f95ea9793 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/chromecast" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" @@ -66,6 +67,7 @@ var bluezManager *bluez.Manager var appPickerManager *apppicker.Manager var cupsManager *cups.Manager var tailscaleManager *tailscale.Manager +var chromecastManager *chromecast.Manager var brightnessManager *brightness.Manager var wlrOutputManager *wlroutput.Manager var evdevManager *evdev.Manager @@ -385,6 +387,12 @@ func InitializeSysUpdateManager() error { return nil } +func InitializeChromecastManager() error { + chromecastManager = chromecast.NewManager() + log.Info("Chromecast manager initialized") + return nil +} + func handleConnection(conn net.Conn) { defer conn.Close() @@ -443,6 +451,10 @@ func getCapabilities() Capabilities { caps = append(caps, "tailscale") } + if chromecastManager != nil { + caps = append(caps, "chromecast") + } + if brightnessManager != nil { caps = append(caps, "brightness") } @@ -509,6 +521,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "tailscale") } + if chromecastManager != nil { + caps = append(caps, "chromecast") + } + if brightnessManager != nil { caps = append(caps, "brightness") } @@ -1013,6 +1029,38 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("chromecast") && chromecastManager != nil { + wg.Add(1) + chromecastChan := chromecastManager.Subscribe(clientID + "-chromecast") + go func() { + defer wg.Done() + defer chromecastManager.Unsubscribe(clientID + "-chromecast") + + initialState := chromecastManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "chromecast", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-chromecastChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "chromecast", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + if shouldSubscribe("brightness") && brightnessManager != nil { wg.Add(2) brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state") @@ -1304,6 +1352,9 @@ func cleanupManagers() { if tailscaleManager != nil { tailscaleManager.Close() } + if chromecastManager != nil { + chromecastManager.Close() + } } func Start(printDocs bool) error { @@ -1616,6 +1667,13 @@ func Start(printDocs bool) error { } }() + if err := InitializeChromecastManager(); err != nil { + log.Warnf("Chromecast manager unavailable: %v", err) + } else { + notifyCapabilityChange() + chromecastManager.StartupReconnect() + } + if err := InitializeAppPickerManager(); err != nil { log.Debugf("AppPicker manager unavailable: %v", err) } From e0488b4d85fa42a02b99a8a0d9cb24cfe68761d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 24 Jun 2026 12:14:12 -0600 Subject: [PATCH 2/4] feat(ui): add cast Control Center widget and service ChromecastService.qml subscribes to the core cast manager and exposes discovery, connect/disconnect, transport controls, screen mirroring and preferred device, with the mDNS browse refcounted to the detail panel. A new Control Center widget (ChromecastWidget) and detail panel (ChromecastDetailContent) list both Chromecast and AirPlay devices, pin the connected device to the top of the list (no duplicate card), and offer stop mirroring plus an auto connect star per device. Registered in the widget model and Control Center components, and added to the default widget set. Co-Authored-By: Claude Opus 4.8 --- quickshell/Common/SettingsData.qml | 5 + .../BuiltinPlugins/ChromecastWidget.qml | 52 +++ .../ControlCenter/Components/DetailHost.qml | 6 + .../ControlCenter/Components/DragDropGrid.qml | 6 + .../ControlCenter/Models/WidgetModel.qml | 40 ++ .../Modules/ControlCenter/utils/widgets.js | 3 +- quickshell/Services/ChromecastService.qml | 271 +++++++++++++ quickshell/Services/DMSService.qml | 3 + .../Widgets/ChromecastDetailContent.qml | 373 ++++++++++++++++++ 9 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml create mode 100644 quickshell/Services/ChromecastService.qml create mode 100644 quickshell/Widgets/ChromecastDetailContent.qml diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 356ded1b6..ed78a4835 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -395,6 +395,11 @@ Singleton { "id": "darkMode", "enabled": true, "width": 50 + }, + { + "id": "builtin_chromecast", + "enabled": true, + "width": 50 } ] diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml new file mode 100644 index 000000000..cbff6d40e --- /dev/null +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml @@ -0,0 +1,52 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins + +PluginComponent { + id: root + + // Keeps the subscription alive while the widget exists so the collapsed tile + // can show connection/playback status. Discovery is driven separately by the + // detail panel (only while it is open). + Ref { + service: ChromecastService + } + + ccWidgetIcon: ChromecastService.connected ? "cast_connected" : "cast" + ccWidgetPrimaryText: I18n.tr("Cast", "Chromecast widget title") + ccWidgetSecondaryText: { + if (!ChromecastService.available) + return I18n.tr("Not available", "Chromecast service not available"); + if (ChromecastService.connected) { + const dev = ChromecastService.activeDevice; + const name = dev ? dev.name : I18n.tr("Connected", "Chromecast connected status"); + if (ChromecastService.screencasting) + return I18n.tr("Mirroring · %1", "Chromecast mirroring a screen to a device").arg(name); + const pb = ChromecastService.playback; + if (pb && pb.title) + return I18n.tr("%1 · %2", "Chromecast now-playing title on a device").arg(pb.title).arg(name); + return name; + } + if (ChromecastService.discovering) + return I18n.tr("Searching…", "Chromecast searching for devices"); + const count = ChromecastService.deviceCount; + if (count > 0) + return I18n.tr("%1 devices", "Number of Chromecast devices found").arg(count); + return I18n.tr("No devices", "No Chromecast devices found"); + } + ccWidgetIsActive: ChromecastService.connected + + // When connected, the tile toggle disconnects. When not connected, there is + // no single target, so clicking expands the detail to pick a device. + ccWidgetIsToggle: ChromecastService.connected + onCcWidgetToggled: { + if (ChromecastService.connected) + ChromecastService.disconnect(); + } + + ccDetailContent: Component { + ChromecastDetailContent {} + } +} diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml index 5013d110f..aa6a0750b 100644 --- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml +++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml @@ -114,6 +114,12 @@ Item { } builtinInstance = widgetModel.tailscaleBuiltinInstance; } + if (builtinId === "builtin_chromecast") { + if (widgetModel?.chromecastLoader) { + widgetModel.chromecastLoader.active = true; + } + builtinInstance = widgetModel.chromecastBuiltinInstance; + } if (builtinId === "builtin_display_profiles") { if (widgetModel?.displayProfilesLoader) { widgetModel.displayProfilesLoader.active = true; diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index 907f0f761..8262afd94 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -941,6 +941,12 @@ Column { } builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance); } + if (id === "builtin_chromecast") { + if (root.model?.chromecastLoader) { + root.model.chromecastLoader.active = true; + } + builtinInstance = Qt.binding(() => root.model?.chromecastBuiltinInstance); + } if (id === "builtin_display_profiles") { if (root.model?.displayProfilesLoader) { root.model.displayProfilesLoader.active = true; diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index c96939fb1..772dd767e 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -11,6 +11,7 @@ QtObject { property var vpnBuiltinInstance: null property var cupsBuiltinInstance: null property var tailscaleBuiltinInstance: null + property var chromecastBuiltinInstance: null property var displayProfilesBuiltinInstance: null property var vpnLoader: Loader { @@ -94,6 +95,35 @@ QtObject { } } + property var chromecastLoader: Loader { + active: false + sourceComponent: Component { + ChromecastWidget {} + } + + onItemChanged: { + root.chromecastBuiltinInstance = item; + } + + onActiveChanged: { + if (!active) { + root.chromecastBuiltinInstance = null; + } + } + + Connections { + target: SettingsData + function onControlCenterWidgetsChanged() { + const widgets = SettingsData.controlCenterWidgets || []; + const hasChromecastWidget = widgets.some(w => w.id === "builtin_chromecast"); + if (!hasChromecastWidget && chromecastLoader.active) { + root.log.debug("No Chromecast widget in control center, deactivating loader"); + chromecastLoader.active = false; + } + } + } + } + property var displayProfilesLoader: Loader { active: false sourceComponent: Component { @@ -272,6 +302,16 @@ QtObject { "warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined, "isBuiltinPlugin": true }, + { + "id": "builtin_chromecast", + "text": I18n.tr("Cast", "Chromecast widget title"), + "description": I18n.tr("Cast media and mirror your screen to Cast devices", "Chromecast control center widget description"), + "icon": "cast", + "type": "builtin_plugin", + "enabled": ChromecastService.available, + "warning": !ChromecastService.available ? I18n.tr("Casting not available", "Warning when Chromecast service is not running") : undefined, + "isBuiltinPlugin": true + }, { "id": "builtin_display_profiles", "text": I18n.tr("Display Profiles"), diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 4a6c578b9..38c01bcf3 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -81,7 +81,8 @@ function resetToDefault() { {"id": "audioOutput", "enabled": true, "width": 50}, {"id": "audioInput", "enabled": true, "width": 50}, {"id": "nightMode", "enabled": true, "width": 50}, - {"id": "darkMode", "enabled": true, "width": 50} + {"id": "darkMode", "enabled": true, "width": 50}, + {"id": "builtin_chromecast", "enabled": true, "width": 50} ] SettingsData.set("controlCenterWidgets", defaultWidgets) } diff --git a/quickshell/Services/ChromecastService.qml b/quickshell/Services/ChromecastService.qml new file mode 100644 index 000000000..baeca0aed --- /dev/null +++ b/quickshell/Services/ChromecastService.qml @@ -0,0 +1,271 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + readonly property var log: Log.scoped("ChromecastService") + + // refCount tracks interest in the service's *state* (e.g. a collapsed widget + // showing connection status) and drives the subscription. discoveryRefCount + // tracks interest in the *device list* and additionally drives the mDNS + // browse, which is more expensive — so it only runs while a detail UI is open. + property int refCount: 0 + property int discoveryRefCount: 0 + + property bool available: false + property bool stateInitialized: false + + property bool discovering: false + property var devices: [] + + // Connection / playback state, mirrored from the core service. + property bool connected: false + property var activeDevice: null + property var playback: null + property bool screencasting: false + property string preferredId: "" + + readonly property int deviceCount: devices.length + readonly property bool isPlaying: playback && playback.state === "PLAYING" + + readonly property string socketPath: Quickshell.env("DMS_SOCKET") + + readonly property bool wantSubscription: refCount > 0 || discoveryRefCount > 0 + + onRefCountChanged: updateSubscription() + + onDiscoveryRefCountChanged: { + updateSubscription(); + if (discoveryRefCount > 0) + startDiscovery(); + else if (discoveryRefCount === 0) + stopDiscovery(); + } + + function updateSubscription() { + if (wantSubscription) { + ensureSubscription(); + } else if (DMSService.activeSubscriptions.includes("chromecast")) { + DMSService.removeSubscription("chromecast"); + } + } + + function ensureSubscription() { + if (!wantSubscription) + return; + if (!DMSService.isConnected) + return; + if (DMSService.activeSubscriptions.includes("chromecast")) + return; + if (DMSService.activeSubscriptions.includes("all")) + return; + DMSService.addSubscription("chromecast"); + if (available) + getState(); + } + + Component.onCompleted: { + if (socketPath && socketPath.length > 0) + checkDMSCapabilities(); + } + + Connections { + target: DMSService + + function onConnectionStateChanged() { + if (DMSService.isConnected) { + checkDMSCapabilities(); + ensureSubscription(); + if (root.discoveryRefCount > 0) + root.startDiscovery(); + } + } + } + + Connections { + target: DMSService + enabled: DMSService.isConnected + + function onChromecastStateUpdate(data) { + root.log.debug("Subscription update received"); + root.updateState(data); + } + + function onCapabilitiesReceived() { + root.checkDMSCapabilities(); + } + } + + function checkDMSCapabilities() { + if (!DMSService.isConnected) + return; + if (DMSService.capabilities.length === 0) + return; + const wasAvailable = available; + available = DMSService.capabilities.includes("chromecast"); + + if (!available) + return; + if (!stateInitialized) { + stateInitialized = true; + getState(); + } + if (!wasAvailable) + ensureSubscription(); + } + + function getState() { + if (!available) + return; + DMSService.sendRequest("chromecast.getState", null, response => { + if (response.result) + updateState(response.result); + }); + } + + function devicesEqual(a, b) { + if (a.length !== b.length) + return false; + for (var i = 0; i < a.length; i++) { + const x = a[i], y = b[i]; + if (x.id !== y.id || x.name !== y.name || x.model !== y.model || x.host !== y.host || x.port !== y.port || x.protocol !== y.protocol) + return false; + } + return true; + } + + function updateState(data) { + if (!data) + return; + discovering = data.discovering || false; + // The core pushes the full state on every playback tick; only reassign + // the devices array (which re-evaluates list bindings and rebuilds + // delegates) when it actually changed. + const newDevices = data.devices || []; + if (!devicesEqual(devices, newDevices)) + devices = newDevices; + connected = data.connected || false; + activeDevice = data.activeDevice || null; + playback = data.playback || null; + screencasting = data.screencasting || false; + preferredId = data.preferredId || ""; + } + + // sendAction issues a state-changing request; the core refreshes and + // broadcasts on success, so subscribers update without an extra getState. + function sendAction(method, params) { + if (!available) + return; + DMSService.sendRequest(method, params, response => { + if (response.error) { + root.log.warn(method + " failed: " + response.error); + ToastService.showError(I18n.tr("Cast action failed", "Toast shown when a Cast control action is rejected"), response.error); + } + }); + } + + // cast loads a media URL or local file path on the connected device. + function cast(url, contentType) { + if (!url) + return; + sendAction("chromecast.cast", { + "url": url, + "contentType": contentType || "" + }); + } + + function play() { + sendAction("chromecast.play", null); + } + + function pause() { + sendAction("chromecast.pause", null); + } + + function stop() { + sendAction("chromecast.stop", null); + } + + function seek(position) { + sendAction("chromecast.seek", { + "position": position + }); + } + + function setVolume(level) { + sendAction("chromecast.setVolume", { + "level": level + }); + } + + function setMuted(muted) { + sendAction("chromecast.setMuted", { + "muted": muted + }); + } + + // castScreen mirrors the local screen to the connected device (buffered HLS + // path — expect multi-second latency, not real-time mirroring). + function castScreen() { + sendAction("chromecast.castScreen", null); + } + + function stopScreen() { + sendAction("chromecast.stopScreen", null); + } + + // setPreferred marks a device as the auto-reconnect target. Passing the + // already-preferred id (or empty) clears the preference. + function setPreferred(id) { + if (!id || id === preferredId) + sendAction("chromecast.clearPreferred", null); + else + sendAction("chromecast.setPreferred", { + "id": id + }); + } + + function connect(id) { + if (!available || !id) + return; + DMSService.sendRequest("chromecast.connect", { + "id": id + }, response => { + if (response.error) { + root.log.warn("connect failed: " + response.error); + ToastService.showError(I18n.tr("Cast failed", "Toast shown when connecting to a Cast device fails"), response.error); + } + }); + } + + function disconnect() { + if (!available) + return; + DMSService.sendRequest("chromecast.disconnect", null, response => { + if (response.error) + root.log.warn("disconnect failed: " + response.error); + }); + } + + function startDiscovery() { + if (!available) + return; + DMSService.sendRequest("chromecast.startDiscovery", null, response => { + if (response.error) + root.log.warn("startDiscovery failed: " + response.error); + }); + } + + function stopDiscovery() { + if (!available) + return; + DMSService.sendRequest("chromecast.stopDiscovery", null, response => { + if (response.error) + root.log.warn("stopDiscovery failed: " + response.error); + }); + } +} diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 9a75b34ff..982decafa 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -63,6 +63,7 @@ Singleton { signal locationStateUpdate(var data) signal sysupdateStateUpdate(var data) signal tailscaleStateUpdate(var data) + signal chromecastStateUpdate(var data) property bool capsLockState: false property bool screensaverInhibited: false @@ -397,6 +398,8 @@ Singleton { sysupdateStateUpdate(data); } else if (service === "tailscale") { tailscaleStateUpdate(data); + } else if (service === "chromecast") { + chromecastStateUpdate(data); } } diff --git a/quickshell/Widgets/ChromecastDetailContent.qml b/quickshell/Widgets/ChromecastDetailContent.qml new file mode 100644 index 000000000..18d83dae5 --- /dev/null +++ b/quickshell/Widgets/ChromecastDetailContent.qml @@ -0,0 +1,373 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + implicitHeight: contentColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.nestedSurface + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + + // While this detail is shown, keep an mDNS browse running. Released on close. + Component.onCompleted: ChromecastService.discoveryRefCount++ + Component.onDestruction: ChromecastService.discoveryRefCount-- + + function formatTime(seconds) { + if (!seconds || seconds < 0) + return "0:00"; + const s = Math.floor(seconds); + const m = Math.floor(s / 60); + const sec = s % 60; + return m + ":" + (sec < 10 ? "0" + sec : sec); + } + + // Devices with the connected one pinned to the top (and injected if a scan + // dropped it), so the active device shows once — at the top of the list — + // instead of in a separate card above it. + readonly property var sortedDevices: { + // The core already returns devices in a stable order, so we only need to + // pull the connected one to the top (injecting it if a scan dropped it). + const devs = ChromecastService.devices ? ChromecastService.devices.slice() : []; + const active = ChromecastService.connected ? ChromecastService.activeDevice : null; + const activeId = active ? active.id : ""; + if (active && activeId) { + const i = devs.findIndex(d => d.id === activeId); + if (i >= 0) + devs.splice(i, 1); + devs.unshift(active); + } + return devs; + } + + Column { + id: contentColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + // Not-available state + Item { + width: parent.width + height: 80 + visible: !ChromecastService.available + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + DankIcon { + name: "cast" + size: 36 + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("Casting not available", "Chromecast service unavailable message") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + + // ---- Playback controls for the active Chromecast ---- + // AirPlay has no media controls (connecting just mirrors), so this card + // is Chromecast-only; the connected device itself is shown at the top of + // the device list below rather than duplicated here. + Rectangle { + width: parent.width + visible: ChromecastService.available && ChromecastService.connected && ChromecastService.activeDevice && ChromecastService.activeDevice.protocol === "chromecast" + implicitHeight: nowPlayingColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + + Column { + id: nowPlayingColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + // Progress bar (only when media with a known duration is playing) + RowLayout { + width: parent.width + spacing: Theme.spacingS + visible: !ChromecastService.screencasting && ChromecastService.playback && ChromecastService.playback.duration > 0 + + StyledText { + text: root.formatTime(ChromecastService.playback ? ChromecastService.playback.currentTime : 0) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + Layout.alignment: Qt.AlignVCenter + } + + DankSlider { + id: seekSlider + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + minimum: 0 + maximum: Math.max(1, Math.round(ChromecastService.playback ? ChromecastService.playback.duration : 1)) + value: Math.round(ChromecastService.playback ? ChromecastService.playback.currentTime : 0) + unit: "" + showValue: false + onSliderDragFinished: finalValue => ChromecastService.seek(finalValue) + } + + StyledText { + text: root.formatTime(ChromecastService.playback ? ChromecastService.playback.duration : 0) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + Layout.alignment: Qt.AlignVCenter + } + } + + // Transport controls + RowLayout { + width: parent.width + spacing: Theme.spacingS + visible: !ChromecastService.screencasting + + DankActionButton { + iconName: ChromecastService.isPlaying ? "pause" : "play_arrow" + buttonSize: 32 + iconSize: 20 + iconColor: Theme.primary + tooltipText: ChromecastService.isPlaying ? I18n.tr("Pause") : I18n.tr("Play") + onClicked: { + if (ChromecastService.isPlaying) + ChromecastService.pause(); + else + ChromecastService.play(); + } + } + + DankActionButton { + iconName: "stop" + buttonSize: 32 + iconSize: 20 + iconColor: Theme.surfaceVariantText + tooltipText: I18n.tr("Stop") + onClicked: ChromecastService.stop() + } + + DankActionButton { + readonly property bool muted: ChromecastService.playback && ChromecastService.playback.muted + iconName: muted ? "volume_off" : "volume_up" + buttonSize: 32 + iconSize: 20 + iconColor: Theme.surfaceVariantText + tooltipText: muted ? I18n.tr("Unmute") : I18n.tr("Mute") + onClicked: ChromecastService.setMuted(!muted) + } + + DankSlider { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + leftIcon: "volume_down" + minimum: 0 + maximum: 100 + value: Math.round((ChromecastService.playback ? ChromecastService.playback.volume : 0) * 100) + onSliderDragFinished: finalValue => ChromecastService.setVolume(finalValue / 100) + } + } + + // Screen-mirror toggle — Chromecast only. For AirPlay, connecting + // already mirrors the screen, so the Disconnect button stops it. + DankToggle { + width: parent.width + text: I18n.tr("Mirror screen", "Chromecast screen mirroring toggle") + description: I18n.tr("Experimental — expect a few seconds of lag", "Chromecast screen mirroring latency warning") + checked: ChromecastService.screencasting + onToggled: value => { + if (value) + ChromecastService.castScreen(); + else + ChromecastService.stopScreen(); + } + } + } + } + + // ---- Devices header ---- + RowLayout { + width: parent.width + visible: ChromecastService.available + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Devices", "Chromecast devices list header") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + Layout.fillWidth: true + } + + DankIcon { + name: "sync" + size: 14 + color: Theme.surfaceVariantText + visible: ChromecastService.discovering + Layout.alignment: Qt.AlignVCenter + + RotationAnimation on rotation { + running: ChromecastService.discovering + from: 0 + to: 360 + duration: 1200 + loops: Animation.Infinite + } + } + } + + // ---- Device list ---- + DankFlickable { + width: parent.width + height: 160 + visible: ChromecastService.available + contentHeight: deviceColumn.implicitHeight + clip: true + + Column { + id: deviceColumn + width: parent.width + spacing: Theme.spacingXS + + // Empty state + Item { + width: parent.width + height: 60 + visible: root.sortedDevices.length === 0 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: "tv" + size: 28 + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: ChromecastService.discovering ? I18n.tr("Searching for devices…", "Chromecast discovery in progress") : I18n.tr("No devices found", "No Chromecast devices on the network") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + + Repeater { + model: root.sortedDevices + + delegate: Rectangle { + id: deviceCard + required property var modelData + + readonly property bool isActive: ChromecastService.connected && ChromecastService.activeDevice && ChromecastService.activeDevice.id === modelData.id + + width: deviceColumn.width + height: deviceRow.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: isActive ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (deviceArea.containsMouse ? Theme.surfaceContainerHigh : Theme.surfaceContainerHighest) + + RowLayout { + id: deviceRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + readonly property bool isAirplay: deviceCard.modelData.protocol === "airplay" + name: isAirplay ? (deviceCard.isActive ? "connected_tv" : "tv") : (deviceCard.isActive ? "cast_connected" : "cast") + size: 18 + color: deviceCard.isActive ? Theme.primary : Theme.surfaceVariantText + Layout.alignment: Qt.AlignVCenter + } + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 1 + + StyledText { + text: deviceCard.modelData.name || deviceCard.modelData.id + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + elide: Text.ElideRight + } + + StyledText { + // Protocol label (+ model), so Chromecast vs AirPlay is clear. + text: { + const proto = deviceCard.modelData.protocol === "airplay" ? I18n.tr("AirPlay", "AirPlay protocol label") : I18n.tr("Chromecast", "Chromecast protocol label"); + const model = deviceCard.modelData.model || ""; + return model.length > 0 ? (proto + " · " + model) : proto; + } + font.pixelSize: 10 + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + } + } + + DankActionButton { + readonly property bool isPreferred: ChromecastService.preferredId === deviceCard.modelData.id + iconName: isPreferred ? "star" : "star_outline" + buttonSize: 28 + iconSize: 16 + iconColor: isPreferred ? Theme.primary : Theme.surfaceVariantText + tooltipText: isPreferred ? I18n.tr("Auto-connects — click to unset", "Chromecast preferred device hint") : I18n.tr("Auto-connect to this device", "Chromecast set preferred device") + Layout.alignment: Qt.AlignVCenter + onClicked: ChromecastService.setPreferred(deviceCard.modelData.id) + } + + DankActionButton { + iconName: deviceCard.isActive ? (ChromecastService.screencasting ? "stop_screen_share" : "cancel_presentation") : "cast" + buttonSize: 28 + iconSize: 16 + iconColor: deviceCard.isActive ? Theme.surfaceText : Theme.primary + tooltipText: deviceCard.isActive ? I18n.tr("Disconnect") : I18n.tr("Cast to this device") + Layout.alignment: Qt.AlignVCenter + onClicked: { + if (deviceCard.isActive) + ChromecastService.disconnect(); + else + ChromecastService.connect(deviceCard.modelData.id); + } + } + } + + MouseArea { + id: deviceArea + z: -1 + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!deviceCard.isActive) + ChromecastService.connect(deviceCard.modelData.id); + } + } + } + } + } + } + } +} From 2acde751c19ea8890b8f70fd11bf6f6c743e49a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 24 Jun 2026 12:14:32 -0600 Subject: [PATCH 3/4] docs: document the casting feature Covers Chromecast and AirPlay discovery (avahi preferred, with a self healing zeroconf fallback), building dms-cast-helper, the external doubletake dependency for AirPlay, the firewall port range, and the mDNS port 5353 contention gotcha (notably Chrome). Co-Authored-By: Claude Opus 4.8 --- docs/casting.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/casting.md diff --git a/docs/casting.md b/docs/casting.md new file mode 100644 index 000000000..b45d204a9 --- /dev/null +++ b/docs/casting.md @@ -0,0 +1,86 @@ +# Casting (Chromecast + AirPlay) + +The cast feature discovers both **Chromecast** (`_googlecast._tcp`) and **AirPlay 2** +(`_airplay._tcp`) devices and shows them in one Control Center widget. + +- **Chromecast**: media casting + screen mirroring (HLS) handled in-process by the + `dms` core (Go Cast protocol). +- **AirPlay 2**: screen mirroring via **doubletake** (a separate process). + +Screen capture/encode is done by **`dms-cast-helper`**, a small program that uses +the GStreamer **library** (go-gst) — not the `gst-launch`/`ffmpeg`/`wf-recorder` +CLIs. The capture pipeline imports the portal's DMA-BUF via VA-API, forces 4:2:0 +(`I420`) chroma (4:4:4 is rejected by most TV decoders → black screen), and +re-stamps timestamps (the portal delivers `pts=0`). + +## Build + +The main `dms` binary stays CGO-free. The cast helper is opt-in and needs CGO + +GStreamer development packages: + +```sh +make build # dms (no extra deps) +make cast-helper # dms-cast-helper (needs gstreamer-1.0 + gst-plugins-base dev, pkg-config, glib dev) +sudo make install install-cast-helper +``` + +Runtime needs GStreamer plugins (base/good/bad/ugly + pipewire) and, for hardware +DMA-BUF import on Wayland, a VA-API driver. + +## AirPlay: external doubletake dependency + +AirPlay mirroring shells out to **doubletake** (GPLv3) — a separate binary, never +linked into the MIT `dms`. We use the fork +[`domenkozar/doubletake` (`go-gst-capture`)](https://github.com/domenkozar/doubletake/tree/go-gst-capture), +which captures via the GStreamer library (no `gst-launch`) and fixes the DMA-BUF +import + 4:2:0 chroma issues (pending upstream in omarroth/doubletake). + +Build it (needs CGO + GStreamer dev) and put it on `PATH`, or point `DMS_DOUBLETAKE` +at the binary: + +```sh +git clone -b go-gst-capture https://github.com/domenkozar/doubletake +cd doubletake && CGO_ENABLED=1 go build -o doubletake ./cmd/doubletake +``` + +Without doubletake, AirPlay devices still appear in discovery but connecting reports +that doubletake is required. + +## Discovery + +Both protocols are found over mDNS. When **avahi-daemon** is running, `dms` +browses through it (Avahi D-Bus API) and does **not** open its own port-5353 +socket — this avoids contending with avahi for the port. Where avahi is absent, +`dms` falls back to a built-in browser (go-chromecast + zeroconf) that +**re-browses periodically** so a resolver that missed responses recovers on the +next cycle. + +AirPlay devices are keyed by their mDNS **instance name** (stable and always +present), not the `deviceid` TXT record — the TXT is frequently dropped under +port contention, which would otherwise make the same device flip identity +between scans and break the favorite/auto-reconnect match. + +> **Note:** multiple processes sharing UDP port 5353 degrades mDNS for everyone. +> Google Chrome in particular keeps a `224.0.0.251:5353` socket open for its own +> Cast discovery and can intermittently swallow resolve responses (so does any +> second mDNS stack). If discovery shows a device but never resolves its +> address, that contention is the usual cause. Preferring avahi and retrying +> resolves mitigates it, but a misbehaving co-resident mDNS listener can still +> cause flakiness. + +## Firewall + +AirPlay receivers connect *back* to the sender on negotiated ports. `dms` confines +them to a fixed range (default `60000-60010`, override `DMS_CAST_PORT_RANGE`); open +that range inbound (UDP+TCP). On NixOS: + +```nix +networking.firewall.allowedUDPPortRanges = [ { from = 60000; to = 60010; } ]; +networking.firewall.allowedTCPPortRanges = [ { from = 60000; to = 60010; } ]; +``` + +## Environment overrides + +- `DMS_CAST_HELPER` — path to `dms-cast-helper` (default: next to `dms`, then PATH). +- `DMS_DOUBLETAKE` — path to `doubletake` (default: PATH). +- `DMS_CAST_PORT_RANGE` — AirPlay back-channel port range (default `60000-60010`). From 574f994430d8061bbbacc612f6ba40a3d2a4349e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Sat, 27 Jun 2026 14:46:47 -0600 Subject: [PATCH 4/4] docs: doubletake now builds CGO-free via gst-launch The doubletake fork was reworked to drop the go-gst (CGO) capture path and drive gst-launch-1.0 instead, fixing the black-screen + pts-timing issues on that path. Update casting docs (build no longer needs CGO or GStreamer dev packages) and the airplay_mirror comment to match. Co-Authored-By: Claude Opus 4.8 --- core/internal/server/chromecast/airplay_mirror.go | 2 +- docs/casting.md | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/core/internal/server/chromecast/airplay_mirror.go b/core/internal/server/chromecast/airplay_mirror.go index 030eb5299..d06676219 100644 --- a/core/internal/server/chromecast/airplay_mirror.go +++ b/core/internal/server/chromecast/airplay_mirror.go @@ -31,7 +31,7 @@ func portRange() string { } // buildDoubletakeCmd builds the doubletake mirror invocation for host. -// doubletake captures via the go-gst library itself (see the fork at +// doubletake drives gst-launch-1.0 for capture itself (see the fork at // github.com/domenkozar/doubletake), so no capture hook is needed here. // Overridable in tests. var buildDoubletakeCmd = func(host string) *exec.Cmd { diff --git a/docs/casting.md b/docs/casting.md index b45d204a9..47ba4198d 100644 --- a/docs/casting.md +++ b/docs/casting.md @@ -32,15 +32,16 @@ DMA-BUF import on Wayland, a VA-API driver. AirPlay mirroring shells out to **doubletake** (GPLv3) — a separate binary, never linked into the MIT `dms`. We use the fork [`domenkozar/doubletake` (`go-gst-capture`)](https://github.com/domenkozar/doubletake/tree/go-gst-capture), -which captures via the GStreamer library (no `gst-launch`) and fixes the DMA-BUF -import + 4:2:0 chroma issues (pending upstream in omarroth/doubletake). +which drives `gst-launch-1.0` for capture and fixes the black-screen + pts-timing +issues there (DMA-BUF import + 4:2:0 chroma; pending upstream in omarroth/doubletake). -Build it (needs CGO + GStreamer dev) and put it on `PATH`, or point `DMS_DOUBLETAKE` -at the binary: +doubletake builds **CGO-free** — no GStreamer dev packages needed; it only needs the +`gst-launch-1.0` CLI and plugins at runtime. Build it and put `bin/doubletake` on +`PATH`, or point `DMS_DOUBLETAKE` at it: ```sh git clone -b go-gst-capture https://github.com/domenkozar/doubletake -cd doubletake && CGO_ENABLED=1 go build -o doubletake ./cmd/doubletake +cd doubletake && make # builds bin/doubletake (CGO-free) ``` Without doubletake, AirPlay devices still appear in discovery but connecting reports