v0.26.1 75cd777

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

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