portal_inhibitor.go RawHistoryFolder
1// Package idle provides system idle/screensaver inhibition via XDG Desktop Portal.
2package idle
3
4import (
5 "context"
6 "fmt"
7 "sync"
8
9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/zerobus"
12)
13
14// Compile-time interface check.
15var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
16
17// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
18// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
19//
20// Every portal call runs under mu because the client is not safe for
21// concurrent use.
22type PortalInhibitor struct {
23 mu sync.Mutex
24 portal portalClient // nil when the portal is unavailable or after Close
25 requestPath string // active inhibit request handle
26 refcount int
27}
28
29// NewPortalInhibitor creates a new portal-based idle inhibitor.
30// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
31func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
32 conn, err := zerobus.SessionBus()
33 if err != nil {
34 logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
35 return &PortalInhibitor{}
36 }
37 return newPortalInhibitor(ctx, &zerobusPortal{conn: conn})
38}
39
40// newPortalInhibitor takes ownership of portal and keeps it only if it
41// answers.
42func newPortalInhibitor(ctx context.Context, portal portalClient) *PortalInhibitor {
43 log := logging.FromContext(ctx)
44
45 version, err := portal.Version()
46 if err != nil {
47 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
48 _ = portal.Close()
49 return &PortalInhibitor{}
50 }
51
52 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
53 return &PortalInhibitor{portal: portal}
54}
55
56// Inhibit increments the inhibit refcount. First call activates inhibition.
57func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
58 log := logging.FromContext(ctx)
59
60 p.mu.Lock()
61 defer p.mu.Unlock()
62
63 p.refcount++
64 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
65
66 // Already inhibiting
67 if p.refcount > 1 {
68 return nil
69 }
70
71 if p.portal == nil {
72 log.Debug().Msg("idle inhibitor: not supported, skipping")
73 return nil
74 }
75
76 handle, err := p.portal.Inhibit(reason)
77 if err != nil {
78 p.refcount--
79 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
80 return fmt.Errorf("portal inhibit: %w", err)
81 }
82 p.requestPath = handle
83
84 log.Info().
85 Str("handle", handle).
86 Str("reason", reason).
87 Msg("idle inhibitor: activated")
88
89 return nil
90}
91
92// Uninhibit decrements the refcount. When zero, releases inhibition.
93func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
94 log := logging.FromContext(ctx)
95
96 p.mu.Lock()
97 defer p.mu.Unlock()
98
99 if p.refcount <= 0 {
100 return nil // Nothing to uninhibit
101 }
102
103 p.refcount--
104 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
105
106 // Still have active inhibitors
107 if p.refcount > 0 || p.requestPath == "" {
108 return nil
109 }
110
111 p.closeRequest()
112 log.Info().Msg("idle inhibitor: deactivated")
113 return nil
114}
115
116// closeRequest releases the active inhibit request. A portal that already
117// ended the request answers with an error, which is ignored.
118func (p *PortalInhibitor) closeRequest() {
119 if p.portal != nil && p.requestPath != "" {
120 _ = p.portal.CloseRequest(p.requestPath)
121 }
122 p.requestPath = ""
123}
124
125// IsInhibited returns true if currently inhibiting idle.
126func (p *PortalInhibitor) IsInhibited() bool {
127 p.mu.Lock()
128 defer p.mu.Unlock()
129 return p.refcount > 0
130}
131
132// Close releases D-Bus resources and any active inhibition.
133func (p *PortalInhibitor) Close() error {
134 p.mu.Lock()
135 defer p.mu.Unlock()
136
137 p.closeRequest()
138 p.refcount = 0
139
140 if p.portal == nil {
141 return nil
142 }
143 err := p.portal.Close()
144 p.portal = nil
145 return err
146}