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 mu sync.Mutex
38}
39
40// NewPortalInhibitor creates a new portal-based idle inhibitor.
41// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
42func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
43 log := logging.FromContext(ctx)
44
45 inhibitor := &PortalInhibitor{
46 supported: false,
47 }
48
49 // Connect to session bus
50 conn, err := dbus.ConnectSessionBus()
51 if err != nil {
52 log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
53 return inhibitor
54 }
55 inhibitor.conn = conn
56
57 // Check if portal is available
58 obj := conn.Object(portalDest, portalPath)
59 var version uint32
60 err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
61 portalInterface, "version").Store(&version)
62 if err != nil {
63 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
64 return inhibitor
65 }
66
67 inhibitor.supported = true
68 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
69
70 return inhibitor
71}
72
73// Inhibit increments the inhibit refcount. First call activates inhibition.
74func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
75 log := logging.FromContext(ctx)
76
77 p.mu.Lock()
78 defer p.mu.Unlock()
79
80 p.refcount++
81 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
82
83 // Already inhibiting
84 if p.refcount > 1 {
85 return nil
86 }
87
88 // First inhibit request
89 if !p.supported || p.conn == nil {
90 log.Debug().Msg("idle inhibitor: not supported, skipping")
91 return nil
92 }
93
94 // Call portal Inhibit method
95 // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
96 obj := p.conn.Object(portalDest, portalPath)
97
98 options := map[string]dbus.Variant{
99 "reason": dbus.MakeVariant(reason),
100 }
101
102 var handlePath dbus.ObjectPath
103 err := obj.Call(portalInterface+".Inhibit", 0,
104 "", // window identifier (empty for non-sandboxed)
105 uint32(flagIdle|flagSuspend), // inhibit idle and suspend
106 options,
107 ).Store(&handlePath)
108
109 if err != nil {
110 p.refcount--
111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
112 return fmt.Errorf("portal inhibit: %w", err)
113 }
114
115 p.requestPath = handlePath
116 log.Info().
117 Str("handle", string(handlePath)).
118 Str("reason", reason).
119 Msg("idle inhibitor: activated")
120
121 return nil
122}
123
124// Uninhibit decrements the refcount. When zero, releases inhibition.
125func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
126 log := logging.FromContext(ctx)
127
128 p.mu.Lock()
129 defer p.mu.Unlock()
130
131 if p.refcount <= 0 {
132 return nil // Nothing to uninhibit
133 }
134
135 p.refcount--
136 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
137
138 // Still have active inhibitors
139 if p.refcount > 0 {
140 return nil
141 }
142
143 // Release the inhibition
144 if !p.supported || p.conn == nil || p.requestPath == "" {
145 return nil
146 }
147
148 // Close the request to release inhibition
149 obj := p.conn.Object(portalDest, p.requestPath)
150 err := obj.Call(requestIface+".Close", 0).Err
151
152 if err != nil {
153 log.Warn().Err(err).Msg("idle inhibitor: failed to close request")
154 // Don't return error - the request may have already been closed
155 } else {
156 log.Info().Msg("idle inhibitor: deactivated")
157 }
158
159 p.requestPath = ""
160 return nil
161}
162
163// IsInhibited returns true if currently inhibiting idle.
164func (p *PortalInhibitor) IsInhibited() bool {
165 p.mu.Lock()
166 defer p.mu.Unlock()
167 return p.refcount > 0
168}
169
170// Close releases D-Bus resources and any active inhibition.
171func (p *PortalInhibitor) Close() error {
172 p.mu.Lock()
173 defer p.mu.Unlock()
174
175 // Release any active inhibition
176 if p.conn != nil && p.requestPath != "" {
177 obj := p.conn.Object(portalDest, p.requestPath)
178 _ = obj.Call(requestIface+".Close", 0).Err
179 p.requestPath = ""
180 }
181
182 p.refcount = 0
183
184 if p.conn != nil {
185 err := p.conn.Close()
186 p.conn = nil
187 return err
188 }
189
190 return nil
191}