detached 2eac2e3

Merge pull request 'refactor(idle): put the portal behind an interface and test with mockery' (#8) from refactor/idle-portal-mockery into main

Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/8
commit
2eac2e32ba20ffdf3d00d15d0cbcb85d9f8e9f67
author
bnema · · 2026-10-14 09:56 UTC
merge of
1694d65 + 9b5cbc8 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
5 files · +448 -354
M .mockery.yaml +9 -0
@@ -29,6 +29,15 @@ packages:
2929 toolkitClipboard:
3030 config:
3131 structname: MockToolkitClipboard
32+ github.com/bnema/dumber/internal/infrastructure/idle:
33+ config:
34+ dir: '{{.InterfaceDir}}/mocks'
35+ filename: '{{ .InterfaceFile | base }}'
36+ pkgname: mocks
37+ interfaces:
38+ portalClient:
39+ config:
40+ structname: MockPortalClient
3241 github.com/bnema/dumber/internal/infrastructure/systemviewsbridge:
3342 config:
3443 dir: '{{.InterfaceDir}}/mocks'
@@ -29,6 +29,15 @@ packages:
29 toolkitClipboard:29 toolkitClipboard:
30 config:30 config:
31 structname: MockToolkitClipboard31 structname: MockToolkitClipboard
32+ github.com/bnema/dumber/internal/infrastructure/idle:
33+ config:
34+ dir: '{{.InterfaceDir}}/mocks'
35+ filename: '{{ .InterfaceFile | base }}'
36+ pkgname: mocks
37+ interfaces:
38+ portalClient:
39+ config:
40+ structname: MockPortalClient
32 github.com/bnema/dumber/internal/infrastructure/systemviewsbridge:41 github.com/bnema/dumber/internal/infrastructure/systemviewsbridge:
33 config:42 config:
34 dir: '{{.InterfaceDir}}/mocks'43 dir: '{{.InterfaceDir}}/mocks'
A internal/infrastructure/idle/mocks/portal_client.go +253 -0
@@ -0,0 +1,253 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockPortalClient creates a new instance of MockPortalClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockPortalClient(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockPortalClient {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockPortalClient{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockPortalClient is an autogenerated mock type for the portalClient type
35+type MockPortalClient struct {
36+ mock.Mock
37+}
38+
39+type MockPortalClient_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockPortalClient) EXPECT() *MockPortalClient_Expecter {
44+ return &MockPortalClient_Expecter{mock: &_m.Mock}
45+}
46+
47+// Close provides a mock function for the type MockPortalClient
48+func (_mock *MockPortalClient) Close() error {
49+ ret := _mock.Called()
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Close")
53+ }
54+
55+ var r0 error
56+ if returnFunc, ok := ret.Get(0).(func() error); ok {
57+ r0 = returnFunc()
58+ } else {
59+ r0 = ret.Error(0)
60+ }
61+ return r0
62+}
63+
64+// MockPortalClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
65+type MockPortalClient_Close_Call struct {
66+ *mock.Call
67+}
68+
69+// Close is a helper method to define mock.On call
70+func (_e *MockPortalClient_Expecter) Close() *MockPortalClient_Close_Call {
71+ return &MockPortalClient_Close_Call{Call: _e.mock.On("Close")}
72+}
73+
74+func (_c *MockPortalClient_Close_Call) Run(run func()) *MockPortalClient_Close_Call {
75+ _c.Call.Run(func(args mock.Arguments) {
76+ run()
77+ })
78+ return _c
79+}
80+
81+func (_c *MockPortalClient_Close_Call) Return(err error) *MockPortalClient_Close_Call {
82+ _c.Call.Return(err)
83+ return _c
84+}
85+
86+func (_c *MockPortalClient_Close_Call) RunAndReturn(run func() error) *MockPortalClient_Close_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// CloseRequest provides a mock function for the type MockPortalClient
92+func (_mock *MockPortalClient) CloseRequest(handle string) error {
93+ ret := _mock.Called(handle)
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for CloseRequest")
97+ }
98+
99+ var r0 error
100+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
101+ r0 = returnFunc(handle)
102+ } else {
103+ r0 = ret.Error(0)
104+ }
105+ return r0
106+}
107+
108+// MockPortalClient_CloseRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseRequest'
109+type MockPortalClient_CloseRequest_Call struct {
110+ *mock.Call
111+}
112+
113+// CloseRequest is a helper method to define mock.On call
114+// - handle string
115+func (_e *MockPortalClient_Expecter) CloseRequest(handle any) *MockPortalClient_CloseRequest_Call {
116+ return &MockPortalClient_CloseRequest_Call{Call: _e.mock.On("CloseRequest", handle)}
117+}
118+
119+func (_c *MockPortalClient_CloseRequest_Call) Run(run func(handle string)) *MockPortalClient_CloseRequest_Call {
120+ _c.Call.Run(func(args mock.Arguments) {
121+ var arg0 string
122+ if args[0] != nil {
123+ arg0 = args[0].(string)
124+ }
125+ run(
126+ arg0,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *MockPortalClient_CloseRequest_Call) Return(err error) *MockPortalClient_CloseRequest_Call {
133+ _c.Call.Return(err)
134+ return _c
135+}
136+
137+func (_c *MockPortalClient_CloseRequest_Call) RunAndReturn(run func(handle string) error) *MockPortalClient_CloseRequest_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
141+
142+// Inhibit provides a mock function for the type MockPortalClient
143+func (_mock *MockPortalClient) Inhibit(reason string) (string, error) {
144+ ret := _mock.Called(reason)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for Inhibit")
148+ }
149+
150+ var r0 string
151+ var r1 error
152+ if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
153+ return returnFunc(reason)
154+ }
155+ if returnFunc, ok := ret.Get(0).(func(string) string); ok {
156+ r0 = returnFunc(reason)
157+ } else {
158+ r0 = ret.Get(0).(string)
159+ }
160+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
161+ r1 = returnFunc(reason)
162+ } else {
163+ r1 = ret.Error(1)
164+ }
165+ return r0, r1
166+}
167+
168+// MockPortalClient_Inhibit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inhibit'
169+type MockPortalClient_Inhibit_Call struct {
170+ *mock.Call
171+}
172+
173+// Inhibit is a helper method to define mock.On call
174+// - reason string
175+func (_e *MockPortalClient_Expecter) Inhibit(reason any) *MockPortalClient_Inhibit_Call {
176+ return &MockPortalClient_Inhibit_Call{Call: _e.mock.On("Inhibit", reason)}
177+}
178+
179+func (_c *MockPortalClient_Inhibit_Call) Run(run func(reason string)) *MockPortalClient_Inhibit_Call {
180+ _c.Call.Run(func(args mock.Arguments) {
181+ var arg0 string
182+ if args[0] != nil {
183+ arg0 = args[0].(string)
184+ }
185+ run(
186+ arg0,
187+ )
188+ })
189+ return _c
190+}
191+
192+func (_c *MockPortalClient_Inhibit_Call) Return(s string, err error) *MockPortalClient_Inhibit_Call {
193+ _c.Call.Return(s, err)
194+ return _c
195+}
196+
197+func (_c *MockPortalClient_Inhibit_Call) RunAndReturn(run func(reason string) (string, error)) *MockPortalClient_Inhibit_Call {
198+ _c.Call.Return(run)
199+ return _c
200+}
201+
202+// Version provides a mock function for the type MockPortalClient
203+func (_mock *MockPortalClient) Version() (uint32, error) {
204+ ret := _mock.Called()
205+
206+ if len(ret) == 0 {
207+ panic("no return value specified for Version")
208+ }
209+
210+ var r0 uint32
211+ var r1 error
212+ if returnFunc, ok := ret.Get(0).(func() (uint32, error)); ok {
213+ return returnFunc()
214+ }
215+ if returnFunc, ok := ret.Get(0).(func() uint32); ok {
216+ r0 = returnFunc()
217+ } else {
218+ r0 = ret.Get(0).(uint32)
219+ }
220+ if returnFunc, ok := ret.Get(1).(func() error); ok {
221+ r1 = returnFunc()
222+ } else {
223+ r1 = ret.Error(1)
224+ }
225+ return r0, r1
226+}
227+
228+// MockPortalClient_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version'
229+type MockPortalClient_Version_Call struct {
230+ *mock.Call
231+}
232+
233+// Version is a helper method to define mock.On call
234+func (_e *MockPortalClient_Expecter) Version() *MockPortalClient_Version_Call {
235+ return &MockPortalClient_Version_Call{Call: _e.mock.On("Version")}
236+}
237+
238+func (_c *MockPortalClient_Version_Call) Run(run func()) *MockPortalClient_Version_Call {
239+ _c.Call.Run(func(args mock.Arguments) {
240+ run()
241+ })
242+ return _c
243+}
244+
245+func (_c *MockPortalClient_Version_Call) Return(v uint32, err error) *MockPortalClient_Version_Call {
246+ _c.Call.Return(v, err)
247+ return _c
248+}
249+
250+func (_c *MockPortalClient_Version_Call) RunAndReturn(run func() (uint32, error)) *MockPortalClient_Version_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
@@ -0,0 +1,253 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockPortalClient creates a new instance of MockPortalClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockPortalClient(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockPortalClient {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockPortalClient{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockPortalClient is an autogenerated mock type for the portalClient type
35+type MockPortalClient struct {
36+ mock.Mock
37+}
38+
39+type MockPortalClient_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockPortalClient) EXPECT() *MockPortalClient_Expecter {
44+ return &MockPortalClient_Expecter{mock: &_m.Mock}
45+}
46+
47+// Close provides a mock function for the type MockPortalClient
48+func (_mock *MockPortalClient) Close() error {
49+ ret := _mock.Called()
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Close")
53+ }
54+
55+ var r0 error
56+ if returnFunc, ok := ret.Get(0).(func() error); ok {
57+ r0 = returnFunc()
58+ } else {
59+ r0 = ret.Error(0)
60+ }
61+ return r0
62+}
63+
64+// MockPortalClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
65+type MockPortalClient_Close_Call struct {
66+ *mock.Call
67+}
68+
69+// Close is a helper method to define mock.On call
70+func (_e *MockPortalClient_Expecter) Close() *MockPortalClient_Close_Call {
71+ return &MockPortalClient_Close_Call{Call: _e.mock.On("Close")}
72+}
73+
74+func (_c *MockPortalClient_Close_Call) Run(run func()) *MockPortalClient_Close_Call {
75+ _c.Call.Run(func(args mock.Arguments) {
76+ run()
77+ })
78+ return _c
79+}
80+
81+func (_c *MockPortalClient_Close_Call) Return(err error) *MockPortalClient_Close_Call {
82+ _c.Call.Return(err)
83+ return _c
84+}
85+
86+func (_c *MockPortalClient_Close_Call) RunAndReturn(run func() error) *MockPortalClient_Close_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// CloseRequest provides a mock function for the type MockPortalClient
92+func (_mock *MockPortalClient) CloseRequest(handle string) error {
93+ ret := _mock.Called(handle)
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for CloseRequest")
97+ }
98+
99+ var r0 error
100+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
101+ r0 = returnFunc(handle)
102+ } else {
103+ r0 = ret.Error(0)
104+ }
105+ return r0
106+}
107+
108+// MockPortalClient_CloseRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseRequest'
109+type MockPortalClient_CloseRequest_Call struct {
110+ *mock.Call
111+}
112+
113+// CloseRequest is a helper method to define mock.On call
114+// - handle string
115+func (_e *MockPortalClient_Expecter) CloseRequest(handle any) *MockPortalClient_CloseRequest_Call {
116+ return &MockPortalClient_CloseRequest_Call{Call: _e.mock.On("CloseRequest", handle)}
117+}
118+
119+func (_c *MockPortalClient_CloseRequest_Call) Run(run func(handle string)) *MockPortalClient_CloseRequest_Call {
120+ _c.Call.Run(func(args mock.Arguments) {
121+ var arg0 string
122+ if args[0] != nil {
123+ arg0 = args[0].(string)
124+ }
125+ run(
126+ arg0,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *MockPortalClient_CloseRequest_Call) Return(err error) *MockPortalClient_CloseRequest_Call {
133+ _c.Call.Return(err)
134+ return _c
135+}
136+
137+func (_c *MockPortalClient_CloseRequest_Call) RunAndReturn(run func(handle string) error) *MockPortalClient_CloseRequest_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
141+
142+// Inhibit provides a mock function for the type MockPortalClient
143+func (_mock *MockPortalClient) Inhibit(reason string) (string, error) {
144+ ret := _mock.Called(reason)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for Inhibit")
148+ }
149+
150+ var r0 string
151+ var r1 error
152+ if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
153+ return returnFunc(reason)
154+ }
155+ if returnFunc, ok := ret.Get(0).(func(string) string); ok {
156+ r0 = returnFunc(reason)
157+ } else {
158+ r0 = ret.Get(0).(string)
159+ }
160+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
161+ r1 = returnFunc(reason)
162+ } else {
163+ r1 = ret.Error(1)
164+ }
165+ return r0, r1
166+}
167+
168+// MockPortalClient_Inhibit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inhibit'
169+type MockPortalClient_Inhibit_Call struct {
170+ *mock.Call
171+}
172+
173+// Inhibit is a helper method to define mock.On call
174+// - reason string
175+func (_e *MockPortalClient_Expecter) Inhibit(reason any) *MockPortalClient_Inhibit_Call {
176+ return &MockPortalClient_Inhibit_Call{Call: _e.mock.On("Inhibit", reason)}
177+}
178+
179+func (_c *MockPortalClient_Inhibit_Call) Run(run func(reason string)) *MockPortalClient_Inhibit_Call {
180+ _c.Call.Run(func(args mock.Arguments) {
181+ var arg0 string
182+ if args[0] != nil {
183+ arg0 = args[0].(string)
184+ }
185+ run(
186+ arg0,
187+ )
188+ })
189+ return _c
190+}
191+
192+func (_c *MockPortalClient_Inhibit_Call) Return(s string, err error) *MockPortalClient_Inhibit_Call {
193+ _c.Call.Return(s, err)
194+ return _c
195+}
196+
197+func (_c *MockPortalClient_Inhibit_Call) RunAndReturn(run func(reason string) (string, error)) *MockPortalClient_Inhibit_Call {
198+ _c.Call.Return(run)
199+ return _c
200+}
201+
202+// Version provides a mock function for the type MockPortalClient
203+func (_mock *MockPortalClient) Version() (uint32, error) {
204+ ret := _mock.Called()
205+
206+ if len(ret) == 0 {
207+ panic("no return value specified for Version")
208+ }
209+
210+ var r0 uint32
211+ var r1 error
212+ if returnFunc, ok := ret.Get(0).(func() (uint32, error)); ok {
213+ return returnFunc()
214+ }
215+ if returnFunc, ok := ret.Get(0).(func() uint32); ok {
216+ r0 = returnFunc()
217+ } else {
218+ r0 = ret.Get(0).(uint32)
219+ }
220+ if returnFunc, ok := ret.Get(1).(func() error); ok {
221+ r1 = returnFunc()
222+ } else {
223+ r1 = ret.Error(1)
224+ }
225+ return r0, r1
226+}
227+
228+// MockPortalClient_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version'
229+type MockPortalClient_Version_Call struct {
230+ *mock.Call
231+}
232+
233+// Version is a helper method to define mock.On call
234+func (_e *MockPortalClient_Expecter) Version() *MockPortalClient_Version_Call {
235+ return &MockPortalClient_Version_Call{Call: _e.mock.On("Version")}
236+}
237+
238+func (_c *MockPortalClient_Version_Call) Run(run func()) *MockPortalClient_Version_Call {
239+ _c.Call.Run(func(args mock.Arguments) {
240+ run()
241+ })
242+ return _c
243+}
244+
245+func (_c *MockPortalClient_Version_Call) Return(v uint32, err error) *MockPortalClient_Version_Call {
246+ _c.Call.Return(v, err)
247+ return _c
248+}
249+
250+func (_c *MockPortalClient_Version_Call) RunAndReturn(run func() (uint32, error)) *MockPortalClient_Version_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
A internal/infrastructure/idle/portal_client.go +93 -0
@@ -0,0 +1,93 @@
1+package idle
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/zerobus"
8+)
9+
10+const (
11+ portalDest = "org.freedesktop.portal.Desktop"
12+ portalPath = "/org/freedesktop/portal/desktop"
13+ portalInterface = "org.freedesktop.portal.Inhibit"
14+ requestIface = "org.freedesktop.portal.Request"
15+ propertiesIface = "org.freedesktop.DBus.Properties"
16+
17+ // Inhibit flags from portal spec
18+ flagLogout = 1
19+ flagUserSwitch = 2
20+ flagSuspend = 4
21+ flagIdle = 8
22+)
23+
24+// portalClient is the XDG Desktop Portal Inhibit API used by PortalInhibitor.
25+type portalClient interface {
26+ // Version returns the Inhibit portal version; it fails when no portal
27+ // answers.
28+ Version() (uint32, error)
29+ // Inhibit asks the portal to inhibit idle and suspend and returns the
30+ // request handle.
31+ Inhibit(reason string) (string, error)
32+ // CloseRequest ends the inhibit request with the given handle.
33+ CloseRequest(handle string) error
34+ // Close releases the bus connection.
35+ Close() error
36+}
37+
38+// zerobusPortal implements portalClient over a zerobus connection.
39+// It is not safe for concurrent use.
40+type zerobusPortal struct {
41+ conn *zerobus.Conn
42+}
43+
44+func (z *zerobusPortal) Version() (uint32, error) {
45+ e := z.conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
46+ e.Str(portalInterface)
47+ e.Str("version")
48+ m, err := z.conn.Call()
49+ if err != nil {
50+ return 0, err
51+ }
52+ body := m.Body()
53+ if sig := body.Variant(); sig != "u" {
54+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
55+ }
56+ version := body.Uint32()
57+ return version, body.Err()
58+}
59+
60+// Inhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o.
61+func (z *zerobusPortal) Inhibit(reason string) (string, error) {
62+ e := z.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
63+ e.Str("") // window identifier (empty for non-sandboxed)
64+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
65+ options := e.BeginArray('{')
66+ e.Struct()
67+ e.Str("reason")
68+ e.Variant("s")
69+ e.Str(reason)
70+ e.EndArray(options)
71+
72+ m, err := z.conn.Call()
73+ if err != nil {
74+ return "", err
75+ }
76+ body := m.Body()
77+ // The reply points into the receive buffer: copy the handle we keep.
78+ handle := strings.Clone(body.ObjectPath())
79+ if err := body.Err(); err != nil {
80+ return "", err
81+ }
82+ return handle, nil
83+}
84+
85+func (z *zerobusPortal) CloseRequest(handle string) error {
86+ z.conn.NewCall(portalDest, handle, requestIface, "Close", "")
87+ _, err := z.conn.Call()
88+ return err
89+}
90+
91+func (z *zerobusPortal) Close() error {
92+ return z.conn.Close()
93+}
@@ -0,0 +1,93 @@
1+package idle
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/zerobus"
8+)
9+
10+const (
11+ portalDest = "org.freedesktop.portal.Desktop"
12+ portalPath = "/org/freedesktop/portal/desktop"
13+ portalInterface = "org.freedesktop.portal.Inhibit"
14+ requestIface = "org.freedesktop.portal.Request"
15+ propertiesIface = "org.freedesktop.DBus.Properties"
16+
17+ // Inhibit flags from portal spec
18+ flagLogout = 1
19+ flagUserSwitch = 2
20+ flagSuspend = 4
21+ flagIdle = 8
22+)
23+
24+// portalClient is the XDG Desktop Portal Inhibit API used by PortalInhibitor.
25+type portalClient interface {
26+ // Version returns the Inhibit portal version; it fails when no portal
27+ // answers.
28+ Version() (uint32, error)
29+ // Inhibit asks the portal to inhibit idle and suspend and returns the
30+ // request handle.
31+ Inhibit(reason string) (string, error)
32+ // CloseRequest ends the inhibit request with the given handle.
33+ CloseRequest(handle string) error
34+ // Close releases the bus connection.
35+ Close() error
36+}
37+
38+// zerobusPortal implements portalClient over a zerobus connection.
39+// It is not safe for concurrent use.
40+type zerobusPortal struct {
41+ conn *zerobus.Conn
42+}
43+
44+func (z *zerobusPortal) Version() (uint32, error) {
45+ e := z.conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
46+ e.Str(portalInterface)
47+ e.Str("version")
48+ m, err := z.conn.Call()
49+ if err != nil {
50+ return 0, err
51+ }
52+ body := m.Body()
53+ if sig := body.Variant(); sig != "u" {
54+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
55+ }
56+ version := body.Uint32()
57+ return version, body.Err()
58+}
59+
60+// Inhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o.
61+func (z *zerobusPortal) Inhibit(reason string) (string, error) {
62+ e := z.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
63+ e.Str("") // window identifier (empty for non-sandboxed)
64+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
65+ options := e.BeginArray('{')
66+ e.Struct()
67+ e.Str("reason")
68+ e.Variant("s")
69+ e.Str(reason)
70+ e.EndArray(options)
71+
72+ m, err := z.conn.Call()
73+ if err != nil {
74+ return "", err
75+ }
76+ body := m.Body()
77+ // The reply points into the receive buffer: copy the handle we keep.
78+ handle := strings.Clone(body.ObjectPath())
79+ if err := body.Err(); err != nil {
80+ return "", err
81+ }
82+ return handle, nil
83+}
84+
85+func (z *zerobusPortal) CloseRequest(handle string) error {
86+ z.conn.NewCall(portalDest, handle, requestIface, "Close", "")
87+ _, err := z.conn.Call()
88+ return err
89+}
90+
91+func (z *zerobusPortal) Close() error {
92+ return z.conn.Close()
93+}
M internal/infrastructure/idle/portal_inhibitor.go +18 -77
@@ -4,7 +4,6 @@ package idle
44 import (
55 "context"
66 "fmt"
7- "strings"
87 "sync"
98
109 "github.com/bnema/dumber/internal/application/port"
@@ -12,32 +11,18 @@ import (
1211 "github.com/bnema/zerobus"
1312 )
1413
15-const (
16- portalDest = "org.freedesktop.portal.Desktop"
17- portalPath = "/org/freedesktop/portal/desktop"
18- portalInterface = "org.freedesktop.portal.Inhibit"
19- requestIface = "org.freedesktop.portal.Request"
20- propertiesIface = "org.freedesktop.DBus.Properties"
21-
22- // Inhibit flags from portal spec
23- flagLogout = 1
24- flagUserSwitch = 2
25- flagSuspend = 4
26- flagIdle = 8
27-)
28-
2914 // Compile-time interface check.
3015 var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
3116
3217 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
3318 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
3419 //
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.
20+// Every portal call runs under mu because the client is not safe for
21+// concurrent use.
3722 type PortalInhibitor struct {
3823 mu sync.Mutex
39- conn *zerobus.Conn // nil when the portal is unavailable or after Close
40- requestPath string // active inhibit request handle
24+ portal portalClient // nil when the portal is unavailable or after Close
25+ requestPath string // active inhibit request handle
4126 refcount int
4227 }
4328
@@ -49,40 +34,23 @@ func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
4934 logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
5035 return &PortalInhibitor{}
5136 }
52- return newPortalInhibitor(ctx, conn)
37+ return newPortalInhibitor(ctx, &zerobusPortal{conn: conn})
5338 }
5439
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 {
40+// newPortalInhibitor takes ownership of portal and keeps it only if it
41+// answers.
42+func newPortalInhibitor(ctx context.Context, portal portalClient) *PortalInhibitor {
5843 log := logging.FromContext(ctx)
5944
60- version, err := portalVersion(conn)
45+ version, err := portal.Version()
6146 if err != nil {
6247 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
63- _ = conn.Close()
48+ _ = portal.Close()
6449 return &PortalInhibitor{}
6550 }
6651
6752 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68- return &PortalInhibitor{conn: conn}
69-}
70-
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()
53+ return &PortalInhibitor{portal: portal}
8654 }
8755
8856 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -100,12 +68,12 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
10068 return nil
10169 }
10270
103- if p.conn == nil {
71+ if p.portal == nil {
10472 log.Debug().Msg("idle inhibitor: not supported, skipping")
10573 return nil
10674 }
10775
108- handle, err := p.callInhibit(reason)
76+ handle, err := p.portal.Inhibit(reason)
10977 if err != nil {
11078 p.refcount--
11179 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
@@ -121,32 +89,6 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
12189 return nil
12290 }
12391
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
140- }
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
146- }
147- return handle, nil
148-}
149-
15092 // Uninhibit decrements the refcount. When zero, releases inhibition.
15193 func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
15294 log := logging.FromContext(ctx)
@@ -174,9 +116,8 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
174116 // closeRequest releases the active inhibit request. A portal that already
175117 // ended the request answers with an error, which is ignored.
176118 func (p *PortalInhibitor) closeRequest() {
177- if p.conn != nil && p.requestPath != "" {
178- p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")
179- _, _ = p.conn.Call()
119+ if p.portal != nil && p.requestPath != "" {
120+ _ = p.portal.CloseRequest(p.requestPath)
180121 }
181122 p.requestPath = ""
182123 }
@@ -196,10 +137,10 @@ func (p *PortalInhibitor) Close() error {
196137 p.closeRequest()
197138 p.refcount = 0
198139
199- if p.conn == nil {
140+ if p.portal == nil {
200141 return nil
201142 }
202- err := p.conn.Close()
203- p.conn = nil
143+ err := p.portal.Close()
144+ p.portal = nil
204145 return err
205146 }
@@ -4,7 +4,6 @@ package idle
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7- "strings"
8 "sync"7 "sync"
9 8
10 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -12,32 +11,18 @@ import (
12 "github.com/bnema/zerobus"11 "github.com/bnema/zerobus"
13 )12 )
14 13
15-const (
16- portalDest = "org.freedesktop.portal.Desktop"
17- portalPath = "/org/freedesktop/portal/desktop"
18- portalInterface = "org.freedesktop.portal.Inhibit"
19- requestIface = "org.freedesktop.portal.Request"
20- propertiesIface = "org.freedesktop.DBus.Properties"
21-
22- // Inhibit flags from portal spec
23- flagLogout = 1
24- flagUserSwitch = 2
25- flagSuspend = 4
26- flagIdle = 8
27-)
28-
29 // Compile-time interface check.14 // Compile-time interface check.
30 var _ port.IdleInhibitor = (*PortalInhibitor)(nil)15 var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
31 16
32 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.17 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
33 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).18 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
34 //19 //
35-// A zerobus connection is not safe for concurrent use, so every bus call20+// Every portal call runs under mu because the client is not safe for
36-// runs under mu. Calls drop the signals that arrive meanwhile.21+// concurrent use.
37 type PortalInhibitor struct {22 type PortalInhibitor struct {
38 mu sync.Mutex23 mu sync.Mutex
39- conn *zerobus.Conn // nil when the portal is unavailable or after Close24+ portal portalClient // nil when the portal is unavailable or after Close
40- requestPath string // active inhibit request handle25+ requestPath string // active inhibit request handle
41 refcount int26 refcount int
42 }27 }
43 28
@@ -49,40 +34,23 @@ func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
49 logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")34 logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
50 return &PortalInhibitor{}35 return &PortalInhibitor{}
51 }36 }
52- return newPortalInhibitor(ctx, conn)37+ return newPortalInhibitor(ctx, &zerobusPortal{conn: conn})
53 }38 }
54 39
55-// newPortalInhibitor takes ownership of conn and keeps it only if the portal40+// newPortalInhibitor takes ownership of portal and keeps it only if it
56-// answers on it.41+// answers.
57-func newPortalInhibitor(ctx context.Context, conn *zerobus.Conn) *PortalInhibitor {42+func newPortalInhibitor(ctx context.Context, portal portalClient) *PortalInhibitor {
58 log := logging.FromContext(ctx)43 log := logging.FromContext(ctx)
59 44
60- version, err := portalVersion(conn)45+ version, err := portal.Version()
61 if err != nil {46 if err != nil {
62 log.Debug().Err(err).Msg("idle inhibitor: portal not available")47 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
63- _ = conn.Close()48+ _ = portal.Close()
64 return &PortalInhibitor{}49 return &PortalInhibitor{}
65 }50 }
66 51
67 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")52 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68- return &PortalInhibitor{conn: conn}53+ return &PortalInhibitor{portal: portal}
69-}
70-
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()
86 }54 }
87 55
88 // Inhibit increments the inhibit refcount. First call activates inhibition.56 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -100,12 +68,12 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
100 return nil68 return nil
101 }69 }
102 70
103- if p.conn == nil {71+ if p.portal == nil {
104 log.Debug().Msg("idle inhibitor: not supported, skipping")72 log.Debug().Msg("idle inhibitor: not supported, skipping")
105 return nil73 return nil
106 }74 }
107 75
108- handle, err := p.callInhibit(reason)76+ handle, err := p.portal.Inhibit(reason)
109 if err != nil {77 if err != nil {
110 p.refcount--78 p.refcount--
111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")79 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
@@ -121,32 +89,6 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
121 return nil89 return nil
122 }90 }
123 91
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
140- }
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
146- }
147- return handle, nil
148-}
149-
150 // Uninhibit decrements the refcount. When zero, releases inhibition.92 // Uninhibit decrements the refcount. When zero, releases inhibition.
151 func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {93 func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
152 log := logging.FromContext(ctx)94 log := logging.FromContext(ctx)
@@ -174,9 +116,8 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
174 // closeRequest releases the active inhibit request. A portal that already116 // closeRequest releases the active inhibit request. A portal that already
175 // ended the request answers with an error, which is ignored.117 // ended the request answers with an error, which is ignored.
176 func (p *PortalInhibitor) closeRequest() {118 func (p *PortalInhibitor) closeRequest() {
177- if p.conn != nil && p.requestPath != "" {119+ if p.portal != nil && p.requestPath != "" {
178- p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")120+ _ = p.portal.CloseRequest(p.requestPath)
179- _, _ = p.conn.Call()
180 }121 }
181 p.requestPath = ""122 p.requestPath = ""
182 }123 }
@@ -196,10 +137,10 @@ func (p *PortalInhibitor) Close() error {
196 p.closeRequest()137 p.closeRequest()
197 p.refcount = 0138 p.refcount = 0
198 139
199- if p.conn == nil {140+ if p.portal == nil {
200 return nil141 return nil
201 }142 }
202- err := p.conn.Close()143+ err := p.portal.Close()
203- p.conn = nil144+ p.portal = nil
204 return err145 return err
205 }146 }
M internal/infrastructure/idle/portal_inhibitor_test.go +75 -277
@@ -1,314 +1,112 @@
11 package idle
22
33 import (
4- "bufio"
5- "bytes"
64 "context"
7- "os"
8- "os/exec"
9- "path/filepath"
10- "strings"
5+ "errors"
116 "testing"
127
13- "github.com/bnema/zerobus"
8+ "github.com/bnema/dumber/internal/infrastructure/idle/mocks"
9+ "github.com/stretchr/testify/require"
1410 )
1511
16-const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"
12+const testHandle = "/org/freedesktop/portal/desktop/request/dumber/test"
1713
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-}
14+var errPortal = errors.New("portal error")
3615
37-func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {
16+// newTestInhibitor returns an inhibitor whose portal answered Version.
17+func newTestInhibitor(t *testing.T) (*PortalInhibitor, *mocks.MockPortalClient) {
3818 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)
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- }()
19+ portal := mocks.NewMockPortalClient(t)
20+ portal.EXPECT().Version().Return(uint32(3), nil).Once()
21+ return newPortalInhibitor(context.Background(), portal), portal
5522 }
5623
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-}
97-
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{}
106- }
107-}
108-
109-func (f *fakePortal) none(t *testing.T) {
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-}
24+func TestNewPortalInhibitorClosesUnavailablePortal(t *testing.T) {
25+ portal := mocks.NewMockPortalClient(t)
26+ portal.EXPECT().Version().Return(uint32(0), errPortal).Once()
27+ portal.EXPECT().Close().Return(nil).Once()
11728
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()
122- address := startPrivateSessionBus(t)
123- portal.calls = make(chan portalCall, 8)
124- portal.serve(t, dial(t, address))
29+ p := newPortalInhibitor(context.Background(), portal)
30+ require.Nil(t, p.portal)
12531
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
32+ // Without a portal, the refcount still works and makes no portal call.
33+ ctx := context.Background()
34+ require.NoError(t, p.Inhibit(ctx, "video playing"))
35+ require.True(t, p.IsInhibited())
36+ require.NoError(t, p.Uninhibit(ctx))
37+ require.False(t, p.IsInhibited())
38+ require.NoError(t, p.Close())
13239 }
13340
13441 func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
135- portal := &fakePortal{}
136- p := newTestInhibitor(t, portal)
42+ p, portal := newTestInhibitor(t)
13743 ctx := context.Background()
44+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
13845
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- }
46+ require.NoError(t, p.Inhibit(ctx, "video playing"))
47+ require.NoError(t, p.Inhibit(ctx, "second video"))
48+ require.NoError(t, p.Uninhibit(ctx))
49+ require.True(t, p.IsInhibited(), "released while one inhibit is still held")
14650
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- }
51+ portal.EXPECT().CloseRequest(testHandle).Return(nil).Once()
52+ require.NoError(t, p.Uninhibit(ctx))
53+ require.False(t, p.IsInhibited())
15754
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- }
55+ // Extra Uninhibit calls are no-ops.
56+ require.NoError(t, p.Uninhibit(ctx))
16757 }
16858
169-func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {
170- portal := &fakePortal{endRequest: true}
171- p := newTestInhibitor(t, portal)
59+func TestPortalInhibitorIgnoresCloseRequestError(t *testing.T) {
60+ // A portal that already ended the request answers Close with an error.
61+ p, portal := newTestInhibitor(t)
17262 ctx := context.Background()
63+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
64+ portal.EXPECT().CloseRequest(testHandle).Return(errPortal).Once()
17365
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)
66+ require.NoError(t, p.Inhibit(ctx, "video playing"))
67+ require.NoError(t, p.Uninhibit(ctx))
18268
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- }
69+ // The next inhibition starts a new request.
70+ portal.EXPECT().Inhibit("next video").Return(testHandle, nil).Once()
71+ require.NoError(t, p.Inhibit(ctx, "next video"))
19072 }
19173
19274 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-
75+ p, portal := newTestInhibitor(t)
23776 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-}
77+ portal.EXPECT().Inhibit("video playing").Return("", errPortal).Once()
25178
252-func dial(t *testing.T, address string) *zerobus.Conn {
253- t.Helper()
254- conn, err := zerobus.Dial(address)
255- if err != nil {
256- t.Fatalf("connect to private D-Bus session bus: %v", err)
257- }
258- t.Cleanup(func() { _ = conn.Close() })
259- return conn
260-}
79+ require.ErrorIs(t, p.Inhibit(ctx, "video playing"), errPortal)
80+ require.False(t, p.IsInhibited())
26181
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-
277-func startPrivateSessionBus(t *testing.T) string {
278- t.Helper()
279-
280- if _, err := exec.LookPath("dbus-daemon"); err != nil {
281- t.Skipf("dbus-daemon unavailable: %v", err)
282- }
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-
289- var stderr bytes.Buffer
290- cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
291- cmd.Stderr = &stderr
292-
293- stdout, err := cmd.StdoutPipe()
294- if err != nil {
295- t.Fatalf("open dbus-daemon stdout: %v", err)
296- }
297- err = cmd.Start()
298- if err != nil {
299- t.Fatalf("start dbus-daemon: %v", err)
300- }
82+ // Nothing to release: no CloseRequest expected.
83+ require.NoError(t, p.Uninhibit(ctx))
84+}
30185
302- t.Cleanup(func() {
303- if cmd.Process != nil {
304- _ = cmd.Process.Kill()
305- }
306- _ = cmd.Wait()
307- })
86+func TestPortalInhibitorClose(t *testing.T) {
87+ tests := []struct {
88+ name string
89+ inhibit bool
90+ closeErr error
91+ }{
92+ {name: "releases active request", inhibit: true},
93+ {name: "without active request"},
94+ {name: "returns connection close error once", closeErr: errPortal},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ p, portal := newTestInhibitor(t)
99+ if tt.inhibit {
100+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
101+ portal.EXPECT().CloseRequest(testHandle).Return(nil).Once()
102+ require.NoError(t, p.Inhibit(context.Background(), "video playing"))
103+ }
104+ portal.EXPECT().Close().Return(tt.closeErr).Once()
308105
309- address, err := bufio.NewReader(stdout).ReadString('\n')
310- if err != nil {
311- t.Fatalf("read dbus-daemon address: %v; stderr: %s", err, stderr.String())
106+ require.ErrorIs(t, p.Close(), tt.closeErr)
107+ require.False(t, p.IsInhibited())
108+ // Close is idempotent.
109+ require.NoError(t, p.Close())
110+ })
312111 }
313- return strings.TrimSpace(address)
314112 }
@@ -1,314 +1,112 @@
1 package idle1 package idle
2 2
3 import (3 import (
4- "bufio"
5- "bytes"
6 "context"4 "context"
7- "os"5+ "errors"
8- "os/exec"
9- "path/filepath"
10- "strings"
11 "testing"6 "testing"
12 7
13- "github.com/bnema/zerobus"8+ "github.com/bnema/dumber/internal/infrastructure/idle/mocks"
9+ "github.com/stretchr/testify/require"
14 )10 )
15 11
16-const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"12+const testHandle = "/org/freedesktop/portal/desktop/request/dumber/test"
17 13
18-// portalCall is a method call received by the fake portal.14+var errPortal = errors.New("portal error")
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 15
37-func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {16+// newTestInhibitor returns an inhibitor whose portal answered Version.
17+func newTestInhibitor(t *testing.T) (*PortalInhibitor, *mocks.MockPortalClient) {
38 t.Helper()18 t.Helper()
39- code, err := conn.RequestName(portalDest, zerobus.NameFlagDoNotQueue)19+ portal := mocks.NewMockPortalClient(t)
40- if err != nil || code != zerobus.NamePrimaryOwner {20+ portal.EXPECT().Version().Return(uint32(3), nil).Once()
41- t.Fatalf("RequestName = %d, %v", code, err)21+ return newPortalInhibitor(context.Background(), portal), portal
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 }22 }
56 23
57-func (f *fakePortal) answer(conn *zerobus.Conn, m *zerobus.Message) {24+func TestNewPortalInhibitorClosesUnavailablePortal(t *testing.T) {
58- call := portalCall{member: strings.Clone(m.Member), path: strings.Clone(m.Path)}25+ portal := mocks.NewMockPortalClient(t)
59- switch m.Member {26+ portal.EXPECT().Version().Return(uint32(0), errPortal).Once()
60- case "Get":27+ portal.EXPECT().Close().Return(nil).Once()
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-}
97-
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{}
106- }
107-}
108-
109-func (f *fakePortal) none(t *testing.T) {
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-}
117 28
118-// newTestInhibitor starts a private bus with a fake portal and returns an29+ p := newPortalInhibitor(context.Background(), portal)
119-// inhibitor connected to it.30+ require.Nil(t, p.portal)
120-func newTestInhibitor(t *testing.T, portal *fakePortal) *PortalInhibitor {
121- t.Helper()
122- address := startPrivateSessionBus(t)
123- portal.calls = make(chan portalCall, 8)
124- portal.serve(t, dial(t, address))
125 31
126- p := newPortalInhibitor(context.Background(), dial(t, address))32+ // Without a portal, the refcount still works and makes no portal call.
127- t.Cleanup(func() { _ = p.Close() })33+ ctx := context.Background()
128- if c := portal.next(t); c.member != "Get" {34+ require.NoError(t, p.Inhibit(ctx, "video playing"))
129- t.Fatalf("first call = %s, want Get", c.member)35+ require.True(t, p.IsInhibited())
130- }36+ require.NoError(t, p.Uninhibit(ctx))
131- return p37+ require.False(t, p.IsInhibited())
38+ require.NoError(t, p.Close())
132 }39 }
133 40
134 func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {41 func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
135- portal := &fakePortal{}42+ p, portal := newTestInhibitor(t)
136- p := newTestInhibitor(t, portal)
137 ctx := context.Background()43 ctx := context.Background()
44+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
138 45
139- if err := p.Inhibit(ctx, "video playing"); err != nil {46+ require.NoError(t, p.Inhibit(ctx, "video playing"))
140- t.Fatal(err)47+ require.NoError(t, p.Inhibit(ctx, "second video"))
141- }48+ require.NoError(t, p.Uninhibit(ctx))
142- c := portal.next(t)49+ require.True(t, p.IsInhibited(), "released while one inhibit is still held")
143- if c.member != "Inhibit" || c.flags != flagIdle|flagSuspend || c.reason != "video playing" {
144- t.Fatalf("Inhibit call = %+v", c)
145- }
146 50
147- if err := p.Inhibit(ctx, "second video"); err != nil {51+ portal.EXPECT().CloseRequest(testHandle).Return(nil).Once()
148- t.Fatal(err)52+ require.NoError(t, p.Uninhibit(ctx))
149- }53+ require.False(t, p.IsInhibited())
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 54
158- if err := p.Uninhibit(ctx); err != nil {55+ // Extra Uninhibit calls are no-ops.
159- t.Fatal(err)56+ require.NoError(t, p.Uninhibit(ctx))
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 }57 }
168 58
169-func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {59+func TestPortalInhibitorIgnoresCloseRequestError(t *testing.T) {
170- portal := &fakePortal{endRequest: true}60+ // A portal that already ended the request answers Close with an error.
171- p := newTestInhibitor(t, portal)61+ p, portal := newTestInhibitor(t)
172 ctx := context.Background()62 ctx := context.Background()
63+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
64+ portal.EXPECT().CloseRequest(testHandle).Return(errPortal).Once()
173 65
174- if err := p.Inhibit(ctx, "video playing"); err != nil {66+ require.NoError(t, p.Inhibit(ctx, "video playing"))
175- t.Fatal(err)67+ require.NoError(t, p.Uninhibit(ctx))
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 68
183- // The connection stays usable for the next inhibition.69+ // The next inhibition starts a new request.
184- if err := p.Inhibit(ctx, "next video"); err != nil {70+ portal.EXPECT().Inhibit("next video").Return(testHandle, nil).Once()
185- t.Fatal(err)71+ require.NoError(t, p.Inhibit(ctx, "next video"))
186- }
187- if c := portal.next(t); c.member != "Inhibit" {
188- t.Fatalf("call = %s, want Inhibit", c.member)
189- }
190 }72 }
191 73
192 func TestPortalInhibitorRollsBackFailedInhibit(t *testing.T) {74 func TestPortalInhibitorRollsBackFailedInhibit(t *testing.T) {
193- portal := &fakePortal{failInhibit: true}75+ p, portal := newTestInhibitor(t)
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()76 ctx := context.Background()
238- if err := p.Inhibit(ctx, "video playing"); err != nil {77+ portal.EXPECT().Inhibit("video playing").Return("", errPortal).Once()
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 78
252-func dial(t *testing.T, address string) *zerobus.Conn {79+ require.ErrorIs(t, p.Inhibit(ctx, "video playing"), errPortal)
253- t.Helper()80+ require.False(t, p.IsInhibited())
254- conn, err := zerobus.Dial(address)
255- if err != nil {
256- t.Fatalf("connect to private D-Bus session bus: %v", err)
257- }
258- t.Cleanup(func() { _ = conn.Close() })
259- return conn
260-}
261 81
262-// privateBusConfig is a session bus without service directories, so it never82+ // Nothing to release: no CloseRequest expected.
263-// auto-starts the real portal installed on the host.83+ require.NoError(t, p.Uninhibit(ctx))
264-const privateBusConfig = `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"84+}
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-
277-func startPrivateSessionBus(t *testing.T) string {
278- t.Helper()
279-
280- if _, err := exec.LookPath("dbus-daemon"); err != nil {
281- t.Skipf("dbus-daemon unavailable: %v", err)
282- }
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-
289- var stderr bytes.Buffer
290- cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
291- cmd.Stderr = &stderr
292-
293- stdout, err := cmd.StdoutPipe()
294- if err != nil {
295- t.Fatalf("open dbus-daemon stdout: %v", err)
296- }
297- err = cmd.Start()
298- if err != nil {
299- t.Fatalf("start dbus-daemon: %v", err)
300- }
301 85
302- t.Cleanup(func() {86+func TestPortalInhibitorClose(t *testing.T) {
303- if cmd.Process != nil {87+ tests := []struct {
304- _ = cmd.Process.Kill()88+ name string
305- }89+ inhibit bool
306- _ = cmd.Wait()90+ closeErr error
307- })91+ }{
92+ {name: "releases active request", inhibit: true},
93+ {name: "without active request"},
94+ {name: "returns connection close error once", closeErr: errPortal},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ p, portal := newTestInhibitor(t)
99+ if tt.inhibit {
100+ portal.EXPECT().Inhibit("video playing").Return(testHandle, nil).Once()
101+ portal.EXPECT().CloseRequest(testHandle).Return(nil).Once()
102+ require.NoError(t, p.Inhibit(context.Background(), "video playing"))
103+ }
104+ portal.EXPECT().Close().Return(tt.closeErr).Once()
308 105
309- address, err := bufio.NewReader(stdout).ReadString('\n')106+ require.ErrorIs(t, p.Close(), tt.closeErr)
310- if err != nil {107+ require.False(t, p.IsInhibited())
311- t.Fatalf("read dbus-daemon address: %v; stderr: %s", err, stderr.String())108+ // Close is idempotent.
109+ require.NoError(t, p.Close())
110+ })
312 }111 }
313- return strings.TrimSpace(address)
314 }112 }

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