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 p.mu.Lock()
139 conn := p.conn
140 p.mu.Unlock()
141 if conn == nil {
142 return
143 }
144
145 // Add match rule for the Response signal on this specific request
146 matchRule := fmt.Sprintf(
147 "type='signal',interface='%s',member='Response',path='%s'",
148 requestIface, handlePath,
149 )
150
151 if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
152 log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
153 return
154 }
155
156 // Create a channel to receive signals
157 signals := make(chan *dbus.Signal, 1)
158 conn.Signal(signals)
159
160 defer func() {
161 conn.RemoveSignal(signals)
162 _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
163 }()
164
165 // Wait for either a Response signal or context cancellation
166 for {
167 select {
168 case sig := <-signals:
169 if sig == nil {
170 return
171 }
172 if sig.Path == handlePath && sig.Name == requestIface+".Response" {
173 p.mu.Lock()
174 p.requestComplete = true
175 p.mu.Unlock()
176 log.Debug().
177 Str("handle", string(handlePath)).
178 Msg("idle inhibitor: request completed by portal")
179 return
180 }
181 case <-ctx.Done():
182 return
183 }
184 }
185}
186
187// Uninhibit decrements the refcount. When zero, releases inhibition.
188func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
189 log := logging.FromContext(ctx)
190
191 p.mu.Lock()
192 defer p.mu.Unlock()
193
194 if p.refcount <= 0 {
195 return nil // Nothing to uninhibit
196 }
197
198 p.refcount--
199 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
200
201 // Still have active inhibitors
202 if p.refcount > 0 {
203 return nil
204 }
205
206 // Release the inhibition
207 if !p.supported || p.conn == nil || p.requestPath == "" {
208 return nil
209 }
210
211 // If the portal already completed the request with a Response signal,
212 // the Request object no longer exists - don't try to close it
213 if p.requestComplete {
214 log.Info().Msg("idle inhibitor: deactivated (completed by portal)")
215 p.requestPath = ""
216 return nil
217 }
218
219 // Close the request to release inhibition
220 obj := p.conn.Object(portalDest, p.requestPath)
221 _ = obj.Call(requestIface+".Close", 0).Err
222
223 log.Info().Msg("idle inhibitor: deactivated")
224 p.requestPath = ""
225 return nil
226}
227
228// IsInhibited returns true if currently inhibiting idle.
229func (p *PortalInhibitor) IsInhibited() bool {
230 p.mu.Lock()
231 defer p.mu.Unlock()
232 return p.refcount > 0
233}
234
235// Close releases D-Bus resources and any active inhibition.
236func (p *PortalInhibitor) Close() error {
237 p.mu.Lock()
238 defer p.mu.Unlock()
239
240 // Release any active inhibition (only if not already completed by portal)
241 if p.conn != nil && p.requestPath != "" && !p.requestComplete {
242 obj := p.conn.Object(portalDest, p.requestPath)
243 _ = obj.Call(requestIface+".Close", 0).Err
244 }
245 p.requestPath = ""
246 p.requestComplete = false
247 p.refcount = 0
248
249 if p.conn != nil {
250 err := p.conn.Close()
251 p.conn = nil
252 return err
253 }
254
255 return nil
256}