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}