detached ad51442

feat(cef): wire CEF permission requests into existing permission flow

Implement purecef.PermissionHandler on handlerSet so camera, microphone,
screen capture, geolocation and notification requests reach
WebViewCallbacks.OnPermissionRequest. Requests are resolved exactly once
(allow/deny/dismiss/browser close), unsupported types and missing handlers
deny, and CEF callbacks are continued on the CEF UI thread.

Also accept notification/geolocation in the content coordinator and label
them in the permission dialog.
commit
ad51442c0c757616b9f7ee767ada199e3366461b
author
brice · · 2026-10-03 06:05 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
e8e7279
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +660 -2
A internal/infrastructure/cef/permission_handler.go +323 -0
@@ -0,0 +1,323 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ urlutil "github.com/bnema/dumber/internal/domain/url"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// Mask of media access bits Dumber knows how to map to a permission type.
17+const supportedMediaAccessMask = uint32(
18+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture |
19+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture |
20+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
21+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
22+
23+// mediaPermissionTypes maps a CEF media access bitmask to Dumber permission
24+// types. The returned allowedMask contains only the requested bits that Dumber
25+// understands; it is what must be granted when the user allows the request.
26+// An empty request or one containing unknown bits yields no types (deny).
27+func mediaPermissionTypes(requested uint32) (types []string, allowedMask uint32) {
28+ if requested == 0 || requested&^supportedMediaAccessMask != 0 {
29+ return nil, 0
30+ }
31+ allowedMask = requested
32+ if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) != 0 {
33+ types = append(types, string(entity.PermissionTypeMicrophone))
34+ }
35+ if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) != 0 {
36+ types = append(types, string(entity.PermissionTypeCamera))
37+ }
38+ const desktop = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
39+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
40+ if allowedMask&desktop != 0 {
41+ types = append(types, string(entity.PermissionTypeDisplay))
42+ }
43+ return types, allowedMask
44+}
45+
46+// promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
47+// permission types. ok is false when the mask is empty or contains any bit that
48+// Dumber does not support, in which case the whole request must be denied.
49+func promptPermissionTypes(requested uint32) (types []string, ok bool) {
50+ if requested == 0 {
51+ return nil, false
52+ }
53+ const (
54+ camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream |
55+ purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
56+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
57+ geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
58+ notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
59+ supported = camera | mic | geolocation | notifications
60+ )
61+ if requested&^supported != 0 {
62+ return nil, false
63+ }
64+ if requested&mic != 0 {
65+ types = append(types, string(entity.PermissionTypeMicrophone))
66+ }
67+ if requested&camera != 0 {
68+ types = append(types, string(entity.PermissionTypeCamera))
69+ }
70+ if requested&geolocation != 0 {
71+ types = append(types, string(entity.PermissionTypeGeolocation))
72+ }
73+ if requested&notifications != 0 {
74+ types = append(types, string(entity.PermissionTypeNotification))
75+ }
76+ return types, true
77+}
78+
79+// cefPermissionRequest guarantees that a CEF permission callback is resolved
80+// exactly once, whichever of allow, deny, dismissal or browser destruction
81+// happens first.
82+type cefPermissionRequest struct {
83+ done atomic.Bool
84+ finish func(allow bool)
85+ untrack func()
86+}
87+
88+// resolve completes the request with the given decision. Only the first call
89+// (of resolve and abandon combined) has any effect.
90+func (r *cefPermissionRequest) resolve(allow bool) {
91+ if !r.done.CompareAndSwap(false, true) {
92+ return
93+ }
94+ if r.untrack != nil {
95+ r.untrack()
96+ }
97+ r.finish(allow)
98+}
99+
100+// abandon marks the request as finished without touching the CEF callback.
101+// It is used when CEF has already invalidated the callback (prompt dismissed).
102+func (r *cefPermissionRequest) abandon() bool {
103+ if !r.done.CompareAndSwap(false, true) {
104+ return false
105+ }
106+ if r.untrack != nil {
107+ r.untrack()
108+ }
109+ return true
110+}
111+
112+func (r *cefPermissionRequest) isDone() bool { return r.done.Load() }
113+
114+type permissionKey struct {
115+ prompt bool
116+ id uint64
117+}
118+
119+// permissionTracker holds the unresolved requests of one WebView. The zero
120+// value is ready to use.
121+type permissionTracker struct {
122+ mu sync.Mutex
123+ nextID uint64
124+ pending map[permissionKey]*cefPermissionRequest
125+}
126+
127+func (t *permissionTracker) nextMediaKey() permissionKey {
128+ t.mu.Lock()
129+ defer t.mu.Unlock()
130+ t.nextID++
131+ return permissionKey{id: t.nextID}
132+}
133+
134+// add registers req under key and returns any request previously registered
135+// under the same key.
136+func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest {
137+ t.mu.Lock()
138+ defer t.mu.Unlock()
139+ if t.pending == nil {
140+ t.pending = make(map[permissionKey]*cefPermissionRequest)
141+ }
142+ prev := t.pending[key]
143+ t.pending[key] = req
144+ req.untrack = func() {
145+ t.mu.Lock()
146+ defer t.mu.Unlock()
147+ if t.pending[key] == req {
148+ delete(t.pending, key)
149+ }
150+ }
151+ return prev
152+}
153+
154+func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest {
155+ t.mu.Lock()
156+ defer t.mu.Unlock()
157+ return t.pending[key]
158+}
159+
160+func (t *permissionTracker) drain() []*cefPermissionRequest {
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ reqs := make([]*cefPermissionRequest, 0, len(t.pending))
164+ for _, req := range t.pending {
165+ reqs = append(reqs, req)
166+ }
167+ return reqs
168+}
169+
170+// OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests.
171+func (h *handlerSet) OnRequestMediaAccessPermission(
172+ _ purecef.Browser,
173+ _ purecef.Frame,
174+ requestingOrigin string,
175+ requestedPermissions uint32,
176+ callback purecef.MediaAccessCallback,
177+) int32 {
178+ if h == nil || h.wv == nil || callback == nil {
179+ return 0
180+ }
181+ types, allowedMask := mediaPermissionTypes(requestedPermissions)
182+ h.beginPermissionRequest(h.permissions.nextMediaKey(), requestingOrigin, types, func(allow bool) {
183+ if allow && allowedMask != 0 {
184+ callback.Cont(allowedMask)
185+ return
186+ }
187+ callback.Cancel()
188+ })
189+ return 1
190+}
191+
192+// OnShowPermissionPrompt handles non-media permission requests
193+// (geolocation, notifications, ...).
194+func (h *handlerSet) OnShowPermissionPrompt(
195+ _ purecef.Browser,
196+ promptID uint64,
197+ requestingOrigin string,
198+ requestedPermissions uint32,
199+ callback purecef.PermissionPromptCallback,
200+) int32 {
201+ if h == nil || h.wv == nil || callback == nil {
202+ return 0
203+ }
204+ types, _ := promptPermissionTypes(requestedPermissions)
205+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, requestingOrigin, types, func(allow bool) {
206+ if allow {
207+ callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
208+ return
209+ }
210+ callback.Cont(purecef.PermissionRequestResultPermissionResultDeny)
211+ })
212+ return 1
213+}
214+
215+// OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled.
216+// The callback is no longer valid, so it must not be invoked anymore.
217+func (h *handlerSet) OnDismissPermissionPrompt(
218+ _ purecef.Browser,
219+ promptID uint64,
220+ result purecef.PermissionRequestResult,
221+) {
222+ if h == nil || h.wv == nil {
223+ return
224+ }
225+ req := h.permissions.get(permissionKey{prompt: true, id: promptID})
226+ if req == nil || !req.abandon() {
227+ return
228+ }
229+ logCEFPermission(h.wv).Debug().
230+ Uint64("prompt_id", promptID).
231+ Int32("result", result).
232+ Msg("cef: permission prompt dismissed")
233+}
234+
235+// denyPendingPermissions resolves every unresolved request as denied. It is
236+// called when the browser is closing so nothing is left hanging.
237+func (h *handlerSet) denyPendingPermissions() {
238+ if h == nil {
239+ return
240+ }
241+ for _, req := range h.permissions.drain() {
242+ req.resolve(false)
243+ }
244+}
245+
246+// beginPermissionRequest registers a request, forwards it to the WebView's
247+// OnPermissionRequest callback on the GTK thread, and guarantees finish is
248+// invoked exactly once on the CEF UI thread. It denies when the request cannot
249+// be mapped, the origin is invalid, no callback is available, or the WebView
250+// is destroyed.
251+func (h *handlerSet) beginPermissionRequest(
252+ key permissionKey,
253+ requestingOrigin string,
254+ types []string,
255+ finish func(allow bool),
256+) {
257+ wv := h.wv
258+ req := &cefPermissionRequest{
259+ finish: func(allow bool) {
260+ dispatchPermissionResult(wv, func() { finish(allow) })
261+ },
262+ }
263+ if prev := h.permissions.add(key, req); prev != nil {
264+ prev.resolve(false)
265+ }
266+
267+ log := logCEFPermission(wv)
268+ if len(types) == 0 || wv.destroyed.Load() {
269+ log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request")
270+ req.resolve(false)
271+ return
272+ }
273+ origin, err := urlutil.ExtractOrigin(requestingOrigin)
274+ if err != nil {
275+ log.Debug().Err(err).Msg("cef: denying permission request with invalid origin")
276+ req.resolve(false)
277+ return
278+ }
279+
280+ wv.mu.RLock()
281+ cb := wv.callbacks
282+ wv.mu.RUnlock()
283+ if cb == nil || cb.OnPermissionRequest == nil {
284+ log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler")
285+ req.resolve(false)
286+ return
287+ }
288+
289+ allow := func() { req.resolve(true) }
290+ deny := func() { req.resolve(false) }
291+ wv.runOnGTK(func() {
292+ if req.isDone() {
293+ return
294+ }
295+ if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) {
296+ req.resolve(false)
297+ }
298+ })
299+}
300+
301+// dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission
302+// callbacks must be continued there. When the UI thread is unreachable fn runs
303+// inline so the page never hangs.
304+func dispatchPermissionResult(wv *WebView, fn func()) {
305+ if wv == nil || wv.engine == nil {
306+ fn()
307+ return
308+ }
309+ task := cefNewTask(cefTaskFunc(fn))
310+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
311+ logCEFPermission(wv).Warn().
312+ Int32("post_result", result).
313+ Msg("cef: failed to post permission result to CEF UI thread; continuing inline")
314+ fn()
315+ }
316+}
317+
318+func logCEFPermission(wv *WebView) *zerolog.Logger {
319+ if wv == nil || wv.ctx == nil {
320+ return logging.FromContext(context.Background())
321+ }
322+ return logging.FromContext(wv.ctx)
323+}
@@ -0,0 +1,323 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ urlutil "github.com/bnema/dumber/internal/domain/url"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// Mask of media access bits Dumber knows how to map to a permission type.
17+const supportedMediaAccessMask = uint32(
18+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture |
19+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture |
20+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
21+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
22+
23+// mediaPermissionTypes maps a CEF media access bitmask to Dumber permission
24+// types. The returned allowedMask contains only the requested bits that Dumber
25+// understands; it is what must be granted when the user allows the request.
26+// An empty request or one containing unknown bits yields no types (deny).
27+func mediaPermissionTypes(requested uint32) (types []string, allowedMask uint32) {
28+ if requested == 0 || requested&^supportedMediaAccessMask != 0 {
29+ return nil, 0
30+ }
31+ allowedMask = requested
32+ if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) != 0 {
33+ types = append(types, string(entity.PermissionTypeMicrophone))
34+ }
35+ if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) != 0 {
36+ types = append(types, string(entity.PermissionTypeCamera))
37+ }
38+ const desktop = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
39+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
40+ if allowedMask&desktop != 0 {
41+ types = append(types, string(entity.PermissionTypeDisplay))
42+ }
43+ return types, allowedMask
44+}
45+
46+// promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
47+// permission types. ok is false when the mask is empty or contains any bit that
48+// Dumber does not support, in which case the whole request must be denied.
49+func promptPermissionTypes(requested uint32) (types []string, ok bool) {
50+ if requested == 0 {
51+ return nil, false
52+ }
53+ const (
54+ camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream |
55+ purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
56+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
57+ geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
58+ notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
59+ supported = camera | mic | geolocation | notifications
60+ )
61+ if requested&^supported != 0 {
62+ return nil, false
63+ }
64+ if requested&mic != 0 {
65+ types = append(types, string(entity.PermissionTypeMicrophone))
66+ }
67+ if requested&camera != 0 {
68+ types = append(types, string(entity.PermissionTypeCamera))
69+ }
70+ if requested&geolocation != 0 {
71+ types = append(types, string(entity.PermissionTypeGeolocation))
72+ }
73+ if requested&notifications != 0 {
74+ types = append(types, string(entity.PermissionTypeNotification))
75+ }
76+ return types, true
77+}
78+
79+// cefPermissionRequest guarantees that a CEF permission callback is resolved
80+// exactly once, whichever of allow, deny, dismissal or browser destruction
81+// happens first.
82+type cefPermissionRequest struct {
83+ done atomic.Bool
84+ finish func(allow bool)
85+ untrack func()
86+}
87+
88+// resolve completes the request with the given decision. Only the first call
89+// (of resolve and abandon combined) has any effect.
90+func (r *cefPermissionRequest) resolve(allow bool) {
91+ if !r.done.CompareAndSwap(false, true) {
92+ return
93+ }
94+ if r.untrack != nil {
95+ r.untrack()
96+ }
97+ r.finish(allow)
98+}
99+
100+// abandon marks the request as finished without touching the CEF callback.
101+// It is used when CEF has already invalidated the callback (prompt dismissed).
102+func (r *cefPermissionRequest) abandon() bool {
103+ if !r.done.CompareAndSwap(false, true) {
104+ return false
105+ }
106+ if r.untrack != nil {
107+ r.untrack()
108+ }
109+ return true
110+}
111+
112+func (r *cefPermissionRequest) isDone() bool { return r.done.Load() }
113+
114+type permissionKey struct {
115+ prompt bool
116+ id uint64
117+}
118+
119+// permissionTracker holds the unresolved requests of one WebView. The zero
120+// value is ready to use.
121+type permissionTracker struct {
122+ mu sync.Mutex
123+ nextID uint64
124+ pending map[permissionKey]*cefPermissionRequest
125+}
126+
127+func (t *permissionTracker) nextMediaKey() permissionKey {
128+ t.mu.Lock()
129+ defer t.mu.Unlock()
130+ t.nextID++
131+ return permissionKey{id: t.nextID}
132+}
133+
134+// add registers req under key and returns any request previously registered
135+// under the same key.
136+func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest {
137+ t.mu.Lock()
138+ defer t.mu.Unlock()
139+ if t.pending == nil {
140+ t.pending = make(map[permissionKey]*cefPermissionRequest)
141+ }
142+ prev := t.pending[key]
143+ t.pending[key] = req
144+ req.untrack = func() {
145+ t.mu.Lock()
146+ defer t.mu.Unlock()
147+ if t.pending[key] == req {
148+ delete(t.pending, key)
149+ }
150+ }
151+ return prev
152+}
153+
154+func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest {
155+ t.mu.Lock()
156+ defer t.mu.Unlock()
157+ return t.pending[key]
158+}
159+
160+func (t *permissionTracker) drain() []*cefPermissionRequest {
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ reqs := make([]*cefPermissionRequest, 0, len(t.pending))
164+ for _, req := range t.pending {
165+ reqs = append(reqs, req)
166+ }
167+ return reqs
168+}
169+
170+// OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests.
171+func (h *handlerSet) OnRequestMediaAccessPermission(
172+ _ purecef.Browser,
173+ _ purecef.Frame,
174+ requestingOrigin string,
175+ requestedPermissions uint32,
176+ callback purecef.MediaAccessCallback,
177+) int32 {
178+ if h == nil || h.wv == nil || callback == nil {
179+ return 0
180+ }
181+ types, allowedMask := mediaPermissionTypes(requestedPermissions)
182+ h.beginPermissionRequest(h.permissions.nextMediaKey(), requestingOrigin, types, func(allow bool) {
183+ if allow && allowedMask != 0 {
184+ callback.Cont(allowedMask)
185+ return
186+ }
187+ callback.Cancel()
188+ })
189+ return 1
190+}
191+
192+// OnShowPermissionPrompt handles non-media permission requests
193+// (geolocation, notifications, ...).
194+func (h *handlerSet) OnShowPermissionPrompt(
195+ _ purecef.Browser,
196+ promptID uint64,
197+ requestingOrigin string,
198+ requestedPermissions uint32,
199+ callback purecef.PermissionPromptCallback,
200+) int32 {
201+ if h == nil || h.wv == nil || callback == nil {
202+ return 0
203+ }
204+ types, _ := promptPermissionTypes(requestedPermissions)
205+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, requestingOrigin, types, func(allow bool) {
206+ if allow {
207+ callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
208+ return
209+ }
210+ callback.Cont(purecef.PermissionRequestResultPermissionResultDeny)
211+ })
212+ return 1
213+}
214+
215+// OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled.
216+// The callback is no longer valid, so it must not be invoked anymore.
217+func (h *handlerSet) OnDismissPermissionPrompt(
218+ _ purecef.Browser,
219+ promptID uint64,
220+ result purecef.PermissionRequestResult,
221+) {
222+ if h == nil || h.wv == nil {
223+ return
224+ }
225+ req := h.permissions.get(permissionKey{prompt: true, id: promptID})
226+ if req == nil || !req.abandon() {
227+ return
228+ }
229+ logCEFPermission(h.wv).Debug().
230+ Uint64("prompt_id", promptID).
231+ Int32("result", result).
232+ Msg("cef: permission prompt dismissed")
233+}
234+
235+// denyPendingPermissions resolves every unresolved request as denied. It is
236+// called when the browser is closing so nothing is left hanging.
237+func (h *handlerSet) denyPendingPermissions() {
238+ if h == nil {
239+ return
240+ }
241+ for _, req := range h.permissions.drain() {
242+ req.resolve(false)
243+ }
244+}
245+
246+// beginPermissionRequest registers a request, forwards it to the WebView's
247+// OnPermissionRequest callback on the GTK thread, and guarantees finish is
248+// invoked exactly once on the CEF UI thread. It denies when the request cannot
249+// be mapped, the origin is invalid, no callback is available, or the WebView
250+// is destroyed.
251+func (h *handlerSet) beginPermissionRequest(
252+ key permissionKey,
253+ requestingOrigin string,
254+ types []string,
255+ finish func(allow bool),
256+) {
257+ wv := h.wv
258+ req := &cefPermissionRequest{
259+ finish: func(allow bool) {
260+ dispatchPermissionResult(wv, func() { finish(allow) })
261+ },
262+ }
263+ if prev := h.permissions.add(key, req); prev != nil {
264+ prev.resolve(false)
265+ }
266+
267+ log := logCEFPermission(wv)
268+ if len(types) == 0 || wv.destroyed.Load() {
269+ log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request")
270+ req.resolve(false)
271+ return
272+ }
273+ origin, err := urlutil.ExtractOrigin(requestingOrigin)
274+ if err != nil {
275+ log.Debug().Err(err).Msg("cef: denying permission request with invalid origin")
276+ req.resolve(false)
277+ return
278+ }
279+
280+ wv.mu.RLock()
281+ cb := wv.callbacks
282+ wv.mu.RUnlock()
283+ if cb == nil || cb.OnPermissionRequest == nil {
284+ log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler")
285+ req.resolve(false)
286+ return
287+ }
288+
289+ allow := func() { req.resolve(true) }
290+ deny := func() { req.resolve(false) }
291+ wv.runOnGTK(func() {
292+ if req.isDone() {
293+ return
294+ }
295+ if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) {
296+ req.resolve(false)
297+ }
298+ })
299+}
300+
301+// dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission
302+// callbacks must be continued there. When the UI thread is unreachable fn runs
303+// inline so the page never hangs.
304+func dispatchPermissionResult(wv *WebView, fn func()) {
305+ if wv == nil || wv.engine == nil {
306+ fn()
307+ return
308+ }
309+ task := cefNewTask(cefTaskFunc(fn))
310+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
311+ logCEFPermission(wv).Warn().
312+ Int32("post_result", result).
313+ Msg("cef: failed to post permission result to CEF UI thread; continuing inline")
314+ fn()
315+ }
316+}
317+
318+func logCEFPermission(wv *WebView) *zerolog.Logger {
319+ if wv == nil || wv.ctx == nil {
320+ return logging.FromContext(context.Background())
321+ }
322+ return logging.FromContext(wv.ctx)
323+}
A internal/infrastructure/cef/permission_handler_test.go +298 -0
@@ -0,0 +1,298 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+type stubMediaAccessCallback struct {
15+ contCalls []uint32
16+ cancelCalls int
17+}
18+
19+func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) }
20+func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ }
21+
22+type stubPermissionPromptCallback struct {
23+ results []purecef.PermissionRequestResult
24+}
25+
26+func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) {
27+ s.results = append(s.results, result)
28+}
29+
30+type permissionCapture struct {
31+ origin string
32+ types []string
33+ allow func()
34+ deny func()
35+ calls int
36+}
37+
38+func newPermissionTestHandler(handled bool) (*handlerSet, *permissionCapture) {
39+ capture := &permissionCapture{}
40+ wv := &WebView{
41+ ctx: context.Background(),
42+ callbacks: &port.WebViewCallbacks{
43+ OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool {
44+ capture.calls++
45+ capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny
46+ return handled
47+ },
48+ },
49+ }
50+ return &handlerSet{wv: wv}, capture
51+}
52+
53+const (
54+ mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
55+ mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
56+ mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
57+ mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
58+)
59+
60+func TestMediaPermissionTypes(t *testing.T) {
61+ tests := []struct {
62+ name string
63+ requested uint32
64+ types []string
65+ allowed uint32
66+ }{
67+ {"none", 0, nil, 0},
68+ {"microphone", mediaMic, []string{"microphone"}, mediaMic},
69+ {"camera", mediaCamera, []string{"camera"}, mediaCamera},
70+ {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}, mediaMic | mediaCamera},
71+ {"desktop video", mediaDeskV, []string{"display"}, mediaDeskV},
72+ {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}, mediaDeskA | mediaDeskV},
73+ {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}, mediaCamera | mediaDeskV},
74+ {"unknown bit denies everything", mediaMic | 1<<10, nil, 0},
75+ }
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ types, allowed := mediaPermissionTypes(tt.requested)
79+ assert.Equal(t, tt.types, types)
80+ assert.Equal(t, tt.allowed, allowed)
81+ })
82+ }
83+}
84+
85+func TestPromptPermissionTypes(t *testing.T) {
86+ const (
87+ geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
88+ notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
89+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
90+ cam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
91+ ptz = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
92+ clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
93+ )
94+ tests := []struct {
95+ name string
96+ requested uint32
97+ types []string
98+ ok bool
99+ }{
100+ {"none", 0, nil, false},
101+ {"geolocation", geo, []string{"geolocation"}, true},
102+ {"notifications", notif, []string{"notification"}, true},
103+ {"mic and camera", mic | cam, []string{"microphone", "camera"}, true},
104+ {"camera pan tilt zoom", ptz, []string{"camera"}, true},
105+ {"unsupported", clip, nil, false},
106+ {"supported plus unsupported denies all", geo | clip, nil, false},
107+ }
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ types, ok := promptPermissionTypes(tt.requested)
111+ assert.Equal(t, tt.ok, ok)
112+ assert.Equal(t, tt.types, types)
113+ })
114+ }
115+}
116+
117+func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) {
118+ h, capture := newPermissionTestHandler(true)
119+ cb := &stubMediaAccessCallback{}
120+
121+ handled := h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com/room", mediaMic|mediaCamera, cb)
122+
123+ require.Equal(t, int32(1), handled)
124+ require.Equal(t, "https://meet.example.com", capture.origin)
125+ require.Equal(t, []string{"microphone", "camera"}, capture.types)
126+ require.Empty(t, cb.contCalls)
127+
128+ capture.allow()
129+ capture.allow()
130+ capture.deny()
131+
132+ assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls)
133+ assert.Zero(t, cb.cancelCalls)
134+ assert.Empty(t, h.permissions.pending)
135+}
136+
137+func TestOnRequestMediaAccessPermissionDenyOnlyOnce(t *testing.T) {
138+ h, capture := newPermissionTestHandler(true)
139+ cb := &stubMediaAccessCallback{}
140+
141+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
142+ capture.deny()
143+ capture.allow()
144+ capture.deny()
145+
146+ assert.Empty(t, cb.contCalls)
147+ assert.Equal(t, 1, cb.cancelCalls)
148+}
149+
150+func TestOnRequestMediaAccessPermissionDeniesWhenNotHandledOrUnsupported(t *testing.T) {
151+ t.Run("handler returns false", func(t *testing.T) {
152+ h, _ := newPermissionTestHandler(false)
153+ cb := &stubMediaAccessCallback{}
154+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
155+ assert.Equal(t, 1, cb.cancelCalls)
156+ assert.Empty(t, cb.contCalls)
157+ })
158+ t.Run("unknown bits", func(t *testing.T) {
159+ h, capture := newPermissionTestHandler(true)
160+ cb := &stubMediaAccessCallback{}
161+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic|1<<10, cb))
162+ assert.Equal(t, 1, cb.cancelCalls)
163+ assert.Zero(t, capture.calls)
164+ })
165+ t.Run("invalid origin", func(t *testing.T) {
166+ h, capture := newPermissionTestHandler(true)
167+ cb := &stubMediaAccessCallback{}
168+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "", mediaMic, cb))
169+ assert.Equal(t, 1, cb.cancelCalls)
170+ assert.Zero(t, capture.calls)
171+ })
172+}
173+
174+func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) {
175+ for name, cbs := range map[string]*port.WebViewCallbacks{
176+ "nil callbacks": nil,
177+ "nil OnPermissionRequest fn": {},
178+ } {
179+ t.Run(name, func(t *testing.T) {
180+ h := &handlerSet{wv: &WebView{ctx: context.Background(), callbacks: cbs}}
181+ cb := &stubMediaAccessCallback{}
182+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
183+ assert.Equal(t, 1, cb.cancelCalls)
184+ assert.Empty(t, cb.contCalls)
185+ })
186+ }
187+}
188+
189+func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) {
190+ h, capture := newPermissionTestHandler(true)
191+ h.wv.destroyed.Store(true)
192+ cb := &stubMediaAccessCallback{}
193+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
194+ assert.Equal(t, 1, cb.cancelCalls)
195+ assert.Zero(t, capture.calls)
196+}
197+
198+func TestOnShowPermissionPromptAllowAndDeny(t *testing.T) {
199+ const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
200+
201+ h, capture := newPermissionTestHandler(true)
202+ allowCb := &stubPermissionPromptCallback{}
203+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 7, "https://maps.example", geo, allowCb))
204+ require.Equal(t, []string{"geolocation"}, capture.types)
205+ capture.allow()
206+ capture.deny()
207+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultAccept}, allowCb.results)
208+
209+ denyCb := &stubPermissionPromptCallback{}
210+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 8, "https://maps.example", geo, denyCb))
211+ capture.deny()
212+ capture.allow()
213+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, denyCb.results)
214+}
215+
216+func TestOnShowPermissionPromptDeniesUnsupportedType(t *testing.T) {
217+ const clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
218+ h, capture := newPermissionTestHandler(true)
219+ cb := &stubPermissionPromptCallback{}
220+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 1, "https://a.example", clip, cb))
221+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, cb.results)
222+ assert.Zero(t, capture.calls)
223+}
224+
225+func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) {
226+ const notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
227+ h, capture := newPermissionTestHandler(true)
228+ cb := &stubPermissionPromptCallback{}
229+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 3, "https://a.example", notif, cb))
230+
231+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
232+ capture.allow()
233+ capture.deny()
234+
235+ assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued")
236+ assert.Empty(t, h.permissions.pending)
237+
238+ // Dismissing an unknown or already-resolved prompt is a no-op.
239+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
240+ h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss)
241+}
242+
243+func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) {
244+ const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
245+ h, capture := newPermissionTestHandler(true)
246+ media := &stubMediaAccessCallback{}
247+ prompt := &stubPermissionPromptCallback{}
248+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, media))
249+ mediaAllow := capture.allow
250+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 5, "https://a.example", geo, prompt))
251+ promptAllow := capture.allow
252+
253+ h.denyPendingPermissions()
254+ h.denyPendingPermissions()
255+ mediaAllow()
256+ promptAllow()
257+
258+ assert.Equal(t, 1, media.cancelCalls)
259+ assert.Empty(t, media.contCalls)
260+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, prompt.results)
261+ assert.Empty(t, h.permissions.pending)
262+}
263+
264+func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) {
265+ prevNew, prevPost := cefNewTask, cefPostTask
266+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
267+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
268+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
269+
270+ ran := 0
271+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
272+ assert.Equal(t, 1, ran)
273+}
274+
275+func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
276+ prevNew, prevPost := cefNewTask, cefPostTask
277+ var posted purecef.Task
278+ var gotThread purecef.ThreadID
279+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
280+ cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 {
281+ gotThread, posted = id, task
282+ return 1
283+ }
284+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
285+
286+ ran := 0
287+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
288+ require.NotNil(t, posted)
289+ assert.Zero(t, ran)
290+ assert.Equal(t, purecef.ThreadIDTidUi, gotThread)
291+ posted.Execute()
292+ assert.Equal(t, 1, ran)
293+}
294+
295+func TestGetPermissionHandlerEnabled(t *testing.T) {
296+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
297+ assert.NotNil(t, h.GetPermissionHandler())
298+}
@@ -0,0 +1,298 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+type stubMediaAccessCallback struct {
15+ contCalls []uint32
16+ cancelCalls int
17+}
18+
19+func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) }
20+func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ }
21+
22+type stubPermissionPromptCallback struct {
23+ results []purecef.PermissionRequestResult
24+}
25+
26+func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) {
27+ s.results = append(s.results, result)
28+}
29+
30+type permissionCapture struct {
31+ origin string
32+ types []string
33+ allow func()
34+ deny func()
35+ calls int
36+}
37+
38+func newPermissionTestHandler(handled bool) (*handlerSet, *permissionCapture) {
39+ capture := &permissionCapture{}
40+ wv := &WebView{
41+ ctx: context.Background(),
42+ callbacks: &port.WebViewCallbacks{
43+ OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool {
44+ capture.calls++
45+ capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny
46+ return handled
47+ },
48+ },
49+ }
50+ return &handlerSet{wv: wv}, capture
51+}
52+
53+const (
54+ mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
55+ mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
56+ mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
57+ mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
58+)
59+
60+func TestMediaPermissionTypes(t *testing.T) {
61+ tests := []struct {
62+ name string
63+ requested uint32
64+ types []string
65+ allowed uint32
66+ }{
67+ {"none", 0, nil, 0},
68+ {"microphone", mediaMic, []string{"microphone"}, mediaMic},
69+ {"camera", mediaCamera, []string{"camera"}, mediaCamera},
70+ {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}, mediaMic | mediaCamera},
71+ {"desktop video", mediaDeskV, []string{"display"}, mediaDeskV},
72+ {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}, mediaDeskA | mediaDeskV},
73+ {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}, mediaCamera | mediaDeskV},
74+ {"unknown bit denies everything", mediaMic | 1<<10, nil, 0},
75+ }
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ types, allowed := mediaPermissionTypes(tt.requested)
79+ assert.Equal(t, tt.types, types)
80+ assert.Equal(t, tt.allowed, allowed)
81+ })
82+ }
83+}
84+
85+func TestPromptPermissionTypes(t *testing.T) {
86+ const (
87+ geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
88+ notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
89+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
90+ cam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
91+ ptz = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
92+ clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
93+ )
94+ tests := []struct {
95+ name string
96+ requested uint32
97+ types []string
98+ ok bool
99+ }{
100+ {"none", 0, nil, false},
101+ {"geolocation", geo, []string{"geolocation"}, true},
102+ {"notifications", notif, []string{"notification"}, true},
103+ {"mic and camera", mic | cam, []string{"microphone", "camera"}, true},
104+ {"camera pan tilt zoom", ptz, []string{"camera"}, true},
105+ {"unsupported", clip, nil, false},
106+ {"supported plus unsupported denies all", geo | clip, nil, false},
107+ }
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ types, ok := promptPermissionTypes(tt.requested)
111+ assert.Equal(t, tt.ok, ok)
112+ assert.Equal(t, tt.types, types)
113+ })
114+ }
115+}
116+
117+func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) {
118+ h, capture := newPermissionTestHandler(true)
119+ cb := &stubMediaAccessCallback{}
120+
121+ handled := h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com/room", mediaMic|mediaCamera, cb)
122+
123+ require.Equal(t, int32(1), handled)
124+ require.Equal(t, "https://meet.example.com", capture.origin)
125+ require.Equal(t, []string{"microphone", "camera"}, capture.types)
126+ require.Empty(t, cb.contCalls)
127+
128+ capture.allow()
129+ capture.allow()
130+ capture.deny()
131+
132+ assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls)
133+ assert.Zero(t, cb.cancelCalls)
134+ assert.Empty(t, h.permissions.pending)
135+}
136+
137+func TestOnRequestMediaAccessPermissionDenyOnlyOnce(t *testing.T) {
138+ h, capture := newPermissionTestHandler(true)
139+ cb := &stubMediaAccessCallback{}
140+
141+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
142+ capture.deny()
143+ capture.allow()
144+ capture.deny()
145+
146+ assert.Empty(t, cb.contCalls)
147+ assert.Equal(t, 1, cb.cancelCalls)
148+}
149+
150+func TestOnRequestMediaAccessPermissionDeniesWhenNotHandledOrUnsupported(t *testing.T) {
151+ t.Run("handler returns false", func(t *testing.T) {
152+ h, _ := newPermissionTestHandler(false)
153+ cb := &stubMediaAccessCallback{}
154+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
155+ assert.Equal(t, 1, cb.cancelCalls)
156+ assert.Empty(t, cb.contCalls)
157+ })
158+ t.Run("unknown bits", func(t *testing.T) {
159+ h, capture := newPermissionTestHandler(true)
160+ cb := &stubMediaAccessCallback{}
161+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic|1<<10, cb))
162+ assert.Equal(t, 1, cb.cancelCalls)
163+ assert.Zero(t, capture.calls)
164+ })
165+ t.Run("invalid origin", func(t *testing.T) {
166+ h, capture := newPermissionTestHandler(true)
167+ cb := &stubMediaAccessCallback{}
168+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "", mediaMic, cb))
169+ assert.Equal(t, 1, cb.cancelCalls)
170+ assert.Zero(t, capture.calls)
171+ })
172+}
173+
174+func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) {
175+ for name, cbs := range map[string]*port.WebViewCallbacks{
176+ "nil callbacks": nil,
177+ "nil OnPermissionRequest fn": {},
178+ } {
179+ t.Run(name, func(t *testing.T) {
180+ h := &handlerSet{wv: &WebView{ctx: context.Background(), callbacks: cbs}}
181+ cb := &stubMediaAccessCallback{}
182+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
183+ assert.Equal(t, 1, cb.cancelCalls)
184+ assert.Empty(t, cb.contCalls)
185+ })
186+ }
187+}
188+
189+func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) {
190+ h, capture := newPermissionTestHandler(true)
191+ h.wv.destroyed.Store(true)
192+ cb := &stubMediaAccessCallback{}
193+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb))
194+ assert.Equal(t, 1, cb.cancelCalls)
195+ assert.Zero(t, capture.calls)
196+}
197+
198+func TestOnShowPermissionPromptAllowAndDeny(t *testing.T) {
199+ const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
200+
201+ h, capture := newPermissionTestHandler(true)
202+ allowCb := &stubPermissionPromptCallback{}
203+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 7, "https://maps.example", geo, allowCb))
204+ require.Equal(t, []string{"geolocation"}, capture.types)
205+ capture.allow()
206+ capture.deny()
207+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultAccept}, allowCb.results)
208+
209+ denyCb := &stubPermissionPromptCallback{}
210+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 8, "https://maps.example", geo, denyCb))
211+ capture.deny()
212+ capture.allow()
213+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, denyCb.results)
214+}
215+
216+func TestOnShowPermissionPromptDeniesUnsupportedType(t *testing.T) {
217+ const clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
218+ h, capture := newPermissionTestHandler(true)
219+ cb := &stubPermissionPromptCallback{}
220+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 1, "https://a.example", clip, cb))
221+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, cb.results)
222+ assert.Zero(t, capture.calls)
223+}
224+
225+func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) {
226+ const notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
227+ h, capture := newPermissionTestHandler(true)
228+ cb := &stubPermissionPromptCallback{}
229+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 3, "https://a.example", notif, cb))
230+
231+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
232+ capture.allow()
233+ capture.deny()
234+
235+ assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued")
236+ assert.Empty(t, h.permissions.pending)
237+
238+ // Dismissing an unknown or already-resolved prompt is a no-op.
239+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
240+ h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss)
241+}
242+
243+func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) {
244+ const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
245+ h, capture := newPermissionTestHandler(true)
246+ media := &stubMediaAccessCallback{}
247+ prompt := &stubPermissionPromptCallback{}
248+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, media))
249+ mediaAllow := capture.allow
250+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 5, "https://a.example", geo, prompt))
251+ promptAllow := capture.allow
252+
253+ h.denyPendingPermissions()
254+ h.denyPendingPermissions()
255+ mediaAllow()
256+ promptAllow()
257+
258+ assert.Equal(t, 1, media.cancelCalls)
259+ assert.Empty(t, media.contCalls)
260+ assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, prompt.results)
261+ assert.Empty(t, h.permissions.pending)
262+}
263+
264+func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) {
265+ prevNew, prevPost := cefNewTask, cefPostTask
266+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
267+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
268+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
269+
270+ ran := 0
271+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
272+ assert.Equal(t, 1, ran)
273+}
274+
275+func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
276+ prevNew, prevPost := cefNewTask, cefPostTask
277+ var posted purecef.Task
278+ var gotThread purecef.ThreadID
279+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
280+ cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 {
281+ gotThread, posted = id, task
282+ return 1
283+ }
284+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
285+
286+ ran := 0
287+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
288+ require.NotNil(t, posted)
289+ assert.Zero(t, ran)
290+ assert.Equal(t, purecef.ThreadIDTidUi, gotThread)
291+ posted.Execute()
292+ assert.Equal(t, 1, ran)
293+}
294+
295+func TestGetPermissionHandlerEnabled(t *testing.T) {
296+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
297+ assert.NotNil(t, h.GetPermissionHandler())
298+}
M internal/infrastructure/cef/webview_handlers.go +4 -1
@@ -30,6 +30,7 @@ type handlerSet struct {
3030 fileDialogPresenter fileDialogPresenter
3131 renderHandlerOnce sync.Once
3232 renderHandler purecef.RenderHandler
33+ permissions permissionTracker
3334 }
3435
3536 // Compile-time interface checks.
@@ -44,6 +45,7 @@ var (
4445 _ purecef.DialogHandler = (*handlerSet)(nil)
4546 _ purecef.DownloadHandler = (*handlerSet)(nil)
4647 _ purecef.FindHandler = (*handlerSet)(nil)
48+ _ purecef.PermissionHandler = (*handlerSet)(nil)
4749 )
4850
4951 // ===========================================================================
@@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n
7173 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
7274 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
7375 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
76+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h }
7577 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
7678 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
7779 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
@@ -1114,6 +1116,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
11141116
11151117 // OnBeforeClose fires the OnClose callback.
11161118 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1119+ h.denyPendingPermissions()
11171120 if h.wv.engine != nil {
11181121 browserID := int32(0)
11191122 if browser != nil {
@@ -30,6 +30,7 @@ type handlerSet struct {
30 fileDialogPresenter fileDialogPresenter30 fileDialogPresenter fileDialogPresenter
31 renderHandlerOnce sync.Once31 renderHandlerOnce sync.Once
32 renderHandler purecef.RenderHandler32 renderHandler purecef.RenderHandler
33+ permissions permissionTracker
33 }34 }
34 35
35 // Compile-time interface checks.36 // Compile-time interface checks.
@@ -44,6 +45,7 @@ var (
44 _ purecef.DialogHandler = (*handlerSet)(nil)45 _ purecef.DialogHandler = (*handlerSet)(nil)
45 _ purecef.DownloadHandler = (*handlerSet)(nil)46 _ purecef.DownloadHandler = (*handlerSet)(nil)
46 _ purecef.FindHandler = (*handlerSet)(nil)47 _ purecef.FindHandler = (*handlerSet)(nil)
48+ _ purecef.PermissionHandler = (*handlerSet)(nil)
47 )49 )
48 50
49 // ===========================================================================51 // ===========================================================================
@@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n
71 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }73 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }74 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }75 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }76+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h }
75 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }77 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }78 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }79 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
@@ -1114,6 +1116,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
1114 1116
1115 // OnBeforeClose fires the OnClose callback.1117 // OnBeforeClose fires the OnClose callback.
1116 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {1118 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1119+ h.denyPendingPermissions()
1117 if h.wv.engine != nil {1120 if h.wv.engine != nil {
1118 browserID := int32(0)1121 browserID := int32(0)
1119 if browser != nil {1122 if browser != nil {
M internal/ui/coordinator/content/callbacks.go +4 -0
@@ -213,6 +213,10 @@ func (c *Coordinator) handlePermissionRequest(
213213 entityTypes = append(entityTypes, entity.PermissionTypeCamera)
214214 case "display":
215215 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
216+ case "notification":
217+ entityTypes = append(entityTypes, entity.PermissionTypeNotification)
218+ case "geolocation":
219+ entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
216220 case "device_info":
217221 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
218222 case "website_data_access":
@@ -213,6 +213,10 @@ func (c *Coordinator) handlePermissionRequest(
213 entityTypes = append(entityTypes, entity.PermissionTypeCamera)213 entityTypes = append(entityTypes, entity.PermissionTypeCamera)
214 case "display":214 case "display":
215 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)215 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
216+ case "notification":
217+ entityTypes = append(entityTypes, entity.PermissionTypeNotification)
218+ case "geolocation":
219+ entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
216 case "device_info":220 case "device_info":
217 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)221 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
218 case "website_data_access":222 case "website_data_access":
M internal/ui/dialog/permission.go +19 -1
@@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() {
150150
151151 // permFlags holds parsed permission type flags.
152152 type permFlags struct {
153- mic, cam, display, dataAccess bool
153+ mic, cam, display, dataAccess, notification, geolocation bool
154154 }
155155
156156 // parsePermFlags extracts boolean flags from permission types.
@@ -166,6 +166,10 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
166166 f.display = true
167167 case entity.PermissionTypeWebsiteDataAccess:
168168 f.dataAccess = true
169+ case entity.PermissionTypeNotification:
170+ f.notification = true
171+ case entity.PermissionTypeGeolocation:
172+ f.geolocation = true
169173 }
170174 }
171175 return f
@@ -204,6 +208,12 @@ func (d *PermissionDialog) buildHeading(
204208 if f.dataAccess {
205209 labels = append(labels, "Data Access")
206210 }
211+ if f.notification {
212+ labels = append(labels, "Notifications")
213+ }
214+ if f.geolocation {
215+ labels = append(labels, "Location")
216+ }
207217 switch {
208218 case len(labels) == 0:
209219 return "Allow Permission?"
@@ -211,6 +221,8 @@ func (d *PermissionDialog) buildHeading(
211221 return "Allow Third-Party Data Access?"
212222 case len(labels) == 1 && f.display:
213223 return "Allow Screen Sharing?"
224+ case len(labels) == 1 && f.notification:
225+ return "Allow Notifications?"
214226 case len(labels) == 1:
215227 return "Allow " + labels[0] + " Access?"
216228 default:
@@ -234,6 +246,12 @@ func (d *PermissionDialog) buildBody(
234246 if f.display {
235247 parts = append(parts, "share your screen")
236248 }
249+ if f.notification {
250+ parts = append(parts, "show notifications")
251+ }
252+ if f.geolocation {
253+ parts = append(parts, "access your location")
254+ }
237255 if f.dataAccess {
238256 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
239257 curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain]
@@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() {
150 150
151 // permFlags holds parsed permission type flags.151 // permFlags holds parsed permission type flags.
152 type permFlags struct {152 type permFlags struct {
153- mic, cam, display, dataAccess bool153+ mic, cam, display, dataAccess, notification, geolocation bool
154 }154 }
155 155
156 // parsePermFlags extracts boolean flags from permission types.156 // parsePermFlags extracts boolean flags from permission types.
@@ -166,6 +166,10 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
166 f.display = true166 f.display = true
167 case entity.PermissionTypeWebsiteDataAccess:167 case entity.PermissionTypeWebsiteDataAccess:
168 f.dataAccess = true168 f.dataAccess = true
169+ case entity.PermissionTypeNotification:
170+ f.notification = true
171+ case entity.PermissionTypeGeolocation:
172+ f.geolocation = true
169 }173 }
170 }174 }
171 return f175 return f
@@ -204,6 +208,12 @@ func (d *PermissionDialog) buildHeading(
204 if f.dataAccess {208 if f.dataAccess {
205 labels = append(labels, "Data Access")209 labels = append(labels, "Data Access")
206 }210 }
211+ if f.notification {
212+ labels = append(labels, "Notifications")
213+ }
214+ if f.geolocation {
215+ labels = append(labels, "Location")
216+ }
207 switch {217 switch {
208 case len(labels) == 0:218 case len(labels) == 0:
209 return "Allow Permission?"219 return "Allow Permission?"
@@ -211,6 +221,8 @@ func (d *PermissionDialog) buildHeading(
211 return "Allow Third-Party Data Access?"221 return "Allow Third-Party Data Access?"
212 case len(labels) == 1 && f.display:222 case len(labels) == 1 && f.display:
213 return "Allow Screen Sharing?"223 return "Allow Screen Sharing?"
224+ case len(labels) == 1 && f.notification:
225+ return "Allow Notifications?"
214 case len(labels) == 1:226 case len(labels) == 1:
215 return "Allow " + labels[0] + " Access?"227 return "Allow " + labels[0] + " Access?"
216 default:228 default:
@@ -234,6 +246,12 @@ func (d *PermissionDialog) buildBody(
234 if f.display {246 if f.display {
235 parts = append(parts, "share your screen")247 parts = append(parts, "share your screen")
236 }248 }
249+ if f.notification {
250+ parts = append(parts, "show notifications")
251+ }
252+ if f.geolocation {
253+ parts = append(parts, "access your location")
254+ }
237 if f.dataAccess {255 if f.dataAccess {
238 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]256 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
239 curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain]257 curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain]
M internal/ui/dialog/permission_test.go +12 -0
@@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
142142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143143 expectedAction: "access your microphone and camera, and share your screen",
144144 },
145+ {
146+ name: "notification only",
147+ permTypes: []entity.PermissionType{entity.PermissionTypeNotification},
148+ expectHeading: "Allow Notifications?",
149+ expectedAction: "show notifications",
150+ },
151+ {
152+ name: "geolocation only",
153+ permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation},
154+ expectHeading: "Allow Location Access?",
155+ expectedAction: "access your location",
156+ },
145157 {
146158 name: "website data access only",
147159 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},
@@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143 expectedAction: "access your microphone and camera, and share your screen",143 expectedAction: "access your microphone and camera, and share your screen",
144 },144 },
145+ {
146+ name: "notification only",
147+ permTypes: []entity.PermissionType{entity.PermissionTypeNotification},
148+ expectHeading: "Allow Notifications?",
149+ expectedAction: "show notifications",
150+ },
151+ {
152+ name: "geolocation only",
153+ permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation},
154+ expectHeading: "Allow Location Access?",
155+ expectedAction: "access your location",
156+ },
145 {157 {
146 name: "website data access only",158 name: "website data access only",
147 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},159 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},

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