detached 45bd51f

fix(cef): require user approval for unmediated screen capture

CEF grants the full screen on Cont() for desktop video requests without any
picker or portal, so display auto-allow must not apply there. Add the
unmediated_capture permission metadata flag, set by the CEF media access
handler for desktop video; the use case and the coordinator's no-use-case
fallback no longer auto-allow display when it is set, so the request goes to
the dialog (or is denied). Display remains non-persistable. The dialog body
says the entire screen will be shared.
commit
45bd51f070ee5086f4d72b367c8144e41899246d
author
brice · · 2026-10-03 06:57 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
75e8c77
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +243 -14
M internal/application/usecase/handle_permission.go +9 -7
@@ -133,20 +133,20 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
133133 return
134134 }
135135
136- if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
136+ if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes, metadata) {
137137 log.Debug().Msg("manual override denied auto-allow permission request")
138138 callback.Deny()
139139 return
140140 }
141141
142- if isAutoAllowSet(permTypes) {
142+ if isAutoAllowSet(permTypes, metadata) {
143143 log.Debug().Msg("auto-allowing permission request")
144144 callback.Allow()
145145 return
146146 }
147147
148148 // For mic/camera: check stored permissions first
149- decision := uc.checkStoredPermissions(ctx, origin, permTypes)
149+ decision := uc.checkStoredPermissions(ctx, origin, permTypes, metadata)
150150 switch decision {
151151 case entity.PermissionGranted:
152152 if isWebsiteDataAccessPermission(permTypes) {
@@ -212,9 +212,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState(
212212 }
213213
214214 // isAutoAllowSet returns true if all permission types in the set are auto-allow.
215-func isAutoAllowSet(permTypes []entity.PermissionType) bool {
215+func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool {
216216 for _, pt := range permTypes {
217- if !entity.IsAutoAllow(pt) {
217+ if !entity.IsAutoAllowFor(pt, metadata) {
218218 return false
219219 }
220220 }
@@ -227,6 +227,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
227227 ctx context.Context,
228228 origin string,
229229 permTypes []entity.PermissionType,
230+ metadata entity.PermissionMetadata,
230231 ) entity.PermissionDecision {
231232 log := uc.logger(ctx)
232233
@@ -234,7 +235,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
234235
235236 for _, permType := range permTypes {
236237 // Skip auto-allow types - they're handled before this function
237- if entity.IsAutoAllow(permType) {
238+ if entity.IsAutoAllowFor(permType, metadata) {
238239 continue
239240 }
240241
@@ -414,9 +415,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
414415 ctx context.Context,
415416 origin string,
416417 permTypes []entity.PermissionType,
418+ metadata entity.PermissionMetadata,
417419 ) bool {
418420 for _, permType := range permTypes {
419- if !entity.IsAutoAllow(permType) {
421+ if !entity.IsAutoAllowFor(permType, metadata) {
420422 continue
421423 }
422424
@@ -133,20 +133,20 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
133 return133 return
134 }134 }
135 135
136- if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {136+ if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes, metadata) {
137 log.Debug().Msg("manual override denied auto-allow permission request")137 log.Debug().Msg("manual override denied auto-allow permission request")
138 callback.Deny()138 callback.Deny()
139 return139 return
140 }140 }
141 141
142- if isAutoAllowSet(permTypes) {142+ if isAutoAllowSet(permTypes, metadata) {
143 log.Debug().Msg("auto-allowing permission request")143 log.Debug().Msg("auto-allowing permission request")
144 callback.Allow()144 callback.Allow()
145 return145 return
146 }146 }
147 147
148 // For mic/camera: check stored permissions first148 // For mic/camera: check stored permissions first
149- decision := uc.checkStoredPermissions(ctx, origin, permTypes)149+ decision := uc.checkStoredPermissions(ctx, origin, permTypes, metadata)
150 switch decision {150 switch decision {
151 case entity.PermissionGranted:151 case entity.PermissionGranted:
152 if isWebsiteDataAccessPermission(permTypes) {152 if isWebsiteDataAccessPermission(permTypes) {
@@ -212,9 +212,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState(
212 }212 }
213 213
214 // isAutoAllowSet returns true if all permission types in the set are auto-allow.214 // isAutoAllowSet returns true if all permission types in the set are auto-allow.
215-func isAutoAllowSet(permTypes []entity.PermissionType) bool {215+func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool {
216 for _, pt := range permTypes {216 for _, pt := range permTypes {
217- if !entity.IsAutoAllow(pt) {217+ if !entity.IsAutoAllowFor(pt, metadata) {
218 return false218 return false
219 }219 }
220 }220 }
@@ -227,6 +227,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
227 ctx context.Context,227 ctx context.Context,
228 origin string,228 origin string,
229 permTypes []entity.PermissionType,229 permTypes []entity.PermissionType,
230+ metadata entity.PermissionMetadata,
230 ) entity.PermissionDecision {231 ) entity.PermissionDecision {
231 log := uc.logger(ctx)232 log := uc.logger(ctx)
232 233
@@ -234,7 +235,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
234 235
235 for _, permType := range permTypes {236 for _, permType := range permTypes {
236 // Skip auto-allow types - they're handled before this function237 // Skip auto-allow types - they're handled before this function
237- if entity.IsAutoAllow(permType) {238+ if entity.IsAutoAllowFor(permType, metadata) {
238 continue239 continue
239 }240 }
240 241
@@ -414,9 +415,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
414 ctx context.Context,415 ctx context.Context,
415 origin string,416 origin string,
416 permTypes []entity.PermissionType,417 permTypes []entity.PermissionType,
418+ metadata entity.PermissionMetadata,
417 ) bool {419 ) bool {
418 for _, permType := range permTypes {420 for _, permType := range permTypes {
419- if !entity.IsAutoAllow(permType) {421+ if !entity.IsAutoAllowFor(permType, metadata) {
420 continue422 continue
421 }423 }
422 424
M internal/application/usecase/handle_permission_test.go +105 -0
@@ -610,3 +610,108 @@ func TestHandlePermissionUseCase_WebsiteDataAccess_UsesStoredGrant(t *testing.T)
610610 assert.True(t, allowed, "should use stored granted permission without dialog")
611611 dialog.AssertNotCalled(t, "ShowPermissionDialog")
612612 }
613+
614+var unmediatedDisplayMetadata = entity.PermissionMetadata{
615+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
616+}
617+
618+func TestHandlePermissionUseCase_UnmediatedDisplayShowsDialog(t *testing.T) {
619+ tests := []struct {
620+ name string
621+ result port.PermissionDialogResult
622+ wantAllow bool
623+ wantDenied bool
624+ }{
625+ {"allow", port.PermissionDialogResult{Allowed: true}, true, false},
626+ {"always allow is never persisted", port.PermissionDialogResult{Allowed: true, Persistent: true}, true, false},
627+ {"deny", port.PermissionDialogResult{Allowed: false}, false, true},
628+ {"always deny is never persisted", port.PermissionDialogResult{Allowed: false, Persistent: true}, false, true},
629+ }
630+ for _, tt := range tests {
631+ t.Run(tt.name, func(t *testing.T) {
632+ permRepo := portmocks.NewMockPermissionRepository(t)
633+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
634+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
635+
636+ var gotMeta entity.PermissionMetadata
637+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",
638+ []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).
639+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, meta entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
640+ gotMeta = meta
641+ cb(tt.result)
642+ })
643+
644+ allowed, denied := false, false
645+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
646+ []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,
647+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() { denied = true }})
648+
649+ assert.Equal(t, tt.wantAllow, allowed)
650+ assert.Equal(t, tt.wantDenied, denied)
651+ assert.True(t, gotMeta.IsUnmediatedCapture(), "metadata must reach the dialog")
652+ permRepo.AssertNotCalled(t, "Set")
653+ })
654+ }
655+}
656+
657+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)
663+
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()
672+
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+ }
678+}
679+
680+func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
681+ permRepo := portmocks.NewMockPermissionRepository(t)
682+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
683+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
684+
685+ types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}
686+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil)
687+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything).
688+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
689+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
690+ })
691+ permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool {
692+ return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted
693+ })).Return(nil).Once()
694+
695+ allowed := false
696+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata,
697+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
698+
699+ assert.True(t, allowed)
700+}
701+
702+func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) {
703+ permRepo := portmocks.NewMockPermissionRepository(t)
704+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
705+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
706+
707+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil)
708+
709+ allowed := false
710+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
711+ []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{},
712+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
713+
714+ assert.True(t, allowed)
715+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
716+ permRepo.AssertNotCalled(t, "Set")
717+}
@@ -610,3 +610,108 @@ func TestHandlePermissionUseCase_WebsiteDataAccess_UsesStoredGrant(t *testing.T)
610 assert.True(t, allowed, "should use stored granted permission without dialog")610 assert.True(t, allowed, "should use stored granted permission without dialog")
611 dialog.AssertNotCalled(t, "ShowPermissionDialog")611 dialog.AssertNotCalled(t, "ShowPermissionDialog")
612 }612 }
613+
614+var unmediatedDisplayMetadata = entity.PermissionMetadata{
615+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
616+}
617+
618+func TestHandlePermissionUseCase_UnmediatedDisplayShowsDialog(t *testing.T) {
619+ tests := []struct {
620+ name string
621+ result port.PermissionDialogResult
622+ wantAllow bool
623+ wantDenied bool
624+ }{
625+ {"allow", port.PermissionDialogResult{Allowed: true}, true, false},
626+ {"always allow is never persisted", port.PermissionDialogResult{Allowed: true, Persistent: true}, true, false},
627+ {"deny", port.PermissionDialogResult{Allowed: false}, false, true},
628+ {"always deny is never persisted", port.PermissionDialogResult{Allowed: false, Persistent: true}, false, true},
629+ }
630+ for _, tt := range tests {
631+ t.Run(tt.name, func(t *testing.T) {
632+ permRepo := portmocks.NewMockPermissionRepository(t)
633+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
634+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
635+
636+ var gotMeta entity.PermissionMetadata
637+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com",
638+ []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything).
639+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, meta entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
640+ gotMeta = meta
641+ cb(tt.result)
642+ })
643+
644+ allowed, denied := false, false
645+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
646+ []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata,
647+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() { denied = true }})
648+
649+ assert.Equal(t, tt.wantAllow, allowed)
650+ assert.Equal(t, tt.wantDenied, denied)
651+ assert.True(t, gotMeta.IsUnmediatedCapture(), "metadata must reach the dialog")
652+ permRepo.AssertNotCalled(t, "Set")
653+ })
654+ }
655+}
656+
657+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)
663+
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()
672+
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+ }
678+}
679+
680+func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
681+ permRepo := portmocks.NewMockPermissionRepository(t)
682+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
683+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
684+
685+ types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}
686+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil)
687+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything).
688+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
689+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
690+ })
691+ permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool {
692+ return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted
693+ })).Return(nil).Once()
694+
695+ allowed := false
696+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata,
697+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
698+
699+ assert.True(t, allowed)
700+}
701+
702+func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) {
703+ permRepo := portmocks.NewMockPermissionRepository(t)
704+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
705+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
706+
707+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil)
708+
709+ allowed := false
710+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
711+ []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{},
712+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
713+
714+ assert.True(t, allowed)
715+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
716+ permRepo.AssertNotCalled(t, "Set")
717+}
M internal/domain/entity/permission.go +26 -0
@@ -47,6 +47,14 @@ const (
4747 // PermissionMetadataKeyCurrentDomain is the first-party domain currently loaded in
4848 // the WebView — the context in which the cross-site request is happening.
4949 PermissionMetadataKeyCurrentDomain = "current_domain"
50+
51+ // PermissionMetadataKeyUnmediatedCapture is set to PermissionMetadataValueTrue when no
52+ // system picker/portal will mediate a display capture grant, i.e. allowing the request
53+ // shares the entire screen immediately (CEF). It disables display auto-allow.
54+ PermissionMetadataKeyUnmediatedCapture = "unmediated_capture"
55+
56+ // PermissionMetadataValueTrue is the metadata value for boolean flags.
57+ PermissionMetadataValueTrue = "true"
5058 )
5159
5260 // PermissionMetadata carries extra context for specific permission types.
@@ -107,7 +115,25 @@ func CanPersist(permType PermissionType) bool {
107115 }
108116 }
109117
118+// IsUnmediatedCapture reports whether the metadata flags a display capture that no
119+// system picker/portal mediates.
120+func (m PermissionMetadata) IsUnmediatedCapture() bool {
121+ return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
122+}
123+
124+// IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
125+// unmediated display capture is never auto-allowed.
126+func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
127+ if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() {
128+ return false
129+ }
130+ return IsAutoAllow(permType)
131+}
132+
110133 // IsAutoAllow returns true if this permission type should be auto-allowed.
134+// Exception: display capture is only auto-allowed when a system picker/portal
135+// mediates it (WebKit); use IsAutoAllowFor for requests that may carry
136+// PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user.
111137 func IsAutoAllow(permType PermissionType) bool {
112138 switch permType {
113139 case PermissionTypeDisplay:
@@ -47,6 +47,14 @@ const (
47 // PermissionMetadataKeyCurrentDomain is the first-party domain currently loaded in47 // PermissionMetadataKeyCurrentDomain is the first-party domain currently loaded in
48 // the WebView — the context in which the cross-site request is happening.48 // the WebView — the context in which the cross-site request is happening.
49 PermissionMetadataKeyCurrentDomain = "current_domain"49 PermissionMetadataKeyCurrentDomain = "current_domain"
50+
51+ // PermissionMetadataKeyUnmediatedCapture is set to PermissionMetadataValueTrue when no
52+ // system picker/portal will mediate a display capture grant, i.e. allowing the request
53+ // shares the entire screen immediately (CEF). It disables display auto-allow.
54+ PermissionMetadataKeyUnmediatedCapture = "unmediated_capture"
55+
56+ // PermissionMetadataValueTrue is the metadata value for boolean flags.
57+ PermissionMetadataValueTrue = "true"
50 )58 )
51 59
52 // PermissionMetadata carries extra context for specific permission types.60 // PermissionMetadata carries extra context for specific permission types.
@@ -107,7 +115,25 @@ func CanPersist(permType PermissionType) bool {
107 }115 }
108 }116 }
109 117
118+// IsUnmediatedCapture reports whether the metadata flags a display capture that no
119+// system picker/portal mediates.
120+func (m PermissionMetadata) IsUnmediatedCapture() bool {
121+ return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
122+}
123+
124+// IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
125+// unmediated display capture is never auto-allowed.
126+func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
127+ if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() {
128+ return false
129+ }
130+ return IsAutoAllow(permType)
131+}
132+
110 // IsAutoAllow returns true if this permission type should be auto-allowed.133 // IsAutoAllow returns true if this permission type should be auto-allowed.
134+// Exception: display capture is only auto-allowed when a system picker/portal
135+// mediates it (WebKit); use IsAutoAllowFor for requests that may carry
136+// PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user.
111 func IsAutoAllow(permType PermissionType) bool {137 func IsAutoAllow(permType PermissionType) bool {
112 switch permType {138 switch permType {
113 case PermissionTypeDisplay:139 case PermissionTypeDisplay:
M internal/infrastructure/cef/permission_handler.go +17 -3
@@ -51,6 +51,17 @@ func mediaPermissionTypes(requested uint32) []string {
5151 return types
5252 }
5353
54+// mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants
55+// the full screen on Cont() without any picker or portal, so Dumber must ask the
56+// user explicitly.
57+func mediaPermissionMetadata(requested uint32) map[string]string {
58+ metadata := map[string]string{}
59+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
60+ metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
61+ }
62+ return metadata
63+}
64+
5465 // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
5566 // permission types. It returns nil when the mask is empty or contains any bit
5667 // Dumber does not support.
@@ -186,6 +197,7 @@ func (h *handlerSet) OnRequestMediaAccessPermission(
186197 h.beginPermissionRequest(
187198 h.permissions.nextMediaKey(), browser, requestingOrigin,
188199 mediaPermissionTypes(requestedPermissions),
200+ mediaPermissionMetadata(requestedPermissions),
189201 func(allow bool) {
190202 if allow {
191203 callback.Cont(requestedPermissions)
@@ -213,7 +225,7 @@ func (h *handlerSet) OnShowPermissionPrompt(
213225 if len(types) == 0 {
214226 return 0
215227 }
216- h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, func(allow bool) {
228+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) {
217229 if allow {
218230 callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
219231 return
@@ -269,6 +281,7 @@ func (h *handlerSet) beginPermissionRequest(
269281 browser purecef.Browser,
270282 requestingOrigin string,
271283 types []string,
284+ metadata map[string]string,
272285 finish func(allow bool),
273286 ) {
274287 wv := h.wv
@@ -303,7 +316,7 @@ func (h *handlerSet) beginPermissionRequest(
303316 return
304317 }
305318
306- wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types) })
319+ wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) })
307320 }
308321
309322 // deliverPermissionRequest runs on the GTK thread. It skips requests that were
@@ -314,6 +327,7 @@ func (h *handlerSet) deliverPermissionRequest(
314327 cb *port.WebViewCallbacks,
315328 origin string,
316329 types []string,
330+ metadata map[string]string,
317331 ) {
318332 if req.isDone() {
319333 return
@@ -324,7 +338,7 @@ func (h *handlerSet) deliverPermissionRequest(
324338 }
325339 allow := func() { req.resolve(true) }
326340 deny := func() { req.resolve(false) }
327- if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) {
341+ if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) {
328342 req.resolve(false)
329343 }
330344 }
@@ -51,6 +51,17 @@ func mediaPermissionTypes(requested uint32) []string {
51 return types51 return types
52 }52 }
53 53
54+// mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants
55+// the full screen on Cont() without any picker or portal, so Dumber must ask the
56+// user explicitly.
57+func mediaPermissionMetadata(requested uint32) map[string]string {
58+ metadata := map[string]string{}
59+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
60+ metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
61+ }
62+ return metadata
63+}
64+
54 // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber65 // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
55 // permission types. It returns nil when the mask is empty or contains any bit66 // permission types. It returns nil when the mask is empty or contains any bit
56 // Dumber does not support.67 // Dumber does not support.
@@ -186,6 +197,7 @@ func (h *handlerSet) OnRequestMediaAccessPermission(
186 h.beginPermissionRequest(197 h.beginPermissionRequest(
187 h.permissions.nextMediaKey(), browser, requestingOrigin,198 h.permissions.nextMediaKey(), browser, requestingOrigin,
188 mediaPermissionTypes(requestedPermissions),199 mediaPermissionTypes(requestedPermissions),
200+ mediaPermissionMetadata(requestedPermissions),
189 func(allow bool) {201 func(allow bool) {
190 if allow {202 if allow {
191 callback.Cont(requestedPermissions)203 callback.Cont(requestedPermissions)
@@ -213,7 +225,7 @@ func (h *handlerSet) OnShowPermissionPrompt(
213 if len(types) == 0 {225 if len(types) == 0 {
214 return 0226 return 0
215 }227 }
216- h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, func(allow bool) {228+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) {
217 if allow {229 if allow {
218 callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)230 callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
219 return231 return
@@ -269,6 +281,7 @@ func (h *handlerSet) beginPermissionRequest(
269 browser purecef.Browser,281 browser purecef.Browser,
270 requestingOrigin string,282 requestingOrigin string,
271 types []string,283 types []string,
284+ metadata map[string]string,
272 finish func(allow bool),285 finish func(allow bool),
273 ) {286 ) {
274 wv := h.wv287 wv := h.wv
@@ -303,7 +316,7 @@ func (h *handlerSet) beginPermissionRequest(
303 return316 return
304 }317 }
305 318
306- wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types) })319+ wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) })
307 }320 }
308 321
309 // deliverPermissionRequest runs on the GTK thread. It skips requests that were322 // deliverPermissionRequest runs on the GTK thread. It skips requests that were
@@ -314,6 +327,7 @@ func (h *handlerSet) deliverPermissionRequest(
314 cb *port.WebViewCallbacks,327 cb *port.WebViewCallbacks,
315 origin string,328 origin string,
316 types []string,329 types []string,
330+ metadata map[string]string,
317 ) {331 ) {
318 if req.isDone() {332 if req.isDone() {
319 return333 return
@@ -324,7 +338,7 @@ func (h *handlerSet) deliverPermissionRequest(
324 }338 }
325 allow := func() { req.resolve(true) }339 allow := func() { req.resolve(true) }
326 deny := func() { req.resolve(false) }340 deny := func() { req.resolve(false) }
327- if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) {341+ if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) {
328 req.resolve(false)342 req.resolve(false)
329 }343 }
330 }344 }
M internal/infrastructure/cef/permission_handler_test.go +32 -2
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
1314 )
1415
1516 type stubMediaAccessCallback struct {
@@ -33,6 +34,7 @@ type permissionCapture struct {
3334 types []string
3435 allow func()
3536 deny func()
37+ meta map[string]string
3638 calls int
3739 }
3840
@@ -276,7 +278,7 @@ func TestDeliverPermissionRequest(t *testing.T) {
276278 req := newReq(&results)
277279 req.resolve(false)
278280 handlerCalls = 0
279- h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"})
281+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
280282 assert.Zero(t, handlerCalls)
281283 assert.Equal(t, []bool{false}, results)
282284 })
@@ -286,7 +288,7 @@ func TestDeliverPermissionRequest(t *testing.T) {
286288 var results []bool
287289 req := newReq(&results)
288290 handlerCalls = 0
289- h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"})
291+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
290292 assert.Zero(t, handlerCalls)
291293 assert.Equal(t, []bool{false}, results)
292294 })
@@ -413,3 +415,31 @@ func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
413415 posted.Execute()
414416 assert.Equal(t, 1, ran)
415417 }
418+
419+func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) {
420+ tests := []struct {
421+ name string
422+ requested uint32
423+ flagged bool
424+ }{
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},
430+ }
431+ for _, tt := range tests {
432+ t.Run(tt.name, func(t *testing.T) {
433+ capture := &permissionCapture{}
434+ h, _, browser := newPermissionTestHandler(t, true)
435+ h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool {
436+ capture.types, capture.meta = types, meta
437+ return true
438+ }
439+ cb := &stubMediaAccessCallback{}
440+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
441+ require.NotNil(t, capture.types)
442+ assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
443+ })
444+ }
445+}
@@ -10,6 +10,7 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
13 )14 )
14 15
15 type stubMediaAccessCallback struct {16 type stubMediaAccessCallback struct {
@@ -33,6 +34,7 @@ type permissionCapture struct {
33 types []string34 types []string
34 allow func()35 allow func()
35 deny func()36 deny func()
37+ meta map[string]string
36 calls int38 calls int
37 }39 }
38 40
@@ -276,7 +278,7 @@ func TestDeliverPermissionRequest(t *testing.T) {
276 req := newReq(&results)278 req := newReq(&results)
277 req.resolve(false)279 req.resolve(false)
278 handlerCalls = 0280 handlerCalls = 0
279- h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"})281+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
280 assert.Zero(t, handlerCalls)282 assert.Zero(t, handlerCalls)
281 assert.Equal(t, []bool{false}, results)283 assert.Equal(t, []bool{false}, results)
282 })284 })
@@ -286,7 +288,7 @@ func TestDeliverPermissionRequest(t *testing.T) {
286 var results []bool288 var results []bool
287 req := newReq(&results)289 req := newReq(&results)
288 handlerCalls = 0290 handlerCalls = 0
289- h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"})291+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
290 assert.Zero(t, handlerCalls)292 assert.Zero(t, handlerCalls)
291 assert.Equal(t, []bool{false}, results)293 assert.Equal(t, []bool{false}, results)
292 })294 })
@@ -413,3 +415,31 @@ func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
413 posted.Execute()415 posted.Execute()
414 assert.Equal(t, 1, ran)416 assert.Equal(t, 1, ran)
415 }417 }
418+
419+func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) {
420+ tests := []struct {
421+ name string
422+ requested uint32
423+ flagged bool
424+ }{
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},
430+ }
431+ for _, tt := range tests {
432+ t.Run(tt.name, func(t *testing.T) {
433+ capture := &permissionCapture{}
434+ h, _, browser := newPermissionTestHandler(t, true)
435+ h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool {
436+ capture.types, capture.meta = types, meta
437+ return true
438+ }
439+ cb := &stubMediaAccessCallback{}
440+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
441+ require.NotNil(t, capture.types)
442+ assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
443+ })
444+ }
445+}
M internal/ui/coordinator/content/callbacks.go +1 -1
@@ -236,7 +236,7 @@ func (c *Coordinator) handlePermissionRequest(
236236 // Auto-allow display and device_info, deny others
237237 allAutoAllow := true
238238 for _, pt := range entityTypes {
239- if !entity.IsAutoAllow(pt) {
239+ if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
240240 allAutoAllow = false
241241 break
242242 }
@@ -236,7 +236,7 @@ func (c *Coordinator) handlePermissionRequest(
236 // Auto-allow display and device_info, deny others236 // Auto-allow display and device_info, deny others
237 allAutoAllow := true237 allAutoAllow := true
238 for _, pt := range entityTypes {238 for _, pt := range entityTypes {
239- if !entity.IsAutoAllow(pt) {239+ if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
240 allAutoAllow = false240 allAutoAllow = false
241 break241 break
242 }242 }
M internal/ui/coordinator/content/permission_prompt_test.go +32 -0
@@ -58,3 +58,35 @@ func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t *
5858 assert.Zero(t, denied)
5959 assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator")
6060 }
61+
62+func TestHandlePermissionRequest_NoUseCaseFallbackDeniesUnmediatedDisplay(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ metadata map[string]string
66+ wantAllow bool
67+ }{
68+ {"mediated display is auto-allowed", nil, true},
69+ {
70+ "unmediated display is denied",
71+ map[string]string{entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue},
72+ false,
73+ },
74+ }
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ c := &Coordinator{}
78+ allowed, denied := 0, 0
79+ handled := c.handlePermissionRequest(context.Background(), "pane-1", "https://meet.example.com",
80+ []string{"display"}, tt.metadata, func() { allowed++ }, func() { denied++ })
81+
82+ require.True(t, handled)
83+ if tt.wantAllow {
84+ assert.Equal(t, 1, allowed)
85+ assert.Zero(t, denied)
86+ } else {
87+ assert.Zero(t, allowed)
88+ assert.Equal(t, 1, denied)
89+ }
90+ })
91+ }
92+}
@@ -58,3 +58,35 @@ func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t *
58 assert.Zero(t, denied)58 assert.Zero(t, denied)
59 assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator")59 assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator")
60 }60 }
61+
62+func TestHandlePermissionRequest_NoUseCaseFallbackDeniesUnmediatedDisplay(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ metadata map[string]string
66+ wantAllow bool
67+ }{
68+ {"mediated display is auto-allowed", nil, true},
69+ {
70+ "unmediated display is denied",
71+ map[string]string{entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue},
72+ false,
73+ },
74+ }
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ c := &Coordinator{}
78+ allowed, denied := 0, 0
79+ handled := c.handlePermissionRequest(context.Background(), "pane-1", "https://meet.example.com",
80+ []string{"display"}, tt.metadata, func() { allowed++ }, func() { denied++ })
81+
82+ require.True(t, handled)
83+ if tt.wantAllow {
84+ assert.Equal(t, 1, allowed)
85+ assert.Zero(t, denied)
86+ } else {
87+ assert.Zero(t, allowed)
88+ assert.Equal(t, 1, denied)
89+ }
90+ })
91+ }
92+}
M internal/ui/dialog/permission.go +5 -1
@@ -244,7 +244,11 @@ func (d *PermissionDialog) buildBody(
244244 parts = append(parts, "access your camera")
245245 }
246246 if f.display {
247- parts = append(parts, "share your screen")
247+ if metadata.IsUnmediatedCapture() {
248+ parts = append(parts, "share your entire screen")
249+ } else {
250+ parts = append(parts, "share your screen")
251+ }
248252 }
249253 if f.notification {
250254 parts = append(parts, "show notifications")
@@ -244,7 +244,11 @@ func (d *PermissionDialog) buildBody(
244 parts = append(parts, "access your camera")244 parts = append(parts, "access your camera")
245 }245 }
246 if f.display {246 if f.display {
247- parts = append(parts, "share your screen")247+ if metadata.IsUnmediatedCapture() {
248+ parts = append(parts, "share your entire screen")
249+ } else {
250+ parts = append(parts, "share your screen")
251+ }
248 }252 }
249 if f.notification {253 if f.notification {
250 parts = append(parts, "show notifications")254 parts = append(parts, "show notifications")
M internal/ui/dialog/permission_test.go +16 -0
@@ -179,3 +179,19 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
179179 assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body)
180180 })
181181 }
182+
183+func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) {
184+ d := &PermissionDialog{}
185+ origin := "https://meet.example.com"
186+ meta := entity.PermissionMetadata{
187+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
188+ }
189+
190+ assert.Equal(t, origin+" wants to share your entire screen.",
191+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta))
192+ assert.Equal(t, origin+" wants to access your camera and share your entire screen.",
193+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeCamera, entity.PermissionTypeDisplay}, meta))
194+ // Mediated display (WebKit portal) keeps the generic wording.
195+ assert.Equal(t, origin+" wants to share your screen.",
196+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil))
197+}
@@ -179,3 +179,19 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
179 assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body)179 assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body)
180 })180 })
181 }181 }
182+
183+func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) {
184+ d := &PermissionDialog{}
185+ origin := "https://meet.example.com"
186+ meta := entity.PermissionMetadata{
187+ entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue,
188+ }
189+
190+ assert.Equal(t, origin+" wants to share your entire screen.",
191+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta))
192+ assert.Equal(t, origin+" wants to access your camera and share your entire screen.",
193+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeCamera, entity.PermissionTypeDisplay}, meta))
194+ // Mediated display (WebKit portal) keeps the generic wording.
195+ assert.Equal(t, origin+" wants to share your screen.",
196+ d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil))
197+}

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