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.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user