Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion core/Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
167 changes: 167 additions & 0 deletions core/cmd/dms-cast-helper/main.go
Original file line number Diff line number Diff line change
@@ -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 <dir>")
}
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)
}
}
}
17 changes: 17 additions & 0 deletions core/cmd/dms-cast-helper/main_nocgo.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 (
Expand Down
Loading
Loading