detached 6fcb6f0

fix(cef): disclose system audio and hide Always buttons for non-persistable prompts

- CEF flags desktop audio with unmediated_audio; the dialog says "entire
  screen and system audio"
- the popup hides Always Allow/Deny when no requested type is persistable
  (e.g. unmediated display alone); mixed sets keep them
- note that QueryPermissionState has no CEF caller and ignores the
  unmediated flag
- simplify the stored-decision usecase test
commit
6fcb6f07301e170f2c3b2660668ba303c15ed77b
author
brice · · 2026-10-03 07:04 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
8fbbe5e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +149 -40
M internal/application/usecase/handle_permission.go +2 -0
@@ -173,6 +173,8 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
173173 }
174174
175175 // QueryPermissionState returns the current permission state for the W3C Permissions API.
176+// Note: it has no CEF caller and does not consider PermissionMetadataKeyUnmediatedCapture
177+// (display is reported as granted); revisit if a CEF Permissions API bridge is added.
176178 // This is used by websites to check if they already have permission before calling getUserMedia().
177179 func (uc *HandlePermissionUseCase) QueryPermissionState(
178180 ctx context.Context,
@@ -173,6 +173,8 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
173 }173 }
174 174
175 // QueryPermissionState returns the current permission state for the W3C Permissions API.175 // QueryPermissionState returns the current permission state for the W3C Permissions API.
176+// Note: it has no CEF caller and does not consider PermissionMetadataKeyUnmediatedCapture
177+// (display is reported as granted); revisit if a CEF Permissions API bridge is added.
176 // This is used by websites to check if they already have permission before calling getUserMedia().178 // This is used by websites to check if they already have permission before calling getUserMedia().
177 func (uc *HandlePermissionUseCase) QueryPermissionState(179 func (uc *HandlePermissionUseCase) QueryPermissionState(
178 ctx context.Context,180 ctx context.Context,
M internal/application/usecase/handle_permission_test.go +14 -18
@@ -655,26 +655,22 @@ func TestHandlePermissionUseCase_UnmediatedDisplayShowsDialog(t *testing.T) {
655655 }
656656
657657 func TestHandlePermissionUseCase_UnmediatedDisplayIgnoresStoredDecision(t *testing.T) {
658- for _, decision := range []entity.PermissionDecision{entity.PermissionGranted, entity.PermissionDenied} {
659- t.Run(string(decision), func(t *testing.T) {
660- permRepo := portmocks.NewMockPermissionRepository(t)
661- dialog := portmocks.NewMockPermissionDialogPresenter(t)
662- uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
658+ permRepo := portmocks.NewMockPermissionRepository(t)
659+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
660+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
663661
664- // A stale stored record must neither auto-allow nor auto-deny.
665- permRepo.EXPECT().Get(mock.Anything, mock.Anything, entity.PermissionTypeDisplay).
666- Return(&entity.PermissionRecord{Type: entity.PermissionTypeDisplay, Decision: decision}, nil).Maybe()
667- dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",
668- []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).
669- Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
670- cb(port.PermissionDialogResult{Allowed: false})
671- }).Once()
662+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",
663+ []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).
664+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
665+ cb(port.PermissionDialogResult{Allowed: false})
666+ }).Once()
672667
673- uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
674- []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,
675- usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}})
676- })
677- }
668+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
669+ []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,
670+ usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}})
671+
672+ // Display is never looked up, so no stored decision can auto-allow or auto-deny it.
673+ permRepo.AssertNotCalled(t, "Get", mock.Anything, mock.Anything, entity.PermissionTypeDisplay)
678674 }
679675
680676 func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
@@ -655,26 +655,22 @@ func TestHandlePermissionUseCase_UnmediatedDisplayShowsDialog(t *testing.T) {
655 }655 }
656 656
657 func TestHandlePermissionUseCase_UnmediatedDisplayIgnoresStoredDecision(t *testing.T) {657 func TestHandlePermissionUseCase_UnmediatedDisplayIgnoresStoredDecision(t *testing.T) {
658- for _, decision := range []entity.PermissionDecision{entity.PermissionGranted, entity.PermissionDenied} {658+ permRepo := portmocks.NewMockPermissionRepository(t)
659- t.Run(string(decision), func(t *testing.T) {659+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
660- permRepo := portmocks.NewMockPermissionRepository(t)660+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
661- dialog := portmocks.NewMockPermissionDialogPresenter(t)
662- uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
663 661
664- // A stale stored record must neither auto-allow nor auto-deny.662+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",
665- permRepo.EXPECT().Get(mock.Anything, mock.Anything, entity.PermissionTypeDisplay).663+ []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).
666- Return(&entity.PermissionRecord{Type: entity.PermissionTypeDisplay, Decision: decision}, nil).Maybe()664+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
667- dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",665+ cb(port.PermissionDialogResult{Allowed: false})
668- []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).666+ }).Once()
669- Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
670- cb(port.PermissionDialogResult{Allowed: false})
671- }).Once()
672 667
673- uc.HandlePermissionRequest(testContext(), "https://meet.example.com",668+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
674- []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,669+ []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,
675- usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}})670+ usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}})
676- })671+
677- }672+ // Display is never looked up, so no stored decision can auto-allow or auto-deny it.
673+ permRepo.AssertNotCalled(t, "Get", mock.Anything, mock.Anything, entity.PermissionTypeDisplay)
678 }674 }
679 675
680 func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {676 func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
M internal/domain/entity/permission.go +9 -0
@@ -53,6 +53,10 @@ const (
5353 // shares the entire screen immediately (CEF). It disables display auto-allow.
5454 PermissionMetadataKeyUnmediatedCapture = "unmediated_capture"
5555
56+ // PermissionMetadataKeyUnmediatedAudio is set to PermissionMetadataValueTrue when the
57+ // request also captures system (desktop) audio, which no system UI mediates either.
58+ PermissionMetadataKeyUnmediatedAudio = "unmediated_audio"
59+
5660 // PermissionMetadataValueTrue is the metadata value for boolean flags.
5761 PermissionMetadataValueTrue = "true"
5862 )
@@ -121,6 +125,11 @@ func (m PermissionMetadata) IsUnmediatedCapture() bool {
121125 return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
122126 }
123127
128+// IsUnmediatedAudio reports whether the metadata flags system audio capture.
129+func (m PermissionMetadata) IsUnmediatedAudio() bool {
130+ return m[PermissionMetadataKeyUnmediatedAudio] == PermissionMetadataValueTrue
131+}
132+
124133 // IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
125134 // unmediated display capture is never auto-allowed.
126135 func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
@@ -53,6 +53,10 @@ const (
53 // shares the entire screen immediately (CEF). It disables display auto-allow.53 // shares the entire screen immediately (CEF). It disables display auto-allow.
54 PermissionMetadataKeyUnmediatedCapture = "unmediated_capture"54 PermissionMetadataKeyUnmediatedCapture = "unmediated_capture"
55 55
56+ // PermissionMetadataKeyUnmediatedAudio is set to PermissionMetadataValueTrue when the
57+ // request also captures system (desktop) audio, which no system UI mediates either.
58+ PermissionMetadataKeyUnmediatedAudio = "unmediated_audio"
59+
56 // PermissionMetadataValueTrue is the metadata value for boolean flags.60 // PermissionMetadataValueTrue is the metadata value for boolean flags.
57 PermissionMetadataValueTrue = "true"61 PermissionMetadataValueTrue = "true"
58 )62 )
@@ -121,6 +125,11 @@ func (m PermissionMetadata) IsUnmediatedCapture() bool {
121 return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue125 return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
122 }126 }
123 127
128+// IsUnmediatedAudio reports whether the metadata flags system audio capture.
129+func (m PermissionMetadata) IsUnmediatedAudio() bool {
130+ return m[PermissionMetadataKeyUnmediatedAudio] == PermissionMetadataValueTrue
131+}
132+
124 // IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an133 // IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
125 // unmediated display capture is never auto-allowed.134 // unmediated display capture is never auto-allowed.
126 func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {135 func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
M internal/infrastructure/cef/permission_handler.go +5 -1
@@ -53,12 +53,16 @@ func mediaPermissionTypes(requested uint32) []string {
5353
5454 // mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants
5555 // the full screen on Cont() without any picker or portal, so Dumber must ask the
56-// user explicitly.
56+// user explicitly. Desktop audio (system loopback) is flagged separately so the
57+// dialog can disclose it.
5758 func mediaPermissionMetadata(requested uint32) map[string]string {
5859 metadata := map[string]string{}
5960 if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
6061 metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
6162 }
63+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) != 0 {
64+ metadata[entity.PermissionMetadataKeyUnmediatedAudio] = entity.PermissionMetadataValueTrue
65+ }
6266 return metadata
6367 }
6468
@@ -53,12 +53,16 @@ func mediaPermissionTypes(requested uint32) []string {
53 53
54 // mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants54 // mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants
55 // the full screen on Cont() without any picker or portal, so Dumber must ask the55 // the full screen on Cont() without any picker or portal, so Dumber must ask the
56-// user explicitly.56+// user explicitly. Desktop audio (system loopback) is flagged separately so the
57+// dialog can disclose it.
57 func mediaPermissionMetadata(requested uint32) map[string]string {58 func mediaPermissionMetadata(requested uint32) map[string]string {
58 metadata := map[string]string{}59 metadata := map[string]string{}
59 if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {60 if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
60 metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue61 metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
61 }62 }
63+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) != 0 {
64+ metadata[entity.PermissionMetadataKeyUnmediatedAudio] = entity.PermissionMetadataValueTrue
65+ }
62 return metadata66 return metadata
63 }67 }
64 68
M internal/infrastructure/cef/permission_handler_test.go +7 -5
@@ -421,12 +421,13 @@ func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.
421421 name string
422422 requested uint32
423423 flagged bool
424+ audio bool
424425 }{
425- {"desktop video", mediaDeskV, true},
426- {"desktop audio and video", mediaDeskA | mediaDeskV, true},
427- {"camera and desktop video", mediaCamera | mediaDeskV, true},
428- {"camera only", mediaCamera, false},
429- {"microphone and camera", mediaMic | mediaCamera, false},
426+ {"desktop video", mediaDeskV, true, false},
427+ {"desktop audio and video", mediaDeskA | mediaDeskV, true, true},
428+ {"camera and desktop video", mediaCamera | mediaDeskV, true, false},
429+ {"camera only", mediaCamera, false, false},
430+ {"microphone and camera", mediaMic | mediaCamera, false, false},
430431 }
431432 for _, tt := range tests {
432433 t.Run(tt.name, func(t *testing.T) {
@@ -440,6 +441,7 @@ func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.
440441 require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
441442 require.NotNil(t, capture.types)
442443 assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
444+ assert.Equal(t, tt.audio, entity.PermissionMetadata(capture.meta).IsUnmediatedAudio())
443445 })
444446 }
445447 }
@@ -421,12 +421,13 @@ func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.
421 name string421 name string
422 requested uint32422 requested uint32
423 flagged bool423 flagged bool
424+ audio bool
424 }{425 }{
425- {"desktop video", mediaDeskV, true},426+ {"desktop video", mediaDeskV, true, false},
426- {"desktop audio and video", mediaDeskA | mediaDeskV, true},427+ {"desktop audio and video", mediaDeskA | mediaDeskV, true, true},
427- {"camera and desktop video", mediaCamera | mediaDeskV, true},428+ {"camera and desktop video", mediaCamera | mediaDeskV, true, false},
428- {"camera only", mediaCamera, false},429+ {"camera only", mediaCamera, false, false},
429- {"microphone and camera", mediaMic | mediaCamera, false},430+ {"microphone and camera", mediaMic | mediaCamera, false, false},
430 }431 }
431 for _, tt := range tests {432 for _, tt := range tests {
432 t.Run(tt.name, func(t *testing.T) {433 t.Run(tt.name, func(t *testing.T) {
@@ -440,6 +441,7 @@ func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.
440 require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))441 require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
441 require.NotNil(t, capture.types)442 require.NotNil(t, capture.types)
442 assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())443 assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
444+ assert.Equal(t, tt.audio, entity.PermissionMetadata(capture.meta).IsUnmediatedAudio())
443 })445 })
444 }446 }
445 }447 }
M internal/ui/component/permission_popup.go +8 -1
@@ -65,8 +65,9 @@ func (pp *PermissionPopup) Widget() *gtk.Widget {
6565 }
6666
6767 // Show displays the permission popup with the given heading and body text.
68+// When persistable is false the "Always Allow/Deny" buttons are hidden.
6869 // The callback receives (allowed, persistent) when the user makes a choice.
69-func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
70+func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool)) {
7071 log := logging.FromContext(ctx)
7172
7273 pp.mu.Lock()
@@ -85,6 +86,12 @@ func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callb
8586 if pp.bodyLabel != nil {
8687 pp.bodyLabel.SetText(body)
8788 }
89+ if pp.btnAlwaysAllow != nil {
90+ pp.btnAlwaysAllow.SetVisible(persistable)
91+ }
92+ if pp.btnAlwaysDeny != nil {
93+ pp.btnAlwaysDeny.SetVisible(persistable)
94+ }
8895
8996 pp.resizeAndCenter()
9097 if pp.outerBox != nil {
@@ -65,8 +65,9 @@ func (pp *PermissionPopup) Widget() *gtk.Widget {
65 }65 }
66 66
67 // Show displays the permission popup with the given heading and body text.67 // Show displays the permission popup with the given heading and body text.
68+// When persistable is false the "Always Allow/Deny" buttons are hidden.
68 // The callback receives (allowed, persistent) when the user makes a choice.69 // The callback receives (allowed, persistent) when the user makes a choice.
69-func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {70+func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool)) {
70 log := logging.FromContext(ctx)71 log := logging.FromContext(ctx)
71 72
72 pp.mu.Lock()73 pp.mu.Lock()
@@ -85,6 +86,12 @@ func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callb
85 if pp.bodyLabel != nil {86 if pp.bodyLabel != nil {
86 pp.bodyLabel.SetText(body)87 pp.bodyLabel.SetText(body)
87 }88 }
89+ if pp.btnAlwaysAllow != nil {
90+ pp.btnAlwaysAllow.SetVisible(persistable)
91+ }
92+ if pp.btnAlwaysDeny != nil {
93+ pp.btnAlwaysDeny.SetVisible(persistable)
94+ }
88 95
89 pp.resizeAndCenter()96 pp.resizeAndCenter()
90 if pp.outerBox != nil {97 if pp.outerBox != nil {
M internal/ui/dialog/mocks/permission.go +24 -9
@@ -16,10 +16,19 @@ func NewMockPermissionPopup(t interface {
1616 mock.TestingT
1717 Cleanup(func())
1818 }) *MockPermissionPopup {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
1923 mock := &MockPermissionPopup{}
2024 mock.Mock.Test(t)
2125
22- t.Cleanup(func() { mock.AssertExpectations(t) })
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
2332
2433 return mock
2534 }
@@ -38,8 +47,8 @@ func (_m *MockPermissionPopup) EXPECT() *MockPermissionPopup_Expecter {
3847 }
3948
4049 // Show provides a mock function for the type MockPermissionPopup
41-func (_mock *MockPermissionPopup) Show(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool)) {
42- _mock.Called(ctx, heading, body, callback)
50+func (_mock *MockPermissionPopup) Show(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool)) {
51+ _mock.Called(ctx, heading, body, persistable, callback)
4352 return
4453 }
4554
@@ -52,12 +61,13 @@ type MockPermissionPopup_Show_Call struct {
5261 // - ctx context.Context
5362 // - heading string
5463 // - body string
64+// - persistable bool
5565 // - callback func(allowed bool, persistent bool)
56-func (_e *MockPermissionPopup_Expecter) Show(ctx any, heading any, body any, callback any) *MockPermissionPopup_Show_Call {
57- return &MockPermissionPopup_Show_Call{Call: _e.mock.On("Show", ctx, heading, body, callback)}
66+func (_e *MockPermissionPopup_Expecter) Show(ctx any, heading any, body any, persistable any, callback any) *MockPermissionPopup_Show_Call {
67+ return &MockPermissionPopup_Show_Call{Call: _e.mock.On("Show", ctx, heading, body, persistable, callback)}
5868 }
5969
60-func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
70+func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
6171 _c.Call.Run(func(args mock.Arguments) {
6272 var arg0 context.Context
6373 if args[0] != nil {
@@ -71,15 +81,20 @@ func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, headi
7181 if args[2] != nil {
7282 arg2 = args[2].(string)
7383 }
74- var arg3 func(allowed bool, persistent bool)
84+ var arg3 bool
7585 if args[3] != nil {
76- arg3 = args[3].(func(allowed bool, persistent bool))
86+ arg3 = args[3].(bool)
87+ }
88+ var arg4 func(allowed bool, persistent bool)
89+ if args[4] != nil {
90+ arg4 = args[4].(func(allowed bool, persistent bool))
7791 }
7892 run(
7993 arg0,
8094 arg1,
8195 arg2,
8296 arg3,
97+ arg4,
8398 )
8499 })
85100 return _c
@@ -90,7 +105,7 @@ func (_c *MockPermissionPopup_Show_Call) Return() *MockPermissionPopup_Show_Call
90105 return _c
91106 }
92107
93-func (_c *MockPermissionPopup_Show_Call) RunAndReturn(run func(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
108+func (_c *MockPermissionPopup_Show_Call) RunAndReturn(run func(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
94109 _c.Run(run)
95110 return _c
96111 }
@@ -16,10 +16,19 @@ func NewMockPermissionPopup(t interface {
16 mock.TestingT16 mock.TestingT
17 Cleanup(func())17 Cleanup(func())
18 }) *MockPermissionPopup {18 }) *MockPermissionPopup {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
19 mock := &MockPermissionPopup{}23 mock := &MockPermissionPopup{}
20 mock.Mock.Test(t)24 mock.Mock.Test(t)
21 25
22- t.Cleanup(func() { mock.AssertExpectations(t) })26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
23 32
24 return mock33 return mock
25 }34 }
@@ -38,8 +47,8 @@ func (_m *MockPermissionPopup) EXPECT() *MockPermissionPopup_Expecter {
38 }47 }
39 48
40 // Show provides a mock function for the type MockPermissionPopup49 // Show provides a mock function for the type MockPermissionPopup
41-func (_mock *MockPermissionPopup) Show(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool)) {50+func (_mock *MockPermissionPopup) Show(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool)) {
42- _mock.Called(ctx, heading, body, callback)51+ _mock.Called(ctx, heading, body, persistable, callback)
43 return52 return
44 }53 }
45 54
@@ -52,12 +61,13 @@ type MockPermissionPopup_Show_Call struct {
52 // - ctx context.Context61 // - ctx context.Context
53 // - heading string62 // - heading string
54 // - body string63 // - body string
64+// - persistable bool
55 // - callback func(allowed bool, persistent bool)65 // - callback func(allowed bool, persistent bool)
56-func (_e *MockPermissionPopup_Expecter) Show(ctx any, heading any, body any, callback any) *MockPermissionPopup_Show_Call {66+func (_e *MockPermissionPopup_Expecter) Show(ctx any, heading any, body any, persistable any, callback any) *MockPermissionPopup_Show_Call {
57- return &MockPermissionPopup_Show_Call{Call: _e.mock.On("Show", ctx, heading, body, callback)}67+ return &MockPermissionPopup_Show_Call{Call: _e.mock.On("Show", ctx, heading, body, persistable, callback)}
58 }68 }
59 69
60-func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {70+func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
61 _c.Call.Run(func(args mock.Arguments) {71 _c.Call.Run(func(args mock.Arguments) {
62 var arg0 context.Context72 var arg0 context.Context
63 if args[0] != nil {73 if args[0] != nil {
@@ -71,15 +81,20 @@ func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, headi
71 if args[2] != nil {81 if args[2] != nil {
72 arg2 = args[2].(string)82 arg2 = args[2].(string)
73 }83 }
74- var arg3 func(allowed bool, persistent bool)84+ var arg3 bool
75 if args[3] != nil {85 if args[3] != nil {
76- arg3 = args[3].(func(allowed bool, persistent bool))86+ arg3 = args[3].(bool)
87+ }
88+ var arg4 func(allowed bool, persistent bool)
89+ if args[4] != nil {
90+ arg4 = args[4].(func(allowed bool, persistent bool))
77 }91 }
78 run(92 run(
79 arg0,93 arg0,
80 arg1,94 arg1,
81 arg2,95 arg2,
82 arg3,96 arg3,
97+ arg4,
83 )98 )
84 })99 })
85 return _c100 return _c
@@ -90,7 +105,7 @@ func (_c *MockPermissionPopup_Show_Call) Return() *MockPermissionPopup_Show_Call
90 return _c105 return _c
91 }106 }
92 107
93-func (_c *MockPermissionPopup_Show_Call) RunAndReturn(run func(ctx context.Context, heading string, body string, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {108+func (_c *MockPermissionPopup_Show_Call) RunAndReturn(run func(ctx context.Context, heading string, body string, persistable bool, callback func(allowed bool, persistent bool))) *MockPermissionPopup_Show_Call {
94 _c.Run(run)109 _c.Run(run)
95 return _c110 return _c
96 }111 }
M internal/ui/dialog/permission.go +18 -4
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 type permissionPopup interface {
17- Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))
17+ Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool))
1818 }
1919
2020 type permissionDialogRequest struct {
@@ -109,7 +109,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
109109 Msg("showing website data access permission dialog")
110110 }
111111
112- d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
112+ d.popup.Show(ctx, heading, body, anyPersistable(permTypes), func(allowed, persistent bool) {
113113 if isDataAccess {
114114 log.Info().
115115 Str("origin", origin).
@@ -175,6 +175,17 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
175175 return f
176176 }
177177
178+// anyPersistable reports whether at least one requested type can be remembered
179+// ("Always Allow/Deny"). When none can, the popup hides those buttons.
180+func anyPersistable(permTypes []entity.PermissionType) bool {
181+ for _, pt := range permTypes {
182+ if entity.CanPersist(pt) {
183+ return true
184+ }
185+ }
186+ return false
187+}
188+
178189 // joinPermissionLabels joins labels with commas and "and".
179190 func joinPermissionLabels(labels []string) string {
180191 switch len(labels) {
@@ -244,9 +255,12 @@ func (d *PermissionDialog) buildBody(
244255 parts = append(parts, "access your camera")
245256 }
246257 if f.display {
247- if metadata.IsUnmediatedCapture() {
258+ switch {
259+ case metadata.IsUnmediatedCapture() && metadata.IsUnmediatedAudio():
260+ parts = append(parts, "share your entire screen and system audio")
261+ case metadata.IsUnmediatedCapture():
248262 parts = append(parts, "share your entire screen")
249- } else {
263+ default:
250264 parts = append(parts, "share your screen")
251265 }
252266 }
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 type permissionPopup interface {16 type permissionPopup interface {
17- Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))17+ Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool))
18 }18 }
19 19
20 type permissionDialogRequest struct {20 type permissionDialogRequest struct {
@@ -109,7 +109,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
109 Msg("showing website data access permission dialog")109 Msg("showing website data access permission dialog")
110 }110 }
111 111
112- d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {112+ d.popup.Show(ctx, heading, body, anyPersistable(permTypes), func(allowed, persistent bool) {
113 if isDataAccess {113 if isDataAccess {
114 log.Info().114 log.Info().
115 Str("origin", origin).115 Str("origin", origin).
@@ -175,6 +175,17 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
175 return f175 return f
176 }176 }
177 177
178+// anyPersistable reports whether at least one requested type can be remembered
179+// ("Always Allow/Deny"). When none can, the popup hides those buttons.
180+func anyPersistable(permTypes []entity.PermissionType) bool {
181+ for _, pt := range permTypes {
182+ if entity.CanPersist(pt) {
183+ return true
184+ }
185+ }
186+ return false
187+}
188+
178 // joinPermissionLabels joins labels with commas and "and".189 // joinPermissionLabels joins labels with commas and "and".
179 func joinPermissionLabels(labels []string) string {190 func joinPermissionLabels(labels []string) string {
180 switch len(labels) {191 switch len(labels) {
@@ -244,9 +255,12 @@ func (d *PermissionDialog) buildBody(
244 parts = append(parts, "access your camera")255 parts = append(parts, "access your camera")
245 }256 }
246 if f.display {257 if f.display {
247- if metadata.IsUnmediatedCapture() {258+ switch {
259+ case metadata.IsUnmediatedCapture() && metadata.IsUnmediatedAudio():
260+ parts = append(parts, "share your entire screen and system audio")
261+ case metadata.IsUnmediatedCapture():
248 parts = append(parts, "share your entire screen")262 parts = append(parts, "share your entire screen")
249- } else {263+ default:
250 parts = append(parts, "share your screen")264 parts = append(parts, "share your screen")
251 }265 }
252 }266 }
M internal/ui/dialog/permission_test.go +62 -2
@@ -21,8 +21,8 @@ func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) {
2121 var showCalls []permissionPopupShowCall
2222 var callback func(allowed, persistent bool)
2323 popup.EXPECT().
24- Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
25- Run(func(_ context.Context, heading string, body string, cb func(allowed, persistent bool)) {
24+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
25+ Run(func(_ context.Context, heading string, body string, _ bool, cb func(allowed, persistent bool)) {
2626 showCalls = append(showCalls, permissionPopupShowCall{
2727 heading: heading,
2828 body: body,
@@ -195,3 +195,63 @@ func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) {
195195 assert.Equal(t, origin+" wants to share your screen.",
196196 d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil))
197197 }
198+
199+func TestPermissionDialog_BuildBody_UnmediatedDisplayWithSystemAudio(t *testing.T) {
200+ d := &PermissionDialog{}
201+ origin := "https://meet.example.com"
202+ meta := entity.PermissionMetadata{
203+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
204+ entity.PermissionMetadataKeyUnmediatedAudio: entity.PermissionMetadataValueTrue,
205+ }
206+
207+ assert.Equal(t, origin+" wants to share your entire screen and system audio.",
208+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta))
209+}
210+
211+func TestAnyPersistable(t *testing.T) {
212+ tests := []struct {
213+ name string
214+ types []entity.PermissionType
215+ want bool
216+ }{
217+ {"display only", []entity.PermissionType{entity.PermissionTypeDisplay}, false},
218+ {"microphone", []entity.PermissionType{entity.PermissionTypeMicrophone}, true},
219+ {"microphone and display", []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}, true},
220+ {"geolocation", []entity.PermissionType{entity.PermissionTypeGeolocation}, true},
221+ {"none", nil, false},
222+ }
223+ for _, tt := range tests {
224+ t.Run(tt.name, func(t *testing.T) {
225+ assert.Equal(t, tt.want, anyPersistable(tt.types))
226+ })
227+ }
228+}
229+
230+func TestPermissionDialog_PassesPersistableToPopup(t *testing.T) {
231+ tests := []struct {
232+ name string
233+ types []entity.PermissionType
234+ want bool
235+ }{
236+ {"display only hides always buttons", []entity.PermissionType{entity.PermissionTypeDisplay}, false},
237+ {"mixed keeps always buttons", []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}, true},
238+ }
239+ for _, tt := range tests {
240+ t.Run(tt.name, func(t *testing.T) {
241+ popup := dialogmocks.NewMockPermissionPopup(t)
242+ var got *bool
243+ popup.EXPECT().
244+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
245+ Run(func(_ context.Context, _ string, _ string, persistable bool, _ func(allowed, persistent bool)) {
246+ got = &persistable
247+ }).Once()
248+
249+ d := &PermissionDialog{popup: popup}
250+ d.ShowPermissionDialog(context.Background(), "https://meet.example.com", tt.types, nil, func(port.PermissionDialogResult) {})
251+
252+ if assert.NotNil(t, got) {
253+ assert.Equal(t, tt.want, *got)
254+ }
255+ })
256+ }
257+}
@@ -21,8 +21,8 @@ func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) {
21 var showCalls []permissionPopupShowCall21 var showCalls []permissionPopupShowCall
22 var callback func(allowed, persistent bool)22 var callback func(allowed, persistent bool)
23 popup.EXPECT().23 popup.EXPECT().
24- Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything).24+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
25- Run(func(_ context.Context, heading string, body string, cb func(allowed, persistent bool)) {25+ Run(func(_ context.Context, heading string, body string, _ bool, cb func(allowed, persistent bool)) {
26 showCalls = append(showCalls, permissionPopupShowCall{26 showCalls = append(showCalls, permissionPopupShowCall{
27 heading: heading,27 heading: heading,
28 body: body,28 body: body,
@@ -195,3 +195,63 @@ func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) {
195 assert.Equal(t, origin+" wants to share your screen.",195 assert.Equal(t, origin+" wants to share your screen.",
196 d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil))196 d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil))
197 }197 }
198+
199+func TestPermissionDialog_BuildBody_UnmediatedDisplayWithSystemAudio(t *testing.T) {
200+ d := &PermissionDialog{}
201+ origin := "https://meet.example.com"
202+ meta := entity.PermissionMetadata{
203+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
204+ entity.PermissionMetadataKeyUnmediatedAudio: entity.PermissionMetadataValueTrue,
205+ }
206+
207+ assert.Equal(t, origin+" wants to share your entire screen and system audio.",
208+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta))
209+}
210+
211+func TestAnyPersistable(t *testing.T) {
212+ tests := []struct {
213+ name string
214+ types []entity.PermissionType
215+ want bool
216+ }{
217+ {"display only", []entity.PermissionType{entity.PermissionTypeDisplay}, false},
218+ {"microphone", []entity.PermissionType{entity.PermissionTypeMicrophone}, true},
219+ {"microphone and display", []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}, true},
220+ {"geolocation", []entity.PermissionType{entity.PermissionTypeGeolocation}, true},
221+ {"none", nil, false},
222+ }
223+ for _, tt := range tests {
224+ t.Run(tt.name, func(t *testing.T) {
225+ assert.Equal(t, tt.want, anyPersistable(tt.types))
226+ })
227+ }
228+}
229+
230+func TestPermissionDialog_PassesPersistableToPopup(t *testing.T) {
231+ tests := []struct {
232+ name string
233+ types []entity.PermissionType
234+ want bool
235+ }{
236+ {"display only hides always buttons", []entity.PermissionType{entity.PermissionTypeDisplay}, false},
237+ {"mixed keeps always buttons", []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}, true},
238+ }
239+ for _, tt := range tests {
240+ t.Run(tt.name, func(t *testing.T) {
241+ popup := dialogmocks.NewMockPermissionPopup(t)
242+ var got *bool
243+ popup.EXPECT().
244+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
245+ Run(func(_ context.Context, _ string, _ string, persistable bool, _ func(allowed, persistent bool)) {
246+ got = &persistable
247+ }).Once()
248+
249+ d := &PermissionDialog{popup: popup}
250+ d.ShowPermissionDialog(context.Background(), "https://meet.example.com", tt.types, nil, func(port.PermissionDialogResult) {})
251+
252+ if assert.NotNil(t, got) {
253+ assert.Equal(t, tt.want, *got)
254+ }
255+ })
256+ }
257+}

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