diff --git a/internal/capture/monitors.go b/internal/capture/monitors.go index a4d0895..6ceec31 100644 --- a/internal/capture/monitors.go +++ b/internal/capture/monitors.go @@ -29,12 +29,16 @@ type Monitor struct { // ErrNoMonitors is returned when monitor enumeration is unsupported or fails. var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor") -// ListMonitors returns the available monitors for the GUI picker. +// ListMonitors returns the available monitors for the GUI picker. It prefers +// the Hyprland IPC when available, falling back to the xdg-desktop-portal +// ScreenCast API for other compositors. func ListMonitors() ([]Monitor, error) { if hyprctlAvailable() { - return hyprctlMonitors() + if mons, err := hyprctlMonitors(); err == nil { + return mons, nil + } } - return nil, ErrNoMonitors + return portalMonitors() } // hyprctlAvailable reports whether the Hyprland monitor command exists. diff --git a/internal/capture/portal.go b/internal/capture/portal.go new file mode 100644 index 0000000..8eb3661 --- /dev/null +++ b/internal/capture/portal.go @@ -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") + } + } +}