permission_handler.go RawHistoryFolder

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&notifications != 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}

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