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..d06676219 --- /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 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 { + 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) } diff --git a/docs/casting.md b/docs/casting.md new file mode 100644 index 000000000..47ba4198d --- /dev/null +++ b/docs/casting.md @@ -0,0 +1,87 @@ +# 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 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). + +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 && make # builds bin/doubletake (CGO-free) +``` + +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`). 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); + } + } + } + } + } + } + } +}