v0.31.0 bf0756b

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/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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close