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.
252 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|