// 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") } } }