v0.20.1 d9a391f

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

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