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/godbus/dbus/v5"
12)
13
14const (
15 portalDest = "org.freedesktop.portal.Desktop"
16 portalPath = "/org/freedesktop/portal/desktop"
17 portalInterface = "org.freedesktop.portal.Inhibit"
18 requestIface = "org.freedesktop.portal.Request"
19
20 // Inhibit flags from portal spec
21 flagLogout = 1
22 flagUserSwitch = 2
23 flagSuspend = 4
24 flagIdle = 8
25)
26
27// Compile-time interface check.
28var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29
30// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
32type PortalInhibitor struct {
33 conn *dbus.Conn
34 requestPath dbus.ObjectPath // Active inhibit request handle
35 refcount int
36 supported bool
37 requestComplete bool // True if the portal sent a Response signal (request no longer exists)
38 mu sync.Mutex
39}
40
41// NewPortalInhibitor creates a new portal-based idle inhibitor.
42// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
43func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
44 log := logging.FromContext(ctx)
45
46 inhibitor := &PortalInhibitor{
47 supported: false,
48 }
49
50 // Connect to session bus
51 conn, err := dbus.ConnectSessionBus()
52 if err != nil {
53 log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
54 return inhibitor
55 }
56 inhibitor.conn = conn
57
58 // Check if portal is available
59 obj := conn.Object(portalDest, portalPath)
60 var version uint32
61 err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
62 portalInterface, "version").Store(&version)
63 if err != nil {
64 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
65 return inhibitor
66 }
67
68 inhibitor.supported = true
69 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
70
71 return inhibitor
72}
73
74// Inhibit increments the inhibit refcount. First call activates inhibition.
75func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
76 log := logging.FromContext(ctx)
77
78 p.mu.Lock()
79 defer p.mu.Unlock()
80
81 p.refcount++
82 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
83
84 // Already inhibiting
85 if p.refcount > 1 {
86 return nil
87 }
88
89 // First inhibit request
90 if !p.supported || p.conn == nil {
91 log.Debug().Msg("idle inhibitor: not supported, skipping")
92 return nil
93 }
94
95 // Call portal Inhibit method
96 // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
97 obj := p.conn.Object(portalDest, portalPath)
98
99 options := map[string]dbus.Variant{
100 "reason": dbus.MakeVariant(reason),
101 }
102
103 var handlePath dbus.ObjectPath
104 err := obj.Call(portalInterface+".Inhibit", 0,
105 "", // window identifier (empty for non-sandboxed)
106 uint32(flagIdle|flagSuspend), // inhibit idle and suspend
107 options,
108 ).Store(&handlePath)
109
110 if err != nil {
111 p.refcount--
112 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
113 return fmt.Errorf("portal inhibit: %w", err)
114 }
115
116 p.requestPath = handlePath
117 p.requestComplete = false
118
119 // Subscribe to the Response signal to know when the inhibition ends
120 // Some portals complete the request immediately with a Response signal
121 go p.watchForResponse(ctx, handlePath)
122
123 log.Info().
124 Str("handle", string(handlePath)).
125 Str("reason", reason).
126 Msg("idle inhibitor: activated")
127
128 return nil
129}
130
131// watchForResponse monitors for the Response signal on the request object.
132// Some portals (particularly GNOME) complete the Inhibit request immediately,
133// which removes the Request object. We need to track this to avoid calling
134// Close on a non-existent object.
135func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {
136 log := logging.FromContext(ctx)
137
138 if p.conn == nil {
139 return
140 }
141
142 // Add match rule for the Response signal on this specific request
143 matchRule := fmt.Sprintf(
144 "type='signal',interface='%s',member='Response',path='%s'",
145 requestIface, handlePath,
146 )
147
148 if err := p.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
149 log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
150 return
151 }
152
153 // Create a channel to receive signals
154 signals := make(chan *dbus.Signal, 1)
155 p.conn.Signal(signals)
156
157 defer func() {
158 p.conn.RemoveSignal(signals)
159 _ = p.conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
160 }()
161
162 // Wait for either a Response signal or context cancellation
163 for {
164 select {
165 case sig := <-signals:
166 if sig == nil {
167 return
168 }
169 if sig.Path == handlePath && sig.Name == requestIface+".Response" {
170 p.mu.Lock()
171 p.requestComplete = true
172 p.mu.Unlock()
173 log.Debug().
174 Str("handle", string(handlePath)).
175 Msg("idle inhibitor: request completed by portal")
176 return
177 }
178 case <-ctx.Done():
179 return
180 }
181 }
182}
183
184// Uninhibit decrements the refcount. When zero, releases inhibition.
185func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
186 log := logging.FromContext(ctx)
187
188 p.mu.Lock()
189 defer p.mu.Unlock()
190
191 if p.refcount <= 0 {
192 return nil // Nothing to uninhibit
193 }
194
195 p.refcount--
196 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
197
198 // Still have active inhibitors
199 if p.refcount > 0 {
200 return nil
201 }
202
203 // Release the inhibition
204 if !p.supported || p.conn == nil || p.requestPath == "" {
205 return nil
206 }
207
208 // If the portal already completed the request with a Response signal,
209 // the Request object no longer exists - don't try to close it
210 if p.requestComplete {
211 log.Info().Msg("idle inhibitor: deactivated (completed by portal)")
212 p.requestPath = ""
213 return nil
214 }
215
216 // Close the request to release inhibition
217 obj := p.conn.Object(portalDest, p.requestPath)
218 _ = obj.Call(requestIface+".Close", 0).Err
219
220 log.Info().Msg("idle inhibitor: deactivated")
221 p.requestPath = ""
222 return nil
223}
224
225// IsInhibited returns true if currently inhibiting idle.
226func (p *PortalInhibitor) IsInhibited() bool {
227 p.mu.Lock()
228 defer p.mu.Unlock()
229 return p.refcount > 0
230}
231
232// Close releases D-Bus resources and any active inhibition.
233func (p *PortalInhibitor) Close() error {
234 p.mu.Lock()
235 defer p.mu.Unlock()
236
237 // Release any active inhibition (only if not already completed by portal)
238 if p.conn != nil && p.requestPath != "" && !p.requestComplete {
239 obj := p.conn.Object(portalDest, p.requestPath)
240 _ = obj.Call(requestIface+".Close", 0).Err
241 }
242 p.requestPath = ""
243 p.requestComplete = false
244 p.refcount = 0
245
246 if p.conn != nil {
247 err := p.conn.Close()
248 p.conn = nil
249 return err
250 }
251
252 return nil
253}