main 5041353

webrtc_permission_indicator.go RawHistoryFolder

1package component
2
3import (
4	"fmt"
5	"sync"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/puregotk/v4/gtk"
9)
10
11// revealerTransitionDuration is the animation duration in milliseconds
12// for the details panel slide-down.
13const revealerTransitionDuration = 180
14
15var trackedWebRTCPermissionTypes = []entity.PermissionType{
16	entity.PermissionTypeMicrophone,
17	entity.PermissionTypeCamera,
18	entity.PermissionTypeDisplay,
19}
20
21// WebRTCPermissionIndicator is a discrete top-right overlay that shows
22// permission activity for microphone, camera, and display capture.
23//
24// Collapsed: a small colored dot.
25// Hovered: expands left and downward to show per-device states.
26// Uses GtkRevealer for smooth slide-down animation of the details panel.
27type WebRTCPermissionIndicator struct {
28	outerBox    *gtk.Box
29	cardBox     *gtk.Box
30	dotBox      *gtk.Box
31	originLabel *gtk.Label
32	detailsBox  *gtk.Box
33	revealer    *gtk.Revealer
34
35	rowButtons map[entity.PermissionType]*gtk.Button
36	states     map[entity.PermissionType]webrtcPermissionState
37	stored     map[entity.PermissionType]entity.PermissionDecision
38
39	currentOrigin string
40	onToggleLock  func(origin string, permType entity.PermissionType, state string, hasStored bool)
41
42	mu                sync.Mutex
43	expanded          bool
44	retainedCallbacks []any
45}
46
47// NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator.
48func NewWebRTCPermissionIndicator() *WebRTCPermissionIndicator {
49	indicator := buildWebRTCPermissionIndicatorWidgets()
50	if indicator == nil {
51		return nil
52	}
53
54	indicator.wireRowButtons()
55	indicator.attachHoverController()
56	indicator.refreshLocked()
57
58	return indicator
59}
60
61// buildWebRTCPermissionIndicatorWidgets creates and assembles all GTK widgets
62// for the indicator: outer container, card, dot, revealer, details, and rows.
63func buildWebRTCPermissionIndicatorWidgets() *WebRTCPermissionIndicator {
64	outer, card := createIndicatorContainers()
65	if outer == nil || card == nil {
66		return nil
67	}
68
69	dotRow, dot, originLabel := createIndicatorDot()
70	if dotRow == nil || dot == nil || originLabel == nil {
71		return nil
72	}
73
74	details := createIndicatorDetails()
75	if details == nil {
76		return nil
77	}
78
79	rowButtons, ok := createWebRTCPermissionRows(details)
80	if !ok {
81		return nil
82	}
83
84	revealer := createIndicatorRevealer(details)
85	if revealer == nil {
86		return nil
87	}
88
89	card.Append(&dotRow.Widget)
90	card.Append(&revealer.Widget)
91	outer.Append(&card.Widget)
92
93	return &WebRTCPermissionIndicator{
94		outerBox:    outer,
95		cardBox:     card,
96		dotBox:      dot,
97		originLabel: originLabel,
98		detailsBox:  details,
99		revealer:    revealer,
100		rowButtons:  rowButtons,
101		states:      make(map[entity.PermissionType]webrtcPermissionState, len(trackedWebRTCPermissionTypes)),
102		stored:      make(map[entity.PermissionType]entity.PermissionDecision, len(trackedWebRTCPermissionTypes)),
103	}
104}
105
106func createIndicatorContainers() (*gtk.Box, *gtk.Box) {
107	outer := gtk.NewBox(gtk.OrientationVerticalValue, 0)
108	if outer == nil {
109		return nil, nil
110	}
111	outer.AddCssClass("webrtc-indicator-outer")
112	outer.SetHalign(gtk.AlignEndValue)
113	outer.SetValign(gtk.AlignStartValue)
114	outer.SetHexpand(true)
115	outer.SetVexpand(true)
116	outer.SetVisible(false)
117
118	card := gtk.NewBox(gtk.OrientationVerticalValue, 0)
119	if card == nil {
120		return nil, nil
121	}
122	card.AddCssClass("webrtc-indicator-card")
123
124	return outer, card
125}
126
127func createIndicatorDot() (*gtk.Box, *gtk.Box, *gtk.Label) {
128	dotRow := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
129	if dotRow == nil {
130		return nil, nil, nil
131	}
132	dotRow.AddCssClass("webrtc-indicator-header")
133	dotRow.SetHalign(gtk.AlignEndValue)
134
135	// Origin label on the left, dot on the right.
136	originText := ""
137	originLabel := gtk.NewLabel(&originText)
138	if originLabel == nil {
139		return nil, nil, nil
140	}
141	originLabel.AddCssClass("webrtc-indicator-origin")
142	originLabel.SetHalign(gtk.AlignStartValue)
143	originLabel.SetVisible(false) // Hidden when collapsed; shown on expand.
144
145	dot := gtk.NewBox(gtk.OrientationVerticalValue, 0)
146	if dot == nil {
147		return nil, nil, nil
148	}
149	dot.AddCssClass("webrtc-indicator-dot")
150	dot.SetHalign(gtk.AlignEndValue)
151	dot.SetValign(gtk.AlignCenterValue)
152
153	dotRow.Append(&originLabel.Widget)
154	dotRow.Append(&dot.Widget)
155
156	return dotRow, dot, originLabel
157}
158
159func createIndicatorDetails() *gtk.Box {
160	details := gtk.NewBox(gtk.OrientationVerticalValue, 0)
161	if details == nil {
162		return nil
163	}
164	details.AddCssClass("webrtc-indicator-details")
165	return details
166}
167
168func createIndicatorRevealer(child *gtk.Box) *gtk.Revealer {
169	revealer := gtk.NewRevealer()
170	if revealer == nil {
171		return nil
172	}
173	revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideDownValue)
174	revealer.SetTransitionDuration(revealerTransitionDuration)
175	revealer.SetRevealChild(false)
176	revealer.AddCssClass("webrtc-indicator-revealer")
177	revealer.SetChild(&child.Widget)
178
179	return revealer
180}
181
182func createWebRTCPermissionRows(details *gtk.Box) (map[entity.PermissionType]*gtk.Button, bool) {
183	rows := make(map[entity.PermissionType]*gtk.Button, len(trackedWebRTCPermissionTypes))
184	for _, permType := range trackedWebRTCPermissionTypes {
185		text := formatRowLabel(permType, webrtcPermissionStateIdle, "", false)
186		row := gtk.NewButtonWithLabel(text)
187		if row == nil {
188			return nil, false
189		}
190		row.AddCssClass("webrtc-indicator-row")
191		row.SetHalign(gtk.AlignFillValue)
192		details.Append(&row.Widget)
193		rows[permType] = row
194	}
195	return rows, true
196}
197
198func (w *WebRTCPermissionIndicator) wireRowButtons() {
199	for _, permType := range trackedWebRTCPermissionTypes {
200		capturedPermType := permType
201		btn := w.rowButtons[capturedPermType]
202		if btn == nil {
203			continue
204		}
205
206		clickCb := func(_ gtk.Button) {
207			w.mu.Lock()
208			origin := w.currentOrigin
209			state := w.states[capturedPermType]
210			_, hasStored := w.stored[capturedPermType]
211			toggle := w.onToggleLock
212			w.mu.Unlock()
213
214			if toggle == nil {
215				return
216			}
217			toggle(origin, capturedPermType, string(state), hasStored)
218		}
219
220		w.retainedCallbacks = append(w.retainedCallbacks, clickCb)
221		btn.ConnectClicked(&clickCb)
222	}
223}
224
225// Widget returns the GTK widget for adding to the main overlay.
226func (w *WebRTCPermissionIndicator) Widget() *gtk.Widget {
227	if w == nil || w.outerBox == nil {
228		return nil
229	}
230	return &w.outerBox.Widget
231}
232
233// MarkRequesting marks tracked permissions as currently requesting access.
234func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) {
235	w.setState(permTypes, webrtcPermissionStateRequesting)
236}
237
238// MarkAllowed marks tracked permissions as allowed.
239func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) {
240	w.setState(permTypes, webrtcPermissionStateAllowed)
241}
242
243// MarkBlocked marks tracked permissions as blocked.
244func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) {
245	w.setState(permTypes, webrtcPermissionStateBlocked)
246}
247
248// Origin returns the current origin tracked by the indicator.
249func (w *WebRTCPermissionIndicator) Origin() string {
250	if w == nil {
251		return ""
252	}
253	w.mu.Lock()
254	defer w.mu.Unlock()
255	return w.currentOrigin
256}
257
258// SetOrigin sets the active origin for lock/unlock actions shown in details.
259func (w *WebRTCPermissionIndicator) SetOrigin(origin string) {
260	if w == nil {
261		return
262	}
263	w.mu.Lock()
264	defer w.mu.Unlock()
265	w.currentOrigin = origin
266	w.refreshLocked()
267}
268
269// SetStoredDecision updates whether a permission type has an explicit stored decision.
270func (w *WebRTCPermissionIndicator) SetStoredDecision(
271	permType entity.PermissionType,
272	decision entity.PermissionDecision,
273	hasStored bool,
274) {
275	if w == nil || !isTrackedWebRTCPermissionType(permType) {
276		return
277	}
278
279	w.mu.Lock()
280	defer w.mu.Unlock()
281
282	if hasStored {
283		w.stored[permType] = decision
284	} else {
285		delete(w.stored, permType)
286	}
287
288	w.refreshLocked()
289}
290
291// Reset clears all permission states and stored decisions, hiding the indicator.
292// Call this when the active page navigates to a different origin.
293func (w *WebRTCPermissionIndicator) Reset() {
294	if w == nil {
295		return
296	}
297
298	w.mu.Lock()
299	defer w.mu.Unlock()
300
301	clear(w.states)
302	clear(w.stored)
303	w.currentOrigin = ""
304
305	w.refreshLocked()
306}
307
308// SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked.
309func (w *WebRTCPermissionIndicator) SetOnToggleLock(
310	callback func(origin string, permType entity.PermissionType, state string, hasStored bool),
311) {
312	if w == nil {
313		return
314	}
315	w.mu.Lock()
316	defer w.mu.Unlock()
317	w.onToggleLock = callback
318}
319
320func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) {
321	if w == nil {
322		return
323	}
324
325	w.mu.Lock()
326	defer w.mu.Unlock()
327
328	for _, permType := range permTypes {
329		if !isTrackedWebRTCPermissionType(permType) {
330			continue
331		}
332		w.states[permType] = state
333	}
334
335	w.refreshLocked()
336}
337
338func (w *WebRTCPermissionIndicator) refreshLocked() {
339	if w.outerBox == nil {
340		return
341	}
342
343	visible := shouldShowWebRTCPermissionIndicator(w.states)
344	w.outerBox.SetVisible(visible)
345
346	summary := summarizeWebRTCPermissionState(w.states)
347	if w.dotBox != nil {
348		updateStateCSSClass(&w.dotBox.Widget, summary)
349	}
350
351	if w.originLabel != nil {
352		origin := w.currentOrigin
353		if origin == "" {
354			origin = "Current site"
355		}
356		w.originLabel.SetText(origin)
357	}
358
359	for _, permType := range trackedWebRTCPermissionTypes {
360		row := w.rowButtons[permType]
361		if row == nil {
362			continue
363		}
364
365		state := w.states[permType]
366		if state == "" {
367			state = webrtcPermissionStateIdle
368		}
369
370		storedDecision, hasStored := w.stored[permType]
371		label := formatRowLabel(permType, state, storedDecision, hasStored)
372		row.SetLabel(label)
373		updateStateCSSClass(&row.Widget, state)
374		updateLockedCSSClass(&row.Widget, hasStored)
375	}
376
377	if !visible {
378		w.setExpandedLocked(false)
379	}
380}
381
382func (w *WebRTCPermissionIndicator) attachHoverController() {
383	if w == nil || w.outerBox == nil {
384		return
385	}
386
387	controller := gtk.NewEventControllerMotion()
388	if controller == nil {
389		return
390	}
391
392	enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
393		w.mu.Lock()
394		defer w.mu.Unlock()
395		w.setExpandedLocked(true)
396	}
397	leaveCb := func(_ gtk.EventControllerMotion) {
398		w.mu.Lock()
399		defer w.mu.Unlock()
400		w.setExpandedLocked(false)
401	}
402
403	w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb)
404	controller.ConnectEnter(&enterCb)
405	controller.ConnectLeave(&leaveCb)
406	w.outerBox.AddController(&controller.EventController)
407}
408
409func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) {
410	if w.revealer == nil || w.cardBox == nil {
411		return
412	}
413	if w.expanded == expanded {
414		return
415	}
416
417	w.expanded = expanded
418	w.revealer.SetRevealChild(expanded)
419
420	// Show/hide origin label so it doesn't take space when collapsed to just a dot.
421	// Toggle hexpand alongside visibility to prevent expand propagation when collapsed.
422	if w.originLabel != nil {
423		w.originLabel.SetVisible(expanded)
424		w.originLabel.SetHexpand(expanded)
425	}
426
427	if expanded {
428		w.cardBox.AddCssClass("expanded")
429	} else {
430		w.cardBox.RemoveCssClass("expanded")
431	}
432}
433
434func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool {
435	switch permType {
436	case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
437		return true
438	default:
439		return false
440	}
441}
442
443func permissionDisplayName(permType entity.PermissionType) string {
444	switch permType {
445	case entity.PermissionTypeMicrophone:
446		return "\U0001F3A4 Mic" // 🎤
447	case entity.PermissionTypeCamera:
448		return "\U0001F4F7 Cam" // 📷
449	case entity.PermissionTypeDisplay:
450		return "\U0001F4BB Screen" // 💻
451	default:
452		return "Unknown"
453	}
454}
455
456func stateDisplayName(state webrtcPermissionState) string {
457	switch state {
458	case webrtcPermissionStateRequesting:
459		return "\u26A0 requesting" // ⚠
460	case webrtcPermissionStateAllowed:
461		return "\u2713 active" // ✓
462	case webrtcPermissionStateBlocked:
463		return "\u2717 blocked" // ✗
464	default:
465		return "idle"
466	}
467}
468
469func formatRowLabel(
470	permType entity.PermissionType,
471	state webrtcPermissionState,
472	storedDecision entity.PermissionDecision,
473	hasStored bool,
474) string {
475	name := permissionDisplayName(permType)
476	status := stateDisplayName(state)
477
478	if hasStored {
479		// Locked: show current policy and offer to reset.
480		policy := "allow"
481		if storedDecision == entity.PermissionDenied {
482			policy = "deny"
483		}
484		return fmt.Sprintf("%s  %s  \U0001F512 %s  \u00B7  reset", name, status, policy)
485	}
486
487	// Not locked: show the flip action.
488	switch state {
489	case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting:
490		return fmt.Sprintf("%s  %s  \u00B7  block", name, status)
491	case webrtcPermissionStateBlocked:
492		return fmt.Sprintf("%s  %s  \u00B7  allow", name, status)
493	default:
494		return fmt.Sprintf("%s  %s", name, status)
495	}
496}
497
498func updateLockedCSSClass(widget *gtk.Widget, locked bool) {
499	if widget == nil {
500		return
501	}
502	if locked {
503		widget.AddCssClass("row-locked")
504	} else {
505		widget.RemoveCssClass("row-locked")
506	}
507}
508
509func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) {
510	if widget == nil {
511		return
512	}
513
514	widget.RemoveCssClass("state-idle")
515	widget.RemoveCssClass("state-requesting")
516	widget.RemoveCssClass("state-allowed")
517	widget.RemoveCssClass("state-blocked")
518
519	switch state {
520	case webrtcPermissionStateRequesting:
521		widget.AddCssClass("state-requesting")
522	case webrtcPermissionStateAllowed:
523		widget.AddCssClass("state-allowed")
524	case webrtcPermissionStateBlocked:
525		widget.AddCssClass("state-blocked")
526	default:
527		widget.AddCssClass("state-idle")
528	}
529}

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