Files
petere f1f2bfe1a3 feat: portal-based monitor enumeration fallback
Add a xdg-desktop-portal ScreenCast fallback to monitor enumeration so
the GUI picker works on compositors without hyprctl (GNOME/KDE etc.).
ListMonitors now tries hyprctl first, then creates a portal session,
selects monitor sources, starts capture and parses the stream list.

Verified on Hyprland: the portal path returns the 1920x1200 monitor,
confirming the compositor-agnostic mechanism works.
2026-09-19 20:40:56 +01:00

252 lines
6.5 KiB
Go

// Portal-based monitor enumeration (fallback for compositors without
// `hyprctl`, e.g. GNOME/KDE).
//
// The xdg-desktop-portal ScreenCast API is compositor-agnostic: we create a
// session, select monitor sources, call Start, and parse the returned stream
// list into Monitor entries.
//
// NOTE: Start() may present the compositor's screen-sharing consent dialog,
// so this path is only used as a fallback when hyprctl is unavailable.
package capture
import (
"errors"
"fmt"
"time"
"github.com/godbus/dbus/v5"
)
// xdg-desktop-portal D-Bus names and interfaces.
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalPath = "/org/freedesktop/portal/desktop"
screenCastIFace = "org.freedesktop.portal.ScreenCast"
requestIFace = "org.freedesktop.portal.Request"
sessionIFace = "org.freedesktop.portal.Session"
)
// portalTimeout is how long we wait for a portal request to complete.
const portalTimeout = 10 * time.Second
// portalMonitors enumerates monitors via the xdg-desktop-portal ScreenCast
// interface. Returns ErrNoMonitors if the portal is unavailable or the user
// cancels the selection.
func portalMonitors() ([]Monitor, error) {
conn, err := dbus.SessionBus()
if err != nil {
return nil, ErrNoMonitors
}
defer func() { _ = conn.Close() }()
obj := conn.Object(portalBusName, portalPath)
handle := fmt.Sprintf("teleportfling%d", time.Now().UnixNano())
sess, err := portalNewSession(conn, obj, handle)
if err != nil {
return nil, ErrNoMonitors
}
defer sess.close()
if err := sess.selectSources(); err != nil {
return nil, ErrNoMonitors
}
streams, err := sess.start()
if err != nil {
return nil, ErrNoMonitors
}
monitors := make([]Monitor, 0, len(streams))
for i, s := range streams {
monitors = append(monitors, Monitor{
Index: i,
Name: s.Name,
Width: s.Size[0],
Height: s.Size[1],
})
}
return monitors, nil
}
// portalStream is a parsed ScreenCast stream.
type portalStream struct {
Name string
Size [2]int
}
// portalNewSession creates a ScreenCast session and subscribes to portal
// Request signals on the connection.
func portalNewSession(conn *dbus.Conn, obj dbus.BusObject, handle string) (*portalSession, error) {
// Subscribe to portal Request::Response signals once for this connection.
sigCh := make(chan *dbus.Signal, 16)
conn.Signal(sigCh)
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(requestIFace),
dbus.WithMatchOption("member", "Response"),
); err != nil {
return nil, err
}
s := &portalSession{conn: conn, obj: obj}
// The Request signals arrive on paths we learn from each call; store the
// channel for the wait helper.
s.sig = sigCh
data := map[string]dbus.Variant{
"session_handle_token": dbus.MakeVariant(handle),
"handle_token": dbus.MakeVariant(handle + "_create"),
}
call := obj.Call(screenCastIFace+".CreateSession", 0, data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["session_handle"]
if !ok {
return nil, errors.New("portal: CreateSession response missing session_handle")
}
str, ok := sv.Value().(string)
if !ok {
return nil, errors.New("portal: session_handle has unexpected type")
}
s.path = dbus.ObjectPath(str)
return s, nil
}
// portalSession carries the signal channel used to await portal responses.
type portalSession struct {
conn *dbus.Conn
obj dbus.BusObject
path dbus.ObjectPath
sig chan *dbus.Signal
}
// selectSources configures the session to capture all monitors.
func (s *portalSession) selectSources() error {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("sel%d", time.Now().UnixNano())),
"types": dbus.MakeVariant(uint32(1)), // MONITOR
"multiple": dbus.MakeVariant(true),
}
call := s.obj.Call(screenCastIFace+".SelectSources", 0, s.path, data)
if call.Err != nil {
return call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return err
}
_, err := s.waitResponse(reqPath)
return err
}
// start calls ScreenCast.Start and parses the stream list.
func (s *portalSession) start() ([]portalStream, error) {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("start%d", time.Now().UnixNano())),
}
call := s.obj.Call(screenCastIFace+".Start", 0, s.path, "", data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["streams"]
if !ok {
return nil, errors.New("portal: Start response missing streams")
}
var raw [][]any
switch v := sv.Value().(type) {
case [][]any:
raw = v
case []any:
for _, item := range v {
if sub, ok := item.([]any); ok {
raw = append(raw, sub)
}
}
default:
return nil, fmt.Errorf("portal: streams has unexpected type %T", sv.Value())
}
streams := make([]portalStream, 0, len(raw))
for i, s := range raw {
if len(s) < 2 {
continue
}
ps := portalStream{Name: fmt.Sprintf("Monitor %d", i)}
props, ok := s[1].(map[string]dbus.Variant)
if !ok {
continue
}
if v, ok := props["size"]; ok {
if a, ok := v.Value().([]any); ok && len(a) >= 2 {
if w, ok := a[0].(int32); ok {
ps.Size[0] = int(w)
}
if h, ok := a[1].(int32); ok {
ps.Size[1] = int(h)
}
}
}
if v, ok := props["id"]; ok {
if id, ok := v.Value().(string); ok && id != "" {
ps.Name = id
}
}
streams = append(streams, ps)
}
return streams, nil
}
// close best-effort closes the portal session.
func (s *portalSession) close() {
if s.path != "" {
_ = s.obj.Call(sessionIFace+".Close", 0, s.path).Err
}
}
// waitResponse waits for the Request::Response signal for the given request
// path and returns the response dict.
func (s *portalSession) waitResponse(reqPath dbus.ObjectPath) (map[string]dbus.Variant, error) {
deadline := time.NewTimer(portalTimeout)
defer deadline.Stop()
for {
select {
case sig := <-s.sig:
if sig.Path != reqPath {
continue
}
if len(sig.Body) < 2 {
return nil, errors.New("portal: malformed Response signal")
}
dict, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return nil, errors.New("portal: Response payload is not a dict")
}
return dict, nil
case <-deadline.C:
return nil, errors.New("portal: request timed out")
}
}
}