detached 1694d65

Merge pull request 'refactor(idle): use zerobus for the portal inhibitor instead of godbus' (#7) from refactor/zerobus-idle-inhibitor into main

Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/7
commit
1694d653491e3139d8d891f0603a4e02d3fb8315
author
bnema · · 2026-10-14 09:53 UTC
merge of
998b312 + 731bbf7 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
4 files · +340 -181
M go.mod +1 -1
@@ -12,11 +12,11 @@ require (
1212 github.com/bnema/purego-sqlite v0.1.5
1313 github.com/bnema/purego-webp v0.2.1
1414 github.com/bnema/puregotk v0.7.6
15+ github.com/bnema/zerobus v0.1.1
1516 github.com/charmbracelet/bubbles v1.0.0
1617 github.com/charmbracelet/bubbletea v1.3.10
1718 github.com/charmbracelet/lipgloss v1.1.0
1819 github.com/fsnotify/fsnotify v1.10.1
19- github.com/godbus/dbus/v5 v5.2.4
2020 github.com/pelletier/go-toml/v2 v2.4.3
2121 github.com/pressly/goose/v3 v3.29.0
2222 github.com/rs/zerolog v1.35.1
@@ -12,11 +12,11 @@ require (
12 github.com/bnema/purego-sqlite v0.1.512 github.com/bnema/purego-sqlite v0.1.5
13 github.com/bnema/purego-webp v0.2.113 github.com/bnema/purego-webp v0.2.1
14 github.com/bnema/puregotk v0.7.614 github.com/bnema/puregotk v0.7.6
15+ github.com/bnema/zerobus v0.1.1
15 github.com/charmbracelet/bubbles v1.0.016 github.com/charmbracelet/bubbles v1.0.0
16 github.com/charmbracelet/bubbletea v1.3.1017 github.com/charmbracelet/bubbletea v1.3.10
17 github.com/charmbracelet/lipgloss v1.1.018 github.com/charmbracelet/lipgloss v1.1.0
18 github.com/fsnotify/fsnotify v1.10.119 github.com/fsnotify/fsnotify v1.10.1
19- github.com/godbus/dbus/v5 v5.2.4
20 github.com/pelletier/go-toml/v2 v2.4.320 github.com/pelletier/go-toml/v2 v2.4.3
21 github.com/pressly/goose/v3 v3.29.021 github.com/pressly/goose/v3 v3.29.0
22 github.com/rs/zerolog v1.35.122 github.com/rs/zerolog v1.35.1
M go.sum +2 -2 generated file: collapsed
M internal/infrastructure/idle/portal_inhibitor.go +83 -134
@@ -4,11 +4,12 @@ package idle
44 import (
55 "context"
66 "fmt"
7+ "strings"
78 "sync"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
11- "github.com/godbus/dbus/v5"
12+ "github.com/bnema/zerobus"
1213 )
1314
1415 const (
@@ -16,6 +17,7 @@ const (
1617 portalPath = "/org/freedesktop/portal/desktop"
1718 portalInterface = "org.freedesktop.portal.Inhibit"
1819 requestIface = "org.freedesktop.portal.Request"
20+ propertiesIface = "org.freedesktop.DBus.Properties"
1921
2022 // Inhibit flags from portal spec
2123 flagLogout = 1
@@ -29,46 +31,58 @@ var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
2931
3032 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
3133 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
34+//
35+// A zerobus connection is not safe for concurrent use, so every bus call
36+// runs under mu. Calls drop the signals that arrive meanwhile.
3237 type 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
38+ mu sync.Mutex
39+ conn *zerobus.Conn // nil when the portal is unavailable or after Close
40+ requestPath string // active inhibit request handle
41+ refcount int
3942 }
4043
4144 // NewPortalInhibitor creates a new portal-based idle inhibitor.
4245 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
4346 func 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()
47+ conn, err := zerobus.SessionBus()
5248 if err != nil {
53- log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
54- return inhibitor
49+ logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
50+ return &PortalInhibitor{}
5551 }
56- inhibitor.conn = conn
52+ return newPortalInhibitor(ctx, conn)
53+}
5754
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)
55+// newPortalInhibitor takes ownership of conn and keeps it only if the portal
56+// answers on it.
57+func newPortalInhibitor(ctx context.Context, conn *zerobus.Conn) *PortalInhibitor {
58+ log := logging.FromContext(ctx)
59+
60+ version, err := portalVersion(conn)
6361 if err != nil {
6462 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
65- return inhibitor
63+ _ = conn.Close()
64+ return &PortalInhibitor{}
6665 }
6766
68- inhibitor.supported = true
6967 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68+ return &PortalInhibitor{conn: conn}
69+}
7070
71- return inhibitor
71+// portalVersion reads the version property of the Inhibit portal.
72+func portalVersion(conn *zerobus.Conn) (uint32, error) {
73+ e := conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
74+ e.Str(portalInterface)
75+ e.Str("version")
76+ m, err := conn.Call()
77+ if err != nil {
78+ return 0, err
79+ }
80+ body := m.Body()
81+ if sig := body.Variant(); sig != "u" {
82+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
83+ }
84+ version := body.Uint32()
85+ return version, body.Err()
7286 }
7387
7488 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -86,102 +100,51 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
86100 return nil
87101 }
88102
89- // First inhibit request
90- if !p.supported || p.conn == nil {
103+ if p.conn == nil {
91104 log.Debug().Msg("idle inhibitor: not supported, skipping")
92105 return nil
93106 }
94107
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-
108+ handle, err := p.callInhibit(reason)
110109 if err != nil {
111110 p.refcount--
112111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
113112 return fmt.Errorf("portal inhibit: %w", err)
114113 }
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)
114+ p.requestPath = handle
122115
123116 log.Info().
124- Str("handle", string(handlePath)).
117+ Str("handle", handle).
125118 Str("reason", reason).
126119 Msg("idle inhibitor: activated")
127120
128121 return nil
129122 }
130123
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.
135-func (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
124+// callInhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
125+// and returns the request handle.
126+func (p *PortalInhibitor) callInhibit(reason string) (string, error) {
127+ e := p.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
128+ e.Str("") // window identifier (empty for non-sandboxed)
129+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
130+ options := e.BeginArray('{')
131+ e.Struct()
132+ e.Str("reason")
133+ e.Variant("s")
134+ e.Str(reason)
135+ e.EndArray(options)
136+
137+ m, err := p.conn.Call()
138+ if err != nil {
139+ return "", err
154140 }
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- }
141+ body := m.Body()
142+ // The reply points into the receive buffer: copy the handle we keep.
143+ handle := strings.Clone(body.ObjectPath())
144+ if err := body.Err(); err != nil {
145+ return "", err
184146 }
147+ return handle, nil
185148 }
186149
187150 // Uninhibit decrements the refcount. When zero, releases inhibition.
@@ -199,30 +162,23 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
199162 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
200163
201164 // Still have active inhibitors
202- if p.refcount > 0 {
165+ if p.refcount > 0 || p.requestPath == "" {
203166 return nil
204167 }
205168
206- // Release the inhibition
207- if !p.supported || p.conn == nil || p.requestPath == "" {
208- return nil
209- }
169+ p.closeRequest()
170+ log.Info().Msg("idle inhibitor: deactivated")
171+ return nil
172+}
210173
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
174+// closeRequest releases the active inhibit request. A portal that already
175+// ended the request answers with an error, which is ignored.
176+func (p *PortalInhibitor) closeRequest() {
177+ if p.conn != nil && p.requestPath != "" {
178+ p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")
179+ _, _ = p.conn.Call()
217180 }
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")
224181 p.requestPath = ""
225- return nil
226182 }
227183
228184 // IsInhibited returns true if currently inhibiting idle.
@@ -237,20 +193,13 @@ func (p *PortalInhibitor) Close() error {
237193 p.mu.Lock()
238194 defer p.mu.Unlock()
239195
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
196+ p.closeRequest()
247197 p.refcount = 0
248198
249- if p.conn != nil {
250- err := p.conn.Close()
251- p.conn = nil
252- return err
199+ if p.conn == nil {
200+ return nil
253201 }
254-
255- return nil
202+ err := p.conn.Close()
203+ p.conn = nil
204+ return err
256205 }
@@ -4,11 +4,12 @@ package idle
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ "strings"
7 "sync"8 "sync"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
11- "github.com/godbus/dbus/v5"12+ "github.com/bnema/zerobus"
12 )13 )
13 14
14 const (15 const (
@@ -16,6 +17,7 @@ const (
16 portalPath = "/org/freedesktop/portal/desktop"17 portalPath = "/org/freedesktop/portal/desktop"
17 portalInterface = "org.freedesktop.portal.Inhibit"18 portalInterface = "org.freedesktop.portal.Inhibit"
18 requestIface = "org.freedesktop.portal.Request"19 requestIface = "org.freedesktop.portal.Request"
20+ propertiesIface = "org.freedesktop.DBus.Properties"
19 21
20 // Inhibit flags from portal spec22 // Inhibit flags from portal spec
21 flagLogout = 123 flagLogout = 1
@@ -29,46 +31,58 @@ var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29 31
30 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.32 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).33 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
34+//
35+// A zerobus connection is not safe for concurrent use, so every bus call
36+// runs under mu. Calls drop the signals that arrive meanwhile.
32 type PortalInhibitor struct {37 type PortalInhibitor struct {
33- conn *dbus.Conn38+ mu sync.Mutex
34- requestPath dbus.ObjectPath // Active inhibit request handle39+ conn *zerobus.Conn // nil when the portal is unavailable or after Close
35- refcount int40+ requestPath string // active inhibit request handle
36- supported bool41+ refcount int
37- requestComplete bool // True if the portal sent a Response signal (request no longer exists)
38- mu sync.Mutex
39 }42 }
40 43
41 // NewPortalInhibitor creates a new portal-based idle inhibitor.44 // NewPortalInhibitor creates a new portal-based idle inhibitor.
42 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).45 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
43 func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {46 func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
44- log := logging.FromContext(ctx)47+ conn, err := zerobus.SessionBus()
45-
46- inhibitor := &PortalInhibitor{
47- supported: false,
48- }
49-
50- // Connect to session bus
51- conn, err := dbus.ConnectSessionBus()
52 if err != nil {48 if err != nil {
53- log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")49+ logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
54- return inhibitor50+ return &PortalInhibitor{}
55 }51 }
56- inhibitor.conn = conn52+ return newPortalInhibitor(ctx, conn)
53+}
57 54
58- // Check if portal is available55+// newPortalInhibitor takes ownership of conn and keeps it only if the portal
59- obj := conn.Object(portalDest, portalPath)56+// answers on it.
60- var version uint3257+func newPortalInhibitor(ctx context.Context, conn *zerobus.Conn) *PortalInhibitor {
61- err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,58+ log := logging.FromContext(ctx)
62- portalInterface, "version").Store(&version)59+
60+ version, err := portalVersion(conn)
63 if err != nil {61 if err != nil {
64 log.Debug().Err(err).Msg("idle inhibitor: portal not available")62 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
65- return inhibitor63+ _ = conn.Close()
64+ return &PortalInhibitor{}
66 }65 }
67 66
68- inhibitor.supported = true
69 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")67 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68+ return &PortalInhibitor{conn: conn}
69+}
70 70
71- return inhibitor71+// portalVersion reads the version property of the Inhibit portal.
72+func portalVersion(conn *zerobus.Conn) (uint32, error) {
73+ e := conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
74+ e.Str(portalInterface)
75+ e.Str("version")
76+ m, err := conn.Call()
77+ if err != nil {
78+ return 0, err
79+ }
80+ body := m.Body()
81+ if sig := body.Variant(); sig != "u" {
82+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
83+ }
84+ version := body.Uint32()
85+ return version, body.Err()
72 }86 }
73 87
74 // Inhibit increments the inhibit refcount. First call activates inhibition.88 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -86,102 +100,51 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
86 return nil100 return nil
87 }101 }
88 102
89- // First inhibit request103+ if p.conn == nil {
90- if !p.supported || p.conn == nil {
91 log.Debug().Msg("idle inhibitor: not supported, skipping")104 log.Debug().Msg("idle inhibitor: not supported, skipping")
92 return nil105 return nil
93 }106 }
94 107
95- // Call portal Inhibit method108+ handle, err := p.callInhibit(reason)
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 {109 if err != nil {
111 p.refcount--110 p.refcount--
112 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
113 return fmt.Errorf("portal inhibit: %w", err)112 return fmt.Errorf("portal inhibit: %w", err)
114 }113 }
115-114+ p.requestPath = handle
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 115
123 log.Info().116 log.Info().
124- Str("handle", string(handlePath)).117+ Str("handle", handle).
125 Str("reason", reason).118 Str("reason", reason).
126 Msg("idle inhibitor: activated")119 Msg("idle inhibitor: activated")
127 120
128 return nil121 return nil
129 }122 }
130 123
131-// watchForResponse monitors for the Response signal on the request object.124+// callInhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
132-// Some portals (particularly GNOME) complete the Inhibit request immediately,125+// and returns the request handle.
133-// which removes the Request object. We need to track this to avoid calling126+func (p *PortalInhibitor) callInhibit(reason string) (string, error) {
134-// Close on a non-existent object.127+ e := p.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
135-func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {128+ e.Str("") // window identifier (empty for non-sandboxed)
136- log := logging.FromContext(ctx)129+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
137-130+ options := e.BeginArray('{')
138- p.mu.Lock()131+ e.Struct()
139- conn := p.conn132+ e.Str("reason")
140- p.mu.Unlock()133+ e.Variant("s")
141- if conn == nil {134+ e.Str(reason)
142- return135+ e.EndArray(options)
143- }136+
144-137+ m, err := p.conn.Call()
145- // Add match rule for the Response signal on this specific request138+ if err != nil {
146- matchRule := fmt.Sprintf(139+ return "", err
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 }140 }
155-141+ body := m.Body()
156- // Create a channel to receive signals142+ // The reply points into the receive buffer: copy the handle we keep.
157- signals := make(chan *dbus.Signal, 1)143+ handle := strings.Clone(body.ObjectPath())
158- conn.Signal(signals)144+ if err := body.Err(); err != nil {
159-145+ return "", err
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 }146 }
147+ return handle, nil
185 }148 }
186 149
187 // Uninhibit decrements the refcount. When zero, releases inhibition.150 // Uninhibit decrements the refcount. When zero, releases inhibition.
@@ -199,30 +162,23 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
199 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")162 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
200 163
201 // Still have active inhibitors164 // Still have active inhibitors
202- if p.refcount > 0 {165+ if p.refcount > 0 || p.requestPath == "" {
203 return nil166 return nil
204 }167 }
205 168
206- // Release the inhibition169+ p.closeRequest()
207- if !p.supported || p.conn == nil || p.requestPath == "" {170+ log.Info().Msg("idle inhibitor: deactivated")
208- return nil171+ return nil
209- }172+}
210 173
211- // If the portal already completed the request with a Response signal,174+// closeRequest releases the active inhibit request. A portal that already
212- // the Request object no longer exists - don't try to close it175+// ended the request answers with an error, which is ignored.
213- if p.requestComplete {176+func (p *PortalInhibitor) closeRequest() {
214- log.Info().Msg("idle inhibitor: deactivated (completed by portal)")177+ if p.conn != nil && p.requestPath != "" {
215- p.requestPath = ""178+ p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")
216- return nil179+ _, _ = p.conn.Call()
217 }180 }
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 = ""181 p.requestPath = ""
225- return nil
226 }182 }
227 183
228 // IsInhibited returns true if currently inhibiting idle.184 // IsInhibited returns true if currently inhibiting idle.
@@ -237,20 +193,13 @@ func (p *PortalInhibitor) Close() error {
237 p.mu.Lock()193 p.mu.Lock()
238 defer p.mu.Unlock()194 defer p.mu.Unlock()
239 195
240- // Release any active inhibition (only if not already completed by portal)196+ p.closeRequest()
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 = 0197 p.refcount = 0
248 198
249- if p.conn != nil {199+ if p.conn == nil {
250- err := p.conn.Close()200+ return nil
251- p.conn = nil
252- return err
253 }201 }
254-202+ err := p.conn.Close()
255- return nil203+ p.conn = nil
204+ return err
256 }205 }
M internal/infrastructure/idle/portal_inhibitor_test.go +254 -44
@@ -4,53 +4,276 @@ import (
44 "bufio"
55 "bytes"
66 "context"
7+ "os"
78 "os/exec"
9+ "path/filepath"
810 "strings"
9- "sync"
1011 "testing"
1112
12- "github.com/godbus/dbus/v5"
13+ "github.com/bnema/zerobus"
1314 )
1415
15-func TestWatchForResponseCleanupSurvivesConnFieldCleared(t *testing.T) {
16- // clearConnOnDoneContext clears inhibitor.conn after watchForResponse has
17- // installed its cleanup defer; the test fails if cleanup dereferences the
18- // mutable field instead of the D-Bus connection snapshot.
19- conn := connectPrivateSessionBus(t)
20- defer conn.Close()
16+const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"
2117
22- inhibitor := &PortalInhibitor{conn: conn}
23- ctx := &clearConnOnDoneContext{
24- Context: context.Background(),
25- done: closedDoneChannel(),
26- clear: func() {
27- inhibitor.mu.Lock()
28- inhibitor.conn = nil
29- inhibitor.mu.Unlock()
30- },
18+// portalCall is a method call received by the fake portal.
19+type portalCall struct {
20+ member string
21+ path string
22+ flags uint32
23+ reason string
24+}
25+
26+// fakePortal serves the Inhibit portal on a private bus and reports every
27+// call it answers on calls, before replying.
28+type fakePortal struct {
29+ calls chan portalCall
30+ // endRequest makes the portal answer Close with an error, like a
31+ // portal that already ended the request with a Response signal.
32+ endRequest bool
33+ // failInhibit makes the portal reject Inhibit calls.
34+ failInhibit bool
35+}
36+
37+func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {
38+ t.Helper()
39+ code, err := conn.RequestName(portalDest, zerobus.NameFlagDoNotQueue)
40+ if err != nil || code != zerobus.NamePrimaryOwner {
41+ t.Fatalf("RequestName = %d, %v", code, err)
3142 }
43+ go func() {
44+ for {
45+ m, err := conn.ReadMessage()
46+ if err != nil {
47+ return // connection closed at cleanup
48+ }
49+ if m.Type != zerobus.TypeMethodCall {
50+ continue
51+ }
52+ f.answer(conn, m)
53+ }
54+ }()
55+}
3256
33- inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")
57+func (f *fakePortal) answer(conn *zerobus.Conn, m *zerobus.Message) {
58+ call := portalCall{member: strings.Clone(m.Member), path: strings.Clone(m.Path)}
59+ switch m.Member {
60+ case "Get":
61+ e := conn.NewReply(m, "v")
62+ e.Variant("u")
63+ e.Uint32(3)
64+ case "Inhibit":
65+ if f.failInhibit {
66+ conn.NewError(m, "org.freedesktop.DBus.Error.AccessDenied", "s").Str("denied")
67+ break
68+ }
69+ body := m.Body()
70+ body.Str() // window
71+ call.flags = body.Uint32()
72+ end := body.Array('{')
73+ for body.More(end) {
74+ body.Struct()
75+ key := strings.Clone(body.Str())
76+ sig := body.Variant()
77+ if key == "reason" && sig == "s" {
78+ call.reason = strings.Clone(body.Str())
79+ continue
80+ }
81+ body.Skip(sig)
82+ }
83+ conn.NewReply(m, "o").ObjectPath(testRequestPath)
84+ case "Close":
85+ if f.endRequest {
86+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("request ended")
87+ } else {
88+ conn.NewReply(m, "")
89+ }
90+ default:
91+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "")
92+ }
93+ // Record before replying so the call is visible once the caller returns.
94+ f.calls <- call
95+ _, _ = conn.Send()
96+}
3497
35- inhibitor.mu.Lock()
36- connCleared := inhibitor.conn == nil
37- inhibitor.mu.Unlock()
38- if !connCleared {
39- t.Fatal("watchForResponse returned before observing context cancellation")
98+func (f *fakePortal) next(t *testing.T) portalCall {
99+ t.Helper()
100+ select {
101+ case c := <-f.calls:
102+ return c
103+ default:
104+ t.Fatal("expected a portal call")
105+ return portalCall{}
40106 }
41107 }
42108
43-func connectPrivateSessionBus(t *testing.T) *dbus.Conn {
109+func (f *fakePortal) none(t *testing.T) {
44110 t.Helper()
111+ select {
112+ case c := <-f.calls:
113+ t.Fatalf("unexpected portal call %s on %s", c.member, c.path)
114+ default:
115+ }
116+}
45117
118+// newTestInhibitor starts a private bus with a fake portal and returns an
119+// inhibitor connected to it.
120+func newTestInhibitor(t *testing.T, portal *fakePortal) *PortalInhibitor {
121+ t.Helper()
46122 address := startPrivateSessionBus(t)
47- conn, err := dbus.Connect(address)
123+ portal.calls = make(chan portalCall, 8)
124+ portal.serve(t, dial(t, address))
125+
126+ p := newPortalInhibitor(context.Background(), dial(t, address))
127+ t.Cleanup(func() { _ = p.Close() })
128+ if c := portal.next(t); c.member != "Get" {
129+ t.Fatalf("first call = %s, want Get", c.member)
130+ }
131+ return p
132+}
133+
134+func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
135+ portal := &fakePortal{}
136+ p := newTestInhibitor(t, portal)
137+ ctx := context.Background()
138+
139+ if err := p.Inhibit(ctx, "video playing"); err != nil {
140+ t.Fatal(err)
141+ }
142+ c := portal.next(t)
143+ if c.member != "Inhibit" || c.flags != flagIdle|flagSuspend || c.reason != "video playing" {
144+ t.Fatalf("Inhibit call = %+v", c)
145+ }
146+
147+ if err := p.Inhibit(ctx, "second video"); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := p.Uninhibit(ctx); err != nil {
151+ t.Fatal(err)
152+ }
153+ portal.none(t)
154+ if !p.IsInhibited() {
155+ t.Fatal("released while one inhibit is still held")
156+ }
157+
158+ if err := p.Uninhibit(ctx); err != nil {
159+ t.Fatal(err)
160+ }
161+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
162+ t.Fatalf("release call = %+v", c)
163+ }
164+ if p.IsInhibited() {
165+ t.Fatal("still inhibited after the last Uninhibit")
166+ }
167+}
168+
169+func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {
170+ portal := &fakePortal{endRequest: true}
171+ p := newTestInhibitor(t, portal)
172+ ctx := context.Background()
173+
174+ if err := p.Inhibit(ctx, "video playing"); err != nil {
175+ t.Fatal(err)
176+ }
177+ portal.next(t)
178+ if err := p.Uninhibit(ctx); err != nil {
179+ t.Fatalf("Uninhibit after the portal ended the request: %v", err)
180+ }
181+ portal.next(t)
182+
183+ // The connection stays usable for the next inhibition.
184+ if err := p.Inhibit(ctx, "next video"); err != nil {
185+ t.Fatal(err)
186+ }
187+ if c := portal.next(t); c.member != "Inhibit" {
188+ t.Fatalf("call = %s, want Inhibit", c.member)
189+ }
190+}
191+
192+func TestPortalInhibitorRollsBackFailedInhibit(t *testing.T) {
193+ portal := &fakePortal{failInhibit: true}
194+ p := newTestInhibitor(t, portal)
195+
196+ if err := p.Inhibit(context.Background(), "video playing"); err == nil {
197+ t.Fatal("Inhibit succeeded although the portal rejected it")
198+ }
199+ portal.next(t)
200+ if p.IsInhibited() {
201+ t.Fatal("refcount kept after a failed Inhibit")
202+ }
203+
204+ // Nothing to release: Uninhibit must not call Close.
205+ if err := p.Uninhibit(context.Background()); err != nil {
206+ t.Fatal(err)
207+ }
208+ portal.none(t)
209+}
210+
211+func TestPortalInhibitorCloseReleasesActiveRequest(t *testing.T) {
212+ portal := &fakePortal{}
213+ p := newTestInhibitor(t, portal)
214+
215+ if err := p.Inhibit(context.Background(), "video playing"); err != nil {
216+ t.Fatal(err)
217+ }
218+ portal.next(t)
219+ if err := p.Close(); err != nil {
220+ t.Fatal(err)
221+ }
222+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
223+ t.Fatalf("Close call = %+v", c)
224+ }
225+ if p.IsInhibited() {
226+ t.Fatal("still inhibited after Close")
227+ }
228+}
229+
230+func TestPortalInhibitorWithoutPortalIsNoop(t *testing.T) {
231+ p := newPortalInhibitor(context.Background(), dial(t, startPrivateSessionBus(t)))
232+ t.Cleanup(func() { _ = p.Close() })
233+ if p.conn != nil {
234+ t.Fatal("kept the connection although no portal answered")
235+ }
236+
237+ ctx := context.Background()
238+ if err := p.Inhibit(ctx, "video playing"); err != nil {
239+ t.Fatal(err)
240+ }
241+ if !p.IsInhibited() {
242+ t.Fatal("refcount not tracked without a portal")
243+ }
244+ if err := p.Uninhibit(ctx); err != nil {
245+ t.Fatal(err)
246+ }
247+ if p.IsInhibited() {
248+ t.Fatal("still inhibited after Uninhibit")
249+ }
250+}
251+
252+func dial(t *testing.T, address string) *zerobus.Conn {
253+ t.Helper()
254+ conn, err := zerobus.Dial(address)
48255 if err != nil {
49256 t.Fatalf("connect to private D-Bus session bus: %v", err)
50257 }
258+ t.Cleanup(func() { _ = conn.Close() })
51259 return conn
52260 }
53261
262+// privateBusConfig is a session bus without service directories, so it never
263+// auto-starts the real portal installed on the host.
264+const privateBusConfig = `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
265+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
266+<busconfig>
267+ <type>session</type>
268+ <listen>unix:tmpdir=/tmp</listen>
269+ <policy context="default">
270+ <allow send_destination="*" eavesdrop="true"/>
271+ <allow eavesdrop="true"/>
272+ <allow own="*"/>
273+ </policy>
274+</busconfig>
275+`
276+
54277 func startPrivateSessionBus(t *testing.T) string {
55278 t.Helper()
56279
@@ -58,8 +281,13 @@ func startPrivateSessionBus(t *testing.T) string {
58281 t.Skipf("dbus-daemon unavailable: %v", err)
59282 }
60283
284+ config := filepath.Join(t.TempDir(), "bus.conf")
285+ if err := os.WriteFile(config, []byte(privateBusConfig), 0o600); err != nil {
286+ t.Fatalf("write dbus-daemon config: %v", err)
287+ }
288+
61289 var stderr bytes.Buffer
62- cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")
290+ cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
63291 cmd.Stderr = &stderr
64292
65293 stdout, err := cmd.StdoutPipe()
@@ -84,21 +312,3 @@ func startPrivateSessionBus(t *testing.T) string {
84312 }
85313 return strings.TrimSpace(address)
86314 }
87-
88-type clearConnOnDoneContext struct {
89- context.Context
90- done <-chan struct{}
91- once sync.Once
92- clear func()
93-}
94-
95-func (c *clearConnOnDoneContext) Done() <-chan struct{} {
96- c.once.Do(c.clear)
97- return c.done
98-}
99-
100-func closedDoneChannel() <-chan struct{} {
101- done := make(chan struct{})
102- close(done)
103- return done
104-}
@@ -4,53 +4,276 @@ import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6 "context"6 "context"
7+ "os"
7 "os/exec"8 "os/exec"
9+ "path/filepath"
8 "strings"10 "strings"
9- "sync"
10 "testing"11 "testing"
11 12
12- "github.com/godbus/dbus/v5"13+ "github.com/bnema/zerobus"
13 )14 )
14 15
15-func TestWatchForResponseCleanupSurvivesConnFieldCleared(t *testing.T) {16+const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"
16- // clearConnOnDoneContext clears inhibitor.conn after watchForResponse has
17- // installed its cleanup defer; the test fails if cleanup dereferences the
18- // mutable field instead of the D-Bus connection snapshot.
19- conn := connectPrivateSessionBus(t)
20- defer conn.Close()
21 17
22- inhibitor := &PortalInhibitor{conn: conn}18+// portalCall is a method call received by the fake portal.
23- ctx := &clearConnOnDoneContext{19+type portalCall struct {
24- Context: context.Background(),20+ member string
25- done: closedDoneChannel(),21+ path string
26- clear: func() {22+ flags uint32
27- inhibitor.mu.Lock()23+ reason string
28- inhibitor.conn = nil24+}
29- inhibitor.mu.Unlock()25+
30- },26+// fakePortal serves the Inhibit portal on a private bus and reports every
27+// call it answers on calls, before replying.
28+type fakePortal struct {
29+ calls chan portalCall
30+ // endRequest makes the portal answer Close with an error, like a
31+ // portal that already ended the request with a Response signal.
32+ endRequest bool
33+ // failInhibit makes the portal reject Inhibit calls.
34+ failInhibit bool
35+}
36+
37+func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {
38+ t.Helper()
39+ code, err := conn.RequestName(portalDest, zerobus.NameFlagDoNotQueue)
40+ if err != nil || code != zerobus.NamePrimaryOwner {
41+ t.Fatalf("RequestName = %d, %v", code, err)
31 }42 }
43+ go func() {
44+ for {
45+ m, err := conn.ReadMessage()
46+ if err != nil {
47+ return // connection closed at cleanup
48+ }
49+ if m.Type != zerobus.TypeMethodCall {
50+ continue
51+ }
52+ f.answer(conn, m)
53+ }
54+ }()
55+}
32 56
33- inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")57+func (f *fakePortal) answer(conn *zerobus.Conn, m *zerobus.Message) {
58+ call := portalCall{member: strings.Clone(m.Member), path: strings.Clone(m.Path)}
59+ switch m.Member {
60+ case "Get":
61+ e := conn.NewReply(m, "v")
62+ e.Variant("u")
63+ e.Uint32(3)
64+ case "Inhibit":
65+ if f.failInhibit {
66+ conn.NewError(m, "org.freedesktop.DBus.Error.AccessDenied", "s").Str("denied")
67+ break
68+ }
69+ body := m.Body()
70+ body.Str() // window
71+ call.flags = body.Uint32()
72+ end := body.Array('{')
73+ for body.More(end) {
74+ body.Struct()
75+ key := strings.Clone(body.Str())
76+ sig := body.Variant()
77+ if key == "reason" && sig == "s" {
78+ call.reason = strings.Clone(body.Str())
79+ continue
80+ }
81+ body.Skip(sig)
82+ }
83+ conn.NewReply(m, "o").ObjectPath(testRequestPath)
84+ case "Close":
85+ if f.endRequest {
86+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("request ended")
87+ } else {
88+ conn.NewReply(m, "")
89+ }
90+ default:
91+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "")
92+ }
93+ // Record before replying so the call is visible once the caller returns.
94+ f.calls <- call
95+ _, _ = conn.Send()
96+}
34 97
35- inhibitor.mu.Lock()98+func (f *fakePortal) next(t *testing.T) portalCall {
36- connCleared := inhibitor.conn == nil99+ t.Helper()
37- inhibitor.mu.Unlock()100+ select {
38- if !connCleared {101+ case c := <-f.calls:
39- t.Fatal("watchForResponse returned before observing context cancellation")102+ return c
103+ default:
104+ t.Fatal("expected a portal call")
105+ return portalCall{}
40 }106 }
41 }107 }
42 108
43-func connectPrivateSessionBus(t *testing.T) *dbus.Conn {109+func (f *fakePortal) none(t *testing.T) {
44 t.Helper()110 t.Helper()
111+ select {
112+ case c := <-f.calls:
113+ t.Fatalf("unexpected portal call %s on %s", c.member, c.path)
114+ default:
115+ }
116+}
45 117
118+// newTestInhibitor starts a private bus with a fake portal and returns an
119+// inhibitor connected to it.
120+func newTestInhibitor(t *testing.T, portal *fakePortal) *PortalInhibitor {
121+ t.Helper()
46 address := startPrivateSessionBus(t)122 address := startPrivateSessionBus(t)
47- conn, err := dbus.Connect(address)123+ portal.calls = make(chan portalCall, 8)
124+ portal.serve(t, dial(t, address))
125+
126+ p := newPortalInhibitor(context.Background(), dial(t, address))
127+ t.Cleanup(func() { _ = p.Close() })
128+ if c := portal.next(t); c.member != "Get" {
129+ t.Fatalf("first call = %s, want Get", c.member)
130+ }
131+ return p
132+}
133+
134+func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
135+ portal := &fakePortal{}
136+ p := newTestInhibitor(t, portal)
137+ ctx := context.Background()
138+
139+ if err := p.Inhibit(ctx, "video playing"); err != nil {
140+ t.Fatal(err)
141+ }
142+ c := portal.next(t)
143+ if c.member != "Inhibit" || c.flags != flagIdle|flagSuspend || c.reason != "video playing" {
144+ t.Fatalf("Inhibit call = %+v", c)
145+ }
146+
147+ if err := p.Inhibit(ctx, "second video"); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := p.Uninhibit(ctx); err != nil {
151+ t.Fatal(err)
152+ }
153+ portal.none(t)
154+ if !p.IsInhibited() {
155+ t.Fatal("released while one inhibit is still held")
156+ }
157+
158+ if err := p.Uninhibit(ctx); err != nil {
159+ t.Fatal(err)
160+ }
161+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
162+ t.Fatalf("release call = %+v", c)
163+ }
164+ if p.IsInhibited() {
165+ t.Fatal("still inhibited after the last Uninhibit")
166+ }
167+}
168+
169+func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {
170+ portal := &fakePortal{endRequest: true}
171+ p := newTestInhibitor(t, portal)
172+ ctx := context.Background()
173+
174+ if err := p.Inhibit(ctx, "video playing"); err != nil {
175+ t.Fatal(err)
176+ }
177+ portal.next(t)
178+ if err := p.Uninhibit(ctx); err != nil {
179+ t.Fatalf("Uninhibit after the portal ended the request: %v", err)
180+ }
181+ portal.next(t)
182+
183+ // The connection stays usable for the next inhibition.
184+ if err := p.Inhibit(ctx, "next video"); err != nil {
185+ t.Fatal(err)
186+ }
187+ if c := portal.next(t); c.member != "Inhibit" {
188+ t.Fatalf("call = %s, want Inhibit", c.member)
189+ }
190+}
191+
192+func TestPortalInhibitorRollsBackFailedInhibit(t *testing.T) {
193+ portal := &fakePortal{failInhibit: true}
194+ p := newTestInhibitor(t, portal)
195+
196+ if err := p.Inhibit(context.Background(), "video playing"); err == nil {
197+ t.Fatal("Inhibit succeeded although the portal rejected it")
198+ }
199+ portal.next(t)
200+ if p.IsInhibited() {
201+ t.Fatal("refcount kept after a failed Inhibit")
202+ }
203+
204+ // Nothing to release: Uninhibit must not call Close.
205+ if err := p.Uninhibit(context.Background()); err != nil {
206+ t.Fatal(err)
207+ }
208+ portal.none(t)
209+}
210+
211+func TestPortalInhibitorCloseReleasesActiveRequest(t *testing.T) {
212+ portal := &fakePortal{}
213+ p := newTestInhibitor(t, portal)
214+
215+ if err := p.Inhibit(context.Background(), "video playing"); err != nil {
216+ t.Fatal(err)
217+ }
218+ portal.next(t)
219+ if err := p.Close(); err != nil {
220+ t.Fatal(err)
221+ }
222+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
223+ t.Fatalf("Close call = %+v", c)
224+ }
225+ if p.IsInhibited() {
226+ t.Fatal("still inhibited after Close")
227+ }
228+}
229+
230+func TestPortalInhibitorWithoutPortalIsNoop(t *testing.T) {
231+ p := newPortalInhibitor(context.Background(), dial(t, startPrivateSessionBus(t)))
232+ t.Cleanup(func() { _ = p.Close() })
233+ if p.conn != nil {
234+ t.Fatal("kept the connection although no portal answered")
235+ }
236+
237+ ctx := context.Background()
238+ if err := p.Inhibit(ctx, "video playing"); err != nil {
239+ t.Fatal(err)
240+ }
241+ if !p.IsInhibited() {
242+ t.Fatal("refcount not tracked without a portal")
243+ }
244+ if err := p.Uninhibit(ctx); err != nil {
245+ t.Fatal(err)
246+ }
247+ if p.IsInhibited() {
248+ t.Fatal("still inhibited after Uninhibit")
249+ }
250+}
251+
252+func dial(t *testing.T, address string) *zerobus.Conn {
253+ t.Helper()
254+ conn, err := zerobus.Dial(address)
48 if err != nil {255 if err != nil {
49 t.Fatalf("connect to private D-Bus session bus: %v", err)256 t.Fatalf("connect to private D-Bus session bus: %v", err)
50 }257 }
258+ t.Cleanup(func() { _ = conn.Close() })
51 return conn259 return conn
52 }260 }
53 261
262+// privateBusConfig is a session bus without service directories, so it never
263+// auto-starts the real portal installed on the host.
264+const privateBusConfig = `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
265+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
266+<busconfig>
267+ <type>session</type>
268+ <listen>unix:tmpdir=/tmp</listen>
269+ <policy context="default">
270+ <allow send_destination="*" eavesdrop="true"/>
271+ <allow eavesdrop="true"/>
272+ <allow own="*"/>
273+ </policy>
274+</busconfig>
275+`
276+
54 func startPrivateSessionBus(t *testing.T) string {277 func startPrivateSessionBus(t *testing.T) string {
55 t.Helper()278 t.Helper()
56 279
@@ -58,8 +281,13 @@ func startPrivateSessionBus(t *testing.T) string {
58 t.Skipf("dbus-daemon unavailable: %v", err)281 t.Skipf("dbus-daemon unavailable: %v", err)
59 }282 }
60 283
284+ config := filepath.Join(t.TempDir(), "bus.conf")
285+ if err := os.WriteFile(config, []byte(privateBusConfig), 0o600); err != nil {
286+ t.Fatalf("write dbus-daemon config: %v", err)
287+ }
288+
61 var stderr bytes.Buffer289 var stderr bytes.Buffer
62- cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")290+ cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
63 cmd.Stderr = &stderr291 cmd.Stderr = &stderr
64 292
65 stdout, err := cmd.StdoutPipe()293 stdout, err := cmd.StdoutPipe()
@@ -84,21 +312,3 @@ func startPrivateSessionBus(t *testing.T) string {
84 }312 }
85 return strings.TrimSpace(address)313 return strings.TrimSpace(address)
86 }314 }
87-
88-type clearConnOnDoneContext struct {
89- context.Context
90- done <-chan struct{}
91- once sync.Once
92- clear func()
93-}
94-
95-func (c *clearConnOnDoneContext) Done() <-chan struct{} {
96- c.once.Do(c.clear)
97- return c.done
98-}
99-
100-func closedDoneChannel() <-chan struct{} {
101- done := make(chan struct{})
102- close(done)
103- return done
104-}

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