detached c51ceb8

feat(permission): add WebRTC diagnostics and remove unsafe request checks

commit
c51ceb8f47786a3cf55fbe60b76bceef115acc48
author
brice · · 2026-02-08 06:54 UTC
parent
2db8eeb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +1741 -77
M assets/webui_embed.go +2 -2
@@ -4,8 +4,8 @@ import (
44 "embed"
55 )
66
7-// WebUIAssets contains the embedded webui build output (homepage, error, and config pages).
8-// Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config.
7+// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).
8+// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.
99 //
1010 //go:embed webui/*
1111 var WebUIAssets embed.FS
@@ -4,8 +4,8 @@ import (
4 "embed"4 "embed"
5 )5 )
6 6
7-// WebUIAssets contains the embedded webui build output (homepage, error, and config pages).7+// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).
8-// Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config.8+// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.
9 //9 //
10 //go:embed webui/*10 //go:embed webui/*
11 var WebUIAssets embed.FS11 var WebUIAssets embed.FS
M go.mod +2 -2
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.10
6+ github.com/bnema/puregotk-webkit v0.0.12
77 github.com/charmbracelet/bubbles v0.21.1
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
2222 gopkg.in/yaml.v3 v3.0.1
2323 )
2424
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3
25+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92
2626
2727 require (
2828 github.com/atotto/clipboard v0.1.4 // indirect
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.106+ github.com/bnema/puregotk-webkit v0.0.12
7 github.com/charmbracelet/bubbles v0.21.17 github.com/charmbracelet/bubbles v0.21.1
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
22 gopkg.in/yaml.v3 v3.0.122 gopkg.in/yaml.v3 v3.0.1
23 )23 )
24 24
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d325+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92
26 26
27 require (27 require (
28 github.com/atotto/clipboard v0.1.4 // indirect28 github.com/atotto/clipboard v0.1.4 // indirect
M go.sum +4 -4
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
55 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
66 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
7-github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 h1:bT12Jdm3GsJyINzxsk+WrPiMuwLaTQbBc5Z8VidyBl0=
8-github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.10 h1:Qkwxo6UERwsLXFHtcJpV3McmAWpNswf/AGSV9JXWDOk=
10-github.com/bnema/puregotk-webkit v0.0.10/go.mod h1:Hs5dyovh6jPUzyj8iWweRmN0zjPyUlD+JWH7mjypQiw=
7+github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs=
8+github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9+github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y=
10+github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo=
1111 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
1212 github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
1313 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=5 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
6 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=6 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
7-github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 h1:bT12Jdm3GsJyINzxsk+WrPiMuwLaTQbBc5Z8VidyBl0=7+github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs=
8-github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=8+github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.10 h1:Qkwxo6UERwsLXFHtcJpV3McmAWpNswf/AGSV9JXWDOk=9+github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y=
10-github.com/bnema/puregotk-webkit v0.0.10/go.mod h1:Hs5dyovh6jPUzyj8iWweRmN0zjPyUlD+JWH7mjypQiw=10+github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo=
11 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=11 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
12 github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=12 github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
M internal/application/usecase/handle_permission.go +78 -0
@@ -3,6 +3,7 @@ package usecase
33
44 import (
55 "context"
6+ "errors"
67 "sync"
78 "time"
89
@@ -89,6 +90,13 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
8990 callback.Deny()
9091 return
9192 }
93+
94+ if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
95+ log.Debug().Msg("manual override denied auto-allow permission request")
96+ callback.Deny()
97+ return
98+ }
99+
92100 if isAutoAllowSet(permTypes) {
93101 log.Debug().Msg("auto-allowing permission request")
94102 callback.Allow()
@@ -286,3 +294,73 @@ func (uc *HandlePermissionUseCase) persistPermission(
286294 }
287295 }
288296 }
297+
298+// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
299+func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
300+ ctx context.Context,
301+ origin string,
302+ permType entity.PermissionType,
303+) (*entity.PermissionRecord, error) {
304+ if origin == "" {
305+ return nil, errors.New("origin is required")
306+ }
307+ return uc.permRepo.Get(ctx, origin, permType)
308+}
309+
310+// SetManualPermissionDecision stores an explicit decision for origin/type.
311+// This is used by the UI permission indicator as the source of truth for lock state.
312+func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
313+ ctx context.Context,
314+ origin string,
315+ permType entity.PermissionType,
316+ decision entity.PermissionDecision,
317+) error {
318+ if origin == "" {
319+ return errors.New("origin is required")
320+ }
321+ if decision == entity.PermissionPrompt {
322+ return uc.permRepo.Delete(ctx, origin, permType)
323+ }
324+
325+ return uc.permRepo.Set(ctx, &entity.PermissionRecord{
326+ Origin: origin,
327+ Type: permType,
328+ Decision: decision,
329+ UpdatedAt: time.Now().Unix(),
330+ })
331+}
332+
333+// ResetManualPermissionDecision clears any stored decision for origin/type.
334+func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
335+ ctx context.Context,
336+ origin string,
337+ permType entity.PermissionType,
338+) error {
339+ if origin == "" {
340+ return errors.New("origin is required")
341+ }
342+ return uc.permRepo.Delete(ctx, origin, permType)
343+}
344+
345+func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
346+ ctx context.Context,
347+ origin string,
348+ permTypes []entity.PermissionType,
349+) bool {
350+ for _, permType := range permTypes {
351+ if !entity.IsAutoAllow(permType) {
352+ continue
353+ }
354+
355+ record, err := uc.permRepo.Get(ctx, origin, permType)
356+ if err != nil || record == nil {
357+ continue
358+ }
359+
360+ if record.Decision == entity.PermissionDenied {
361+ return true
362+ }
363+ }
364+
365+ return false
366+}
@@ -3,6 +3,7 @@ package usecase
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "sync"7 "sync"
7 "time"8 "time"
8 9
@@ -89,6 +90,13 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
89 callback.Deny()90 callback.Deny()
90 return91 return
91 }92 }
93+
94+ if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
95+ log.Debug().Msg("manual override denied auto-allow permission request")
96+ callback.Deny()
97+ return
98+ }
99+
92 if isAutoAllowSet(permTypes) {100 if isAutoAllowSet(permTypes) {
93 log.Debug().Msg("auto-allowing permission request")101 log.Debug().Msg("auto-allowing permission request")
94 callback.Allow()102 callback.Allow()
@@ -286,3 +294,73 @@ func (uc *HandlePermissionUseCase) persistPermission(
286 }294 }
287 }295 }
288 }296 }
297+
298+// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
299+func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
300+ ctx context.Context,
301+ origin string,
302+ permType entity.PermissionType,
303+) (*entity.PermissionRecord, error) {
304+ if origin == "" {
305+ return nil, errors.New("origin is required")
306+ }
307+ return uc.permRepo.Get(ctx, origin, permType)
308+}
309+
310+// SetManualPermissionDecision stores an explicit decision for origin/type.
311+// This is used by the UI permission indicator as the source of truth for lock state.
312+func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
313+ ctx context.Context,
314+ origin string,
315+ permType entity.PermissionType,
316+ decision entity.PermissionDecision,
317+) error {
318+ if origin == "" {
319+ return errors.New("origin is required")
320+ }
321+ if decision == entity.PermissionPrompt {
322+ return uc.permRepo.Delete(ctx, origin, permType)
323+ }
324+
325+ return uc.permRepo.Set(ctx, &entity.PermissionRecord{
326+ Origin: origin,
327+ Type: permType,
328+ Decision: decision,
329+ UpdatedAt: time.Now().Unix(),
330+ })
331+}
332+
333+// ResetManualPermissionDecision clears any stored decision for origin/type.
334+func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
335+ ctx context.Context,
336+ origin string,
337+ permType entity.PermissionType,
338+) error {
339+ if origin == "" {
340+ return errors.New("origin is required")
341+ }
342+ return uc.permRepo.Delete(ctx, origin, permType)
343+}
344+
345+func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
346+ ctx context.Context,
347+ origin string,
348+ permTypes []entity.PermissionType,
349+) bool {
350+ for _, permType := range permTypes {
351+ if !entity.IsAutoAllow(permType) {
352+ continue
353+ }
354+
355+ record, err := uc.permRepo.Get(ctx, origin, permType)
356+ if err != nil || record == nil {
357+ continue
358+ }
359+
360+ if record.Decision == entity.PermissionDenied {
361+ return true
362+ }
363+ }
364+
365+ return false
366+}
M internal/application/usecase/handle_permission_test.go +78 -2
@@ -12,6 +12,7 @@ import (
1212 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
1516 )
1617
1718 func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
@@ -21,6 +22,9 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
2122
2223 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
2324
25+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
26+ Return(nil, nil)
27+
2428 allowed := false
2529 denied := false
2630 callback := usecase.PermissionCallback{
@@ -35,7 +39,6 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
3539
3640 assert.True(t, allowed, "display capture should be auto-allowed")
3741 assert.False(t, denied)
38- permRepo.AssertNotCalled(t, "Get")
3942 dialog.AssertNotCalled(t, "ShowPermissionDialog")
4043 }
4144
@@ -46,6 +49,9 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
4649
4750 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
4851
52+ permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo).
53+ Return(nil, nil)
54+
4955 allowed := false
5056 callback := usecase.PermissionCallback{
5157 Allow: func() { allowed = true },
@@ -341,8 +347,10 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
341347 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
342348
343349 // Display capture is not persistable per W3C spec
344- // No Get call since it's auto-allowed
350+ // A stored override may exist, so a Get call is expected before auto-allow.
345351 // No Set call should happen even if we try to persist
352+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
353+ Return(nil, nil)
346354
347355 allowed := false
348356 callback := usecase.PermissionCallback{
@@ -381,3 +389,71 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
381389
382390 assert.True(t, denied, "should deny when no dialog presenter is available")
383391 }
392+
393+func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
394+ ctx := testContext()
395+ permRepo := repomocks.NewMockPermissionRepository(t)
396+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
397+
398+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
399+
400+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
401+ Return(&entity.PermissionRecord{
402+ Origin: "https://meet.example.com",
403+ Type: entity.PermissionTypeDisplay,
404+ Decision: entity.PermissionDenied,
405+ }, nil)
406+
407+ denied := false
408+ callback := usecase.PermissionCallback{
409+ Allow: func() {},
410+ Deny: func() { denied = true },
411+ }
412+
413+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
414+ entity.PermissionTypeDisplay,
415+ }, callback)
416+
417+ assert.True(t, denied, "display should be denied when manual override is denied")
418+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
419+}
420+
421+func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
422+ ctx := testContext()
423+ permRepo := repomocks.NewMockPermissionRepository(t)
424+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil)
425+
426+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
427+ Run(func(_ context.Context, record *entity.PermissionRecord) {
428+ assert.Equal(t, "https://app.zoom.us", record.Origin)
429+ assert.Equal(t, entity.PermissionTypeMicrophone, record.Type)
430+ assert.Equal(t, entity.PermissionGranted, record.Decision)
431+ }).
432+ Return(nil)
433+
434+ err := uc.SetManualPermissionDecision(
435+ ctx,
436+ "https://app.zoom.us",
437+ entity.PermissionTypeMicrophone,
438+ entity.PermissionGranted,
439+ )
440+ require.NoError(t, err)
441+
442+ permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
443+ Return(&entity.PermissionRecord{
444+ Origin: "https://app.zoom.us",
445+ Type: entity.PermissionTypeMicrophone,
446+ Decision: entity.PermissionGranted,
447+ }, nil)
448+
449+ record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
450+ require.NoError(t, err)
451+ if assert.NotNil(t, record) {
452+ assert.Equal(t, entity.PermissionGranted, record.Decision)
453+ }
454+
455+ permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
456+ Return(nil)
457+ err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
458+ require.NoError(t, err)
459+}
@@ -12,6 +12,7 @@ import (
12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
15 )16 )
16 17
17 func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {18 func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
@@ -21,6 +22,9 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
21 22
22 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)23 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
23 24
25+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
26+ Return(nil, nil)
27+
24 allowed := false28 allowed := false
25 denied := false29 denied := false
26 callback := usecase.PermissionCallback{30 callback := usecase.PermissionCallback{
@@ -35,7 +39,6 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
35 39
36 assert.True(t, allowed, "display capture should be auto-allowed")40 assert.True(t, allowed, "display capture should be auto-allowed")
37 assert.False(t, denied)41 assert.False(t, denied)
38- permRepo.AssertNotCalled(t, "Get")
39 dialog.AssertNotCalled(t, "ShowPermissionDialog")42 dialog.AssertNotCalled(t, "ShowPermissionDialog")
40 }43 }
41 44
@@ -46,6 +49,9 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
46 49
47 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)50 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
48 51
52+ permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo).
53+ Return(nil, nil)
54+
49 allowed := false55 allowed := false
50 callback := usecase.PermissionCallback{56 callback := usecase.PermissionCallback{
51 Allow: func() { allowed = true },57 Allow: func() { allowed = true },
@@ -341,8 +347,10 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
341 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)347 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
342 348
343 // Display capture is not persistable per W3C spec349 // Display capture is not persistable per W3C spec
344- // No Get call since it's auto-allowed350+ // A stored override may exist, so a Get call is expected before auto-allow.
345 // No Set call should happen even if we try to persist351 // No Set call should happen even if we try to persist
352+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
353+ Return(nil, nil)
346 354
347 allowed := false355 allowed := false
348 callback := usecase.PermissionCallback{356 callback := usecase.PermissionCallback{
@@ -381,3 +389,71 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
381 389
382 assert.True(t, denied, "should deny when no dialog presenter is available")390 assert.True(t, denied, "should deny when no dialog presenter is available")
383 }391 }
392+
393+func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
394+ ctx := testContext()
395+ permRepo := repomocks.NewMockPermissionRepository(t)
396+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
397+
398+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
399+
400+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
401+ Return(&entity.PermissionRecord{
402+ Origin: "https://meet.example.com",
403+ Type: entity.PermissionTypeDisplay,
404+ Decision: entity.PermissionDenied,
405+ }, nil)
406+
407+ denied := false
408+ callback := usecase.PermissionCallback{
409+ Allow: func() {},
410+ Deny: func() { denied = true },
411+ }
412+
413+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
414+ entity.PermissionTypeDisplay,
415+ }, callback)
416+
417+ assert.True(t, denied, "display should be denied when manual override is denied")
418+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
419+}
420+
421+func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
422+ ctx := testContext()
423+ permRepo := repomocks.NewMockPermissionRepository(t)
424+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil)
425+
426+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
427+ Run(func(_ context.Context, record *entity.PermissionRecord) {
428+ assert.Equal(t, "https://app.zoom.us", record.Origin)
429+ assert.Equal(t, entity.PermissionTypeMicrophone, record.Type)
430+ assert.Equal(t, entity.PermissionGranted, record.Decision)
431+ }).
432+ Return(nil)
433+
434+ err := uc.SetManualPermissionDecision(
435+ ctx,
436+ "https://app.zoom.us",
437+ entity.PermissionTypeMicrophone,
438+ entity.PermissionGranted,
439+ )
440+ require.NoError(t, err)
441+
442+ permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
443+ Return(&entity.PermissionRecord{
444+ Origin: "https://app.zoom.us",
445+ Type: entity.PermissionTypeMicrophone,
446+ Decision: entity.PermissionGranted,
447+ }, nil)
448+
449+ record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
450+ require.NoError(t, err)
451+ if assert.NotNil(t, record) {
452+ assert.Equal(t, entity.PermissionGranted, record.Decision)
453+ }
454+
455+ permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
456+ Return(nil)
457+ err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
458+ require.NoError(t, err)
459+}
M internal/bootstrap/webkit_stack.go +11 -2
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/infrastructure/webkit"
1414 "github.com/bnema/dumber/internal/logging"
1515 "github.com/bnema/dumber/internal/ui/theme"
16+ "github.com/jwijenbergh/puregotk/v4/gdk"
1617 "github.com/rs/zerolog"
1718 )
1819
@@ -211,14 +212,22 @@ func initWebViewPool(
211212 pool.SetFilterApplier(filterManager)
212213 }
213214
214- if err := pool.PrewarmFirst(ctx); err != nil {
215- logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
215+ if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) {
216+ if err := pool.PrewarmFirst(ctx); err != nil {
217+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
218+ }
219+ } else {
220+ logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
216221 }
217222 logging.Trace().Mark("pool_prewarm_first")
218223
219224 return pool
220225 }
221226
227+func shouldPrewarmFirstWebView(display *gdk.Display) bool {
228+ return display != nil
229+}
230+
222231 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
223232 func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
224233 return p.WebProcessMemoryLimitMB > 0 ||
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/infrastructure/webkit"13 "github.com/bnema/dumber/internal/infrastructure/webkit"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/dumber/internal/ui/theme"15 "github.com/bnema/dumber/internal/ui/theme"
16+ "github.com/jwijenbergh/puregotk/v4/gdk"
16 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
17 )18 )
18 19
@@ -211,14 +212,22 @@ func initWebViewPool(
211 pool.SetFilterApplier(filterManager)212 pool.SetFilterApplier(filterManager)
212 }213 }
213 214
214- if err := pool.PrewarmFirst(ctx); err != nil {215+ if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) {
215- logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")216+ if err := pool.PrewarmFirst(ctx); err != nil {
217+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
218+ }
219+ } else {
220+ logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
216 }221 }
217 logging.Trace().Mark("pool_prewarm_first")222 logging.Trace().Mark("pool_prewarm_first")
218 223
219 return pool224 return pool
220 }225 }
221 226
227+func shouldPrewarmFirstWebView(display *gdk.Display) bool {
228+ return display != nil
229+}
230+
222 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.231 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
223 func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {232 func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
224 return p.WebProcessMemoryLimitMB > 0 ||233 return p.WebProcessMemoryLimitMB > 0 ||
M internal/bootstrap/webkit_stack_test.go +6 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
1011 )
@@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
4748 assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
4849 assert.True(t, opts.ITPEnabled)
4950 }
51+
52+func TestShouldPrewarmFirstWebView(t *testing.T) {
53+ assert.False(t, shouldPrewarmFirstWebView(nil))
54+ assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{}))
55+}
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/infrastructure/config"7 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
47 assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)48 assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
48 assert.True(t, opts.ITPEnabled)49 assert.True(t, opts.ITPEnabled)
49 }50 }
51+
52+func TestShouldPrewarmFirstWebView(t *testing.T) {
53+ assert.False(t, shouldPrewarmFirstWebView(nil))
54+ assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{}))
55+}
A internal/infrastructure/webkit/gobject_type_helpers.go +56 -0
@@ -0,0 +1,56 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/jwijenbergh/purego"
7+ "github.com/jwijenbergh/puregotk/pkg/core"
8+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
9+)
10+
11+var (
12+ loadGObjectTypeFnsOnce sync.Once
13+ typeCheckInstanceIsA func(uintptr, gtypes.GType) bool
14+ typeNameFromInstance func(uintptr) string
15+)
16+
17+func gobjectTypeCheckInstanceIsAByPtr(instancePtr uintptr, requestType gtypes.GType) bool {
18+ if instancePtr == 0 {
19+ return false
20+ }
21+ loadGObjectTypeFns()
22+ if typeCheckInstanceIsA == nil {
23+ return false
24+ }
25+ return typeCheckInstanceIsA(instancePtr, requestType)
26+}
27+
28+func permissionRequestTypeName(instancePtr uintptr) string {
29+ if instancePtr == 0 {
30+ return ""
31+ }
32+ loadGObjectTypeFns()
33+ if typeNameFromInstance == nil {
34+ return ""
35+ }
36+ return typeNameFromInstance(instancePtr)
37+}
38+
39+func loadGObjectTypeFns() {
40+ loadGObjectTypeFnsOnce.Do(func() {
41+ libs := make([]uintptr, 0, 2)
42+ for _, libPath := range core.GetPaths("GOBJECT") {
43+ lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
44+ if err != nil {
45+ continue
46+ }
47+ libs = append(libs, lib)
48+ }
49+ if len(libs) == 0 {
50+ return
51+ }
52+
53+ core.PuregoSafeRegister(&typeCheckInstanceIsA, libs, "g_type_check_instance_is_a")
54+ core.PuregoSafeRegister(&typeNameFromInstance, libs, "g_type_name_from_instance")
55+ })
56+}
@@ -0,0 +1,56 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/jwijenbergh/purego"
7+ "github.com/jwijenbergh/puregotk/pkg/core"
8+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
9+)
10+
11+var (
12+ loadGObjectTypeFnsOnce sync.Once
13+ typeCheckInstanceIsA func(uintptr, gtypes.GType) bool
14+ typeNameFromInstance func(uintptr) string
15+)
16+
17+func gobjectTypeCheckInstanceIsAByPtr(instancePtr uintptr, requestType gtypes.GType) bool {
18+ if instancePtr == 0 {
19+ return false
20+ }
21+ loadGObjectTypeFns()
22+ if typeCheckInstanceIsA == nil {
23+ return false
24+ }
25+ return typeCheckInstanceIsA(instancePtr, requestType)
26+}
27+
28+func permissionRequestTypeName(instancePtr uintptr) string {
29+ if instancePtr == 0 {
30+ return ""
31+ }
32+ loadGObjectTypeFns()
33+ if typeNameFromInstance == nil {
34+ return ""
35+ }
36+ return typeNameFromInstance(instancePtr)
37+}
38+
39+func loadGObjectTypeFns() {
40+ loadGObjectTypeFnsOnce.Do(func() {
41+ libs := make([]uintptr, 0, 2)
42+ for _, libPath := range core.GetPaths("GOBJECT") {
43+ lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
44+ if err != nil {
45+ continue
46+ }
47+ libs = append(libs, lib)
48+ }
49+ if len(libs) == 0 {
50+ return
51+ }
52+
53+ core.PuregoSafeRegister(&typeCheckInstanceIsA, libs, "g_type_check_instance_is_a")
54+ core.PuregoSafeRegister(&typeNameFromInstance, libs, "g_type_name_from_instance")
55+ })
56+}
M internal/infrastructure/webkit/injector.go +36 -5
@@ -141,6 +141,25 @@ const autoCopySelectionScript = `(function() {
141141 });
142142 })();`
143143
144+// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
145+// Some pages gate support on window.RTCPeerConnection and report false negatives
146+// when only webkit-prefixed constructors are present.
147+const webRTCCompatScript = `(function() {
148+ if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
149+ window.RTCPeerConnection = window.webkitRTCPeerConnection;
150+ }
151+ if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
152+ window.RTCSessionDescription = window.webkitRTCSessionDescription;
153+ }
154+ if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
155+ window.RTCIceCandidate = window.webkitRTCIceCandidate;
156+ }
157+})();`
158+
159+func buildWebRTCCompatScript() string {
160+ return webRTCCompatScript
161+}
162+
144163 // ContentInjector encapsulates script injection into WebViews.
145164 // It injects dark mode detection scripts for internal pages (dumb://)
146165 // and theme CSS variables for WebUI styling.
@@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
217236
218237 prefersDark := ci.PrefersDark()
219238
220- // 1. Inject WebView ID for debugging (internal pages only)
239+ // 1. Inject WebRTC compatibility aliases for all pages.
240+ addScript(
241+ webkit.NewUserScript(
242+ buildWebRTCCompatScript(),
243+ webkit.UserContentInjectTopFrameValue,
244+ webkit.UserScriptInjectAtDocumentStartValue,
245+ nil,
246+ nil,
247+ ),
248+ "webrtc-compat-shim",
249+ )
250+
251+ // 2. Inject WebView ID for debugging (internal pages only)
221252 if webviewID != 0 {
222253 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
223254 addScript(
@@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
235266 log.Warn().Msg("webview ID is 0, skipping ID injection")
236267 }
237268
238- // 2. Inject dark mode handler for internal pages only
269+ // 3. Inject dark mode handler for internal pages only
239270 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
240271 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
241272 addScript(
@@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
249280 "internal-dark-mode-handler",
250281 )
251282
252- // 3. Inject theme CSS for internal pages (dumb://* only)
283+ // 4. Inject theme CSS for internal pages (dumb://* only)
253284 if ci.themeCSSVars != "" {
254285 // Escape for JS string literal
255286 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
269300 log.Debug().Msg("theme CSS vars injection configured for internal pages")
270301 }
271302
272- // 4. Inject find highlight CSS for all pages
303+ // 5. Inject find highlight CSS for all pages
273304 if ci.findCSS != "" {
274305 stylesheet := webkit.NewUserStyleSheet(
275306 ci.findCSS,
@@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
286317 }
287318 }
288319
289- // 5. Inject auto-copy selection script for all pages (if enabled)
320+ // 6. Inject auto-copy selection script for all pages (if enabled)
290321 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291322 if autoCopyEnabled {
292323 addScript(
@@ -141,6 +141,25 @@ const autoCopySelectionScript = `(function() {
141 });141 });
142 })();`142 })();`
143 143
144+// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
145+// Some pages gate support on window.RTCPeerConnection and report false negatives
146+// when only webkit-prefixed constructors are present.
147+const webRTCCompatScript = `(function() {
148+ if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
149+ window.RTCPeerConnection = window.webkitRTCPeerConnection;
150+ }
151+ if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
152+ window.RTCSessionDescription = window.webkitRTCSessionDescription;
153+ }
154+ if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
155+ window.RTCIceCandidate = window.webkitRTCIceCandidate;
156+ }
157+})();`
158+
159+func buildWebRTCCompatScript() string {
160+ return webRTCCompatScript
161+}
162+
144 // ContentInjector encapsulates script injection into WebViews.163 // ContentInjector encapsulates script injection into WebViews.
145 // It injects dark mode detection scripts for internal pages (dumb://)164 // It injects dark mode detection scripts for internal pages (dumb://)
146 // and theme CSS variables for WebUI styling.165 // and theme CSS variables for WebUI styling.
@@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
217 236
218 prefersDark := ci.PrefersDark()237 prefersDark := ci.PrefersDark()
219 238
220- // 1. Inject WebView ID for debugging (internal pages only)239+ // 1. Inject WebRTC compatibility aliases for all pages.
240+ addScript(
241+ webkit.NewUserScript(
242+ buildWebRTCCompatScript(),
243+ webkit.UserContentInjectTopFrameValue,
244+ webkit.UserScriptInjectAtDocumentStartValue,
245+ nil,
246+ nil,
247+ ),
248+ "webrtc-compat-shim",
249+ )
250+
251+ // 2. Inject WebView ID for debugging (internal pages only)
221 if webviewID != 0 {252 if webviewID != 0 {
222 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))253 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
223 addScript(254 addScript(
@@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
235 log.Warn().Msg("webview ID is 0, skipping ID injection")266 log.Warn().Msg("webview ID is 0, skipping ID injection")
236 }267 }
237 268
238- // 2. Inject dark mode handler for internal pages only269+ // 3. Inject dark mode handler for internal pages only
239 // This sets .dark/.light class on <html> and patches matchMedia for WebUI270 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
240 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)271 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
241 addScript(272 addScript(
@@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
249 "internal-dark-mode-handler",280 "internal-dark-mode-handler",
250 )281 )
251 282
252- // 3. Inject theme CSS for internal pages (dumb://* only)283+ // 4. Inject theme CSS for internal pages (dumb://* only)
253 if ci.themeCSSVars != "" {284 if ci.themeCSSVars != "" {
254 // Escape for JS string literal285 // Escape for JS string literal
255 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")286 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
269 log.Debug().Msg("theme CSS vars injection configured for internal pages")300 log.Debug().Msg("theme CSS vars injection configured for internal pages")
270 }301 }
271 302
272- // 4. Inject find highlight CSS for all pages303+ // 5. Inject find highlight CSS for all pages
273 if ci.findCSS != "" {304 if ci.findCSS != "" {
274 stylesheet := webkit.NewUserStyleSheet(305 stylesheet := webkit.NewUserStyleSheet(
275 ci.findCSS,306 ci.findCSS,
@@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
286 }317 }
287 }318 }
288 319
289- // 5. Inject auto-copy selection script for all pages (if enabled)320+ // 6. Inject auto-copy selection script for all pages (if enabled)
290 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()321 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291 if autoCopyEnabled {322 if autoCopyEnabled {
292 addScript(323 addScript(
A internal/infrastructure/webkit/injector_webrtc_compat_test.go +16 -0
@@ -0,0 +1,16 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestBuildWebRTCCompatScript_AddsPrefixedAliases(t *testing.T) {
10+ script := buildWebRTCCompatScript()
11+
12+ assert.Contains(t, script, "window.webkitRTCPeerConnection")
13+ assert.Contains(t, script, "window.RTCPeerConnection = window.webkitRTCPeerConnection")
14+ assert.Contains(t, script, "window.RTCSessionDescription = window.webkitRTCSessionDescription")
15+ assert.Contains(t, script, "window.RTCIceCandidate = window.webkitRTCIceCandidate")
16+}
@@ -0,0 +1,16 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestBuildWebRTCCompatScript_AddsPrefixedAliases(t *testing.T) {
10+ script := buildWebRTCCompatScript()
11+
12+ assert.Contains(t, script, "window.webkitRTCPeerConnection")
13+ assert.Contains(t, script, "window.RTCPeerConnection = window.webkitRTCPeerConnection")
14+ assert.Contains(t, script, "window.RTCSessionDescription = window.webkitRTCSessionDescription")
15+ assert.Contains(t, script, "window.RTCIceCandidate = window.webkitRTCIceCandidate")
16+}
M internal/infrastructure/webkit/scheme_handler.go +35 -32
@@ -28,6 +28,7 @@ import (
2828 const (
2929 HomePath = "home"
3030 ConfigPath = "config"
31+ WebRTCPath = "webrtc"
3132 ErrorPath = "error"
3233 CrashPath = "crash"
3334 IndexHTML = "index.html"
@@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
511512 return nil
512513 }
513514
514- // Determine the target file based on host and path
515- host := u.Host
516- path := strings.TrimPrefix(u.Path, "/")
517-
518- var relPath string
519- switch {
520- // Home root maps to index.html.
521- case host == HomePath && (path == "" || path == "/"):
522- relPath = IndexHTML
523- // Home asset paths map directly to assets.
524- case host == HomePath && path != "":
525- relPath = path
526- // Config root maps to config.html.
527- case host == ConfigPath && (path == "" || path == "/"):
528- relPath = "config.html"
529- // Config asset paths map directly to assets.
530- case host == ConfigPath && path != "":
531- relPath = path
532- // Error root maps to error.html.
533- case host == ErrorPath && (path == "" || path == "/"):
534- relPath = "error.html"
535- // Error asset paths map directly to assets.
536- case host == ErrorPath && path != "":
537- relPath = path
538- // Opaque home form maps to index.html.
539- case u.Opaque == HomePath:
540- relPath = IndexHTML
541- // Opaque error form maps to error.html.
542- case u.Opaque == ErrorPath:
543- relPath = "error.html"
544- default:
545- // Not a recognized asset path
515+ relPath, ok := resolveAssetPath(u)
516+ if !ok {
546517 return nil
547518 }
548519
@@ -568,6 +539,38 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
568539 }
569540 }
570541
542+func resolveAssetPath(u *url.URL) (string, bool) {
543+ if u == nil {
544+ return "", false
545+ }
546+
547+ rootByHost := map[string]string{
548+ HomePath: IndexHTML,
549+ ConfigPath: "config.html",
550+ WebRTCPath: "webrtc.html",
551+ ErrorPath: "error.html",
552+ }
553+
554+ if root, ok := rootByHost[u.Host]; ok {
555+ path := strings.TrimPrefix(u.Path, "/")
556+ if path == "" {
557+ return root, true
558+ }
559+ return path, true
560+ }
561+
562+ switch u.Opaque {
563+ case HomePath:
564+ return IndexHTML, true
565+ case ErrorPath:
566+ return "error.html", true
567+ case WebRTCPath:
568+ return "webrtc.html", true
569+ default:
570+ return "", false
571+ }
572+}
573+
571574 // getMimeType determines the MIME type for a given file path.
572575 func (h *DumbSchemeHandler) getMimeType(filename string) string {
573576 if h == nil {
@@ -28,6 +28,7 @@ import (
28 const (28 const (
29 HomePath = "home"29 HomePath = "home"
30 ConfigPath = "config"30 ConfigPath = "config"
31+ WebRTCPath = "webrtc"
31 ErrorPath = "error"32 ErrorPath = "error"
32 CrashPath = "crash"33 CrashPath = "crash"
33 IndexHTML = "index.html"34 IndexHTML = "index.html"
@@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
511 return nil512 return nil
512 }513 }
513 514
514- // Determine the target file based on host and path515+ relPath, ok := resolveAssetPath(u)
515- host := u.Host516+ if !ok {
516- path := strings.TrimPrefix(u.Path, "/")
517-
518- var relPath string
519- switch {
520- // Home root maps to index.html.
521- case host == HomePath && (path == "" || path == "/"):
522- relPath = IndexHTML
523- // Home asset paths map directly to assets.
524- case host == HomePath && path != "":
525- relPath = path
526- // Config root maps to config.html.
527- case host == ConfigPath && (path == "" || path == "/"):
528- relPath = "config.html"
529- // Config asset paths map directly to assets.
530- case host == ConfigPath && path != "":
531- relPath = path
532- // Error root maps to error.html.
533- case host == ErrorPath && (path == "" || path == "/"):
534- relPath = "error.html"
535- // Error asset paths map directly to assets.
536- case host == ErrorPath && path != "":
537- relPath = path
538- // Opaque home form maps to index.html.
539- case u.Opaque == HomePath:
540- relPath = IndexHTML
541- // Opaque error form maps to error.html.
542- case u.Opaque == ErrorPath:
543- relPath = "error.html"
544- default:
545- // Not a recognized asset path
546 return nil517 return nil
547 }518 }
548 519
@@ -568,6 +539,38 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
568 }539 }
569 }540 }
570 541
542+func resolveAssetPath(u *url.URL) (string, bool) {
543+ if u == nil {
544+ return "", false
545+ }
546+
547+ rootByHost := map[string]string{
548+ HomePath: IndexHTML,
549+ ConfigPath: "config.html",
550+ WebRTCPath: "webrtc.html",
551+ ErrorPath: "error.html",
552+ }
553+
554+ if root, ok := rootByHost[u.Host]; ok {
555+ path := strings.TrimPrefix(u.Path, "/")
556+ if path == "" {
557+ return root, true
558+ }
559+ return path, true
560+ }
561+
562+ switch u.Opaque {
563+ case HomePath:
564+ return IndexHTML, true
565+ case ErrorPath:
566+ return "error.html", true
567+ case WebRTCPath:
568+ return "webrtc.html", true
569+ default:
570+ return "", false
571+ }
572+}
573+
571 // getMimeType determines the MIME type for a given file path.574 // getMimeType determines the MIME type for a given file path.
572 func (h *DumbSchemeHandler) getMimeType(filename string) string {575 func (h *DumbSchemeHandler) getMimeType(filename string) string {
573 if h == nil {576 if h == nil {
M internal/infrastructure/webkit/scheme_handler_test.go +28 -0
@@ -2,8 +2,10 @@ package webkit
22
33 import (
44 "context"
5+ "net/url"
56 "testing"
67
8+ "github.com/bnema/dumber/assets"
79 "github.com/stretchr/testify/assert"
810 "github.com/stretchr/testify/require"
911 )
@@ -87,3 +89,29 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
8789 require.NotNil(t, resp)
8890 assert.Contains(t, string(resp.Data), `data-target=""`)
8991 }
92+
93+func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) {
94+ h := NewDumbSchemeHandler(context.Background())
95+ h.SetAssets(assets.WebUIAssets)
96+
97+ u, err := url.Parse("dumb://webrtc/")
98+ require.NoError(t, err)
99+
100+ resp := h.handleAsset(u)
101+ require.NotNil(t, resp)
102+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
103+ assert.Contains(t, string(resp.Data), "webrtc.min.js")
104+}
105+
106+func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
107+ h := NewDumbSchemeHandler(context.Background())
108+ h.SetAssets(assets.WebUIAssets)
109+
110+ u, err := url.Parse("dumb:webrtc")
111+ require.NoError(t, err)
112+
113+ resp := h.handleAsset(u)
114+ require.NotNil(t, resp)
115+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
116+ assert.Contains(t, string(resp.Data), "webrtc.min.js")
117+}
@@ -2,8 +2,10 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net/url"
5 "testing"6 "testing"
6 7
8+ "github.com/bnema/dumber/assets"
7 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
9 )11 )
@@ -87,3 +89,29 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
87 require.NotNil(t, resp)89 require.NotNil(t, resp)
88 assert.Contains(t, string(resp.Data), `data-target=""`)90 assert.Contains(t, string(resp.Data), `data-target=""`)
89 }91 }
92+
93+func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) {
94+ h := NewDumbSchemeHandler(context.Background())
95+ h.SetAssets(assets.WebUIAssets)
96+
97+ u, err := url.Parse("dumb://webrtc/")
98+ require.NoError(t, err)
99+
100+ resp := h.handleAsset(u)
101+ require.NotNil(t, resp)
102+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
103+ assert.Contains(t, string(resp.Data), "webrtc.min.js")
104+}
105+
106+func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
107+ h := NewDumbSchemeHandler(context.Background())
108+ h.SetAssets(assets.WebUIAssets)
109+
110+ u, err := url.Parse("dumb:webrtc")
111+ require.NoError(t, err)
112+
113+ resp := h.handleAsset(u)
114+ require.NotNil(t, resp)
115+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
116+ assert.Contains(t, string(resp.Data), "webrtc.min.js")
117+}
M internal/infrastructure/webkit/settings.go +9 -0
@@ -60,10 +60,15 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
6060 applyCanvasSettings(settings)
6161 applyWebRTCSettings(settings)
6262
63+ webrtcEnabled := settings.GetPropertyEnableWebrtc()
64+ mediaStreamEnabled := settings.GetPropertyEnableMediaStream()
65+
6366 log.Debug().
6467 Str("sans_font", cfg.Appearance.SansFont).
6568 Str("rendering_mode", string(cfg.Rendering.Mode)).
6669 Bool("developer_extras", cfg.Debug.EnableDevTools).
70+ Bool("webrtc_enabled", webrtcEnabled).
71+ Bool("media_stream_enabled", mediaStreamEnabled).
6772 Msg("settings applied")
6873 }
6974
@@ -152,6 +157,10 @@ func applyCanvasSettings(settings *webkit.Settings) {
152157 }
153158
154159 func applyWebRTCSettings(settings *webkit.Settings) {
160+ // Set both direct API and gobject properties for robustness with generated bindings.
161+ settings.SetPropertyEnableMediaStream(true)
162+ settings.SetPropertyEnableWebrtc(true)
163+
155164 settings.SetEnableWebrtc(true)
156165 }
157166
@@ -60,10 +60,15 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
60 applyCanvasSettings(settings)60 applyCanvasSettings(settings)
61 applyWebRTCSettings(settings)61 applyWebRTCSettings(settings)
62 62
63+ webrtcEnabled := settings.GetPropertyEnableWebrtc()
64+ mediaStreamEnabled := settings.GetPropertyEnableMediaStream()
65+
63 log.Debug().66 log.Debug().
64 Str("sans_font", cfg.Appearance.SansFont).67 Str("sans_font", cfg.Appearance.SansFont).
65 Str("rendering_mode", string(cfg.Rendering.Mode)).68 Str("rendering_mode", string(cfg.Rendering.Mode)).
66 Bool("developer_extras", cfg.Debug.EnableDevTools).69 Bool("developer_extras", cfg.Debug.EnableDevTools).
70+ Bool("webrtc_enabled", webrtcEnabled).
71+ Bool("media_stream_enabled", mediaStreamEnabled).
67 Msg("settings applied")72 Msg("settings applied")
68 }73 }
69 74
@@ -152,6 +157,10 @@ func applyCanvasSettings(settings *webkit.Settings) {
152 }157 }
153 158
154 func applyWebRTCSettings(settings *webkit.Settings) {159 func applyWebRTCSettings(settings *webkit.Settings) {
160+ // Set both direct API and gobject properties for robustness with generated bindings.
161+ settings.SetPropertyEnableMediaStream(true)
162+ settings.SetPropertyEnableWebrtc(true)
163+
155 settings.SetEnableWebrtc(true)164 settings.SetEnableWebrtc(true)
156 }165 }
157 166
M internal/infrastructure/webkit/webview.go +113 -21
@@ -19,6 +19,7 @@ import (
1919 "github.com/jwijenbergh/puregotk/v4/gio"
2020 "github.com/jwijenbergh/puregotk/v4/glib"
2121 "github.com/jwijenbergh/puregotk/v4/gobject"
22+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
2223 "github.com/jwijenbergh/puregotk/v4/gtk"
2324 "github.com/rs/zerolog"
2425 )
@@ -414,6 +415,7 @@ func (wv *WebView) connectSignals() {
414415 wv.connectEnterFullscreenSignal()
415416 wv.connectLeaveFullscreenSignal()
416417 wv.connectAudioStateSignal()
418+ wv.connectMediaCaptureStateSignals()
417419 wv.connectMouseTargetChangedSignal()
418420 wv.connectBackForwardListChangedSignal()
419421 wv.connectWebProcessTerminatedSignal()
@@ -835,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() {
835837 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
836838 }
837839
840+func (wv *WebView) connectMediaCaptureStateSignals() {
841+ connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) {
842+ cb := func() {
843+ state := readState()
844+ wv.logger.Debug().
845+ Uint64("id", uint64(wv.id)).
846+ Str("kind", kind).
847+ Str("state", mediaCaptureStateString(state)).
848+ Int("state_code", int(state)).
849+ Msg("media capture state changed")
850+ }
851+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb))
852+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
853+ }
854+
855+ connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera")
856+ connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone")
857+ connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display")
858+}
859+
860+func mediaCaptureStateString(state webkit.MediaCaptureState) string {
861+ switch state {
862+ case webkit.MediaCaptureStateNoneValue:
863+ return "none"
864+ case webkit.MediaCaptureStateActiveValue:
865+ return "active"
866+ case webkit.MediaCaptureStateMutedValue:
867+ return "muted"
868+ default:
869+ return "unknown"
870+ }
871+}
872+
838873 func (wv *WebView) connectMouseTargetChangedSignal() {
839874 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
840875 if wv.OnLinkHover == nil {
@@ -1004,47 +1039,104 @@ func (wv *WebView) connectPermissionRequestSignal() {
10041039
10051040 // determinePermissionTypes extracts permission types from a WebKit permission request.
10061041 // This uses type checking to identify UserMediaPermissionRequest and its specific types.
1007-// We use GObject property accessors as the primary method because the C function wrappers
1008-// can hit a purego bug where bool return values are misread.
1042+// We use GObject property accessors for audio/video request flags. Display detection
1043+// uses the dedicated WebKit API because this WebKit build does not expose an
1044+// "is-for-display-device" GObject property.
10091045 func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string {
1010- var types []string
1046+ requestKind := detectPermissionRequestKind(requestPtr)
1047+ switch requestKind {
1048+ case permissionRequestKindUserMedia:
1049+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1050+ if userMediaReq == nil {
1051+ return nil
1052+ }
10111053
1012- // Try to cast to UserMediaPermissionRequest
1013- userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1014- if userMediaReq != nil {
10151054 // Use GObject property accessors — more reliable than the C function wrappers
10161055 // which can hit the purego bool return value bug.
10171056 isAudio := userMediaReq.GetPropertyIsForAudioDevice()
10181057 isVideo := userMediaReq.GetPropertyIsForVideoDevice()
1058+ isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq)
10191059
10201060 wv.logger.Debug().
10211061 Bool("is_audio", isAudio).
10221062 Bool("is_video", isVideo).
1063+ Bool("is_display", isDisplay).
10231064 Msg("permission request type detection")
10241065
1025- if isAudio {
1026- types = append(types, "microphone")
1027- }
1028- if isVideo {
1029- // Check if this is display capture or camera
1030- if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) {
1031- types = append(types, "display")
1066+ return classifyPermissionRequestTypes(requestKind, isAudio, isVideo, isDisplay)
1067+ case permissionRequestKindDeviceInfo:
1068+ return classifyPermissionRequestTypes(requestKind, false, false, false)
1069+ default:
1070+ if requestPtr != 0 {
1071+ typeName := permissionRequestTypeName(requestPtr)
1072+ if typeName != "" {
1073+ wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type")
10321074 } else {
1033- types = append(types, "camera")
1075+ wv.logger.Warn().Msg("unknown permission request type")
10341076 }
10351077 }
1036- return types
1078+ // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1079+ // For now, return empty to trigger denial. Future phases will add these types.
1080+ return nil
10371081 }
1082+}
10381083
1039- // Check for device enumeration request
1040- deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1041- if deviceInfoReq != nil {
1084+type permissionRequestKind int
1085+
1086+const (
1087+ permissionRequestKindUnknown permissionRequestKind = iota
1088+ permissionRequestKindUserMedia
1089+ permissionRequestKindDeviceInfo
1090+)
1091+
1092+func detectPermissionRequestKind(requestPtr uintptr) permissionRequestKind {
1093+ if isPermissionRequestType(requestPtr, webkit.UserMediaPermissionRequestGLibType()) {
1094+ return permissionRequestKindUserMedia
1095+ }
1096+ if isPermissionRequestType(requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) {
1097+ return permissionRequestKindDeviceInfo
1098+ }
1099+ return permissionRequestKindUnknown
1100+}
1101+
1102+func isPermissionRequestType(requestPtr uintptr, requestType gtypes.GType) bool {
1103+ return gobjectTypeCheckInstanceIsAByPtr(requestPtr, requestType)
1104+}
1105+
1106+func classifyPermissionRequestTypes(
1107+ kind permissionRequestKind,
1108+ isAudio, isVideo, isDisplay bool,
1109+) []string {
1110+ switch kind {
1111+ case permissionRequestKindUserMedia:
1112+ return classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay)
1113+ case permissionRequestKindDeviceInfo:
10421114 return []string{"device_info"}
1115+ default:
1116+ return nil
10431117 }
1118+}
10441119
1045- // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1046- // For now, return empty to trigger denial. Future phases will add these types.
1047- return nil
1120+func classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay bool) []string {
1121+ types := make([]string, 0, 2)
1122+
1123+ if isAudio {
1124+ types = append(types, "microphone")
1125+ }
1126+
1127+ if isDisplay {
1128+ types = append(types, "display")
1129+ } else if isVideo {
1130+ types = append(types, "camera")
1131+ }
1132+
1133+ if len(types) == 0 {
1134+ // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all
1135+ // flags false even though the request is a screen-capture request.
1136+ return []string{"display"}
1137+ }
1138+
1139+ return types
10481140 }
10491141
10501142 // allowPermissionRequest calls Allow() on the WebKit permission request.
@@ -19,6 +19,7 @@ import (
19 "github.com/jwijenbergh/puregotk/v4/gio"19 "github.com/jwijenbergh/puregotk/v4/gio"
20 "github.com/jwijenbergh/puregotk/v4/glib"20 "github.com/jwijenbergh/puregotk/v4/glib"
21 "github.com/jwijenbergh/puregotk/v4/gobject"21 "github.com/jwijenbergh/puregotk/v4/gobject"
22+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
22 "github.com/jwijenbergh/puregotk/v4/gtk"23 "github.com/jwijenbergh/puregotk/v4/gtk"
23 "github.com/rs/zerolog"24 "github.com/rs/zerolog"
24 )25 )
@@ -414,6 +415,7 @@ func (wv *WebView) connectSignals() {
414 wv.connectEnterFullscreenSignal()415 wv.connectEnterFullscreenSignal()
415 wv.connectLeaveFullscreenSignal()416 wv.connectLeaveFullscreenSignal()
416 wv.connectAudioStateSignal()417 wv.connectAudioStateSignal()
418+ wv.connectMediaCaptureStateSignals()
417 wv.connectMouseTargetChangedSignal()419 wv.connectMouseTargetChangedSignal()
418 wv.connectBackForwardListChangedSignal()420 wv.connectBackForwardListChangedSignal()
419 wv.connectWebProcessTerminatedSignal()421 wv.connectWebProcessTerminatedSignal()
@@ -835,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() {
835 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))837 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
836 }838 }
837 839
840+func (wv *WebView) connectMediaCaptureStateSignals() {
841+ connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) {
842+ cb := func() {
843+ state := readState()
844+ wv.logger.Debug().
845+ Uint64("id", uint64(wv.id)).
846+ Str("kind", kind).
847+ Str("state", mediaCaptureStateString(state)).
848+ Int("state_code", int(state)).
849+ Msg("media capture state changed")
850+ }
851+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb))
852+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
853+ }
854+
855+ connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera")
856+ connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone")
857+ connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display")
858+}
859+
860+func mediaCaptureStateString(state webkit.MediaCaptureState) string {
861+ switch state {
862+ case webkit.MediaCaptureStateNoneValue:
863+ return "none"
864+ case webkit.MediaCaptureStateActiveValue:
865+ return "active"
866+ case webkit.MediaCaptureStateMutedValue:
867+ return "muted"
868+ default:
869+ return "unknown"
870+ }
871+}
872+
838 func (wv *WebView) connectMouseTargetChangedSignal() {873 func (wv *WebView) connectMouseTargetChangedSignal() {
839 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {874 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
840 if wv.OnLinkHover == nil {875 if wv.OnLinkHover == nil {
@@ -1004,47 +1039,104 @@ func (wv *WebView) connectPermissionRequestSignal() {
1004 1039
1005 // determinePermissionTypes extracts permission types from a WebKit permission request.1040 // determinePermissionTypes extracts permission types from a WebKit permission request.
1006 // This uses type checking to identify UserMediaPermissionRequest and its specific types.1041 // This uses type checking to identify UserMediaPermissionRequest and its specific types.
1007-// We use GObject property accessors as the primary method because the C function wrappers1042+// We use GObject property accessors for audio/video request flags. Display detection
1008-// can hit a purego bug where bool return values are misread.1043+// uses the dedicated WebKit API because this WebKit build does not expose an
1044+// "is-for-display-device" GObject property.
1009 func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string {1045 func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string {
1010- var types []string1046+ requestKind := detectPermissionRequestKind(requestPtr)
1047+ switch requestKind {
1048+ case permissionRequestKindUserMedia:
1049+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1050+ if userMediaReq == nil {
1051+ return nil
1052+ }
1011 1053
1012- // Try to cast to UserMediaPermissionRequest
1013- userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1014- if userMediaReq != nil {
1015 // Use GObject property accessors — more reliable than the C function wrappers1054 // Use GObject property accessors — more reliable than the C function wrappers
1016 // which can hit the purego bool return value bug.1055 // which can hit the purego bool return value bug.
1017 isAudio := userMediaReq.GetPropertyIsForAudioDevice()1056 isAudio := userMediaReq.GetPropertyIsForAudioDevice()
1018 isVideo := userMediaReq.GetPropertyIsForVideoDevice()1057 isVideo := userMediaReq.GetPropertyIsForVideoDevice()
1058+ isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq)
1019 1059
1020 wv.logger.Debug().1060 wv.logger.Debug().
1021 Bool("is_audio", isAudio).1061 Bool("is_audio", isAudio).
1022 Bool("is_video", isVideo).1062 Bool("is_video", isVideo).
1063+ Bool("is_display", isDisplay).
1023 Msg("permission request type detection")1064 Msg("permission request type detection")
1024 1065
1025- if isAudio {1066+ return classifyPermissionRequestTypes(requestKind, isAudio, isVideo, isDisplay)
1026- types = append(types, "microphone")1067+ case permissionRequestKindDeviceInfo:
1027- }1068+ return classifyPermissionRequestTypes(requestKind, false, false, false)
1028- if isVideo {1069+ default:
1029- // Check if this is display capture or camera1070+ if requestPtr != 0 {
1030- if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) {1071+ typeName := permissionRequestTypeName(requestPtr)
1031- types = append(types, "display")1072+ if typeName != "" {
1073+ wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type")
1032 } else {1074 } else {
1033- types = append(types, "camera")1075+ wv.logger.Warn().Msg("unknown permission request type")
1034 }1076 }
1035 }1077 }
1036- return types1078+ // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1079+ // For now, return empty to trigger denial. Future phases will add these types.
1080+ return nil
1037 }1081 }
1082+}
1038 1083
1039- // Check for device enumeration request1084+type permissionRequestKind int
1040- deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)1085+
1041- if deviceInfoReq != nil {1086+const (
1087+ permissionRequestKindUnknown permissionRequestKind = iota
1088+ permissionRequestKindUserMedia
1089+ permissionRequestKindDeviceInfo
1090+)
1091+
1092+func detectPermissionRequestKind(requestPtr uintptr) permissionRequestKind {
1093+ if isPermissionRequestType(requestPtr, webkit.UserMediaPermissionRequestGLibType()) {
1094+ return permissionRequestKindUserMedia
1095+ }
1096+ if isPermissionRequestType(requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) {
1097+ return permissionRequestKindDeviceInfo
1098+ }
1099+ return permissionRequestKindUnknown
1100+}
1101+
1102+func isPermissionRequestType(requestPtr uintptr, requestType gtypes.GType) bool {
1103+ return gobjectTypeCheckInstanceIsAByPtr(requestPtr, requestType)
1104+}
1105+
1106+func classifyPermissionRequestTypes(
1107+ kind permissionRequestKind,
1108+ isAudio, isVideo, isDisplay bool,
1109+) []string {
1110+ switch kind {
1111+ case permissionRequestKindUserMedia:
1112+ return classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay)
1113+ case permissionRequestKindDeviceInfo:
1042 return []string{"device_info"}1114 return []string{"device_info"}
1115+ default:
1116+ return nil
1043 }1117 }
1118+}
1044 1119
1045- // Unknown permission type - could be clipboard, notifications, geolocation, etc.1120+func classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay bool) []string {
1046- // For now, return empty to trigger denial. Future phases will add these types.1121+ types := make([]string, 0, 2)
1047- return nil1122+
1123+ if isAudio {
1124+ types = append(types, "microphone")
1125+ }
1126+
1127+ if isDisplay {
1128+ types = append(types, "display")
1129+ } else if isVideo {
1130+ types = append(types, "camera")
1131+ }
1132+
1133+ if len(types) == 0 {
1134+ // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all
1135+ // flags false even though the request is a screen-capture request.
1136+ return []string{"display"}
1137+ }
1138+
1139+ return types
1048 }1140 }
1049 1141
1050 // allowPermissionRequest calls Allow() on the WebKit permission request.1142 // allowPermissionRequest calls Allow() on the WebKit permission request.
A internal/infrastructure/webkit/webview_permission_types_test.go +96 -0
@@ -0,0 +1,96 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestClassifyUserMediaPermissionTypes(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ isAudio bool
13+ isVideo bool
14+ isDisplay bool
15+ expected []string
16+ }{
17+ {
18+ name: "audio and camera",
19+ isAudio: true,
20+ isVideo: true,
21+ isDisplay: false,
22+ expected: []string{"microphone", "camera"},
23+ },
24+ {
25+ name: "screen only",
26+ isAudio: false,
27+ isVideo: false,
28+ isDisplay: true,
29+ expected: []string{"display"},
30+ },
31+ {
32+ name: "screen with audio",
33+ isAudio: true,
34+ isVideo: false,
35+ isDisplay: true,
36+ expected: []string{"microphone", "display"},
37+ },
38+ {
39+ name: "webkit display fallback when all flags false",
40+ isAudio: false,
41+ isVideo: false,
42+ isDisplay: false,
43+ expected: []string{"display"},
44+ },
45+ }
46+
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(tt.isAudio, tt.isVideo, tt.isDisplay))
50+ })
51+ }
52+}
53+
54+func TestClassifyPermissionRequestTypes(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ kind permissionRequestKind
58+ isAudio bool
59+ isVideo bool
60+ isDisplay bool
61+ expected []string
62+ }{
63+ {
64+ name: "device info request stays device_info",
65+ kind: permissionRequestKindDeviceInfo,
66+ expected: []string{"device_info"},
67+ },
68+ {
69+ name: "user media camera",
70+ kind: permissionRequestKindUserMedia,
71+ isAudio: false,
72+ isVideo: true,
73+ isDisplay: false,
74+ expected: []string{"camera"},
75+ },
76+ {
77+ name: "user media fallback display",
78+ kind: permissionRequestKindUserMedia,
79+ isAudio: false,
80+ isVideo: false,
81+ isDisplay: false,
82+ expected: []string{"display"},
83+ },
84+ {
85+ name: "unknown request denied",
86+ kind: permissionRequestKindUnknown,
87+ expected: nil,
88+ },
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ assert.Equal(t, tt.expected, classifyPermissionRequestTypes(tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay))
94+ })
95+ }
96+}
@@ -0,0 +1,96 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestClassifyUserMediaPermissionTypes(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ isAudio bool
13+ isVideo bool
14+ isDisplay bool
15+ expected []string
16+ }{
17+ {
18+ name: "audio and camera",
19+ isAudio: true,
20+ isVideo: true,
21+ isDisplay: false,
22+ expected: []string{"microphone", "camera"},
23+ },
24+ {
25+ name: "screen only",
26+ isAudio: false,
27+ isVideo: false,
28+ isDisplay: true,
29+ expected: []string{"display"},
30+ },
31+ {
32+ name: "screen with audio",
33+ isAudio: true,
34+ isVideo: false,
35+ isDisplay: true,
36+ expected: []string{"microphone", "display"},
37+ },
38+ {
39+ name: "webkit display fallback when all flags false",
40+ isAudio: false,
41+ isVideo: false,
42+ isDisplay: false,
43+ expected: []string{"display"},
44+ },
45+ }
46+
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(tt.isAudio, tt.isVideo, tt.isDisplay))
50+ })
51+ }
52+}
53+
54+func TestClassifyPermissionRequestTypes(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ kind permissionRequestKind
58+ isAudio bool
59+ isVideo bool
60+ isDisplay bool
61+ expected []string
62+ }{
63+ {
64+ name: "device info request stays device_info",
65+ kind: permissionRequestKindDeviceInfo,
66+ expected: []string{"device_info"},
67+ },
68+ {
69+ name: "user media camera",
70+ kind: permissionRequestKindUserMedia,
71+ isAudio: false,
72+ isVideo: true,
73+ isDisplay: false,
74+ expected: []string{"camera"},
75+ },
76+ {
77+ name: "user media fallback display",
78+ kind: permissionRequestKindUserMedia,
79+ isAudio: false,
80+ isVideo: false,
81+ isDisplay: false,
82+ expected: []string{"display"},
83+ },
84+ {
85+ name: "unknown request denied",
86+ kind: permissionRequestKindUnknown,
87+ expected: nil,
88+ },
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ assert.Equal(t, tt.expected, classifyPermissionRequestTypes(tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay))
94+ })
95+ }
96+}
M internal/ui/app.go +109 -2
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
1516 "github.com/bnema/dumber/internal/infrastructure/desktop"
1617 "github.com/bnema/dumber/internal/infrastructure/filesystem"
@@ -94,6 +95,8 @@ type App struct {
9495 appToaster *component.Toaster
9596 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
9697 modeToaster *component.Toaster
98+ // Top-right WebRTC permission activity indicator
99+ webrtcIndicator *component.WebRTCPermissionIndicator
97100
98101 // Session management
99102 sessionManager *component.SessionManager
@@ -202,8 +205,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
202205 }
203206 }
204207
205- // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
206- a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
208+ appID := AppID
209+ a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)
207210 if a.gtkApp == nil {
208211 log.Error().Msg("failed to create GTK application")
209212 return 1
@@ -252,6 +255,7 @@ func (a *App) onActivate(ctx context.Context) {
252255 a.initDownloadHandler(ctx)
253256
254257 a.initCoordinators(ctx)
258+ a.wireWebRTCPermissionIndicator()
255259 logging.Trace().Mark("coordinators_init")
256260 a.initKeyboardHandler(ctx)
257261 a.initOmniboxConfig(ctx)
@@ -366,6 +370,15 @@ func (a *App) createMainWindow(ctx context.Context) error {
366370 }
367371 }
368372
373+ // Create top-right WebRTC permission activity indicator.
374+ indicator := component.NewWebRTCPermissionIndicator()
375+ if indicator != nil {
376+ if w := indicator.Widget(); w != nil {
377+ a.mainWindow.AddOverlay(w)
378+ a.webrtcIndicator = indicator
379+ }
380+ }
381+
369382 // Apply GTK CSS styling from theme manager.
370383 if a.deps == nil || a.deps.Theme == nil {
371384 return nil
@@ -1504,6 +1517,100 @@ func (a *App) initCoordinators(ctx context.Context) {
15041517 log.Debug().Msg("coordinators initialized")
15051518 }
15061519
1520+func (a *App) wireWebRTCPermissionIndicator() {
1521+ if a.webrtcIndicator == nil || a.contentCoord == nil {
1522+ return
1523+ }
1524+ ctx := context.Background()
1525+ if a.deps != nil && a.deps.Ctx != nil {
1526+ ctx = a.deps.Ctx
1527+ }
1528+ log := logging.FromContext(ctx)
1529+
1530+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1531+ a.webrtcIndicator.SetOrigin(origin)
1532+
1533+ switch state {
1534+ case coordinator.PermissionActivityRequesting:
1535+ a.webrtcIndicator.MarkRequesting(permTypes)
1536+ case coordinator.PermissionActivityAllowed:
1537+ a.webrtcIndicator.MarkAllowed(permTypes)
1538+ case coordinator.PermissionActivityBlocked:
1539+ a.webrtcIndicator.MarkBlocked(permTypes)
1540+ }
1541+
1542+ for _, permType := range permTypes {
1543+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1544+ }
1545+ })
1546+
1547+ a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1548+ if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1549+ return
1550+ }
1551+
1552+ if hasStored {
1553+ // Already locked: reset to prompt (will re-ask on next request).
1554+ if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1555+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1556+ return
1557+ }
1558+ } else {
1559+ // Not locked: flip the state.
1560+ // Allowed/requesting → deny future requests.
1561+ // Blocked → allow future requests.
1562+ decision := entity.PermissionDenied
1563+ if state == string(coordinator.PermissionActivityBlocked) {
1564+ decision = entity.PermissionGranted
1565+ }
1566+ if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1567+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1568+ return
1569+ }
1570+ }
1571+
1572+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1573+ })
1574+
1575+ // Reset indicator when the active pane navigates away from the current origin.
1576+ a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1577+ newOrigin, err := urlutil.ExtractOrigin(uri)
1578+ if err != nil {
1579+ // Internal pages (dumb://, about:) — clear the indicator.
1580+ a.webrtcIndicator.Reset()
1581+ return
1582+ }
1583+
1584+ currentOrigin := a.webrtcIndicator.Origin()
1585+ if currentOrigin != "" && currentOrigin != newOrigin {
1586+ a.webrtcIndicator.Reset()
1587+ }
1588+ })
1589+}
1590+
1591+func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1592+ if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1593+ return
1594+ }
1595+
1596+ record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1597+ if err != nil {
1598+ logging.FromContext(ctx).Warn().
1599+ Err(err).
1600+ Str("origin", origin).
1601+ Str("type", string(permType)).
1602+ Msg("failed to read manual permission decision")
1603+ return
1604+ }
1605+
1606+ if record == nil {
1607+ a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1608+ return
1609+ }
1610+
1611+ a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1612+}
1613+
15071614 // generateID generates a unique ID for tabs and panes.
15081615 // Uses monotonically increasing counter to avoid ID collisions.
15091616 func (a *App) generateID() string {
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
14 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/desktop"16 "github.com/bnema/dumber/internal/infrastructure/desktop"
16 "github.com/bnema/dumber/internal/infrastructure/filesystem"17 "github.com/bnema/dumber/internal/infrastructure/filesystem"
@@ -94,6 +95,8 @@ type App struct {
94 appToaster *component.Toaster95 appToaster *component.Toaster
95 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)96 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
96 modeToaster *component.Toaster97 modeToaster *component.Toaster
98+ // Top-right WebRTC permission activity indicator
99+ webrtcIndicator *component.WebRTCPermissionIndicator
97 100
98 // Session management101 // Session management
99 sessionManager *component.SessionManager102 sessionManager *component.SessionManager
@@ -202,8 +205,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
202 }205 }
203 }206 }
204 207
205- // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)208+ appID := AppID
206- a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)209+ a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)
207 if a.gtkApp == nil {210 if a.gtkApp == nil {
208 log.Error().Msg("failed to create GTK application")211 log.Error().Msg("failed to create GTK application")
209 return 1212 return 1
@@ -252,6 +255,7 @@ func (a *App) onActivate(ctx context.Context) {
252 a.initDownloadHandler(ctx)255 a.initDownloadHandler(ctx)
253 256
254 a.initCoordinators(ctx)257 a.initCoordinators(ctx)
258+ a.wireWebRTCPermissionIndicator()
255 logging.Trace().Mark("coordinators_init")259 logging.Trace().Mark("coordinators_init")
256 a.initKeyboardHandler(ctx)260 a.initKeyboardHandler(ctx)
257 a.initOmniboxConfig(ctx)261 a.initOmniboxConfig(ctx)
@@ -366,6 +370,15 @@ func (a *App) createMainWindow(ctx context.Context) error {
366 }370 }
367 }371 }
368 372
373+ // Create top-right WebRTC permission activity indicator.
374+ indicator := component.NewWebRTCPermissionIndicator()
375+ if indicator != nil {
376+ if w := indicator.Widget(); w != nil {
377+ a.mainWindow.AddOverlay(w)
378+ a.webrtcIndicator = indicator
379+ }
380+ }
381+
369 // Apply GTK CSS styling from theme manager.382 // Apply GTK CSS styling from theme manager.
370 if a.deps == nil || a.deps.Theme == nil {383 if a.deps == nil || a.deps.Theme == nil {
371 return nil384 return nil
@@ -1504,6 +1517,100 @@ func (a *App) initCoordinators(ctx context.Context) {
1504 log.Debug().Msg("coordinators initialized")1517 log.Debug().Msg("coordinators initialized")
1505 }1518 }
1506 1519
1520+func (a *App) wireWebRTCPermissionIndicator() {
1521+ if a.webrtcIndicator == nil || a.contentCoord == nil {
1522+ return
1523+ }
1524+ ctx := context.Background()
1525+ if a.deps != nil && a.deps.Ctx != nil {
1526+ ctx = a.deps.Ctx
1527+ }
1528+ log := logging.FromContext(ctx)
1529+
1530+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1531+ a.webrtcIndicator.SetOrigin(origin)
1532+
1533+ switch state {
1534+ case coordinator.PermissionActivityRequesting:
1535+ a.webrtcIndicator.MarkRequesting(permTypes)
1536+ case coordinator.PermissionActivityAllowed:
1537+ a.webrtcIndicator.MarkAllowed(permTypes)
1538+ case coordinator.PermissionActivityBlocked:
1539+ a.webrtcIndicator.MarkBlocked(permTypes)
1540+ }
1541+
1542+ for _, permType := range permTypes {
1543+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1544+ }
1545+ })
1546+
1547+ a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1548+ if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1549+ return
1550+ }
1551+
1552+ if hasStored {
1553+ // Already locked: reset to prompt (will re-ask on next request).
1554+ if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1555+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1556+ return
1557+ }
1558+ } else {
1559+ // Not locked: flip the state.
1560+ // Allowed/requesting → deny future requests.
1561+ // Blocked → allow future requests.
1562+ decision := entity.PermissionDenied
1563+ if state == string(coordinator.PermissionActivityBlocked) {
1564+ decision = entity.PermissionGranted
1565+ }
1566+ if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1567+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1568+ return
1569+ }
1570+ }
1571+
1572+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1573+ })
1574+
1575+ // Reset indicator when the active pane navigates away from the current origin.
1576+ a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1577+ newOrigin, err := urlutil.ExtractOrigin(uri)
1578+ if err != nil {
1579+ // Internal pages (dumb://, about:) — clear the indicator.
1580+ a.webrtcIndicator.Reset()
1581+ return
1582+ }
1583+
1584+ currentOrigin := a.webrtcIndicator.Origin()
1585+ if currentOrigin != "" && currentOrigin != newOrigin {
1586+ a.webrtcIndicator.Reset()
1587+ }
1588+ })
1589+}
1590+
1591+func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1592+ if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1593+ return
1594+ }
1595+
1596+ record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1597+ if err != nil {
1598+ logging.FromContext(ctx).Warn().
1599+ Err(err).
1600+ Str("origin", origin).
1601+ Str("type", string(permType)).
1602+ Msg("failed to read manual permission decision")
1603+ return
1604+ }
1605+
1606+ if record == nil {
1607+ a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1608+ return
1609+ }
1610+
1611+ a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1612+}
1613+
1507 // generateID generates a unique ID for tabs and panes.1614 // generateID generates a unique ID for tabs and panes.
1508 // Uses monotonically increasing counter to avoid ID collisions.1615 // Uses monotonically increasing counter to avoid ID collisions.
1509 func (a *App) generateID() string {1616 func (a *App) generateID() string {
A internal/ui/component/webrtc_permission_indicator.go +533 -0
@@ -0,0 +1,533 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+)
10+
11+// revealerTransitionDuration is the animation duration in milliseconds
12+// for the details panel slide-down.
13+const revealerTransitionDuration = 180
14+
15+var 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.
27+type 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 []interface{}
45+}
46+
47+// NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator.
48+func 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.
63+func 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+
106+func 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+
127+func 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+
159+func 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+
168+func 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+
182+func 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+
198+func (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.
226+func (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.
234+func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) {
235+ w.setState(permTypes, webrtcPermissionStateRequesting)
236+}
237+
238+// MarkAllowed marks tracked permissions as allowed.
239+func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) {
240+ w.setState(permTypes, webrtcPermissionStateAllowed)
241+}
242+
243+// MarkBlocked marks tracked permissions as blocked.
244+func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) {
245+ w.setState(permTypes, webrtcPermissionStateBlocked)
246+}
247+
248+// Origin returns the current origin tracked by the indicator.
249+func (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.
259+func (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.
270+func (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.
293+func (w *WebRTCPermissionIndicator) Reset() {
294+ if w == nil {
295+ return
296+ }
297+
298+ w.mu.Lock()
299+ defer w.mu.Unlock()
300+
301+ for k := range w.states {
302+ delete(w.states, k)
303+ }
304+ for k := range w.stored {
305+ delete(w.stored, k)
306+ }
307+ w.currentOrigin = ""
308+
309+ w.refreshLocked()
310+}
311+
312+// SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked.
313+func (w *WebRTCPermissionIndicator) SetOnToggleLock(
314+ callback func(origin string, permType entity.PermissionType, state string, hasStored bool),
315+) {
316+ if w == nil {
317+ return
318+ }
319+ w.mu.Lock()
320+ defer w.mu.Unlock()
321+ w.onToggleLock = callback
322+}
323+
324+func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) {
325+ if w == nil {
326+ return
327+ }
328+
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+
332+ for _, permType := range permTypes {
333+ if !isTrackedWebRTCPermissionType(permType) {
334+ continue
335+ }
336+ w.states[permType] = state
337+ }
338+
339+ w.refreshLocked()
340+}
341+
342+func (w *WebRTCPermissionIndicator) refreshLocked() {
343+ if w.outerBox == nil {
344+ return
345+ }
346+
347+ visible := shouldShowWebRTCPermissionIndicator(w.states)
348+ w.outerBox.SetVisible(visible)
349+
350+ summary := summarizeWebRTCPermissionState(w.states)
351+ if w.dotBox != nil {
352+ updateStateCSSClass(&w.dotBox.Widget, summary)
353+ }
354+
355+ if w.originLabel != nil {
356+ origin := w.currentOrigin
357+ if origin == "" {
358+ origin = "Current site"
359+ }
360+ w.originLabel.SetText(origin)
361+ }
362+
363+ for _, permType := range trackedWebRTCPermissionTypes {
364+ row := w.rowButtons[permType]
365+ if row == nil {
366+ continue
367+ }
368+
369+ state := w.states[permType]
370+ if state == "" {
371+ state = webrtcPermissionStateIdle
372+ }
373+
374+ storedDecision, hasStored := w.stored[permType]
375+ label := formatRowLabel(permType, state, storedDecision, hasStored)
376+ row.SetLabel(label)
377+ updateStateCSSClass(&row.Widget, state)
378+ updateLockedCSSClass(&row.Widget, hasStored)
379+ }
380+
381+ if !visible {
382+ w.setExpandedLocked(false)
383+ }
384+}
385+
386+func (w *WebRTCPermissionIndicator) attachHoverController() {
387+ if w == nil || w.outerBox == nil {
388+ return
389+ }
390+
391+ controller := gtk.NewEventControllerMotion()
392+ if controller == nil {
393+ return
394+ }
395+
396+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
397+ w.mu.Lock()
398+ defer w.mu.Unlock()
399+ w.setExpandedLocked(true)
400+ }
401+ leaveCb := func(_ gtk.EventControllerMotion) {
402+ w.mu.Lock()
403+ defer w.mu.Unlock()
404+ w.setExpandedLocked(false)
405+ }
406+
407+ w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb)
408+ controller.ConnectEnter(&enterCb)
409+ controller.ConnectLeave(&leaveCb)
410+ w.outerBox.AddController(&controller.EventController)
411+}
412+
413+func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) {
414+ if w.revealer == nil || w.cardBox == nil {
415+ return
416+ }
417+ if w.expanded == expanded {
418+ return
419+ }
420+
421+ w.expanded = expanded
422+ w.revealer.SetRevealChild(expanded)
423+
424+ // Show/hide origin label so it doesn't take space when collapsed to just a dot.
425+ // Toggle hexpand alongside visibility to prevent expand propagation when collapsed.
426+ if w.originLabel != nil {
427+ w.originLabel.SetVisible(expanded)
428+ w.originLabel.SetHexpand(expanded)
429+ }
430+
431+ if expanded {
432+ w.cardBox.AddCssClass("expanded")
433+ } else {
434+ w.cardBox.RemoveCssClass("expanded")
435+ }
436+}
437+
438+func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool {
439+ switch permType {
440+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
441+ return true
442+ default:
443+ return false
444+ }
445+}
446+
447+func permissionDisplayName(permType entity.PermissionType) string {
448+ switch permType {
449+ case entity.PermissionTypeMicrophone:
450+ return "\U0001F3A4 Mic" // 🎤
451+ case entity.PermissionTypeCamera:
452+ return "\U0001F4F7 Cam" // 📷
453+ case entity.PermissionTypeDisplay:
454+ return "\U0001F4BB Screen" // 💻
455+ default:
456+ return "Unknown"
457+ }
458+}
459+
460+func stateDisplayName(state webrtcPermissionState) string {
461+ switch state {
462+ case webrtcPermissionStateRequesting:
463+ return "\u26A0 requesting" // ⚠
464+ case webrtcPermissionStateAllowed:
465+ return "\u2713 active" // ✓
466+ case webrtcPermissionStateBlocked:
467+ return "\u2717 blocked" // ✗
468+ default:
469+ return "idle"
470+ }
471+}
472+
473+func formatRowLabel(
474+ permType entity.PermissionType,
475+ state webrtcPermissionState,
476+ storedDecision entity.PermissionDecision,
477+ hasStored bool,
478+) string {
479+ name := permissionDisplayName(permType)
480+ status := stateDisplayName(state)
481+
482+ if hasStored {
483+ // Locked: show current policy and offer to reset.
484+ policy := "allow"
485+ if storedDecision == entity.PermissionDenied {
486+ policy = "deny"
487+ }
488+ return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy)
489+ }
490+
491+ // Not locked: show the flip action.
492+ switch state {
493+ case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting:
494+ return fmt.Sprintf("%s %s \u00B7 block", name, status)
495+ case webrtcPermissionStateBlocked:
496+ return fmt.Sprintf("%s %s \u00B7 allow", name, status)
497+ default:
498+ return fmt.Sprintf("%s %s", name, status)
499+ }
500+}
501+
502+func updateLockedCSSClass(widget *gtk.Widget, locked bool) {
503+ if widget == nil {
504+ return
505+ }
506+ if locked {
507+ widget.AddCssClass("row-locked")
508+ } else {
509+ widget.RemoveCssClass("row-locked")
510+ }
511+}
512+
513+func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) {
514+ if widget == nil {
515+ return
516+ }
517+
518+ widget.RemoveCssClass("state-idle")
519+ widget.RemoveCssClass("state-requesting")
520+ widget.RemoveCssClass("state-allowed")
521+ widget.RemoveCssClass("state-blocked")
522+
523+ switch state {
524+ case webrtcPermissionStateRequesting:
525+ widget.AddCssClass("state-requesting")
526+ case webrtcPermissionStateAllowed:
527+ widget.AddCssClass("state-allowed")
528+ case webrtcPermissionStateBlocked:
529+ widget.AddCssClass("state-blocked")
530+ default:
531+ widget.AddCssClass("state-idle")
532+ }
533+}
@@ -0,0 +1,533 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+)
10+
11+// revealerTransitionDuration is the animation duration in milliseconds
12+// for the details panel slide-down.
13+const revealerTransitionDuration = 180
14+
15+var 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.
27+type 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 []interface{}
45+}
46+
47+// NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator.
48+func 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.
63+func 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+
106+func 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+
127+func 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+
159+func 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+
168+func 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+
182+func 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+
198+func (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.
226+func (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.
234+func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) {
235+ w.setState(permTypes, webrtcPermissionStateRequesting)
236+}
237+
238+// MarkAllowed marks tracked permissions as allowed.
239+func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) {
240+ w.setState(permTypes, webrtcPermissionStateAllowed)
241+}
242+
243+// MarkBlocked marks tracked permissions as blocked.
244+func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) {
245+ w.setState(permTypes, webrtcPermissionStateBlocked)
246+}
247+
248+// Origin returns the current origin tracked by the indicator.
249+func (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.
259+func (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.
270+func (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.
293+func (w *WebRTCPermissionIndicator) Reset() {
294+ if w == nil {
295+ return
296+ }
297+
298+ w.mu.Lock()
299+ defer w.mu.Unlock()
300+
301+ for k := range w.states {
302+ delete(w.states, k)
303+ }
304+ for k := range w.stored {
305+ delete(w.stored, k)
306+ }
307+ w.currentOrigin = ""
308+
309+ w.refreshLocked()
310+}
311+
312+// SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked.
313+func (w *WebRTCPermissionIndicator) SetOnToggleLock(
314+ callback func(origin string, permType entity.PermissionType, state string, hasStored bool),
315+) {
316+ if w == nil {
317+ return
318+ }
319+ w.mu.Lock()
320+ defer w.mu.Unlock()
321+ w.onToggleLock = callback
322+}
323+
324+func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) {
325+ if w == nil {
326+ return
327+ }
328+
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+
332+ for _, permType := range permTypes {
333+ if !isTrackedWebRTCPermissionType(permType) {
334+ continue
335+ }
336+ w.states[permType] = state
337+ }
338+
339+ w.refreshLocked()
340+}
341+
342+func (w *WebRTCPermissionIndicator) refreshLocked() {
343+ if w.outerBox == nil {
344+ return
345+ }
346+
347+ visible := shouldShowWebRTCPermissionIndicator(w.states)
348+ w.outerBox.SetVisible(visible)
349+
350+ summary := summarizeWebRTCPermissionState(w.states)
351+ if w.dotBox != nil {
352+ updateStateCSSClass(&w.dotBox.Widget, summary)
353+ }
354+
355+ if w.originLabel != nil {
356+ origin := w.currentOrigin
357+ if origin == "" {
358+ origin = "Current site"
359+ }
360+ w.originLabel.SetText(origin)
361+ }
362+
363+ for _, permType := range trackedWebRTCPermissionTypes {
364+ row := w.rowButtons[permType]
365+ if row == nil {
366+ continue
367+ }
368+
369+ state := w.states[permType]
370+ if state == "" {
371+ state = webrtcPermissionStateIdle
372+ }
373+
374+ storedDecision, hasStored := w.stored[permType]
375+ label := formatRowLabel(permType, state, storedDecision, hasStored)
376+ row.SetLabel(label)
377+ updateStateCSSClass(&row.Widget, state)
378+ updateLockedCSSClass(&row.Widget, hasStored)
379+ }
380+
381+ if !visible {
382+ w.setExpandedLocked(false)
383+ }
384+}
385+
386+func (w *WebRTCPermissionIndicator) attachHoverController() {
387+ if w == nil || w.outerBox == nil {
388+ return
389+ }
390+
391+ controller := gtk.NewEventControllerMotion()
392+ if controller == nil {
393+ return
394+ }
395+
396+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
397+ w.mu.Lock()
398+ defer w.mu.Unlock()
399+ w.setExpandedLocked(true)
400+ }
401+ leaveCb := func(_ gtk.EventControllerMotion) {
402+ w.mu.Lock()
403+ defer w.mu.Unlock()
404+ w.setExpandedLocked(false)
405+ }
406+
407+ w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb)
408+ controller.ConnectEnter(&enterCb)
409+ controller.ConnectLeave(&leaveCb)
410+ w.outerBox.AddController(&controller.EventController)
411+}
412+
413+func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) {
414+ if w.revealer == nil || w.cardBox == nil {
415+ return
416+ }
417+ if w.expanded == expanded {
418+ return
419+ }
420+
421+ w.expanded = expanded
422+ w.revealer.SetRevealChild(expanded)
423+
424+ // Show/hide origin label so it doesn't take space when collapsed to just a dot.
425+ // Toggle hexpand alongside visibility to prevent expand propagation when collapsed.
426+ if w.originLabel != nil {
427+ w.originLabel.SetVisible(expanded)
428+ w.originLabel.SetHexpand(expanded)
429+ }
430+
431+ if expanded {
432+ w.cardBox.AddCssClass("expanded")
433+ } else {
434+ w.cardBox.RemoveCssClass("expanded")
435+ }
436+}
437+
438+func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool {
439+ switch permType {
440+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
441+ return true
442+ default:
443+ return false
444+ }
445+}
446+
447+func permissionDisplayName(permType entity.PermissionType) string {
448+ switch permType {
449+ case entity.PermissionTypeMicrophone:
450+ return "\U0001F3A4 Mic" // 🎤
451+ case entity.PermissionTypeCamera:
452+ return "\U0001F4F7 Cam" // 📷
453+ case entity.PermissionTypeDisplay:
454+ return "\U0001F4BB Screen" // 💻
455+ default:
456+ return "Unknown"
457+ }
458+}
459+
460+func stateDisplayName(state webrtcPermissionState) string {
461+ switch state {
462+ case webrtcPermissionStateRequesting:
463+ return "\u26A0 requesting" // ⚠
464+ case webrtcPermissionStateAllowed:
465+ return "\u2713 active" // ✓
466+ case webrtcPermissionStateBlocked:
467+ return "\u2717 blocked" // ✗
468+ default:
469+ return "idle"
470+ }
471+}
472+
473+func formatRowLabel(
474+ permType entity.PermissionType,
475+ state webrtcPermissionState,
476+ storedDecision entity.PermissionDecision,
477+ hasStored bool,
478+) string {
479+ name := permissionDisplayName(permType)
480+ status := stateDisplayName(state)
481+
482+ if hasStored {
483+ // Locked: show current policy and offer to reset.
484+ policy := "allow"
485+ if storedDecision == entity.PermissionDenied {
486+ policy = "deny"
487+ }
488+ return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy)
489+ }
490+
491+ // Not locked: show the flip action.
492+ switch state {
493+ case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting:
494+ return fmt.Sprintf("%s %s \u00B7 block", name, status)
495+ case webrtcPermissionStateBlocked:
496+ return fmt.Sprintf("%s %s \u00B7 allow", name, status)
497+ default:
498+ return fmt.Sprintf("%s %s", name, status)
499+ }
500+}
501+
502+func updateLockedCSSClass(widget *gtk.Widget, locked bool) {
503+ if widget == nil {
504+ return
505+ }
506+ if locked {
507+ widget.AddCssClass("row-locked")
508+ } else {
509+ widget.RemoveCssClass("row-locked")
510+ }
511+}
512+
513+func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) {
514+ if widget == nil {
515+ return
516+ }
517+
518+ widget.RemoveCssClass("state-idle")
519+ widget.RemoveCssClass("state-requesting")
520+ widget.RemoveCssClass("state-allowed")
521+ widget.RemoveCssClass("state-blocked")
522+
523+ switch state {
524+ case webrtcPermissionStateRequesting:
525+ widget.AddCssClass("state-requesting")
526+ case webrtcPermissionStateAllowed:
527+ widget.AddCssClass("state-allowed")
528+ case webrtcPermissionStateBlocked:
529+ widget.AddCssClass("state-blocked")
530+ default:
531+ widget.AddCssClass("state-idle")
532+ }
533+}
A internal/ui/component/webrtc_permission_indicator_state.go +55 -0
@@ -0,0 +1,55 @@
1+package component
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+type webrtcPermissionState string
6+
7+const (
8+ webrtcPermissionStateIdle webrtcPermissionState = "idle"
9+ webrtcPermissionStateRequesting webrtcPermissionState = "requesting"
10+ webrtcPermissionStateAllowed webrtcPermissionState = "allowed"
11+ webrtcPermissionStateBlocked webrtcPermissionState = "blocked"
12+)
13+
14+func summarizeWebRTCPermissionState(states map[entity.PermissionType]webrtcPermissionState) webrtcPermissionState {
15+ if len(states) == 0 {
16+ return webrtcPermissionStateIdle
17+ }
18+
19+ hasAllowed := false
20+ hasBlocked := false
21+
22+ for _, state := range states {
23+ switch state {
24+ case webrtcPermissionStateRequesting:
25+ return webrtcPermissionStateRequesting
26+ case webrtcPermissionStateBlocked:
27+ hasBlocked = true
28+ case webrtcPermissionStateAllowed:
29+ hasAllowed = true
30+ }
31+ }
32+
33+ if hasBlocked {
34+ return webrtcPermissionStateBlocked
35+ }
36+ if hasAllowed {
37+ return webrtcPermissionStateAllowed
38+ }
39+
40+ return webrtcPermissionStateIdle
41+}
42+
43+func shouldShowWebRTCPermissionIndicator(states map[entity.PermissionType]webrtcPermissionState) bool {
44+ if len(states) == 0 {
45+ return false
46+ }
47+
48+ for _, state := range states {
49+ if state != webrtcPermissionStateIdle {
50+ return true
51+ }
52+ }
53+
54+ return false
55+}
@@ -0,0 +1,55 @@
1+package component
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+type webrtcPermissionState string
6+
7+const (
8+ webrtcPermissionStateIdle webrtcPermissionState = "idle"
9+ webrtcPermissionStateRequesting webrtcPermissionState = "requesting"
10+ webrtcPermissionStateAllowed webrtcPermissionState = "allowed"
11+ webrtcPermissionStateBlocked webrtcPermissionState = "blocked"
12+)
13+
14+func summarizeWebRTCPermissionState(states map[entity.PermissionType]webrtcPermissionState) webrtcPermissionState {
15+ if len(states) == 0 {
16+ return webrtcPermissionStateIdle
17+ }
18+
19+ hasAllowed := false
20+ hasBlocked := false
21+
22+ for _, state := range states {
23+ switch state {
24+ case webrtcPermissionStateRequesting:
25+ return webrtcPermissionStateRequesting
26+ case webrtcPermissionStateBlocked:
27+ hasBlocked = true
28+ case webrtcPermissionStateAllowed:
29+ hasAllowed = true
30+ }
31+ }
32+
33+ if hasBlocked {
34+ return webrtcPermissionStateBlocked
35+ }
36+ if hasAllowed {
37+ return webrtcPermissionStateAllowed
38+ }
39+
40+ return webrtcPermissionStateIdle
41+}
42+
43+func shouldShowWebRTCPermissionIndicator(states map[entity.PermissionType]webrtcPermissionState) bool {
44+ if len(states) == 0 {
45+ return false
46+ }
47+
48+ for _, state := range states {
49+ if state != webrtcPermissionStateIdle {
50+ return true
51+ }
52+ }
53+
54+ return false
55+}
A internal/ui/component/webrtc_permission_indicator_state_test.go +52 -0
@@ -0,0 +1,52 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestWebRTCSummaryState_RequestingWins(t *testing.T) {
11+ states := map[entity.PermissionType]webrtcPermissionState{
12+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
13+ entity.PermissionTypeCamera: webrtcPermissionStateRequesting,
14+ }
15+
16+ summary := summarizeWebRTCPermissionState(states)
17+ assert.Equal(t, webrtcPermissionStateRequesting, summary)
18+}
19+
20+func TestWebRTCSummaryState_BlockedBeatsAllowed(t *testing.T) {
21+ states := map[entity.PermissionType]webrtcPermissionState{
22+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
23+ entity.PermissionTypeCamera: webrtcPermissionStateBlocked,
24+ }
25+
26+ summary := summarizeWebRTCPermissionState(states)
27+ assert.Equal(t, webrtcPermissionStateBlocked, summary)
28+}
29+
30+func TestWebRTCSummaryState_AllowedWhenOnlyAllowed(t *testing.T) {
31+ states := map[entity.PermissionType]webrtcPermissionState{
32+ entity.PermissionTypeDisplay: webrtcPermissionStateAllowed,
33+ }
34+
35+ summary := summarizeWebRTCPermissionState(states)
36+ assert.Equal(t, webrtcPermissionStateAllowed, summary)
37+}
38+
39+func TestWebRTCSummaryState_IdleWhenEmpty(t *testing.T) {
40+ assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(nil))
41+ assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(map[entity.PermissionType]webrtcPermissionState{}))
42+}
43+
44+func TestWebRTCShouldShowIndicator(t *testing.T) {
45+ assert.False(t, shouldShowWebRTCPermissionIndicator(nil))
46+ assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
47+ entity.PermissionTypeMicrophone: webrtcPermissionStateIdle,
48+ }))
49+ assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
50+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
51+ }))
52+}
@@ -0,0 +1,52 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestWebRTCSummaryState_RequestingWins(t *testing.T) {
11+ states := map[entity.PermissionType]webrtcPermissionState{
12+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
13+ entity.PermissionTypeCamera: webrtcPermissionStateRequesting,
14+ }
15+
16+ summary := summarizeWebRTCPermissionState(states)
17+ assert.Equal(t, webrtcPermissionStateRequesting, summary)
18+}
19+
20+func TestWebRTCSummaryState_BlockedBeatsAllowed(t *testing.T) {
21+ states := map[entity.PermissionType]webrtcPermissionState{
22+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
23+ entity.PermissionTypeCamera: webrtcPermissionStateBlocked,
24+ }
25+
26+ summary := summarizeWebRTCPermissionState(states)
27+ assert.Equal(t, webrtcPermissionStateBlocked, summary)
28+}
29+
30+func TestWebRTCSummaryState_AllowedWhenOnlyAllowed(t *testing.T) {
31+ states := map[entity.PermissionType]webrtcPermissionState{
32+ entity.PermissionTypeDisplay: webrtcPermissionStateAllowed,
33+ }
34+
35+ summary := summarizeWebRTCPermissionState(states)
36+ assert.Equal(t, webrtcPermissionStateAllowed, summary)
37+}
38+
39+func TestWebRTCSummaryState_IdleWhenEmpty(t *testing.T) {
40+ assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(nil))
41+ assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(map[entity.PermissionType]webrtcPermissionState{}))
42+}
43+
44+func TestWebRTCShouldShowIndicator(t *testing.T) {
45+ assert.False(t, shouldShowWebRTCPermissionIndicator(nil))
46+ assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
47+ entity.PermissionTypeMicrophone: webrtcPermissionStateIdle,
48+ }))
49+ assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
50+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
51+ }))
52+}
M internal/ui/coordinator/content.go +74 -4
@@ -118,6 +118,12 @@ type ContentCoordinator struct {
118118 // Callback when active pane title changes (for window title updates)
119119 onWindowTitleChanged func(title string)
120120
121+ // Callback when media permission activity changes (requesting/allowed/blocked).
122+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
123+
124+ // Callback when the active pane commits a navigation (new page loading).
125+ onActiveNavigationCommitted func(uri string)
126+
121127 // Callback when the WebView becomes visible (first real commit)
122128 onWebViewShown func(paneID entity.PaneID)
123129
@@ -179,6 +185,15 @@ type popupOAuthState struct {
179185 Seen bool
180186 }
181187
188+// PermissionActivityState represents the visible state for media permission activity.
189+type PermissionActivityState string
190+
191+const (
192+ PermissionActivityRequesting PermissionActivityState = "requesting"
193+ PermissionActivityAllowed PermissionActivityState = "allowed"
194+ PermissionActivityBlocked PermissionActivityState = "blocked"
195+)
196+
182197 // NewContentCoordinator creates a new ContentCoordinator.
183198 func NewContentCoordinator(
184199 ctx context.Context,
@@ -219,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
219234 c.onHistoryRecord = fn
220235 }
221236
237+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
238+func (c *ContentCoordinator) SetOnPermissionActivity(
239+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
240+) {
241+ c.onPermissionActivity = fn
242+}
243+
244+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
245+func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
246+ c.onActiveNavigationCommitted = fn
247+}
248+
222249 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
223250 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
224251 c.onPaneURIUpdated = fn
@@ -1083,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10831110 c.onHistoryRecord(ctx, paneID, uri)
10841111 }
10851112
1113+ // Notify active pane navigation for permission indicator reset.
1114+ c.notifyActiveNavigation(paneID, uri)
1115+
10861116 // Apply zoom
10871117 if c.zoomUC == nil {
10881118 return
@@ -1096,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10961126 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
10971127 }
10981128
1129+func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
1130+ if c.onActiveNavigationCommitted == nil {
1131+ return
1132+ }
1133+ ws, _ := c.getActiveWS()
1134+ if ws != nil && ws.ActivePaneID == paneID {
1135+ c.onActiveNavigationCommitted(uri)
1136+ }
1137+}
1138+
10991139 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
11001140 if wv == nil || wv.IsDestroyed() {
11011141 return false
@@ -1983,6 +2023,25 @@ func (c *ContentCoordinator) handlePermissionRequest(
19832023 return true
19842024 }
19852025
2026+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
2027+ notifyActivity := func(state PermissionActivityState) {
2028+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
2029+ return
2030+ }
2031+ c.onPermissionActivity(origin, trackedTypes, state)
2032+ }
2033+
2034+ notifyActivity(PermissionActivityRequesting)
2035+
2036+ wrappedAllow := func() {
2037+ notifyActivity(PermissionActivityAllowed)
2038+ allow()
2039+ }
2040+ wrappedDeny := func() {
2041+ notifyActivity(PermissionActivityBlocked)
2042+ deny()
2043+ }
2044+
19862045 // Check if permission use case is available
19872046 if c.permissionUC == nil {
19882047 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
@@ -1995,23 +2054,34 @@ func (c *ContentCoordinator) handlePermissionRequest(
19952054 }
19962055 }
19972056 if allAutoAllow {
1998- allow()
2057+ wrappedAllow()
19992058 } else {
2000- deny()
2059+ wrappedDeny()
20012060 }
20022061 return true
20032062 }
20042063
20052064 // Delegate to use case
20062065 callback := usecase.PermissionCallback{
2007- Allow: allow,
2008- Deny: deny,
2066+ Allow: wrappedAllow,
2067+ Deny: wrappedDeny,
20092068 }
20102069
20112070 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
20122071 return true
20132072 }
20142073
2074+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
2075+ filtered := make([]entity.PermissionType, 0, len(types))
2076+ for _, permType := range types {
2077+ switch permType {
2078+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
2079+ filtered = append(filtered, permType)
2080+ }
2081+ }
2082+ return filtered
2083+}
2084+
20152085 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
20162086 // Idle is inhibited when:
20172087 // - The webview enters fullscreen mode (e.g., fullscreen video)
@@ -118,6 +118,12 @@ type ContentCoordinator struct {
118 // Callback when active pane title changes (for window title updates)118 // Callback when active pane title changes (for window title updates)
119 onWindowTitleChanged func(title string)119 onWindowTitleChanged func(title string)
120 120
121+ // Callback when media permission activity changes (requesting/allowed/blocked).
122+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
123+
124+ // Callback when the active pane commits a navigation (new page loading).
125+ onActiveNavigationCommitted func(uri string)
126+
121 // Callback when the WebView becomes visible (first real commit)127 // Callback when the WebView becomes visible (first real commit)
122 onWebViewShown func(paneID entity.PaneID)128 onWebViewShown func(paneID entity.PaneID)
123 129
@@ -179,6 +185,15 @@ type popupOAuthState struct {
179 Seen bool185 Seen bool
180 }186 }
181 187
188+// PermissionActivityState represents the visible state for media permission activity.
189+type PermissionActivityState string
190+
191+const (
192+ PermissionActivityRequesting PermissionActivityState = "requesting"
193+ PermissionActivityAllowed PermissionActivityState = "allowed"
194+ PermissionActivityBlocked PermissionActivityState = "blocked"
195+)
196+
182 // NewContentCoordinator creates a new ContentCoordinator.197 // NewContentCoordinator creates a new ContentCoordinator.
183 func NewContentCoordinator(198 func NewContentCoordinator(
184 ctx context.Context,199 ctx context.Context,
@@ -219,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
219 c.onHistoryRecord = fn234 c.onHistoryRecord = fn
220 }235 }
221 236
237+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
238+func (c *ContentCoordinator) SetOnPermissionActivity(
239+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
240+) {
241+ c.onPermissionActivity = fn
242+}
243+
244+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
245+func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
246+ c.onActiveNavigationCommitted = fn
247+}
248+
222 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).249 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
223 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {250 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
224 c.onPaneURIUpdated = fn251 c.onPaneURIUpdated = fn
@@ -1083,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1083 c.onHistoryRecord(ctx, paneID, uri)1110 c.onHistoryRecord(ctx, paneID, uri)
1084 }1111 }
1085 1112
1113+ // Notify active pane navigation for permission indicator reset.
1114+ c.notifyActiveNavigation(paneID, uri)
1115+
1086 // Apply zoom1116 // Apply zoom
1087 if c.zoomUC == nil {1117 if c.zoomUC == nil {
1088 return1118 return
@@ -1096,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1096 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)1126 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
1097 }1127 }
1098 1128
1129+func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
1130+ if c.onActiveNavigationCommitted == nil {
1131+ return
1132+ }
1133+ ws, _ := c.getActiveWS()
1134+ if ws != nil && ws.ActivePaneID == paneID {
1135+ c.onActiveNavigationCommitted(uri)
1136+ }
1137+}
1138+
1099 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {1139 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
1100 if wv == nil || wv.IsDestroyed() {1140 if wv == nil || wv.IsDestroyed() {
1101 return false1141 return false
@@ -1983,6 +2023,25 @@ func (c *ContentCoordinator) handlePermissionRequest(
1983 return true2023 return true
1984 }2024 }
1985 2025
2026+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
2027+ notifyActivity := func(state PermissionActivityState) {
2028+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
2029+ return
2030+ }
2031+ c.onPermissionActivity(origin, trackedTypes, state)
2032+ }
2033+
2034+ notifyActivity(PermissionActivityRequesting)
2035+
2036+ wrappedAllow := func() {
2037+ notifyActivity(PermissionActivityAllowed)
2038+ allow()
2039+ }
2040+ wrappedDeny := func() {
2041+ notifyActivity(PermissionActivityBlocked)
2042+ deny()
2043+ }
2044+
1986 // Check if permission use case is available2045 // Check if permission use case is available
1987 if c.permissionUC == nil {2046 if c.permissionUC == nil {
1988 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")2047 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
@@ -1995,23 +2054,34 @@ func (c *ContentCoordinator) handlePermissionRequest(
1995 }2054 }
1996 }2055 }
1997 if allAutoAllow {2056 if allAutoAllow {
1998- allow()2057+ wrappedAllow()
1999 } else {2058 } else {
2000- deny()2059+ wrappedDeny()
2001 }2060 }
2002 return true2061 return true
2003 }2062 }
2004 2063
2005 // Delegate to use case2064 // Delegate to use case
2006 callback := usecase.PermissionCallback{2065 callback := usecase.PermissionCallback{
2007- Allow: allow,2066+ Allow: wrappedAllow,
2008- Deny: deny,2067+ Deny: wrappedDeny,
2009 }2068 }
2010 2069
2011 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)2070 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2012 return true2071 return true
2013 }2072 }
2014 2073
2074+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
2075+ filtered := make([]entity.PermissionType, 0, len(types))
2076+ for _, permType := range types {
2077+ switch permType {
2078+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
2079+ filtered = append(filtered, permType)
2080+ }
2081+ }
2082+ return filtered
2083+}
2084+
2015 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.2085 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
2016 // Idle is inhibited when:2086 // Idle is inhibited when:
2017 // - The webview enters fullscreen mode (e.g., fullscreen video)2087 // - The webview enters fullscreen mode (e.g., fullscreen video)
M internal/ui/dialog/permission.go +67 -1
@@ -4,6 +4,7 @@ package dialog
44 import (
55 "context"
66 "fmt"
7+ "sync"
78
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -11,11 +12,26 @@ import (
1112 "github.com/bnema/dumber/internal/ui/component"
1213 )
1314
15+type permissionPopup interface {
16+ Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))
17+}
18+
19+type permissionDialogRequest struct {
20+ ctx context.Context
21+ origin string
22+ permTypes []entity.PermissionType
23+ callback func(result port.PermissionDialogResult)
24+}
25+
1426 // PermissionDialog implements the port.PermissionDialogPresenter interface.
1527 // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
1628 // and match the app's custom UI style.
1729 type PermissionDialog struct {
18- popup *component.PermissionPopup
30+ popup permissionPopup
31+
32+ mu sync.Mutex
33+ active bool
34+ queue []permissionDialogRequest
1935 }
2036
2137 // NewPermissionDialog creates a new permission dialog presenter.
@@ -33,11 +49,45 @@ func (d *PermissionDialog) ShowPermissionDialog(
3349 permTypes []entity.PermissionType,
3450 callback func(result port.PermissionDialogResult),
3551 ) {
52+ req := permissionDialogRequest{
53+ ctx: ctx,
54+ origin: origin,
55+ permTypes: permTypes,
56+ callback: callback,
57+ }
58+
59+ if !d.enqueueOrStart(req) {
60+ return
61+ }
62+
63+ d.showRequest(req)
64+}
65+
66+func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
67+ d.mu.Lock()
68+ defer d.mu.Unlock()
69+
70+ if d.active {
71+ d.queue = append(d.queue, req)
72+ return false
73+ }
74+
75+ d.active = true
76+ return true
77+}
78+
79+func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
80+ ctx := req.ctx
81+ origin := req.origin
82+ permTypes := req.permTypes
83+ callback := req.callback
84+
3685 log := logging.FromContext(ctx)
3786
3887 if d.popup == nil {
3988 log.Error().Msg("permission popup not available")
4089 callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
90+ d.showNextQueuedRequest()
4191 return
4292 }
4393
@@ -51,6 +101,7 @@ func (d *PermissionDialog) ShowPermissionDialog(
51101 Bool("persistent", persistent).
52102 Msg("permission popup response")
53103 callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
104+ d.showNextQueuedRequest()
54105 })
55106
56107 log.Debug().
@@ -59,6 +110,21 @@ func (d *PermissionDialog) ShowPermissionDialog(
59110 Msg("showing permission popup")
60111 }
61112
113+func (d *PermissionDialog) showNextQueuedRequest() {
114+ d.mu.Lock()
115+ if len(d.queue) == 0 {
116+ d.active = false
117+ d.mu.Unlock()
118+ return
119+ }
120+
121+ next := d.queue[0]
122+ d.queue = d.queue[1:]
123+ d.mu.Unlock()
124+
125+ d.showRequest(next)
126+}
127+
62128 // buildHeading creates the dialog heading based on permission types.
63129 func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
64130 hasMic := false
@@ -4,6 +4,7 @@ package dialog
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ "sync"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -11,11 +12,26 @@ import (
11 "github.com/bnema/dumber/internal/ui/component"12 "github.com/bnema/dumber/internal/ui/component"
12 )13 )
13 14
15+type permissionPopup interface {
16+ Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))
17+}
18+
19+type permissionDialogRequest struct {
20+ ctx context.Context
21+ origin string
22+ permTypes []entity.PermissionType
23+ callback func(result port.PermissionDialogResult)
24+}
25+
14 // PermissionDialog implements the port.PermissionDialogPresenter interface.26 // PermissionDialog implements the port.PermissionDialogPresenter interface.
15 // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug27 // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
16 // and match the app's custom UI style.28 // and match the app's custom UI style.
17 type PermissionDialog struct {29 type PermissionDialog struct {
18- popup *component.PermissionPopup30+ popup permissionPopup
31+
32+ mu sync.Mutex
33+ active bool
34+ queue []permissionDialogRequest
19 }35 }
20 36
21 // NewPermissionDialog creates a new permission dialog presenter.37 // NewPermissionDialog creates a new permission dialog presenter.
@@ -33,11 +49,45 @@ func (d *PermissionDialog) ShowPermissionDialog(
33 permTypes []entity.PermissionType,49 permTypes []entity.PermissionType,
34 callback func(result port.PermissionDialogResult),50 callback func(result port.PermissionDialogResult),
35 ) {51 ) {
52+ req := permissionDialogRequest{
53+ ctx: ctx,
54+ origin: origin,
55+ permTypes: permTypes,
56+ callback: callback,
57+ }
58+
59+ if !d.enqueueOrStart(req) {
60+ return
61+ }
62+
63+ d.showRequest(req)
64+}
65+
66+func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
67+ d.mu.Lock()
68+ defer d.mu.Unlock()
69+
70+ if d.active {
71+ d.queue = append(d.queue, req)
72+ return false
73+ }
74+
75+ d.active = true
76+ return true
77+}
78+
79+func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
80+ ctx := req.ctx
81+ origin := req.origin
82+ permTypes := req.permTypes
83+ callback := req.callback
84+
36 log := logging.FromContext(ctx)85 log := logging.FromContext(ctx)
37 86
38 if d.popup == nil {87 if d.popup == nil {
39 log.Error().Msg("permission popup not available")88 log.Error().Msg("permission popup not available")
40 callback(port.PermissionDialogResult{Allowed: false, Persistent: false})89 callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
90+ d.showNextQueuedRequest()
41 return91 return
42 }92 }
43 93
@@ -51,6 +101,7 @@ func (d *PermissionDialog) ShowPermissionDialog(
51 Bool("persistent", persistent).101 Bool("persistent", persistent).
52 Msg("permission popup response")102 Msg("permission popup response")
53 callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})103 callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
104+ d.showNextQueuedRequest()
54 })105 })
55 106
56 log.Debug().107 log.Debug().
@@ -59,6 +110,21 @@ func (d *PermissionDialog) ShowPermissionDialog(
59 Msg("showing permission popup")110 Msg("showing permission popup")
60 }111 }
61 112
113+func (d *PermissionDialog) showNextQueuedRequest() {
114+ d.mu.Lock()
115+ if len(d.queue) == 0 {
116+ d.active = false
117+ d.mu.Unlock()
118+ return
119+ }
120+
121+ next := d.queue[0]
122+ d.queue = d.queue[1:]
123+ d.mu.Unlock()
124+
125+ d.showRequest(next)
126+}
127+
62 // buildHeading creates the dialog heading based on permission types.128 // buildHeading creates the dialog heading based on permission types.
63 func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {129 func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
64 hasMic := false130 hasMic := false
A internal/ui/dialog/permission_test.go +109 -0
@@ -0,0 +1,109 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+type fakePermissionPopup struct {
13+ showCalls []fakePopupShowCall
14+ callback func(allowed, persistent bool)
15+}
16+
17+type fakePopupShowCall struct {
18+ heading string
19+ body string
20+}
21+
22+func (f *fakePermissionPopup) Show(
23+ _ context.Context,
24+ heading string,
25+ body string,
26+ callback func(allowed, persistent bool),
27+) {
28+ f.showCalls = append(f.showCalls, fakePopupShowCall{
29+ heading: heading,
30+ body: body,
31+ })
32+ f.callback = callback
33+}
34+
35+func (f *fakePermissionPopup) Respond(allowed, persistent bool) {
36+ if f.callback == nil {
37+ return
38+ }
39+ cb := f.callback
40+ f.callback = nil
41+ cb(allowed, persistent)
42+}
43+
44+func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) {
45+ popup := &fakePermissionPopup{}
46+ d := &PermissionDialog{popup: popup}
47+
48+ var firstResult port.PermissionDialogResult
49+ firstCalled := false
50+ d.ShowPermissionDialog(
51+ context.Background(),
52+ "https://example.com",
53+ []entity.PermissionType{entity.PermissionTypeMicrophone},
54+ func(result port.PermissionDialogResult) {
55+ firstCalled = true
56+ firstResult = result
57+ },
58+ )
59+
60+ secondCalled := false
61+ var secondResult port.PermissionDialogResult
62+ d.ShowPermissionDialog(
63+ context.Background(),
64+ "https://example.com",
65+ []entity.PermissionType{entity.PermissionTypeCamera},
66+ func(result port.PermissionDialogResult) {
67+ secondCalled = true
68+ secondResult = result
69+ },
70+ )
71+
72+ if assert.Len(t, popup.showCalls, 1) {
73+ assert.Equal(t, "Allow Microphone Access?", popup.showCalls[0].heading)
74+ }
75+ assert.False(t, firstCalled)
76+ assert.False(t, secondCalled)
77+
78+ popup.Respond(true, false)
79+ assert.True(t, firstCalled)
80+ assert.Equal(t, port.PermissionDialogResult{Allowed: true, Persistent: false}, firstResult)
81+
82+ if assert.Len(t, popup.showCalls, 2) {
83+ assert.Equal(t, "Allow Camera Access?", popup.showCalls[1].heading)
84+ }
85+ assert.False(t, secondCalled)
86+
87+ popup.Respond(false, true)
88+ assert.True(t, secondCalled)
89+ assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: true}, secondResult)
90+}
91+
92+func TestPermissionDialog_NoPopup_DeniesRequest(t *testing.T) {
93+ d := &PermissionDialog{popup: nil}
94+
95+ called := false
96+ result := port.PermissionDialogResult{}
97+ d.ShowPermissionDialog(
98+ context.Background(),
99+ "https://example.com",
100+ []entity.PermissionType{entity.PermissionTypeMicrophone},
101+ func(r port.PermissionDialogResult) {
102+ called = true
103+ result = r
104+ },
105+ )
106+
107+ assert.True(t, called)
108+ assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: false}, result)
109+}
@@ -0,0 +1,109 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+type fakePermissionPopup struct {
13+ showCalls []fakePopupShowCall
14+ callback func(allowed, persistent bool)
15+}
16+
17+type fakePopupShowCall struct {
18+ heading string
19+ body string
20+}
21+
22+func (f *fakePermissionPopup) Show(
23+ _ context.Context,
24+ heading string,
25+ body string,
26+ callback func(allowed, persistent bool),
27+) {
28+ f.showCalls = append(f.showCalls, fakePopupShowCall{
29+ heading: heading,
30+ body: body,
31+ })
32+ f.callback = callback
33+}
34+
35+func (f *fakePermissionPopup) Respond(allowed, persistent bool) {
36+ if f.callback == nil {
37+ return
38+ }
39+ cb := f.callback
40+ f.callback = nil
41+ cb(allowed, persistent)
42+}
43+
44+func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) {
45+ popup := &fakePermissionPopup{}
46+ d := &PermissionDialog{popup: popup}
47+
48+ var firstResult port.PermissionDialogResult
49+ firstCalled := false
50+ d.ShowPermissionDialog(
51+ context.Background(),
52+ "https://example.com",
53+ []entity.PermissionType{entity.PermissionTypeMicrophone},
54+ func(result port.PermissionDialogResult) {
55+ firstCalled = true
56+ firstResult = result
57+ },
58+ )
59+
60+ secondCalled := false
61+ var secondResult port.PermissionDialogResult
62+ d.ShowPermissionDialog(
63+ context.Background(),
64+ "https://example.com",
65+ []entity.PermissionType{entity.PermissionTypeCamera},
66+ func(result port.PermissionDialogResult) {
67+ secondCalled = true
68+ secondResult = result
69+ },
70+ )
71+
72+ if assert.Len(t, popup.showCalls, 1) {
73+ assert.Equal(t, "Allow Microphone Access?", popup.showCalls[0].heading)
74+ }
75+ assert.False(t, firstCalled)
76+ assert.False(t, secondCalled)
77+
78+ popup.Respond(true, false)
79+ assert.True(t, firstCalled)
80+ assert.Equal(t, port.PermissionDialogResult{Allowed: true, Persistent: false}, firstResult)
81+
82+ if assert.Len(t, popup.showCalls, 2) {
83+ assert.Equal(t, "Allow Camera Access?", popup.showCalls[1].heading)
84+ }
85+ assert.False(t, secondCalled)
86+
87+ popup.Respond(false, true)
88+ assert.True(t, secondCalled)
89+ assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: true}, secondResult)
90+}
91+
92+func TestPermissionDialog_NoPopup_DeniesRequest(t *testing.T) {
93+ d := &PermissionDialog{popup: nil}
94+
95+ called := false
96+ result := port.PermissionDialogResult{}
97+ d.ShowPermissionDialog(
98+ context.Background(),
99+ "https://example.com",
100+ []entity.PermissionType{entity.PermissionTypeMicrophone},
101+ func(r port.PermissionDialogResult) {
102+ called = true
103+ result = r
104+ },
105+ )
106+
107+ assert.True(t, called)
108+ assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: false}, result)
109+}
M internal/ui/theme/css.go +4 -0
@@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
132132 sb.WriteString(generatePermissionPopupCSS(p))
133133 sb.WriteString("\n")
134134
135+ // WebRTC permission activity indicator styling
136+ sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137+ sb.WriteString("\n")
138+
135139 // Accent picker styling
136140 sb.WriteString(generateAccentPickerCSS(p))
137141
@@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
132 sb.WriteString(generatePermissionPopupCSS(p))132 sb.WriteString(generatePermissionPopupCSS(p))
133 sb.WriteString("\n")133 sb.WriteString("\n")
134 134
135+ // WebRTC permission activity indicator styling
136+ sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137+ sb.WriteString("\n")
138+
135 // Accent picker styling139 // Accent picker styling
136 sb.WriteString(generateAccentPickerCSS(p))140 sb.WriteString(generateAccentPickerCSS(p))
137 141
A internal/ui/theme/webrtc_indicator_css.go +168 -0
@@ -0,0 +1,168 @@
1+package theme
2+
3+// generateWebRTCPermissionIndicatorCSS creates styles for the top-right
4+// WebRTC permission activity indicator.
5+// The dot anchors top-right; on hover the card expands left and down
6+// using a GtkRevealer (slide-down) for the details panel.
7+func generateWebRTCPermissionIndicatorCSS(p Palette) string {
8+ _ = p
9+ return `/* ===== WebRTC Permission Indicator ===== */
10+
11+.webrtc-indicator-outer {
12+ margin-top: 0.125em;
13+ margin-right: 0.125em;
14+}
15+
16+/* Card container: transparent when collapsed, styled when expanded.
17+ Right-aligned so the dot stays top-right and content grows leftward. */
18+.webrtc-indicator-card {
19+ background-color: transparent;
20+ border: 0.0625em solid transparent;
21+ border-radius: 999px;
22+ padding: 0;
23+ transition: background-color 180ms ease-in-out,
24+ border-color 180ms ease-in-out,
25+ box-shadow 180ms ease-in-out;
26+}
27+
28+.webrtc-indicator-card.expanded {
29+ background-color: alpha(var(--surface-variant), 0.96);
30+ border-color: alpha(var(--border), 0.8);
31+ border-radius: 0.375em;
32+ box-shadow: 0 0.25em 1em alpha(black, 0.28),
33+ 0 0.0625em 0.25em alpha(black, 0.12);
34+ padding: 0.4em 0.5em;
35+}
36+
37+/* Summary dot - small colored circle, right-aligned */
38+.webrtc-indicator-dot {
39+ min-width: 0.5em;
40+ min-height: 0.5em;
41+ border-radius: 999px;
42+ background-color: var(--muted);
43+ border: 0.0625em solid alpha(var(--border), 0.6);
44+ transition: background-color 200ms ease-in-out,
45+ border-color 200ms ease-in-out,
46+ box-shadow 200ms ease-in-out;
47+}
48+
49+/* Header row: origin label (left) + dot (right) on the same line */
50+.webrtc-indicator-header {
51+}
52+
53+/* Origin label - shown next to the dot, hidden when collapsed (no text) */
54+.webrtc-indicator-origin {
55+ font-size: 0.6875em;
56+ font-weight: 600;
57+ letter-spacing: 0.02em;
58+ padding: 0 0.4em 0 0;
59+ color: var(--muted);
60+}
61+
62+/* GtkRevealer wrapper */
63+.webrtc-indicator-revealer {
64+}
65+
66+/* Details section inside the revealer (permission rows only) */
67+.webrtc-indicator-details {
68+ margin-top: 0.3em;
69+ padding-top: 0.3em;
70+ border-top: 0.0625em solid alpha(var(--border), 0.5);
71+}
72+
73+/* Permission row buttons */
74+.webrtc-indicator-row {
75+ font-size: 0.6875em;
76+ font-weight: 500;
77+ padding: 0.25em 0.4em;
78+ margin-top: 0.15em;
79+ border-radius: 0.25em;
80+ border-left: 0.1875em solid transparent;
81+ border-top: none;
82+ border-right: none;
83+ border-bottom: none;
84+ background-color: alpha(var(--bg), 0.35);
85+ background-image: none;
86+ color: var(--muted);
87+ text-align: left;
88+ transition: background-color 150ms ease-in-out,
89+ border-color 150ms ease-in-out,
90+ color 150ms ease-in-out;
91+}
92+
93+.webrtc-indicator-row:hover {
94+ background-color: alpha(var(--bg), 0.55);
95+ background-image: none;
96+}
97+
98+/* ---- State: idle ---- */
99+.webrtc-indicator-dot.state-idle {
100+ background-color: var(--muted);
101+ border-color: alpha(var(--border), 0.6);
102+}
103+
104+.webrtc-indicator-row.state-idle {
105+ color: var(--muted);
106+ border-left-color: alpha(var(--muted), 0.4);
107+}
108+
109+/* ---- State: requesting (amber) ---- */
110+.webrtc-indicator-dot.state-requesting {
111+ background-color: var(--warning);
112+ border-color: alpha(var(--warning), 0.6);
113+ box-shadow: 0 0 0.4em alpha(var(--warning), 0.5);
114+}
115+
116+.webrtc-indicator-row.state-requesting {
117+ color: var(--warning);
118+ border-left-color: var(--warning);
119+ background-color: alpha(var(--warning), 0.08);
120+}
121+
122+.webrtc-indicator-row.state-requesting:hover {
123+ background-color: alpha(var(--warning), 0.14);
124+ background-image: none;
125+}
126+
127+/* ---- State: allowed (green) ---- */
128+.webrtc-indicator-dot.state-allowed {
129+ background-color: var(--success);
130+ border-color: alpha(var(--success), 0.6);
131+ box-shadow: 0 0 0.35em alpha(var(--success), 0.35);
132+}
133+
134+.webrtc-indicator-row.state-allowed {
135+ color: var(--success);
136+ border-left-color: var(--success);
137+ background-color: alpha(var(--success), 0.06);
138+}
139+
140+.webrtc-indicator-row.state-allowed:hover {
141+ background-color: alpha(var(--success), 0.12);
142+ background-image: none;
143+}
144+
145+/* ---- State: blocked (red) ---- */
146+.webrtc-indicator-dot.state-blocked {
147+ background-color: var(--destructive);
148+ border-color: alpha(var(--destructive), 0.6);
149+ box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35);
150+}
151+
152+.webrtc-indicator-row.state-blocked {
153+ color: var(--destructive);
154+ border-left-color: var(--destructive);
155+ background-color: alpha(var(--destructive), 0.06);
156+}
157+
158+.webrtc-indicator-row.state-blocked:hover {
159+ background-color: alpha(var(--destructive), 0.12);
160+ background-image: none;
161+}
162+
163+/* ---- Locked row modifier ---- */
164+.webrtc-indicator-row.row-locked {
165+ font-weight: 600;
166+}
167+`
168+}
@@ -0,0 +1,168 @@
1+package theme
2+
3+// generateWebRTCPermissionIndicatorCSS creates styles for the top-right
4+// WebRTC permission activity indicator.
5+// The dot anchors top-right; on hover the card expands left and down
6+// using a GtkRevealer (slide-down) for the details panel.
7+func generateWebRTCPermissionIndicatorCSS(p Palette) string {
8+ _ = p
9+ return `/* ===== WebRTC Permission Indicator ===== */
10+
11+.webrtc-indicator-outer {
12+ margin-top: 0.125em;
13+ margin-right: 0.125em;
14+}
15+
16+/* Card container: transparent when collapsed, styled when expanded.
17+ Right-aligned so the dot stays top-right and content grows leftward. */
18+.webrtc-indicator-card {
19+ background-color: transparent;
20+ border: 0.0625em solid transparent;
21+ border-radius: 999px;
22+ padding: 0;
23+ transition: background-color 180ms ease-in-out,
24+ border-color 180ms ease-in-out,
25+ box-shadow 180ms ease-in-out;
26+}
27+
28+.webrtc-indicator-card.expanded {
29+ background-color: alpha(var(--surface-variant), 0.96);
30+ border-color: alpha(var(--border), 0.8);
31+ border-radius: 0.375em;
32+ box-shadow: 0 0.25em 1em alpha(black, 0.28),
33+ 0 0.0625em 0.25em alpha(black, 0.12);
34+ padding: 0.4em 0.5em;
35+}
36+
37+/* Summary dot - small colored circle, right-aligned */
38+.webrtc-indicator-dot {
39+ min-width: 0.5em;
40+ min-height: 0.5em;
41+ border-radius: 999px;
42+ background-color: var(--muted);
43+ border: 0.0625em solid alpha(var(--border), 0.6);
44+ transition: background-color 200ms ease-in-out,
45+ border-color 200ms ease-in-out,
46+ box-shadow 200ms ease-in-out;
47+}
48+
49+/* Header row: origin label (left) + dot (right) on the same line */
50+.webrtc-indicator-header {
51+}
52+
53+/* Origin label - shown next to the dot, hidden when collapsed (no text) */
54+.webrtc-indicator-origin {
55+ font-size: 0.6875em;
56+ font-weight: 600;
57+ letter-spacing: 0.02em;
58+ padding: 0 0.4em 0 0;
59+ color: var(--muted);
60+}
61+
62+/* GtkRevealer wrapper */
63+.webrtc-indicator-revealer {
64+}
65+
66+/* Details section inside the revealer (permission rows only) */
67+.webrtc-indicator-details {
68+ margin-top: 0.3em;
69+ padding-top: 0.3em;
70+ border-top: 0.0625em solid alpha(var(--border), 0.5);
71+}
72+
73+/* Permission row buttons */
74+.webrtc-indicator-row {
75+ font-size: 0.6875em;
76+ font-weight: 500;
77+ padding: 0.25em 0.4em;
78+ margin-top: 0.15em;
79+ border-radius: 0.25em;
80+ border-left: 0.1875em solid transparent;
81+ border-top: none;
82+ border-right: none;
83+ border-bottom: none;
84+ background-color: alpha(var(--bg), 0.35);
85+ background-image: none;
86+ color: var(--muted);
87+ text-align: left;
88+ transition: background-color 150ms ease-in-out,
89+ border-color 150ms ease-in-out,
90+ color 150ms ease-in-out;
91+}
92+
93+.webrtc-indicator-row:hover {
94+ background-color: alpha(var(--bg), 0.55);
95+ background-image: none;
96+}
97+
98+/* ---- State: idle ---- */
99+.webrtc-indicator-dot.state-idle {
100+ background-color: var(--muted);
101+ border-color: alpha(var(--border), 0.6);
102+}
103+
104+.webrtc-indicator-row.state-idle {
105+ color: var(--muted);
106+ border-left-color: alpha(var(--muted), 0.4);
107+}
108+
109+/* ---- State: requesting (amber) ---- */
110+.webrtc-indicator-dot.state-requesting {
111+ background-color: var(--warning);
112+ border-color: alpha(var(--warning), 0.6);
113+ box-shadow: 0 0 0.4em alpha(var(--warning), 0.5);
114+}
115+
116+.webrtc-indicator-row.state-requesting {
117+ color: var(--warning);
118+ border-left-color: var(--warning);
119+ background-color: alpha(var(--warning), 0.08);
120+}
121+
122+.webrtc-indicator-row.state-requesting:hover {
123+ background-color: alpha(var(--warning), 0.14);
124+ background-image: none;
125+}
126+
127+/* ---- State: allowed (green) ---- */
128+.webrtc-indicator-dot.state-allowed {
129+ background-color: var(--success);
130+ border-color: alpha(var(--success), 0.6);
131+ box-shadow: 0 0 0.35em alpha(var(--success), 0.35);
132+}
133+
134+.webrtc-indicator-row.state-allowed {
135+ color: var(--success);
136+ border-left-color: var(--success);
137+ background-color: alpha(var(--success), 0.06);
138+}
139+
140+.webrtc-indicator-row.state-allowed:hover {
141+ background-color: alpha(var(--success), 0.12);
142+ background-image: none;
143+}
144+
145+/* ---- State: blocked (red) ---- */
146+.webrtc-indicator-dot.state-blocked {
147+ background-color: var(--destructive);
148+ border-color: alpha(var(--destructive), 0.6);
149+ box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35);
150+}
151+
152+.webrtc-indicator-row.state-blocked {
153+ color: var(--destructive);
154+ border-left-color: var(--destructive);
155+ background-color: alpha(var(--destructive), 0.06);
156+}
157+
158+.webrtc-indicator-row.state-blocked:hover {
159+ background-color: alpha(var(--destructive), 0.12);
160+ background-image: none;
161+}
162+
163+/* ---- Locked row modifier ---- */
164+.webrtc-indicator-row.row-locked {
165+ font-weight: 600;
166+}
167+`
168+}

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