1package cef
2
3import (
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/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 urlutil "github.com/bnema/dumber/internal/domain/url"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17// Mask of media access bits Dumber knows how to map to a permission type.
18const supportedMediaAccessMask = uint32(
19 purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture |
20 purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture |
21 purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
22 purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
23
24// mediaPermissionTypes maps a CEF media access bitmask to Dumber permission
25// types. It returns nil (deny) for an empty mask, a mask containing unknown
26// bits, or desktop audio without desktop video: Chromium would grant a
27// "loopback" system audio device for it without going through any portal.
28func mediaPermissionTypes(requested uint32) []string {
29 const (
30 deviceAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
31 deviceVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
32 desktopAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
33 desktopVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
34 )
35 if requested == 0 || requested&^supportedMediaAccessMask != 0 {
36 return nil
37 }
38 if requested&desktopAudio != 0 && requested&desktopVideo == 0 {
39 return nil
40 }
41 var types []string
42 if requested&deviceAudio != 0 {
43 types = append(types, string(entity.PermissionTypeMicrophone))
44 }
45 if requested&deviceVideo != 0 {
46 types = append(types, string(entity.PermissionTypeCamera))
47 }
48 if requested&desktopVideo != 0 {
49 types = append(types, string(entity.PermissionTypeDisplay))
50 }
51 return types
52}
53
54// mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants
55// the full screen on Cont() without any picker or portal, so Dumber must ask the
56// user explicitly. Desktop audio (system loopback) is flagged separately so the
57// dialog can disclose it.
58func mediaPermissionMetadata(requested uint32) map[string]string {
59 metadata := map[string]string{}
60 if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
61 metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
62 }
63 if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) != 0 {
64 metadata[entity.PermissionMetadataKeyUnmediatedAudio] = entity.PermissionMetadataValueTrue
65 }
66 return metadata
67}
68
69// promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
70// permission types. It returns nil when the mask is empty or contains any bit
71// Dumber does not support.
72func promptPermissionTypes(requested uint32) []string {
73 const (
74 camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
75 mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
76 geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
77 notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
78 supported = camera | mic | geolocation | notifications
79 )
80 if requested == 0 || requested&^supported != 0 {
81 return nil
82 }
83 var types []string
84 if requested&mic != 0 {
85 types = append(types, string(entity.PermissionTypeMicrophone))
86 }
87 if requested&camera != 0 {
88 types = append(types, string(entity.PermissionTypeCamera))
89 }
90 if requested&geolocation != 0 {
91 types = append(types, string(entity.PermissionTypeGeolocation))
92 }
93 if requested¬ifications != 0 {
94 types = append(types, string(entity.PermissionTypeNotification))
95 }
96 return types
97}
98
99// cefPermissionRequest guarantees that a CEF permission callback is resolved
100// exactly once, whichever of allow, deny, dismissal or browser destruction
101// happens first.
102type cefPermissionRequest struct {
103 done atomic.Bool
104 finish func(allow bool)
105 untrack func()
106}
107
108// resolve completes the request with the given decision. Only the first call
109// (of resolve and abandon combined) has any effect.
110func (r *cefPermissionRequest) resolve(allow bool) {
111 if !r.done.CompareAndSwap(false, true) {
112 return
113 }
114 if r.untrack != nil {
115 r.untrack()
116 }
117 r.finish(allow)
118}
119
120// abandon marks the request as finished without touching the CEF callback.
121// It is used when CEF has already invalidated the callback (prompt dismissed).
122func (r *cefPermissionRequest) abandon() bool {
123 if !r.done.CompareAndSwap(false, true) {
124 return false
125 }
126 if r.untrack != nil {
127 r.untrack()
128 }
129 return true
130}
131
132func (r *cefPermissionRequest) isDone() bool { return r.done.Load() }
133
134type permissionKey struct {
135 prompt bool
136 id uint64
137}
138
139// permissionTracker holds the unresolved requests of one WebView. The zero
140// value is ready to use.
141type permissionTracker struct {
142 mu sync.Mutex
143 nextID uint64
144 pending map[permissionKey]*cefPermissionRequest
145}
146
147func (t *permissionTracker) nextMediaKey() permissionKey {
148 t.mu.Lock()
149 defer t.mu.Unlock()
150 t.nextID++
151 return permissionKey{id: t.nextID}
152}
153
154// add registers req under key and returns any request previously registered
155// under the same key.
156func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest {
157 t.mu.Lock()
158 defer t.mu.Unlock()
159 if t.pending == nil {
160 t.pending = make(map[permissionKey]*cefPermissionRequest)
161 }
162 prev := t.pending[key]
163 t.pending[key] = req
164 req.untrack = func() {
165 t.mu.Lock()
166 defer t.mu.Unlock()
167 if t.pending[key] == req {
168 delete(t.pending, key)
169 }
170 }
171 return prev
172}
173
174func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest {
175 t.mu.Lock()
176 defer t.mu.Unlock()
177 return t.pending[key]
178}
179
180func (t *permissionTracker) drain() []*cefPermissionRequest {
181 t.mu.Lock()
182 defer t.mu.Unlock()
183 reqs := make([]*cefPermissionRequest, 0, len(t.pending))
184 for _, req := range t.pending {
185 reqs = append(reqs, req)
186 }
187 return reqs
188}
189
190// OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests.
191func (h *handlerSet) OnRequestMediaAccessPermission(
192 browser purecef.Browser,
193 _ purecef.Frame,
194 requestingOrigin string,
195 requestedPermissions uint32,
196 callback purecef.MediaAccessCallback,
197) int32 {
198 if h == nil || h.wv == nil || callback == nil {
199 return 0
200 }
201 h.beginPermissionRequest(
202 h.permissions.nextMediaKey(), browser, requestingOrigin,
203 mediaPermissionTypes(requestedPermissions),
204 mediaPermissionMetadata(requestedPermissions),
205 func(allow bool) {
206 if allow {
207 callback.Cont(requestedPermissions)
208 return
209 }
210 callback.Cancel()
211 })
212 return 1
213}
214
215// OnShowPermissionPrompt handles non-media permission requests
216// (geolocation, notifications). Unsupported requests are left to CEF's default
217// handling by returning 0.
218func (h *handlerSet) OnShowPermissionPrompt(
219 browser purecef.Browser,
220 promptID uint64,
221 requestingOrigin string,
222 requestedPermissions uint32,
223 callback purecef.PermissionPromptCallback,
224) int32 {
225 if h == nil || h.wv == nil || callback == nil {
226 return 0
227 }
228 types := promptPermissionTypes(requestedPermissions)
229 if len(types) == 0 {
230 return 0
231 }
232 h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) {
233 if allow {
234 callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
235 return
236 }
237 callback.Cont(purecef.PermissionRequestResultPermissionResultDeny)
238 })
239 return 1
240}
241
242// OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled.
243// The callback is no longer valid, so it must not be invoked anymore.
244//
245// Known limitation: a Dumber permission dialog that is already open is not
246// closed on dismissal, because the dialog port has no cancel API. Its eventual
247// answer is ignored by the once-only guard.
248func (h *handlerSet) OnDismissPermissionPrompt(
249 _ purecef.Browser,
250 promptID uint64,
251 result purecef.PermissionRequestResult,
252) {
253 if h == nil || h.wv == nil {
254 return
255 }
256 req := h.permissions.get(permissionKey{prompt: true, id: promptID})
257 if req == nil || !req.abandon() {
258 return
259 }
260 logCEFPermission(h.wv).Debug().
261 Uint64("prompt_id", promptID).
262 Int32("result", result).
263 Msg("cef: permission prompt dismissed")
264}
265
266// denyPendingPermissions resolves every unresolved request as denied. It is
267// called when the browser is closing so nothing is left hanging.
268func (h *handlerSet) denyPendingPermissions() {
269 if h == nil {
270 return
271 }
272 for _, req := range h.permissions.drain() {
273 req.resolve(false)
274 }
275}
276
277// beginPermissionRequest registers a request, forwards it to the WebView's
278// OnPermissionRequest callback on the GTK thread, and guarantees finish is
279// invoked exactly once on the CEF UI thread. It denies when the request cannot
280// be mapped, the top-level origin is unavailable or differs from the requesting
281// origin (cross-origin frames are unsupported), no callback is available, or
282// the WebView is destroyed.
283func (h *handlerSet) beginPermissionRequest(
284 key permissionKey,
285 browser purecef.Browser,
286 requestingOrigin string,
287 types []string,
288 metadata map[string]string,
289 finish func(allow bool),
290) {
291 wv := h.wv
292 req := &cefPermissionRequest{
293 finish: func(allow bool) {
294 dispatchPermissionResult(wv, func() { finish(allow) })
295 },
296 }
297 if prev := h.permissions.add(key, req); prev != nil {
298 prev.resolve(false)
299 }
300
301 log := logCEFPermission(wv)
302 if len(types) == 0 || wv.destroyed.Load() {
303 log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request")
304 req.resolve(false)
305 return
306 }
307 origin, ok := topLevelPermissionOrigin(browser, requestingOrigin)
308 if !ok {
309 log.Debug().Msg("cef: denying permission request; requester is not the top-level origin")
310 req.resolve(false)
311 return
312 }
313
314 wv.mu.RLock()
315 cb := wv.callbacks
316 wv.mu.RUnlock()
317 if cb == nil || cb.OnPermissionRequest == nil {
318 log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler")
319 req.resolve(false)
320 return
321 }
322
323 wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) })
324}
325
326// deliverPermissionRequest runs on the GTK thread. It skips requests that were
327// already resolved (dismissed, closed) and denies when the WebView was destroyed
328// or the callback does not handle the request.
329func (h *handlerSet) deliverPermissionRequest(
330 req *cefPermissionRequest,
331 cb *port.WebViewCallbacks,
332 origin string,
333 types []string,
334 metadata map[string]string,
335) {
336 if req.isDone() {
337 return
338 }
339 if h.wv.destroyed.Load() {
340 req.resolve(false)
341 return
342 }
343 allow := func() { req.resolve(true) }
344 deny := func() { req.resolve(false) }
345 if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) {
346 req.resolve(false)
347 }
348}
349
350// topLevelPermissionOrigin returns the canonical top-level (main frame) origin
351// and whether the requesting origin matches it.
352func topLevelPermissionOrigin(browser purecef.Browser, requestingOrigin string) (string, bool) {
353 if browser == nil {
354 return "", false
355 }
356 frame := browser.GetMainFrame()
357 if frame == nil {
358 return "", false
359 }
360 top, err := urlutil.ExtractOrigin(frame.GetURL())
361 if err != nil {
362 return "", false
363 }
364 requester, err := urlutil.ExtractOrigin(requestingOrigin)
365 if err != nil || requester != top {
366 return "", false
367 }
368 return top, true
369}
370
371// dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission
372// callbacks must be continued there. When the UI thread is unreachable fn runs
373// inline so the page never hangs.
374func dispatchPermissionResult(wv *WebView, fn func()) {
375 if wv == nil || wv.engine == nil {
376 fn()
377 return
378 }
379 task := cefNewTask(cefTaskFunc(fn))
380 if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
381 logCEFPermission(wv).Warn().
382 Int32("post_result", result).
383 Msg("cef: failed to post permission result to CEF UI thread; continuing inline")
384 fn()
385 }
386}
387
388func logCEFPermission(wv *WebView) *zerolog.Logger {
389 if wv == nil || wv.ctx == nil {
390 return logging.FromContext(context.Background())
391 }
392 return logging.FromContext(wv.ctx)
393}