v0.33.0 62a09f5

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/bnema/zerobus"
12)
13
14// Compile-time interface check.
15var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
16
17// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
18// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
19//
20// Every portal call runs under mu because the client is not safe for
21// concurrent use.
22type PortalInhibitor struct {
23	mu          sync.Mutex
24	portal      portalClient // nil when the portal is unavailable or after Close
25	requestPath string       // active inhibit request handle
26	refcount    int
27}
28
29// NewPortalInhibitor creates a new portal-based idle inhibitor.
30// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
31func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
32	conn, err := zerobus.SessionBus()
33	if err != nil {
34		logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
35		return &PortalInhibitor{}
36	}
37	return newPortalInhibitor(ctx, &zerobusPortal{conn: conn})
38}
39
40// newPortalInhibitor takes ownership of portal and keeps it only if it
41// answers.
42func newPortalInhibitor(ctx context.Context, portal portalClient) *PortalInhibitor {
43	log := logging.FromContext(ctx)
44
45	version, err := portal.Version()
46	if err != nil {
47		log.Debug().Err(err).Msg("idle inhibitor: portal not available")
48		_ = portal.Close()
49		return &PortalInhibitor{}
50	}
51
52	log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
53	return &PortalInhibitor{portal: portal}
54}
55
56// Inhibit increments the inhibit refcount. First call activates inhibition.
57func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
58	log := logging.FromContext(ctx)
59
60	p.mu.Lock()
61	defer p.mu.Unlock()
62
63	p.refcount++
64	log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
65
66	// Already inhibiting
67	if p.refcount > 1 {
68		return nil
69	}
70
71	if p.portal == nil {
72		log.Debug().Msg("idle inhibitor: not supported, skipping")
73		return nil
74	}
75
76	handle, err := p.portal.Inhibit(reason)
77	if err != nil {
78		p.refcount--
79		log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
80		return fmt.Errorf("portal inhibit: %w", err)
81	}
82	p.requestPath = handle
83
84	log.Info().
85		Str("handle", handle).
86		Str("reason", reason).
87		Msg("idle inhibitor: activated")
88
89	return nil
90}
91
92// Uninhibit decrements the refcount. When zero, releases inhibition.
93func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
94	log := logging.FromContext(ctx)
95
96	p.mu.Lock()
97	defer p.mu.Unlock()
98
99	if p.refcount <= 0 {
100		return nil // Nothing to uninhibit
101	}
102
103	p.refcount--
104	log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
105
106	// Still have active inhibitors
107	if p.refcount > 0 || p.requestPath == "" {
108		return nil
109	}
110
111	p.closeRequest()
112	log.Info().Msg("idle inhibitor: deactivated")
113	return nil
114}
115
116// closeRequest releases the active inhibit request. A portal that already
117// ended the request answers with an error, which is ignored.
118func (p *PortalInhibitor) closeRequest() {
119	if p.portal != nil && p.requestPath != "" {
120		_ = p.portal.CloseRequest(p.requestPath)
121	}
122	p.requestPath = ""
123}
124
125// IsInhibited returns true if currently inhibiting idle.
126func (p *PortalInhibitor) IsInhibited() bool {
127	p.mu.Lock()
128	defer p.mu.Unlock()
129	return p.refcount > 0
130}
131
132// Close releases D-Bus resources and any active inhibition.
133func (p *PortalInhibitor) Close() error {
134	p.mu.Lock()
135	defer p.mu.Unlock()
136
137	p.closeRequest()
138	p.refcount = 0
139
140	if p.portal == nil {
141		return nil
142	}
143	err := p.portal.Close()
144	p.portal = nil
145	return err
146}

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