detached e7ebbf7

feat(cef): handle permission requests through Dumber's permission flow (#461)

* feat(cef): wire CEF permission requests into existing permission flow

Implement purecef.PermissionHandler on handlerSet so camera, microphone,
screen capture, geolocation and notification requests reach
WebViewCallbacks.OnPermissionRequest. Requests are resolved exactly once
(allow/deny/dismiss/browser close), unsupported types and missing handlers
deny, and CEF callbacks are continued on the CEF UI thread.

Also accept notification/geolocation in the content coordinator and label
them in the permission dialog.

* fix(cef): bind permission dialog for every permission request

The dialog presenter was only bound from the WebRTC activity hook, so
geolocation/notification-only requests had no dialog and were denied, and
multi-window setups could bind the wrong window. Add a coordinator hook
invoked before every permission request and bind the pane's window there.

* fix(cef): extract permission type conversion to satisfy funlen

* fix(cef): harden permission handler origin, type mapping and tests

- derive the origin from the main frame URL and deny cross-origin requesters
  or an unavailable main frame
- return 0 (CEF default) from OnShowPermissionPrompt for empty/unsupported
  masks; drop camera pan/tilt/zoom from the supported mask
- deny desktop audio capture without desktop video (loopback audio)
- deny in the GTK dispatch closure when the WebView was destroyed
- mapping functions return only []string; media allow continues with the
  requested mask
- document that an open dialog is not closed on prompt dismissal
- tests: table once-only test, pre-resolved/destroyed delivery, duplicate key,
  cross-origin, desktop-audio-only, unsupported prompt returns 0

* 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.

* fix(cef): bind permission dialog for floating panes

Resolve the owning window with browserWindowForAnyPane for both the
permission dialog binding and the WebRTC indicator. When no window owns the
pane the presenter is cleared so the request is denied instead of being shown
in a stale or wrong window.

* 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

* fix(ui): reset WebRTC indicator for floating panes on navigation
commit
e7ebbf70bfc41a3eeb913812d12dbc2b73d672d5
author
bnema · · 2026-10-07 11:56 UTC
committer
GitHub · · 2026-10-07 11:56 UTC
parent
d625c0c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +1379 -62
M internal/application/usecase/handle_permission.go +11 -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) {
@@ -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,
@@ -212,9 +214,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState(
212214 }
213215
214216 // isAutoAllowSet returns true if all permission types in the set are auto-allow.
215-func isAutoAllowSet(permTypes []entity.PermissionType) bool {
217+func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool {
216218 for _, pt := range permTypes {
217- if !entity.IsAutoAllow(pt) {
219+ if !entity.IsAutoAllowFor(pt, metadata) {
218220 return false
219221 }
220222 }
@@ -227,6 +229,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
227229 ctx context.Context,
228230 origin string,
229231 permTypes []entity.PermissionType,
232+ metadata entity.PermissionMetadata,
230233 ) entity.PermissionDecision {
231234 log := uc.logger(ctx)
232235
@@ -234,7 +237,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
234237
235238 for _, permType := range permTypes {
236239 // Skip auto-allow types - they're handled before this function
237- if entity.IsAutoAllow(permType) {
240+ if entity.IsAutoAllowFor(permType, metadata) {
238241 continue
239242 }
240243
@@ -414,9 +417,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
414417 ctx context.Context,
415418 origin string,
416419 permTypes []entity.PermissionType,
420+ metadata entity.PermissionMetadata,
417421 ) bool {
418422 for _, permType := range permTypes {
419- if !entity.IsAutoAllow(permType) {
423+ if !entity.IsAutoAllowFor(permType, metadata) {
420424 continue
421425 }
422426
@@ -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) {
@@ -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,
@@ -212,9 +214,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState(
212 }214 }
213 215
214 // isAutoAllowSet returns true if all permission types in the set are auto-allow.216 // isAutoAllowSet returns true if all permission types in the set are auto-allow.
215-func isAutoAllowSet(permTypes []entity.PermissionType) bool {217+func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool {
216 for _, pt := range permTypes {218 for _, pt := range permTypes {
217- if !entity.IsAutoAllow(pt) {219+ if !entity.IsAutoAllowFor(pt, metadata) {
218 return false220 return false
219 }221 }
220 }222 }
@@ -227,6 +229,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
227 ctx context.Context,229 ctx context.Context,
228 origin string,230 origin string,
229 permTypes []entity.PermissionType,231 permTypes []entity.PermissionType,
232+ metadata entity.PermissionMetadata,
230 ) entity.PermissionDecision {233 ) entity.PermissionDecision {
231 log := uc.logger(ctx)234 log := uc.logger(ctx)
232 235
@@ -234,7 +237,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
234 237
235 for _, permType := range permTypes {238 for _, permType := range permTypes {
236 // Skip auto-allow types - they're handled before this function239 // Skip auto-allow types - they're handled before this function
237- if entity.IsAutoAllow(permType) {240+ if entity.IsAutoAllowFor(permType, metadata) {
238 continue241 continue
239 }242 }
240 243
@@ -414,9 +417,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
414 ctx context.Context,417 ctx context.Context,
415 origin string,418 origin string,
416 permTypes []entity.PermissionType,419 permTypes []entity.PermissionType,
420+ metadata entity.PermissionMetadata,
417 ) bool {421 ) bool {
418 for _, permType := range permTypes {422 for _, permType := range permTypes {
419- if !entity.IsAutoAllow(permType) {423+ if !entity.IsAutoAllowFor(permType, metadata) {
420 continue424 continue
421 }425 }
422 426
M internal/application/usecase/handle_permission_test.go +101 -0
@@ -610,3 +610,104 @@ 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+ permRepo := portmocks.NewMockPermissionRepository(t)
659+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
660+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
661+
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()
667+
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)
674+}
675+
676+func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
677+ permRepo := portmocks.NewMockPermissionRepository(t)
678+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
679+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
680+
681+ types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}
682+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil)
683+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything).
684+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
685+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
686+ })
687+ permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool {
688+ return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted
689+ })).Return(nil).Once()
690+
691+ allowed := false
692+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata,
693+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
694+
695+ assert.True(t, allowed)
696+}
697+
698+func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) {
699+ permRepo := portmocks.NewMockPermissionRepository(t)
700+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
701+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
702+
703+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil)
704+
705+ allowed := false
706+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
707+ []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{},
708+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
709+
710+ assert.True(t, allowed)
711+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
712+ permRepo.AssertNotCalled(t, "Set")
713+}
@@ -610,3 +610,104 @@ 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+ permRepo := portmocks.NewMockPermissionRepository(t)
659+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
660+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
661+
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()
667+
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)
674+}
675+
676+func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) {
677+ permRepo := portmocks.NewMockPermissionRepository(t)
678+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
679+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
680+
681+ types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay}
682+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil)
683+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything).
684+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) {
685+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
686+ })
687+ permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool {
688+ return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted
689+ })).Return(nil).Once()
690+
691+ allowed := false
692+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata,
693+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
694+
695+ assert.True(t, allowed)
696+}
697+
698+func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) {
699+ permRepo := portmocks.NewMockPermissionRepository(t)
700+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
701+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
702+
703+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil)
704+
705+ allowed := false
706+ uc.HandlePermissionRequest(testContext(), "https://meet.example.com",
707+ []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{},
708+ usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}})
709+
710+ assert.True(t, allowed)
711+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
712+ permRepo.AssertNotCalled(t, "Set")
713+}
M internal/domain/entity/permission.go +35 -0
@@ -47,6 +47,18 @@ 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+ // 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+
60+ // PermissionMetadataValueTrue is the metadata value for boolean flags.
61+ PermissionMetadataValueTrue = "true"
5062 )
5163
5264 // PermissionMetadata carries extra context for specific permission types.
@@ -107,7 +119,30 @@ func CanPersist(permType PermissionType) bool {
107119 }
108120 }
109121
122+// IsUnmediatedCapture reports whether the metadata flags a display capture that no
123+// system picker/portal mediates.
124+func (m PermissionMetadata) IsUnmediatedCapture() bool {
125+ return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
126+}
127+
128+// IsUnmediatedAudio reports whether the metadata flags system audio capture.
129+func (m PermissionMetadata) IsUnmediatedAudio() bool {
130+ return m[PermissionMetadataKeyUnmediatedAudio] == PermissionMetadataValueTrue
131+}
132+
133+// IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
134+// unmediated display capture is never auto-allowed.
135+func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
136+ if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() {
137+ return false
138+ }
139+ return IsAutoAllow(permType)
140+}
141+
110142 // IsAutoAllow returns true if this permission type should be auto-allowed.
143+// Exception: display capture is only auto-allowed when a system picker/portal
144+// mediates it (WebKit); use IsAutoAllowFor for requests that may carry
145+// PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user.
111146 func IsAutoAllow(permType PermissionType) bool {
112147 switch permType {
113148 case PermissionTypeDisplay:
@@ -47,6 +47,18 @@ 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+ // 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+
60+ // PermissionMetadataValueTrue is the metadata value for boolean flags.
61+ PermissionMetadataValueTrue = "true"
50 )62 )
51 63
52 // PermissionMetadata carries extra context for specific permission types.64 // PermissionMetadata carries extra context for specific permission types.
@@ -107,7 +119,30 @@ func CanPersist(permType PermissionType) bool {
107 }119 }
108 }120 }
109 121
122+// IsUnmediatedCapture reports whether the metadata flags a display capture that no
123+// system picker/portal mediates.
124+func (m PermissionMetadata) IsUnmediatedCapture() bool {
125+ return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue
126+}
127+
128+// IsUnmediatedAudio reports whether the metadata flags system audio capture.
129+func (m PermissionMetadata) IsUnmediatedAudio() bool {
130+ return m[PermissionMetadataKeyUnmediatedAudio] == PermissionMetadataValueTrue
131+}
132+
133+// IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an
134+// unmediated display capture is never auto-allowed.
135+func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool {
136+ if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() {
137+ return false
138+ }
139+ return IsAutoAllow(permType)
140+}
141+
110 // IsAutoAllow returns true if this permission type should be auto-allowed.142 // IsAutoAllow returns true if this permission type should be auto-allowed.
143+// Exception: display capture is only auto-allowed when a system picker/portal
144+// mediates it (WebKit); use IsAutoAllowFor for requests that may carry
145+// PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user.
111 func IsAutoAllow(permType PermissionType) bool {146 func IsAutoAllow(permType PermissionType) bool {
112 switch permType {147 switch permType {
113 case PermissionTypeDisplay:148 case PermissionTypeDisplay:
A internal/infrastructure/cef/permission_handler.go +393 -0
@@ -0,0 +1,393 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+// Mask of media access bits Dumber knows how to map to a permission type.
18+const supportedMediaAccessMask = uint32(
19+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture |
20+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture |
21+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
22+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
23+
24+// mediaPermissionTypes maps a CEF media access bitmask to Dumber permission
25+// types. It returns nil (deny) for an empty mask, a mask containing unknown
26+// bits, or desktop audio without desktop video: Chromium would grant a
27+// "loopback" system audio device for it without going through any portal.
28+func mediaPermissionTypes(requested uint32) []string {
29+ const (
30+ deviceAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
31+ deviceVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
32+ desktopAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
33+ desktopVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
34+ )
35+ if requested == 0 || requested&^supportedMediaAccessMask != 0 {
36+ return nil
37+ }
38+ if requested&desktopAudio != 0 && requested&desktopVideo == 0 {
39+ return nil
40+ }
41+ var types []string
42+ if requested&deviceAudio != 0 {
43+ types = append(types, string(entity.PermissionTypeMicrophone))
44+ }
45+ if requested&deviceVideo != 0 {
46+ types = append(types, string(entity.PermissionTypeCamera))
47+ }
48+ if requested&desktopVideo != 0 {
49+ types = append(types, string(entity.PermissionTypeDisplay))
50+ }
51+ return types
52+}
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. Desktop audio (system loopback) is flagged separately so the
57+// dialog can disclose it.
58+func mediaPermissionMetadata(requested uint32) map[string]string {
59+ metadata := map[string]string{}
60+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
61+ metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
62+ }
63+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) != 0 {
64+ metadata[entity.PermissionMetadataKeyUnmediatedAudio] = entity.PermissionMetadataValueTrue
65+ }
66+ return metadata
67+}
68+
69+// promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
70+// permission types. It returns nil when the mask is empty or contains any bit
71+// Dumber does not support.
72+func promptPermissionTypes(requested uint32) []string {
73+ const (
74+ camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
75+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
76+ geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
77+ notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
78+ supported = camera | mic | geolocation | notifications
79+ )
80+ if requested == 0 || requested&^supported != 0 {
81+ return nil
82+ }
83+ var types []string
84+ if requested&mic != 0 {
85+ types = append(types, string(entity.PermissionTypeMicrophone))
86+ }
87+ if requested&camera != 0 {
88+ types = append(types, string(entity.PermissionTypeCamera))
89+ }
90+ if requested&geolocation != 0 {
91+ types = append(types, string(entity.PermissionTypeGeolocation))
92+ }
93+ if requested&notifications != 0 {
94+ types = append(types, string(entity.PermissionTypeNotification))
95+ }
96+ return types
97+}
98+
99+// cefPermissionRequest guarantees that a CEF permission callback is resolved
100+// exactly once, whichever of allow, deny, dismissal or browser destruction
101+// happens first.
102+type cefPermissionRequest struct {
103+ done atomic.Bool
104+ finish func(allow bool)
105+ untrack func()
106+}
107+
108+// resolve completes the request with the given decision. Only the first call
109+// (of resolve and abandon combined) has any effect.
110+func (r *cefPermissionRequest) resolve(allow bool) {
111+ if !r.done.CompareAndSwap(false, true) {
112+ return
113+ }
114+ if r.untrack != nil {
115+ r.untrack()
116+ }
117+ r.finish(allow)
118+}
119+
120+// abandon marks the request as finished without touching the CEF callback.
121+// It is used when CEF has already invalidated the callback (prompt dismissed).
122+func (r *cefPermissionRequest) abandon() bool {
123+ if !r.done.CompareAndSwap(false, true) {
124+ return false
125+ }
126+ if r.untrack != nil {
127+ r.untrack()
128+ }
129+ return true
130+}
131+
132+func (r *cefPermissionRequest) isDone() bool { return r.done.Load() }
133+
134+type permissionKey struct {
135+ prompt bool
136+ id uint64
137+}
138+
139+// permissionTracker holds the unresolved requests of one WebView. The zero
140+// value is ready to use.
141+type permissionTracker struct {
142+ mu sync.Mutex
143+ nextID uint64
144+ pending map[permissionKey]*cefPermissionRequest
145+}
146+
147+func (t *permissionTracker) nextMediaKey() permissionKey {
148+ t.mu.Lock()
149+ defer t.mu.Unlock()
150+ t.nextID++
151+ return permissionKey{id: t.nextID}
152+}
153+
154+// add registers req under key and returns any request previously registered
155+// under the same key.
156+func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest {
157+ t.mu.Lock()
158+ defer t.mu.Unlock()
159+ if t.pending == nil {
160+ t.pending = make(map[permissionKey]*cefPermissionRequest)
161+ }
162+ prev := t.pending[key]
163+ t.pending[key] = req
164+ req.untrack = func() {
165+ t.mu.Lock()
166+ defer t.mu.Unlock()
167+ if t.pending[key] == req {
168+ delete(t.pending, key)
169+ }
170+ }
171+ return prev
172+}
173+
174+func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest {
175+ t.mu.Lock()
176+ defer t.mu.Unlock()
177+ return t.pending[key]
178+}
179+
180+func (t *permissionTracker) drain() []*cefPermissionRequest {
181+ t.mu.Lock()
182+ defer t.mu.Unlock()
183+ reqs := make([]*cefPermissionRequest, 0, len(t.pending))
184+ for _, req := range t.pending {
185+ reqs = append(reqs, req)
186+ }
187+ return reqs
188+}
189+
190+// OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests.
191+func (h *handlerSet) OnRequestMediaAccessPermission(
192+ browser purecef.Browser,
193+ _ purecef.Frame,
194+ requestingOrigin string,
195+ requestedPermissions uint32,
196+ callback purecef.MediaAccessCallback,
197+) int32 {
198+ if h == nil || h.wv == nil || callback == nil {
199+ return 0
200+ }
201+ h.beginPermissionRequest(
202+ h.permissions.nextMediaKey(), browser, requestingOrigin,
203+ mediaPermissionTypes(requestedPermissions),
204+ mediaPermissionMetadata(requestedPermissions),
205+ func(allow bool) {
206+ if allow {
207+ callback.Cont(requestedPermissions)
208+ return
209+ }
210+ callback.Cancel()
211+ })
212+ return 1
213+}
214+
215+// OnShowPermissionPrompt handles non-media permission requests
216+// (geolocation, notifications). Unsupported requests are left to CEF's default
217+// handling by returning 0.
218+func (h *handlerSet) OnShowPermissionPrompt(
219+ browser purecef.Browser,
220+ promptID uint64,
221+ requestingOrigin string,
222+ requestedPermissions uint32,
223+ callback purecef.PermissionPromptCallback,
224+) int32 {
225+ if h == nil || h.wv == nil || callback == nil {
226+ return 0
227+ }
228+ types := promptPermissionTypes(requestedPermissions)
229+ if len(types) == 0 {
230+ return 0
231+ }
232+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) {
233+ if allow {
234+ callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
235+ return
236+ }
237+ callback.Cont(purecef.PermissionRequestResultPermissionResultDeny)
238+ })
239+ return 1
240+}
241+
242+// OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled.
243+// The callback is no longer valid, so it must not be invoked anymore.
244+//
245+// Known limitation: a Dumber permission dialog that is already open is not
246+// closed on dismissal, because the dialog port has no cancel API. Its eventual
247+// answer is ignored by the once-only guard.
248+func (h *handlerSet) OnDismissPermissionPrompt(
249+ _ purecef.Browser,
250+ promptID uint64,
251+ result purecef.PermissionRequestResult,
252+) {
253+ if h == nil || h.wv == nil {
254+ return
255+ }
256+ req := h.permissions.get(permissionKey{prompt: true, id: promptID})
257+ if req == nil || !req.abandon() {
258+ return
259+ }
260+ logCEFPermission(h.wv).Debug().
261+ Uint64("prompt_id", promptID).
262+ Int32("result", result).
263+ Msg("cef: permission prompt dismissed")
264+}
265+
266+// denyPendingPermissions resolves every unresolved request as denied. It is
267+// called when the browser is closing so nothing is left hanging.
268+func (h *handlerSet) denyPendingPermissions() {
269+ if h == nil {
270+ return
271+ }
272+ for _, req := range h.permissions.drain() {
273+ req.resolve(false)
274+ }
275+}
276+
277+// beginPermissionRequest registers a request, forwards it to the WebView's
278+// OnPermissionRequest callback on the GTK thread, and guarantees finish is
279+// invoked exactly once on the CEF UI thread. It denies when the request cannot
280+// be mapped, the top-level origin is unavailable or differs from the requesting
281+// origin (cross-origin frames are unsupported), no callback is available, or
282+// the WebView is destroyed.
283+func (h *handlerSet) beginPermissionRequest(
284+ key permissionKey,
285+ browser purecef.Browser,
286+ requestingOrigin string,
287+ types []string,
288+ metadata map[string]string,
289+ finish func(allow bool),
290+) {
291+ wv := h.wv
292+ req := &cefPermissionRequest{
293+ finish: func(allow bool) {
294+ dispatchPermissionResult(wv, func() { finish(allow) })
295+ },
296+ }
297+ if prev := h.permissions.add(key, req); prev != nil {
298+ prev.resolve(false)
299+ }
300+
301+ log := logCEFPermission(wv)
302+ if len(types) == 0 || wv.destroyed.Load() {
303+ log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request")
304+ req.resolve(false)
305+ return
306+ }
307+ origin, ok := topLevelPermissionOrigin(browser, requestingOrigin)
308+ if !ok {
309+ log.Debug().Msg("cef: denying permission request; requester is not the top-level origin")
310+ req.resolve(false)
311+ return
312+ }
313+
314+ wv.mu.RLock()
315+ cb := wv.callbacks
316+ wv.mu.RUnlock()
317+ if cb == nil || cb.OnPermissionRequest == nil {
318+ log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler")
319+ req.resolve(false)
320+ return
321+ }
322+
323+ wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) })
324+}
325+
326+// deliverPermissionRequest runs on the GTK thread. It skips requests that were
327+// already resolved (dismissed, closed) and denies when the WebView was destroyed
328+// or the callback does not handle the request.
329+func (h *handlerSet) deliverPermissionRequest(
330+ req *cefPermissionRequest,
331+ cb *port.WebViewCallbacks,
332+ origin string,
333+ types []string,
334+ metadata map[string]string,
335+) {
336+ if req.isDone() {
337+ return
338+ }
339+ if h.wv.destroyed.Load() {
340+ req.resolve(false)
341+ return
342+ }
343+ allow := func() { req.resolve(true) }
344+ deny := func() { req.resolve(false) }
345+ if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) {
346+ req.resolve(false)
347+ }
348+}
349+
350+// topLevelPermissionOrigin returns the canonical top-level (main frame) origin
351+// and whether the requesting origin matches it.
352+func topLevelPermissionOrigin(browser purecef.Browser, requestingOrigin string) (string, bool) {
353+ if browser == nil {
354+ return "", false
355+ }
356+ frame := browser.GetMainFrame()
357+ if frame == nil {
358+ return "", false
359+ }
360+ top, err := urlutil.ExtractOrigin(frame.GetURL())
361+ if err != nil {
362+ return "", false
363+ }
364+ requester, err := urlutil.ExtractOrigin(requestingOrigin)
365+ if err != nil || requester != top {
366+ return "", false
367+ }
368+ return top, true
369+}
370+
371+// dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission
372+// callbacks must be continued there. When the UI thread is unreachable fn runs
373+// inline so the page never hangs.
374+func dispatchPermissionResult(wv *WebView, fn func()) {
375+ if wv == nil || wv.engine == nil {
376+ fn()
377+ return
378+ }
379+ task := cefNewTask(cefTaskFunc(fn))
380+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
381+ logCEFPermission(wv).Warn().
382+ Int32("post_result", result).
383+ Msg("cef: failed to post permission result to CEF UI thread; continuing inline")
384+ fn()
385+ }
386+}
387+
388+func logCEFPermission(wv *WebView) *zerolog.Logger {
389+ if wv == nil || wv.ctx == nil {
390+ return logging.FromContext(context.Background())
391+ }
392+ return logging.FromContext(wv.ctx)
393+}
@@ -0,0 +1,393 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+// Mask of media access bits Dumber knows how to map to a permission type.
18+const supportedMediaAccessMask = uint32(
19+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture |
20+ purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture |
21+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture |
22+ purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
23+
24+// mediaPermissionTypes maps a CEF media access bitmask to Dumber permission
25+// types. It returns nil (deny) for an empty mask, a mask containing unknown
26+// bits, or desktop audio without desktop video: Chromium would grant a
27+// "loopback" system audio device for it without going through any portal.
28+func mediaPermissionTypes(requested uint32) []string {
29+ const (
30+ deviceAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
31+ deviceVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
32+ desktopAudio = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
33+ desktopVideo = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
34+ )
35+ if requested == 0 || requested&^supportedMediaAccessMask != 0 {
36+ return nil
37+ }
38+ if requested&desktopAudio != 0 && requested&desktopVideo == 0 {
39+ return nil
40+ }
41+ var types []string
42+ if requested&deviceAudio != 0 {
43+ types = append(types, string(entity.PermissionTypeMicrophone))
44+ }
45+ if requested&deviceVideo != 0 {
46+ types = append(types, string(entity.PermissionTypeCamera))
47+ }
48+ if requested&desktopVideo != 0 {
49+ types = append(types, string(entity.PermissionTypeDisplay))
50+ }
51+ return types
52+}
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. Desktop audio (system loopback) is flagged separately so the
57+// dialog can disclose it.
58+func mediaPermissionMetadata(requested uint32) map[string]string {
59+ metadata := map[string]string{}
60+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 {
61+ metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue
62+ }
63+ if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) != 0 {
64+ metadata[entity.PermissionMetadataKeyUnmediatedAudio] = entity.PermissionMetadataValueTrue
65+ }
66+ return metadata
67+}
68+
69+// promptPermissionTypes maps a CEF permission prompt bitmask to Dumber
70+// permission types. It returns nil when the mask is empty or contains any bit
71+// Dumber does not support.
72+func promptPermissionTypes(requested uint32) []string {
73+ const (
74+ camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
75+ mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
76+ geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
77+ notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
78+ supported = camera | mic | geolocation | notifications
79+ )
80+ if requested == 0 || requested&^supported != 0 {
81+ return nil
82+ }
83+ var types []string
84+ if requested&mic != 0 {
85+ types = append(types, string(entity.PermissionTypeMicrophone))
86+ }
87+ if requested&camera != 0 {
88+ types = append(types, string(entity.PermissionTypeCamera))
89+ }
90+ if requested&geolocation != 0 {
91+ types = append(types, string(entity.PermissionTypeGeolocation))
92+ }
93+ if requested&notifications != 0 {
94+ types = append(types, string(entity.PermissionTypeNotification))
95+ }
96+ return types
97+}
98+
99+// cefPermissionRequest guarantees that a CEF permission callback is resolved
100+// exactly once, whichever of allow, deny, dismissal or browser destruction
101+// happens first.
102+type cefPermissionRequest struct {
103+ done atomic.Bool
104+ finish func(allow bool)
105+ untrack func()
106+}
107+
108+// resolve completes the request with the given decision. Only the first call
109+// (of resolve and abandon combined) has any effect.
110+func (r *cefPermissionRequest) resolve(allow bool) {
111+ if !r.done.CompareAndSwap(false, true) {
112+ return
113+ }
114+ if r.untrack != nil {
115+ r.untrack()
116+ }
117+ r.finish(allow)
118+}
119+
120+// abandon marks the request as finished without touching the CEF callback.
121+// It is used when CEF has already invalidated the callback (prompt dismissed).
122+func (r *cefPermissionRequest) abandon() bool {
123+ if !r.done.CompareAndSwap(false, true) {
124+ return false
125+ }
126+ if r.untrack != nil {
127+ r.untrack()
128+ }
129+ return true
130+}
131+
132+func (r *cefPermissionRequest) isDone() bool { return r.done.Load() }
133+
134+type permissionKey struct {
135+ prompt bool
136+ id uint64
137+}
138+
139+// permissionTracker holds the unresolved requests of one WebView. The zero
140+// value is ready to use.
141+type permissionTracker struct {
142+ mu sync.Mutex
143+ nextID uint64
144+ pending map[permissionKey]*cefPermissionRequest
145+}
146+
147+func (t *permissionTracker) nextMediaKey() permissionKey {
148+ t.mu.Lock()
149+ defer t.mu.Unlock()
150+ t.nextID++
151+ return permissionKey{id: t.nextID}
152+}
153+
154+// add registers req under key and returns any request previously registered
155+// under the same key.
156+func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest {
157+ t.mu.Lock()
158+ defer t.mu.Unlock()
159+ if t.pending == nil {
160+ t.pending = make(map[permissionKey]*cefPermissionRequest)
161+ }
162+ prev := t.pending[key]
163+ t.pending[key] = req
164+ req.untrack = func() {
165+ t.mu.Lock()
166+ defer t.mu.Unlock()
167+ if t.pending[key] == req {
168+ delete(t.pending, key)
169+ }
170+ }
171+ return prev
172+}
173+
174+func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest {
175+ t.mu.Lock()
176+ defer t.mu.Unlock()
177+ return t.pending[key]
178+}
179+
180+func (t *permissionTracker) drain() []*cefPermissionRequest {
181+ t.mu.Lock()
182+ defer t.mu.Unlock()
183+ reqs := make([]*cefPermissionRequest, 0, len(t.pending))
184+ for _, req := range t.pending {
185+ reqs = append(reqs, req)
186+ }
187+ return reqs
188+}
189+
190+// OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests.
191+func (h *handlerSet) OnRequestMediaAccessPermission(
192+ browser purecef.Browser,
193+ _ purecef.Frame,
194+ requestingOrigin string,
195+ requestedPermissions uint32,
196+ callback purecef.MediaAccessCallback,
197+) int32 {
198+ if h == nil || h.wv == nil || callback == nil {
199+ return 0
200+ }
201+ h.beginPermissionRequest(
202+ h.permissions.nextMediaKey(), browser, requestingOrigin,
203+ mediaPermissionTypes(requestedPermissions),
204+ mediaPermissionMetadata(requestedPermissions),
205+ func(allow bool) {
206+ if allow {
207+ callback.Cont(requestedPermissions)
208+ return
209+ }
210+ callback.Cancel()
211+ })
212+ return 1
213+}
214+
215+// OnShowPermissionPrompt handles non-media permission requests
216+// (geolocation, notifications). Unsupported requests are left to CEF's default
217+// handling by returning 0.
218+func (h *handlerSet) OnShowPermissionPrompt(
219+ browser purecef.Browser,
220+ promptID uint64,
221+ requestingOrigin string,
222+ requestedPermissions uint32,
223+ callback purecef.PermissionPromptCallback,
224+) int32 {
225+ if h == nil || h.wv == nil || callback == nil {
226+ return 0
227+ }
228+ types := promptPermissionTypes(requestedPermissions)
229+ if len(types) == 0 {
230+ return 0
231+ }
232+ h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) {
233+ if allow {
234+ callback.Cont(purecef.PermissionRequestResultPermissionResultAccept)
235+ return
236+ }
237+ callback.Cont(purecef.PermissionRequestResultPermissionResultDeny)
238+ })
239+ return 1
240+}
241+
242+// OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled.
243+// The callback is no longer valid, so it must not be invoked anymore.
244+//
245+// Known limitation: a Dumber permission dialog that is already open is not
246+// closed on dismissal, because the dialog port has no cancel API. Its eventual
247+// answer is ignored by the once-only guard.
248+func (h *handlerSet) OnDismissPermissionPrompt(
249+ _ purecef.Browser,
250+ promptID uint64,
251+ result purecef.PermissionRequestResult,
252+) {
253+ if h == nil || h.wv == nil {
254+ return
255+ }
256+ req := h.permissions.get(permissionKey{prompt: true, id: promptID})
257+ if req == nil || !req.abandon() {
258+ return
259+ }
260+ logCEFPermission(h.wv).Debug().
261+ Uint64("prompt_id", promptID).
262+ Int32("result", result).
263+ Msg("cef: permission prompt dismissed")
264+}
265+
266+// denyPendingPermissions resolves every unresolved request as denied. It is
267+// called when the browser is closing so nothing is left hanging.
268+func (h *handlerSet) denyPendingPermissions() {
269+ if h == nil {
270+ return
271+ }
272+ for _, req := range h.permissions.drain() {
273+ req.resolve(false)
274+ }
275+}
276+
277+// beginPermissionRequest registers a request, forwards it to the WebView's
278+// OnPermissionRequest callback on the GTK thread, and guarantees finish is
279+// invoked exactly once on the CEF UI thread. It denies when the request cannot
280+// be mapped, the top-level origin is unavailable or differs from the requesting
281+// origin (cross-origin frames are unsupported), no callback is available, or
282+// the WebView is destroyed.
283+func (h *handlerSet) beginPermissionRequest(
284+ key permissionKey,
285+ browser purecef.Browser,
286+ requestingOrigin string,
287+ types []string,
288+ metadata map[string]string,
289+ finish func(allow bool),
290+) {
291+ wv := h.wv
292+ req := &cefPermissionRequest{
293+ finish: func(allow bool) {
294+ dispatchPermissionResult(wv, func() { finish(allow) })
295+ },
296+ }
297+ if prev := h.permissions.add(key, req); prev != nil {
298+ prev.resolve(false)
299+ }
300+
301+ log := logCEFPermission(wv)
302+ if len(types) == 0 || wv.destroyed.Load() {
303+ log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request")
304+ req.resolve(false)
305+ return
306+ }
307+ origin, ok := topLevelPermissionOrigin(browser, requestingOrigin)
308+ if !ok {
309+ log.Debug().Msg("cef: denying permission request; requester is not the top-level origin")
310+ req.resolve(false)
311+ return
312+ }
313+
314+ wv.mu.RLock()
315+ cb := wv.callbacks
316+ wv.mu.RUnlock()
317+ if cb == nil || cb.OnPermissionRequest == nil {
318+ log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler")
319+ req.resolve(false)
320+ return
321+ }
322+
323+ wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) })
324+}
325+
326+// deliverPermissionRequest runs on the GTK thread. It skips requests that were
327+// already resolved (dismissed, closed) and denies when the WebView was destroyed
328+// or the callback does not handle the request.
329+func (h *handlerSet) deliverPermissionRequest(
330+ req *cefPermissionRequest,
331+ cb *port.WebViewCallbacks,
332+ origin string,
333+ types []string,
334+ metadata map[string]string,
335+) {
336+ if req.isDone() {
337+ return
338+ }
339+ if h.wv.destroyed.Load() {
340+ req.resolve(false)
341+ return
342+ }
343+ allow := func() { req.resolve(true) }
344+ deny := func() { req.resolve(false) }
345+ if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) {
346+ req.resolve(false)
347+ }
348+}
349+
350+// topLevelPermissionOrigin returns the canonical top-level (main frame) origin
351+// and whether the requesting origin matches it.
352+func topLevelPermissionOrigin(browser purecef.Browser, requestingOrigin string) (string, bool) {
353+ if browser == nil {
354+ return "", false
355+ }
356+ frame := browser.GetMainFrame()
357+ if frame == nil {
358+ return "", false
359+ }
360+ top, err := urlutil.ExtractOrigin(frame.GetURL())
361+ if err != nil {
362+ return "", false
363+ }
364+ requester, err := urlutil.ExtractOrigin(requestingOrigin)
365+ if err != nil || requester != top {
366+ return "", false
367+ }
368+ return top, true
369+}
370+
371+// dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission
372+// callbacks must be continued there. When the UI thread is unreachable fn runs
373+// inline so the page never hangs.
374+func dispatchPermissionResult(wv *WebView, fn func()) {
375+ if wv == nil || wv.engine == nil {
376+ fn()
377+ return
378+ }
379+ task := cefNewTask(cefTaskFunc(fn))
380+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
381+ logCEFPermission(wv).Warn().
382+ Int32("post_result", result).
383+ Msg("cef: failed to post permission result to CEF UI thread; continuing inline")
384+ fn()
385+ }
386+}
387+
388+func logCEFPermission(wv *WebView) *zerolog.Logger {
389+ if wv == nil || wv.ctx == nil {
390+ return logging.FromContext(context.Background())
391+ }
392+ return logging.FromContext(wv.ctx)
393+}
A internal/infrastructure/cef/permission_handler_test.go +447 -0
@@ -0,0 +1,447 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+type stubMediaAccessCallback struct {
17+ contCalls []uint32
18+ cancelCalls int
19+}
20+
21+func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) }
22+func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ }
23+
24+type stubPermissionPromptCallback struct {
25+ results []purecef.PermissionRequestResult
26+}
27+
28+func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) {
29+ s.results = append(s.results, result)
30+}
31+
32+type permissionCapture struct {
33+ origin string
34+ types []string
35+ allow func()
36+ deny func()
37+ meta map[string]string
38+ calls int
39+}
40+
41+const testTopURL = "https://meet.example.com/room"
42+
43+// newPermissionTestHandler builds a handlerSet whose OnPermissionRequest
44+// captures the request, and a browser whose main frame is at testTopURL.
45+func newPermissionTestHandler(t *testing.T, handled bool) (*handlerSet, *permissionCapture, purecef.Browser) {
46+ t.Helper()
47+ capture := &permissionCapture{}
48+ wv := &WebView{
49+ ctx: context.Background(),
50+ callbacks: &port.WebViewCallbacks{
51+ OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool {
52+ capture.calls++
53+ capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny
54+ return handled
55+ },
56+ },
57+ }
58+ browser := cefmocks.NewMockBrowser(t)
59+ frame := cefmocks.NewMockFrame(t)
60+ browser.EXPECT().GetMainFrame().Return(frame).Maybe()
61+ frame.EXPECT().GetURL().Return(testTopURL).Maybe()
62+ return &handlerSet{wv: wv}, capture, browser
63+}
64+
65+const (
66+ mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
67+ mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
68+ mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
69+ mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
70+
71+ promptGeo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
72+ promptNotif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
73+ promptMic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
74+ promptCam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
75+ promptPTZ = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
76+ promptClip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
77+)
78+
79+const (
80+ promptAccept = purecef.PermissionRequestResultPermissionResultAccept
81+ promptDeny = purecef.PermissionRequestResultPermissionResultDeny
82+)
83+
84+func TestMediaPermissionTypes(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ requested uint32
88+ types []string
89+ }{
90+ {"none", 0, nil},
91+ {"microphone", mediaMic, []string{"microphone"}},
92+ {"camera", mediaCamera, []string{"camera"}},
93+ {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}},
94+ {"desktop video", mediaDeskV, []string{"display"}},
95+ {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}},
96+ {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}},
97+ {"desktop audio only is denied", mediaDeskA, nil},
98+ {"mic and desktop audio only is denied", mediaMic | mediaDeskA, nil},
99+ {"unknown bit denies everything", mediaMic | 1<<10, nil},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ assert.Equal(t, tt.types, mediaPermissionTypes(tt.requested))
104+ })
105+ }
106+}
107+
108+func TestPromptPermissionTypes(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ requested uint32
112+ types []string
113+ }{
114+ {"none", 0, nil},
115+ {"geolocation", promptGeo, []string{"geolocation"}},
116+ {"notifications", promptNotif, []string{"notification"}},
117+ {"mic and camera", promptMic | promptCam, []string{"microphone", "camera"}},
118+ {"camera pan tilt zoom unsupported", promptPTZ, nil},
119+ {"unsupported", promptClip, nil},
120+ {"supported plus unsupported denies all", promptGeo | promptClip, nil},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ assert.Equal(t, tt.types, promptPermissionTypes(tt.requested))
125+ })
126+ }
127+}
128+
129+func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) {
130+ h, capture, browser := newPermissionTestHandler(t, true)
131+ cb := &stubMediaAccessCallback{}
132+
133+ handled := h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic|mediaCamera, cb)
134+
135+ require.Equal(t, int32(1), handled)
136+ require.Equal(t, "https://meet.example.com", capture.origin)
137+ require.Equal(t, []string{"microphone", "camera"}, capture.types)
138+ require.Empty(t, cb.contCalls)
139+
140+ capture.allow()
141+ assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls)
142+ assert.Zero(t, cb.cancelCalls)
143+ assert.Empty(t, h.permissions.pending)
144+}
145+
146+func TestPermissionRequestResolvesOnlyOnce(t *testing.T) {
147+ steps := map[string]func(c *permissionCapture){
148+ "allow first": func(c *permissionCapture) { c.allow(); c.deny(); c.allow() },
149+ "deny first": func(c *permissionCapture) { c.deny(); c.allow(); c.deny() },
150+ }
151+ for name, step := range steps {
152+ wantAllow := name == "allow first"
153+ t.Run("media "+name, func(t *testing.T) {
154+ h, capture, browser := newPermissionTestHandler(t, true)
155+ cb := &stubMediaAccessCallback{}
156+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
157+ step(capture)
158+ if wantAllow {
159+ assert.Equal(t, []uint32{mediaMic}, cb.contCalls)
160+ assert.Zero(t, cb.cancelCalls)
161+ } else {
162+ assert.Empty(t, cb.contCalls)
163+ assert.Equal(t, 1, cb.cancelCalls)
164+ }
165+ })
166+ t.Run("prompt "+name, func(t *testing.T) {
167+ h, capture, browser := newPermissionTestHandler(t, true)
168+ cb := &stubPermissionPromptCallback{}
169+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 7, "https://meet.example.com", promptGeo, cb))
170+ require.Equal(t, []string{"geolocation"}, capture.types)
171+ step(capture)
172+ want := promptDeny
173+ if wantAllow {
174+ want = promptAccept
175+ }
176+ assert.Equal(t, []purecef.PermissionRequestResult{want}, cb.results)
177+ })
178+ }
179+}
180+
181+func TestOnRequestMediaAccessPermissionDenies(t *testing.T) {
182+ tests := []struct {
183+ name string
184+ handled bool
185+ origin string
186+ requested uint32
187+ wantCalls int
188+ }{
189+ {"handler returns false", false, "https://meet.example.com", mediaMic, 1},
190+ {"unknown bits", true, "https://meet.example.com", mediaMic | 1<<10, 0},
191+ {"desktop audio only", true, "https://meet.example.com", mediaDeskA, 0},
192+ {"invalid origin", true, "", mediaMic, 0},
193+ {"cross-origin requester", true, "https://evil.example.org", mediaMic, 0},
194+ {"different port", true, "https://meet.example.com:8443", mediaMic, 0},
195+ }
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ h, capture, browser := newPermissionTestHandler(t, tt.handled)
199+ cb := &stubMediaAccessCallback{}
200+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, tt.origin, tt.requested, cb))
201+ assert.Equal(t, 1, cb.cancelCalls)
202+ assert.Empty(t, cb.contCalls)
203+ assert.Equal(t, tt.wantCalls, capture.calls)
204+ })
205+ }
206+}
207+
208+func TestOnRequestMediaAccessPermissionDeniesWithoutTopLevelOrigin(t *testing.T) {
209+ t.Run("nil browser", func(t *testing.T) {
210+ h, capture, _ := newPermissionTestHandler(t, true)
211+ cb := &stubMediaAccessCallback{}
212+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com", mediaMic, cb))
213+ assert.Equal(t, 1, cb.cancelCalls)
214+ assert.Zero(t, capture.calls)
215+ })
216+ t.Run("nil main frame", func(t *testing.T) {
217+ h, capture, _ := newPermissionTestHandler(t, true)
218+ browser := cefmocks.NewMockBrowser(t)
219+ browser.EXPECT().GetMainFrame().Return(nil)
220+ cb := &stubMediaAccessCallback{}
221+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
222+ assert.Equal(t, 1, cb.cancelCalls)
223+ assert.Zero(t, capture.calls)
224+ })
225+ t.Run("invalid main frame url", func(t *testing.T) {
226+ h, capture, _ := newPermissionTestHandler(t, true)
227+ browser := cefmocks.NewMockBrowser(t)
228+ frame := cefmocks.NewMockFrame(t)
229+ browser.EXPECT().GetMainFrame().Return(frame)
230+ frame.EXPECT().GetURL().Return("about:blank")
231+ cb := &stubMediaAccessCallback{}
232+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
233+ assert.Equal(t, 1, cb.cancelCalls)
234+ assert.Zero(t, capture.calls)
235+ })
236+}
237+
238+func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) {
239+ for name, cbs := range map[string]*port.WebViewCallbacks{
240+ "nil callbacks": nil,
241+ "nil OnPermissionRequest fn": {},
242+ } {
243+ t.Run(name, func(t *testing.T) {
244+ h, _, browser := newPermissionTestHandler(t, true)
245+ h.wv.callbacks = cbs
246+ cb := &stubMediaAccessCallback{}
247+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
248+ assert.Equal(t, 1, cb.cancelCalls)
249+ assert.Empty(t, cb.contCalls)
250+ })
251+ }
252+}
253+
254+func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) {
255+ h, capture, browser := newPermissionTestHandler(t, true)
256+ h.wv.destroyed.Store(true)
257+ cb := &stubMediaAccessCallback{}
258+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
259+ assert.Equal(t, 1, cb.cancelCalls)
260+ assert.Zero(t, capture.calls)
261+}
262+
263+func TestDeliverPermissionRequest(t *testing.T) {
264+ newReq := func(allowed *[]bool) *cefPermissionRequest {
265+ return &cefPermissionRequest{finish: func(allow bool) { *allowed = append(*allowed, allow) }}
266+ }
267+ handlerCalls := 0
268+ cb := &port.WebViewCallbacks{
269+ OnPermissionRequest: func(string, []string, map[string]string, func(), func()) bool {
270+ handlerCalls++
271+ return true
272+ },
273+ }
274+
275+ t.Run("already resolved is skipped", func(t *testing.T) {
276+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
277+ var results []bool
278+ req := newReq(&results)
279+ req.resolve(false)
280+ handlerCalls = 0
281+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
282+ assert.Zero(t, handlerCalls)
283+ assert.Equal(t, []bool{false}, results)
284+ })
285+ t.Run("destroyed before delivery denies", func(t *testing.T) {
286+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
287+ h.wv.destroyed.Store(true)
288+ var results []bool
289+ req := newReq(&results)
290+ handlerCalls = 0
291+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
292+ assert.Zero(t, handlerCalls)
293+ assert.Equal(t, []bool{false}, results)
294+ })
295+}
296+
297+func TestPermissionTrackerAddDuplicateKeyReturnsPrevious(t *testing.T) {
298+ var tracker permissionTracker
299+ var results []string
300+ mk := func(name string) *cefPermissionRequest {
301+ return &cefPermissionRequest{finish: func(bool) { results = append(results, name) }}
302+ }
303+ key := permissionKey{prompt: true, id: 1}
304+ first, second := mk("first"), mk("second")
305+
306+ assert.Nil(t, tracker.add(key, first))
307+ prev := tracker.add(key, second)
308+ require.Same(t, first, prev)
309+ prev.resolve(false)
310+
311+ assert.Equal(t, []string{"first"}, results)
312+ assert.Same(t, second, tracker.get(key), "resolving the replaced request must not untrack the new one")
313+}
314+
315+func TestOnShowPermissionPromptDuplicateIDDeniesPrevious(t *testing.T) {
316+ h, _, browser := newPermissionTestHandler(t, true)
317+ first, second := &stubPermissionPromptCallback{}, &stubPermissionPromptCallback{}
318+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 4, "https://meet.example.com", promptGeo, first))
319+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 4, "https://meet.example.com", promptGeo, second))
320+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, first.results)
321+ assert.Empty(t, second.results)
322+}
323+
324+func TestOnShowPermissionPromptUnsupportedReturnsZero(t *testing.T) {
325+ for name, mask := range map[string]uint32{
326+ "empty": 0,
327+ "clipboard": promptClip,
328+ "ptz": promptPTZ,
329+ "mixed": promptGeo | promptClip,
330+ "unknown bit": 1 << 30,
331+ } {
332+ t.Run(name, func(t *testing.T) {
333+ h, capture, browser := newPermissionTestHandler(t, true)
334+ cb := &stubPermissionPromptCallback{}
335+ assert.Equal(t, int32(0), h.OnShowPermissionPrompt(browser, 1, "https://meet.example.com", mask, cb))
336+ assert.Empty(t, cb.results)
337+ assert.Zero(t, capture.calls)
338+ assert.Empty(t, h.permissions.pending)
339+ })
340+ }
341+}
342+
343+func TestOnShowPermissionPromptDeniesCrossOrigin(t *testing.T) {
344+ h, capture, browser := newPermissionTestHandler(t, true)
345+ cb := &stubPermissionPromptCallback{}
346+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 1, "https://evil.example.org", promptNotif, cb))
347+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, cb.results)
348+ assert.Zero(t, capture.calls)
349+}
350+
351+func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) {
352+ h, capture, browser := newPermissionTestHandler(t, true)
353+ cb := &stubPermissionPromptCallback{}
354+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 3, "https://meet.example.com", promptNotif, cb))
355+
356+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
357+ capture.allow()
358+ capture.deny()
359+
360+ assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued")
361+ assert.Empty(t, h.permissions.pending)
362+
363+ // Dismissing an unknown or already-resolved prompt is a no-op.
364+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
365+ h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss)
366+}
367+
368+func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) {
369+ h, capture, browser := newPermissionTestHandler(t, true)
370+ media := &stubMediaAccessCallback{}
371+ prompt := &stubPermissionPromptCallback{}
372+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, media))
373+ mediaAllow := capture.allow
374+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 5, "https://meet.example.com", promptGeo, prompt))
375+ promptAllow := capture.allow
376+
377+ h.denyPendingPermissions()
378+ h.denyPendingPermissions()
379+ mediaAllow()
380+ promptAllow()
381+
382+ assert.Equal(t, 1, media.cancelCalls)
383+ assert.Empty(t, media.contCalls)
384+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, prompt.results)
385+ assert.Empty(t, h.permissions.pending)
386+}
387+
388+func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) {
389+ prevNew, prevPost := cefNewTask, cefPostTask
390+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
391+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
392+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
393+
394+ ran := 0
395+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
396+ assert.Equal(t, 1, ran)
397+}
398+
399+func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
400+ prevNew, prevPost := cefNewTask, cefPostTask
401+ var posted purecef.Task
402+ var gotThread purecef.ThreadID
403+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
404+ cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 {
405+ gotThread, posted = id, task
406+ return 1
407+ }
408+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
409+
410+ ran := 0
411+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
412+ require.NotNil(t, posted)
413+ assert.Zero(t, ran)
414+ assert.Equal(t, purecef.ThreadIDTidUi, gotThread)
415+ posted.Execute()
416+ assert.Equal(t, 1, ran)
417+}
418+
419+func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) {
420+ tests := []struct {
421+ name string
422+ requested uint32
423+ flagged bool
424+ audio bool
425+ }{
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},
431+ }
432+ for _, tt := range tests {
433+ t.Run(tt.name, func(t *testing.T) {
434+ capture := &permissionCapture{}
435+ h, _, browser := newPermissionTestHandler(t, true)
436+ h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool {
437+ capture.types, capture.meta = types, meta
438+ return true
439+ }
440+ cb := &stubMediaAccessCallback{}
441+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
442+ require.NotNil(t, capture.types)
443+ assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
444+ assert.Equal(t, tt.audio, entity.PermissionMetadata(capture.meta).IsUnmediatedAudio())
445+ })
446+ }
447+}
@@ -0,0 +1,447 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+type stubMediaAccessCallback struct {
17+ contCalls []uint32
18+ cancelCalls int
19+}
20+
21+func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) }
22+func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ }
23+
24+type stubPermissionPromptCallback struct {
25+ results []purecef.PermissionRequestResult
26+}
27+
28+func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) {
29+ s.results = append(s.results, result)
30+}
31+
32+type permissionCapture struct {
33+ origin string
34+ types []string
35+ allow func()
36+ deny func()
37+ meta map[string]string
38+ calls int
39+}
40+
41+const testTopURL = "https://meet.example.com/room"
42+
43+// newPermissionTestHandler builds a handlerSet whose OnPermissionRequest
44+// captures the request, and a browser whose main frame is at testTopURL.
45+func newPermissionTestHandler(t *testing.T, handled bool) (*handlerSet, *permissionCapture, purecef.Browser) {
46+ t.Helper()
47+ capture := &permissionCapture{}
48+ wv := &WebView{
49+ ctx: context.Background(),
50+ callbacks: &port.WebViewCallbacks{
51+ OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool {
52+ capture.calls++
53+ capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny
54+ return handled
55+ },
56+ },
57+ }
58+ browser := cefmocks.NewMockBrowser(t)
59+ frame := cefmocks.NewMockFrame(t)
60+ browser.EXPECT().GetMainFrame().Return(frame).Maybe()
61+ frame.EXPECT().GetURL().Return(testTopURL).Maybe()
62+ return &handlerSet{wv: wv}, capture, browser
63+}
64+
65+const (
66+ mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture)
67+ mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture)
68+ mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture)
69+ mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture)
70+
71+ promptGeo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation)
72+ promptNotif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications)
73+ promptMic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream)
74+ promptCam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream)
75+ promptPTZ = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom)
76+ promptClip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard)
77+)
78+
79+const (
80+ promptAccept = purecef.PermissionRequestResultPermissionResultAccept
81+ promptDeny = purecef.PermissionRequestResultPermissionResultDeny
82+)
83+
84+func TestMediaPermissionTypes(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ requested uint32
88+ types []string
89+ }{
90+ {"none", 0, nil},
91+ {"microphone", mediaMic, []string{"microphone"}},
92+ {"camera", mediaCamera, []string{"camera"}},
93+ {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}},
94+ {"desktop video", mediaDeskV, []string{"display"}},
95+ {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}},
96+ {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}},
97+ {"desktop audio only is denied", mediaDeskA, nil},
98+ {"mic and desktop audio only is denied", mediaMic | mediaDeskA, nil},
99+ {"unknown bit denies everything", mediaMic | 1<<10, nil},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ assert.Equal(t, tt.types, mediaPermissionTypes(tt.requested))
104+ })
105+ }
106+}
107+
108+func TestPromptPermissionTypes(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ requested uint32
112+ types []string
113+ }{
114+ {"none", 0, nil},
115+ {"geolocation", promptGeo, []string{"geolocation"}},
116+ {"notifications", promptNotif, []string{"notification"}},
117+ {"mic and camera", promptMic | promptCam, []string{"microphone", "camera"}},
118+ {"camera pan tilt zoom unsupported", promptPTZ, nil},
119+ {"unsupported", promptClip, nil},
120+ {"supported plus unsupported denies all", promptGeo | promptClip, nil},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ assert.Equal(t, tt.types, promptPermissionTypes(tt.requested))
125+ })
126+ }
127+}
128+
129+func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) {
130+ h, capture, browser := newPermissionTestHandler(t, true)
131+ cb := &stubMediaAccessCallback{}
132+
133+ handled := h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic|mediaCamera, cb)
134+
135+ require.Equal(t, int32(1), handled)
136+ require.Equal(t, "https://meet.example.com", capture.origin)
137+ require.Equal(t, []string{"microphone", "camera"}, capture.types)
138+ require.Empty(t, cb.contCalls)
139+
140+ capture.allow()
141+ assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls)
142+ assert.Zero(t, cb.cancelCalls)
143+ assert.Empty(t, h.permissions.pending)
144+}
145+
146+func TestPermissionRequestResolvesOnlyOnce(t *testing.T) {
147+ steps := map[string]func(c *permissionCapture){
148+ "allow first": func(c *permissionCapture) { c.allow(); c.deny(); c.allow() },
149+ "deny first": func(c *permissionCapture) { c.deny(); c.allow(); c.deny() },
150+ }
151+ for name, step := range steps {
152+ wantAllow := name == "allow first"
153+ t.Run("media "+name, func(t *testing.T) {
154+ h, capture, browser := newPermissionTestHandler(t, true)
155+ cb := &stubMediaAccessCallback{}
156+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
157+ step(capture)
158+ if wantAllow {
159+ assert.Equal(t, []uint32{mediaMic}, cb.contCalls)
160+ assert.Zero(t, cb.cancelCalls)
161+ } else {
162+ assert.Empty(t, cb.contCalls)
163+ assert.Equal(t, 1, cb.cancelCalls)
164+ }
165+ })
166+ t.Run("prompt "+name, func(t *testing.T) {
167+ h, capture, browser := newPermissionTestHandler(t, true)
168+ cb := &stubPermissionPromptCallback{}
169+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 7, "https://meet.example.com", promptGeo, cb))
170+ require.Equal(t, []string{"geolocation"}, capture.types)
171+ step(capture)
172+ want := promptDeny
173+ if wantAllow {
174+ want = promptAccept
175+ }
176+ assert.Equal(t, []purecef.PermissionRequestResult{want}, cb.results)
177+ })
178+ }
179+}
180+
181+func TestOnRequestMediaAccessPermissionDenies(t *testing.T) {
182+ tests := []struct {
183+ name string
184+ handled bool
185+ origin string
186+ requested uint32
187+ wantCalls int
188+ }{
189+ {"handler returns false", false, "https://meet.example.com", mediaMic, 1},
190+ {"unknown bits", true, "https://meet.example.com", mediaMic | 1<<10, 0},
191+ {"desktop audio only", true, "https://meet.example.com", mediaDeskA, 0},
192+ {"invalid origin", true, "", mediaMic, 0},
193+ {"cross-origin requester", true, "https://evil.example.org", mediaMic, 0},
194+ {"different port", true, "https://meet.example.com:8443", mediaMic, 0},
195+ }
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ h, capture, browser := newPermissionTestHandler(t, tt.handled)
199+ cb := &stubMediaAccessCallback{}
200+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, tt.origin, tt.requested, cb))
201+ assert.Equal(t, 1, cb.cancelCalls)
202+ assert.Empty(t, cb.contCalls)
203+ assert.Equal(t, tt.wantCalls, capture.calls)
204+ })
205+ }
206+}
207+
208+func TestOnRequestMediaAccessPermissionDeniesWithoutTopLevelOrigin(t *testing.T) {
209+ t.Run("nil browser", func(t *testing.T) {
210+ h, capture, _ := newPermissionTestHandler(t, true)
211+ cb := &stubMediaAccessCallback{}
212+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com", mediaMic, cb))
213+ assert.Equal(t, 1, cb.cancelCalls)
214+ assert.Zero(t, capture.calls)
215+ })
216+ t.Run("nil main frame", func(t *testing.T) {
217+ h, capture, _ := newPermissionTestHandler(t, true)
218+ browser := cefmocks.NewMockBrowser(t)
219+ browser.EXPECT().GetMainFrame().Return(nil)
220+ cb := &stubMediaAccessCallback{}
221+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
222+ assert.Equal(t, 1, cb.cancelCalls)
223+ assert.Zero(t, capture.calls)
224+ })
225+ t.Run("invalid main frame url", func(t *testing.T) {
226+ h, capture, _ := newPermissionTestHandler(t, true)
227+ browser := cefmocks.NewMockBrowser(t)
228+ frame := cefmocks.NewMockFrame(t)
229+ browser.EXPECT().GetMainFrame().Return(frame)
230+ frame.EXPECT().GetURL().Return("about:blank")
231+ cb := &stubMediaAccessCallback{}
232+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
233+ assert.Equal(t, 1, cb.cancelCalls)
234+ assert.Zero(t, capture.calls)
235+ })
236+}
237+
238+func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) {
239+ for name, cbs := range map[string]*port.WebViewCallbacks{
240+ "nil callbacks": nil,
241+ "nil OnPermissionRequest fn": {},
242+ } {
243+ t.Run(name, func(t *testing.T) {
244+ h, _, browser := newPermissionTestHandler(t, true)
245+ h.wv.callbacks = cbs
246+ cb := &stubMediaAccessCallback{}
247+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
248+ assert.Equal(t, 1, cb.cancelCalls)
249+ assert.Empty(t, cb.contCalls)
250+ })
251+ }
252+}
253+
254+func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) {
255+ h, capture, browser := newPermissionTestHandler(t, true)
256+ h.wv.destroyed.Store(true)
257+ cb := &stubMediaAccessCallback{}
258+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, cb))
259+ assert.Equal(t, 1, cb.cancelCalls)
260+ assert.Zero(t, capture.calls)
261+}
262+
263+func TestDeliverPermissionRequest(t *testing.T) {
264+ newReq := func(allowed *[]bool) *cefPermissionRequest {
265+ return &cefPermissionRequest{finish: func(allow bool) { *allowed = append(*allowed, allow) }}
266+ }
267+ handlerCalls := 0
268+ cb := &port.WebViewCallbacks{
269+ OnPermissionRequest: func(string, []string, map[string]string, func(), func()) bool {
270+ handlerCalls++
271+ return true
272+ },
273+ }
274+
275+ t.Run("already resolved is skipped", func(t *testing.T) {
276+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
277+ var results []bool
278+ req := newReq(&results)
279+ req.resolve(false)
280+ handlerCalls = 0
281+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
282+ assert.Zero(t, handlerCalls)
283+ assert.Equal(t, []bool{false}, results)
284+ })
285+ t.Run("destroyed before delivery denies", func(t *testing.T) {
286+ h := &handlerSet{wv: &WebView{ctx: context.Background()}}
287+ h.wv.destroyed.Store(true)
288+ var results []bool
289+ req := newReq(&results)
290+ handlerCalls = 0
291+ h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil)
292+ assert.Zero(t, handlerCalls)
293+ assert.Equal(t, []bool{false}, results)
294+ })
295+}
296+
297+func TestPermissionTrackerAddDuplicateKeyReturnsPrevious(t *testing.T) {
298+ var tracker permissionTracker
299+ var results []string
300+ mk := func(name string) *cefPermissionRequest {
301+ return &cefPermissionRequest{finish: func(bool) { results = append(results, name) }}
302+ }
303+ key := permissionKey{prompt: true, id: 1}
304+ first, second := mk("first"), mk("second")
305+
306+ assert.Nil(t, tracker.add(key, first))
307+ prev := tracker.add(key, second)
308+ require.Same(t, first, prev)
309+ prev.resolve(false)
310+
311+ assert.Equal(t, []string{"first"}, results)
312+ assert.Same(t, second, tracker.get(key), "resolving the replaced request must not untrack the new one")
313+}
314+
315+func TestOnShowPermissionPromptDuplicateIDDeniesPrevious(t *testing.T) {
316+ h, _, browser := newPermissionTestHandler(t, true)
317+ first, second := &stubPermissionPromptCallback{}, &stubPermissionPromptCallback{}
318+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 4, "https://meet.example.com", promptGeo, first))
319+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 4, "https://meet.example.com", promptGeo, second))
320+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, first.results)
321+ assert.Empty(t, second.results)
322+}
323+
324+func TestOnShowPermissionPromptUnsupportedReturnsZero(t *testing.T) {
325+ for name, mask := range map[string]uint32{
326+ "empty": 0,
327+ "clipboard": promptClip,
328+ "ptz": promptPTZ,
329+ "mixed": promptGeo | promptClip,
330+ "unknown bit": 1 << 30,
331+ } {
332+ t.Run(name, func(t *testing.T) {
333+ h, capture, browser := newPermissionTestHandler(t, true)
334+ cb := &stubPermissionPromptCallback{}
335+ assert.Equal(t, int32(0), h.OnShowPermissionPrompt(browser, 1, "https://meet.example.com", mask, cb))
336+ assert.Empty(t, cb.results)
337+ assert.Zero(t, capture.calls)
338+ assert.Empty(t, h.permissions.pending)
339+ })
340+ }
341+}
342+
343+func TestOnShowPermissionPromptDeniesCrossOrigin(t *testing.T) {
344+ h, capture, browser := newPermissionTestHandler(t, true)
345+ cb := &stubPermissionPromptCallback{}
346+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 1, "https://evil.example.org", promptNotif, cb))
347+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, cb.results)
348+ assert.Zero(t, capture.calls)
349+}
350+
351+func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) {
352+ h, capture, browser := newPermissionTestHandler(t, true)
353+ cb := &stubPermissionPromptCallback{}
354+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 3, "https://meet.example.com", promptNotif, cb))
355+
356+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
357+ capture.allow()
358+ capture.deny()
359+
360+ assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued")
361+ assert.Empty(t, h.permissions.pending)
362+
363+ // Dismissing an unknown or already-resolved prompt is a no-op.
364+ h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss)
365+ h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss)
366+}
367+
368+func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) {
369+ h, capture, browser := newPermissionTestHandler(t, true)
370+ media := &stubMediaAccessCallback{}
371+ prompt := &stubPermissionPromptCallback{}
372+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", mediaMic, media))
373+ mediaAllow := capture.allow
374+ require.Equal(t, int32(1), h.OnShowPermissionPrompt(browser, 5, "https://meet.example.com", promptGeo, prompt))
375+ promptAllow := capture.allow
376+
377+ h.denyPendingPermissions()
378+ h.denyPendingPermissions()
379+ mediaAllow()
380+ promptAllow()
381+
382+ assert.Equal(t, 1, media.cancelCalls)
383+ assert.Empty(t, media.contCalls)
384+ assert.Equal(t, []purecef.PermissionRequestResult{promptDeny}, prompt.results)
385+ assert.Empty(t, h.permissions.pending)
386+}
387+
388+func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) {
389+ prevNew, prevPost := cefNewTask, cefPostTask
390+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
391+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
392+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
393+
394+ ran := 0
395+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
396+ assert.Equal(t, 1, ran)
397+}
398+
399+func TestDispatchPermissionResultPostsToUIThread(t *testing.T) {
400+ prevNew, prevPost := cefNewTask, cefPostTask
401+ var posted purecef.Task
402+ var gotThread purecef.ThreadID
403+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
404+ cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 {
405+ gotThread, posted = id, task
406+ return 1
407+ }
408+ defer func() { cefNewTask, cefPostTask = prevNew, prevPost }()
409+
410+ ran := 0
411+ dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ })
412+ require.NotNil(t, posted)
413+ assert.Zero(t, ran)
414+ assert.Equal(t, purecef.ThreadIDTidUi, gotThread)
415+ posted.Execute()
416+ assert.Equal(t, 1, ran)
417+}
418+
419+func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) {
420+ tests := []struct {
421+ name string
422+ requested uint32
423+ flagged bool
424+ audio bool
425+ }{
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},
431+ }
432+ for _, tt := range tests {
433+ t.Run(tt.name, func(t *testing.T) {
434+ capture := &permissionCapture{}
435+ h, _, browser := newPermissionTestHandler(t, true)
436+ h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool {
437+ capture.types, capture.meta = types, meta
438+ return true
439+ }
440+ cb := &stubMediaAccessCallback{}
441+ require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb))
442+ require.NotNil(t, capture.types)
443+ assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture())
444+ assert.Equal(t, tt.audio, entity.PermissionMetadata(capture.meta).IsUnmediatedAudio())
445+ })
446+ }
447+}
M internal/infrastructure/cef/webview_handlers.go +4 -1
@@ -30,6 +30,7 @@ type handlerSet struct {
3030 fileDialogPresenter fileDialogPresenter
3131 renderHandlerOnce sync.Once
3232 renderHandler purecef.RenderHandler
33+ permissions permissionTracker
3334 }
3435
3536 // Compile-time interface checks.
@@ -44,6 +45,7 @@ var (
4445 _ purecef.DialogHandler = (*handlerSet)(nil)
4546 _ purecef.DownloadHandler = (*handlerSet)(nil)
4647 _ purecef.FindHandler = (*handlerSet)(nil)
48+ _ purecef.PermissionHandler = (*handlerSet)(nil)
4749 )
4850
4951 // ===========================================================================
@@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n
7173 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
7274 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
7375 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
76+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h }
7577 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
7678 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
7779 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
@@ -1116,6 +1118,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
11161118
11171119 // OnBeforeClose fires the OnClose callback.
11181120 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1121+ h.denyPendingPermissions()
11191122 if h.wv.engine != nil {
11201123 browserID := int32(0)
11211124 if browser != nil {
@@ -30,6 +30,7 @@ type handlerSet struct {
30 fileDialogPresenter fileDialogPresenter30 fileDialogPresenter fileDialogPresenter
31 renderHandlerOnce sync.Once31 renderHandlerOnce sync.Once
32 renderHandler purecef.RenderHandler32 renderHandler purecef.RenderHandler
33+ permissions permissionTracker
33 }34 }
34 35
35 // Compile-time interface checks.36 // Compile-time interface checks.
@@ -44,6 +45,7 @@ var (
44 _ purecef.DialogHandler = (*handlerSet)(nil)45 _ purecef.DialogHandler = (*handlerSet)(nil)
45 _ purecef.DownloadHandler = (*handlerSet)(nil)46 _ purecef.DownloadHandler = (*handlerSet)(nil)
46 _ purecef.FindHandler = (*handlerSet)(nil)47 _ purecef.FindHandler = (*handlerSet)(nil)
48+ _ purecef.PermissionHandler = (*handlerSet)(nil)
47 )49 )
48 50
49 // ===========================================================================51 // ===========================================================================
@@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n
71 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }73 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }74 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }75 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }76+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h }
75 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }77 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }78 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }79 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
@@ -1116,6 +1118,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
1116 1118
1117 // OnBeforeClose fires the OnClose callback.1119 // OnBeforeClose fires the OnClose callback.
1118 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {1120 func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1121+ h.denyPendingPermissions()
1119 if h.wv.engine != nil {1122 if h.wv.engine != nil {
1120 browserID := int32(0)1123 browserID := int32(0)
1121 if browser != nil {1124 if browser != nil {
M internal/ui/app.go +39 -19
@@ -3590,6 +3590,16 @@ func (a *App) wireKeyboardActions() {
35903590 })
35913591 }
35923592
3593+// permissionDialogForPane returns the permission dialog of the window owning the
3594+// pane (including floating panes), or nil when no window or dialog is available.
3595+func (a *App) permissionDialogForPane(paneID entity.PaneID) port.PermissionDialogPresenter {
3596+ bw := a.browserWindowForAnyPane(paneID)
3597+ if bw == nil || bw.permissionDialog == nil {
3598+ return nil
3599+ }
3600+ return bw.permissionDialog
3601+}
3602+
35933603 // paneViewForPane finds a pane's view in any tab (including background tabs).
35943604 func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
35953605 for _, wsView := range a.workspaceViews {
@@ -3612,19 +3622,25 @@ func (a *App) wireWebRTCPermissionIndicator() {
36123622 ctx = a.deps.Ctx
36133623 }
36143624
3625+ a.contentCoord.SetOnPermissionPrompt(func(paneID entity.PaneID) {
3626+ if a.deps == nil || a.deps.PermissionUC == nil {
3627+ return
3628+ }
3629+ // Clears the presenter when no window owns the pane, so the request is
3630+ // denied instead of being shown in a stale or wrong window.
3631+ a.deps.PermissionUC.SetDialogPresenter(a.permissionDialogForPane(paneID))
3632+ })
3633+
36153634 a.contentCoord.SetOnPermissionActivity(func(
36163635 paneID entity.PaneID,
36173636 origin string,
36183637 permTypes []entity.PermissionType,
36193638 state content.PermissionActivityState,
36203639 ) {
3621- bw := a.browserWindowForPane(paneID)
3640+ bw := a.browserWindowForAnyPane(paneID)
36223641 if bw == nil || bw.webrtcIndicator == nil {
36233642 return
36243643 }
3625- if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil {
3626- a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog)
3627- }
36283644 bw.webrtcIndicator.SetOrigin(origin)
36293645
36303646 switch state {
@@ -3642,22 +3658,26 @@ func (a *App) wireWebRTCPermissionIndicator() {
36423658 })
36433659
36443660 // Reset the owning window's indicator when that pane navigates away.
3645- a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) {
3646- bw := a.browserWindowForPane(paneID)
3647- if bw == nil || bw.webrtcIndicator == nil {
3648- return
3649- }
3650- newOrigin, err := urlutil.ExtractOrigin(uri)
3651- if err != nil {
3652- bw.webrtcIndicator.Reset()
3653- return
3654- }
3661+ a.contentCoord.SetOnActiveNavigationCommitted(a.resetWebRTCIndicatorOnNavigation)
3662+}
36553663
3656- currentOrigin := bw.webrtcIndicator.Origin()
3657- if currentOrigin != "" && currentOrigin != newOrigin {
3658- bw.webrtcIndicator.Reset()
3659- }
3660- })
3664+// resetWebRTCIndicatorOnNavigation resets the indicator of the window owning the
3665+// pane (workspace or floating) when the pane navigates to a different origin.
3666+func (a *App) resetWebRTCIndicatorOnNavigation(paneID entity.PaneID, uri string) {
3667+ bw := a.browserWindowForAnyPane(paneID)
3668+ if bw == nil || bw.webrtcIndicator == nil {
3669+ return
3670+ }
3671+ newOrigin, err := urlutil.ExtractOrigin(uri)
3672+ if err != nil {
3673+ bw.webrtcIndicator.Reset()
3674+ return
3675+ }
3676+
3677+ currentOrigin := bw.webrtcIndicator.Origin()
3678+ if currentOrigin != "" && currentOrigin != newOrigin {
3679+ bw.webrtcIndicator.Reset()
3680+ }
36613681 }
36623682
36633683 func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {
@@ -3590,6 +3590,16 @@ func (a *App) wireKeyboardActions() {
3590 })3590 })
3591 }3591 }
3592 3592
3593+// permissionDialogForPane returns the permission dialog of the window owning the
3594+// pane (including floating panes), or nil when no window or dialog is available.
3595+func (a *App) permissionDialogForPane(paneID entity.PaneID) port.PermissionDialogPresenter {
3596+ bw := a.browserWindowForAnyPane(paneID)
3597+ if bw == nil || bw.permissionDialog == nil {
3598+ return nil
3599+ }
3600+ return bw.permissionDialog
3601+}
3602+
3593 // paneViewForPane finds a pane's view in any tab (including background tabs).3603 // paneViewForPane finds a pane's view in any tab (including background tabs).
3594 func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {3604 func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
3595 for _, wsView := range a.workspaceViews {3605 for _, wsView := range a.workspaceViews {
@@ -3612,19 +3622,25 @@ func (a *App) wireWebRTCPermissionIndicator() {
3612 ctx = a.deps.Ctx3622 ctx = a.deps.Ctx
3613 }3623 }
3614 3624
3625+ a.contentCoord.SetOnPermissionPrompt(func(paneID entity.PaneID) {
3626+ if a.deps == nil || a.deps.PermissionUC == nil {
3627+ return
3628+ }
3629+ // Clears the presenter when no window owns the pane, so the request is
3630+ // denied instead of being shown in a stale or wrong window.
3631+ a.deps.PermissionUC.SetDialogPresenter(a.permissionDialogForPane(paneID))
3632+ })
3633+
3615 a.contentCoord.SetOnPermissionActivity(func(3634 a.contentCoord.SetOnPermissionActivity(func(
3616 paneID entity.PaneID,3635 paneID entity.PaneID,
3617 origin string,3636 origin string,
3618 permTypes []entity.PermissionType,3637 permTypes []entity.PermissionType,
3619 state content.PermissionActivityState,3638 state content.PermissionActivityState,
3620 ) {3639 ) {
3621- bw := a.browserWindowForPane(paneID)3640+ bw := a.browserWindowForAnyPane(paneID)
3622 if bw == nil || bw.webrtcIndicator == nil {3641 if bw == nil || bw.webrtcIndicator == nil {
3623 return3642 return
3624 }3643 }
3625- if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil {
3626- a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog)
3627- }
3628 bw.webrtcIndicator.SetOrigin(origin)3644 bw.webrtcIndicator.SetOrigin(origin)
3629 3645
3630 switch state {3646 switch state {
@@ -3642,22 +3658,26 @@ func (a *App) wireWebRTCPermissionIndicator() {
3642 })3658 })
3643 3659
3644 // Reset the owning window's indicator when that pane navigates away.3660 // Reset the owning window's indicator when that pane navigates away.
3645- a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) {3661+ a.contentCoord.SetOnActiveNavigationCommitted(a.resetWebRTCIndicatorOnNavigation)
3646- bw := a.browserWindowForPane(paneID)3662+}
3647- if bw == nil || bw.webrtcIndicator == nil {
3648- return
3649- }
3650- newOrigin, err := urlutil.ExtractOrigin(uri)
3651- if err != nil {
3652- bw.webrtcIndicator.Reset()
3653- return
3654- }
3655 3663
3656- currentOrigin := bw.webrtcIndicator.Origin()3664+// resetWebRTCIndicatorOnNavigation resets the indicator of the window owning the
3657- if currentOrigin != "" && currentOrigin != newOrigin {3665+// pane (workspace or floating) when the pane navigates to a different origin.
3658- bw.webrtcIndicator.Reset()3666+func (a *App) resetWebRTCIndicatorOnNavigation(paneID entity.PaneID, uri string) {
3659- }3667+ bw := a.browserWindowForAnyPane(paneID)
3660- })3668+ if bw == nil || bw.webrtcIndicator == nil {
3669+ return
3670+ }
3671+ newOrigin, err := urlutil.ExtractOrigin(uri)
3672+ if err != nil {
3673+ bw.webrtcIndicator.Reset()
3674+ return
3675+ }
3676+
3677+ currentOrigin := bw.webrtcIndicator.Origin()
3678+ if currentOrigin != "" && currentOrigin != newOrigin {
3679+ bw.webrtcIndicator.Reset()
3680+ }
3661 }3681 }
3662 3682
3663 func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {3683 func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {
M internal/ui/browser_window_test.go +50 -0
@@ -1159,3 +1159,53 @@ func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
11591159 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
11601160 "window at active index must match focused window ID")
11611161 }
1162+
1163+func TestApp_PermissionDialogForPane(t *testing.T) {
1164+ tabID := entity.TabID("tab-1")
1165+ tabPane := entity.PaneID("pane-in-tab")
1166+ floatingPane := entity.PaneID("pane-floating")
1167+ tab := entity.NewTab(tabID, entity.WorkspaceID("ws-1"), entity.NewPane(tabPane))
1168+ tabs := entity.NewTabList()
1169+ tabs.Add(tab)
1170+ dialogA := &testPermissionDialogPresenter{}
1171+ owner := &browserWindow{id: "window-1", tabs: tabs, permissionDialog: dialogA}
1172+ noDialog := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
1173+ app := &App{
1174+ browserWindows: map[string]*browserWindow{owner.id: owner, noDialog.id: noDialog},
1175+ windowForTab: map[entity.TabID]*browserWindow{tabID: owner},
1176+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1177+ {tabID: tabID, sessionID: "profile"}: {paneID: floatingPane},
1178+ },
1179+ }
1180+
1181+ assert.Same(t, dialogA, app.permissionDialogForPane(tabPane), "tiled pane")
1182+ assert.Same(t, dialogA, app.permissionDialogForPane(floatingPane), "floating pane uses its tab's window")
1183+ assert.Nil(t, app.permissionDialogForPane("unknown"), "unowned pane clears the presenter")
1184+ assert.Nil(t, app.permissionDialogForPane(""))
1185+
1186+ owner.permissionDialog = nil
1187+ assert.Nil(t, app.permissionDialogForPane(tabPane), "window without dialog")
1188+}
1189+
1190+func TestApp_ResetWebRTCIndicatorOnNavigationCoversFloatingPanes(t *testing.T) {
1191+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1192+ tabs := entity.NewTabList()
1193+ tabs.Add(tab)
1194+ indicator := &component.WebRTCPermissionIndicator{}
1195+ bw := &browserWindow{id: "window-1", tabs: tabs, webrtcIndicator: indicator}
1196+ floatingPaneID := entity.PaneID("floating-pane")
1197+ app := &App{
1198+ browserWindows: map[string]*browserWindow{bw.id: bw},
1199+ windowForTab: map[entity.TabID]*browserWindow{tab.ID: bw},
1200+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1201+ {tabID: tab.ID, sessionID: "profile:one"}: {paneID: floatingPaneID},
1202+ },
1203+ }
1204+
1205+ indicator.SetOrigin("https://old.example")
1206+ app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://old.example/page")
1207+ assert.Equal(t, "https://old.example", indicator.Origin(), "same-origin navigation keeps the indicator")
1208+
1209+ app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://new.example/")
1210+ assert.Empty(t, indicator.Origin(), "cross-origin navigation in a floating pane resets the indicator")
1211+}
@@ -1159,3 +1159,53 @@ func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
1159 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,1159 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
1160 "window at active index must match focused window ID")1160 "window at active index must match focused window ID")
1161 }1161 }
1162+
1163+func TestApp_PermissionDialogForPane(t *testing.T) {
1164+ tabID := entity.TabID("tab-1")
1165+ tabPane := entity.PaneID("pane-in-tab")
1166+ floatingPane := entity.PaneID("pane-floating")
1167+ tab := entity.NewTab(tabID, entity.WorkspaceID("ws-1"), entity.NewPane(tabPane))
1168+ tabs := entity.NewTabList()
1169+ tabs.Add(tab)
1170+ dialogA := &testPermissionDialogPresenter{}
1171+ owner := &browserWindow{id: "window-1", tabs: tabs, permissionDialog: dialogA}
1172+ noDialog := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
1173+ app := &App{
1174+ browserWindows: map[string]*browserWindow{owner.id: owner, noDialog.id: noDialog},
1175+ windowForTab: map[entity.TabID]*browserWindow{tabID: owner},
1176+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1177+ {tabID: tabID, sessionID: "profile"}: {paneID: floatingPane},
1178+ },
1179+ }
1180+
1181+ assert.Same(t, dialogA, app.permissionDialogForPane(tabPane), "tiled pane")
1182+ assert.Same(t, dialogA, app.permissionDialogForPane(floatingPane), "floating pane uses its tab's window")
1183+ assert.Nil(t, app.permissionDialogForPane("unknown"), "unowned pane clears the presenter")
1184+ assert.Nil(t, app.permissionDialogForPane(""))
1185+
1186+ owner.permissionDialog = nil
1187+ assert.Nil(t, app.permissionDialogForPane(tabPane), "window without dialog")
1188+}
1189+
1190+func TestApp_ResetWebRTCIndicatorOnNavigationCoversFloatingPanes(t *testing.T) {
1191+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1192+ tabs := entity.NewTabList()
1193+ tabs.Add(tab)
1194+ indicator := &component.WebRTCPermissionIndicator{}
1195+ bw := &browserWindow{id: "window-1", tabs: tabs, webrtcIndicator: indicator}
1196+ floatingPaneID := entity.PaneID("floating-pane")
1197+ app := &App{
1198+ browserWindows: map[string]*browserWindow{bw.id: bw},
1199+ windowForTab: map[entity.TabID]*browserWindow{tab.ID: bw},
1200+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1201+ {tabID: tab.ID, sessionID: "profile:one"}: {paneID: floatingPaneID},
1202+ },
1203+ }
1204+
1205+ indicator.SetOrigin("https://old.example")
1206+ app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://old.example/page")
1207+ assert.Equal(t, "https://old.example", indicator.Origin(), "same-origin navigation keeps the indicator")
1208+
1209+ app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://new.example/")
1210+ assert.Empty(t, indicator.Origin(), "cross-origin navigation in a floating pane resets the indicator")
1211+}
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/coordinator/content/callbacks.go +35 -19
@@ -205,24 +205,7 @@ func (c *Coordinator) handlePermissionRequest(
205205 ) bool {
206206 log := logging.FromContext(ctx)
207207
208- // Convert string permission types to entity types
209- entityTypes := make([]entity.PermissionType, 0, len(permTypes))
210- for _, pt := range permTypes {
211- switch pt {
212- case "microphone":
213- entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
214- case "camera":
215- entityTypes = append(entityTypes, entity.PermissionTypeCamera)
216- case "display":
217- entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
218- case "device_info":
219- entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
220- case "website_data_access":
221- entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
222- default:
223- log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
224- }
225- }
208+ entityTypes := permissionTypesFromStrings(ctx, permTypes)
226209
227210 if len(entityTypes) == 0 {
228211 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
@@ -255,7 +238,7 @@ func (c *Coordinator) handlePermissionRequest(
255238 // Auto-allow display and device_info, deny others
256239 allAutoAllow := true
257240 for _, pt := range entityTypes {
258- if !entity.IsAutoAllow(pt) {
241+ if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
259242 allAutoAllow = false
260243 break
261244 }
@@ -268,6 +251,11 @@ func (c *Coordinator) handlePermissionRequest(
268251 return true
269252 }
270253
254+ // Bind the dialog presenter of the window owning this pane before handling.
255+ if c.onPermissionPrompt != nil {
256+ c.onPermissionPrompt(paneID)
257+ }
258+
271259 // Delegate to use case
272260 callback := usecase.PermissionCallback{
273261 Allow: wrappedAllow,
@@ -278,6 +266,34 @@ func (c *Coordinator) handlePermissionRequest(
278266 return true
279267 }
280268
269+// permissionTypesFromStrings converts engine permission type strings to entity
270+// types, skipping unknown ones.
271+func permissionTypesFromStrings(ctx context.Context, permTypes []string) []entity.PermissionType {
272+ log := logging.FromContext(ctx)
273+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
274+ for _, pt := range permTypes {
275+ switch pt {
276+ case "microphone":
277+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
278+ case "camera":
279+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
280+ case "display":
281+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
282+ case "notification":
283+ entityTypes = append(entityTypes, entity.PermissionTypeNotification)
284+ case "geolocation":
285+ entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
286+ case "device_info":
287+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
288+ case "website_data_access":
289+ entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
290+ default:
291+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
292+ }
293+ }
294+ return entityTypes
295+}
296+
281297 func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
282298 filtered := make([]entity.PermissionType, 0, len(types))
283299 for _, permType := range types {
@@ -205,24 +205,7 @@ func (c *Coordinator) handlePermissionRequest(
205 ) bool {205 ) bool {
206 log := logging.FromContext(ctx)206 log := logging.FromContext(ctx)
207 207
208- // Convert string permission types to entity types208+ entityTypes := permissionTypesFromStrings(ctx, permTypes)
209- entityTypes := make([]entity.PermissionType, 0, len(permTypes))
210- for _, pt := range permTypes {
211- switch pt {
212- case "microphone":
213- entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
214- case "camera":
215- entityTypes = append(entityTypes, entity.PermissionTypeCamera)
216- case "display":
217- entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
218- case "device_info":
219- entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
220- case "website_data_access":
221- entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
222- default:
223- log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
224- }
225- }
226 209
227 if len(entityTypes) == 0 {210 if len(entityTypes) == 0 {
228 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")211 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
@@ -255,7 +238,7 @@ func (c *Coordinator) handlePermissionRequest(
255 // Auto-allow display and device_info, deny others238 // Auto-allow display and device_info, deny others
256 allAutoAllow := true239 allAutoAllow := true
257 for _, pt := range entityTypes {240 for _, pt := range entityTypes {
258- if !entity.IsAutoAllow(pt) {241+ if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
259 allAutoAllow = false242 allAutoAllow = false
260 break243 break
261 }244 }
@@ -268,6 +251,11 @@ func (c *Coordinator) handlePermissionRequest(
268 return true251 return true
269 }252 }
270 253
254+ // Bind the dialog presenter of the window owning this pane before handling.
255+ if c.onPermissionPrompt != nil {
256+ c.onPermissionPrompt(paneID)
257+ }
258+
271 // Delegate to use case259 // Delegate to use case
272 callback := usecase.PermissionCallback{260 callback := usecase.PermissionCallback{
273 Allow: wrappedAllow,261 Allow: wrappedAllow,
@@ -278,6 +266,34 @@ func (c *Coordinator) handlePermissionRequest(
278 return true266 return true
279 }267 }
280 268
269+// permissionTypesFromStrings converts engine permission type strings to entity
270+// types, skipping unknown ones.
271+func permissionTypesFromStrings(ctx context.Context, permTypes []string) []entity.PermissionType {
272+ log := logging.FromContext(ctx)
273+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
274+ for _, pt := range permTypes {
275+ switch pt {
276+ case "microphone":
277+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
278+ case "camera":
279+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
280+ case "display":
281+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
282+ case "notification":
283+ entityTypes = append(entityTypes, entity.PermissionTypeNotification)
284+ case "geolocation":
285+ entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
286+ case "device_info":
287+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
288+ case "website_data_access":
289+ entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
290+ default:
291+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
292+ }
293+ }
294+ return entityTypes
295+}
296+
281 func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {297 func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
282 filtered := make([]entity.PermissionType, 0, len(types))298 filtered := make([]entity.PermissionType, 0, len(types))
283 for _, permType := range types {299 for _, permType := range types {
M internal/ui/coordinator/content/coordinator.go +10 -0
@@ -67,6 +67,10 @@ type Coordinator struct {
6767 // Callback when media permission activity changes (requesting/allowed/blocked).
6868 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
6969
70+ // Callback invoked for every permission request, before it is handled, so the
71+ // UI can bind the permission dialog of the window owning the pane.
72+ onPermissionPrompt func(paneID entity.PaneID)
73+
7074 // Callback when the active pane commits a navigation (new page loading).
7175 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
7276
@@ -206,6 +210,12 @@ func (c *Coordinator) SetOnPermissionActivity(
206210 c.onPermissionActivity = fn
207211 }
208212
213+// SetOnPermissionPrompt sets a callback invoked for every permission request
214+// (not only WebRTC ones) before it reaches the permission use case.
215+func (c *Coordinator) SetOnPermissionPrompt(fn func(paneID entity.PaneID)) {
216+ c.onPermissionPrompt = fn
217+}
218+
209219 // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
210220 func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
211221 c.onActiveNavigationCommitted = fn
@@ -67,6 +67,10 @@ type Coordinator struct {
67 // Callback when media permission activity changes (requesting/allowed/blocked).67 // Callback when media permission activity changes (requesting/allowed/blocked).
68 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)68 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
69 69
70+ // Callback invoked for every permission request, before it is handled, so the
71+ // UI can bind the permission dialog of the window owning the pane.
72+ onPermissionPrompt func(paneID entity.PaneID)
73+
70 // Callback when the active pane commits a navigation (new page loading).74 // Callback when the active pane commits a navigation (new page loading).
71 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)75 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
72 76
@@ -206,6 +210,12 @@ func (c *Coordinator) SetOnPermissionActivity(
206 c.onPermissionActivity = fn210 c.onPermissionActivity = fn
207 }211 }
208 212
213+// SetOnPermissionPrompt sets a callback invoked for every permission request
214+// (not only WebRTC ones) before it reaches the permission use case.
215+func (c *Coordinator) SetOnPermissionPrompt(fn func(paneID entity.PaneID)) {
216+ c.onPermissionPrompt = fn
217+}
218+
209 // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.219 // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
210 func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {220 func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
211 c.onActiveNavigationCommitted = fn221 c.onActiveNavigationCommitted = fn
A internal/ui/coordinator/content/permission_prompt_test.go +92 -0
@@ -0,0 +1,92 @@
1+package content
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/rs/zerolog"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+)
17+
18+func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t *testing.T) {
19+ permRepo := portmocks.NewMockPermissionRepository(t)
20+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
21+ // The use case starts without a dialog presenter, like in production.
22+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil, zerolog.Ctx)
23+
24+ permRepo.EXPECT().Get(mock.Anything, "https://maps.example.com", entity.PermissionTypeGeolocation).
25+ Return(nil, nil)
26+
27+ var order []string
28+ dialog.EXPECT().
29+ ShowPermissionDialog(mock.Anything, "https://maps.example.com", []entity.PermissionType{entity.PermissionTypeGeolocation}, mock.Anything, mock.Anything).
30+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, onResult func(port.PermissionDialogResult)) {
31+ order = append(order, "dialog")
32+ onResult(port.PermissionDialogResult{Allowed: true})
33+ }).Return()
34+
35+ pane := entity.PaneID("pane-1")
36+ var gotPane entity.PaneID
37+ activityCalls := 0
38+ c := &Coordinator{
39+ permissionUC: uc,
40+ onPermissionPrompt: func(paneID entity.PaneID) {
41+ order = append(order, "bind")
42+ gotPane = paneID
43+ uc.SetDialogPresenter(dialog)
44+ },
45+ onPermissionActivity: func(entity.PaneID, string, []entity.PermissionType, PermissionActivityState) {
46+ activityCalls++
47+ },
48+ }
49+
50+ allowed, denied := 0, 0
51+ handled := c.handlePermissionRequest(context.Background(), pane, "https://maps.example.com",
52+ []string{"geolocation"}, nil, func() { allowed++ }, func() { denied++ })
53+
54+ require.True(t, handled)
55+ assert.Equal(t, pane, gotPane)
56+ assert.Equal(t, []string{"bind", "dialog"}, order)
57+ assert.Equal(t, 1, allowed)
58+ assert.Zero(t, denied)
59+ assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator")
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+}
@@ -0,0 +1,92 @@
1+package content
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/rs/zerolog"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+)
17+
18+func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t *testing.T) {
19+ permRepo := portmocks.NewMockPermissionRepository(t)
20+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
21+ // The use case starts without a dialog presenter, like in production.
22+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil, zerolog.Ctx)
23+
24+ permRepo.EXPECT().Get(mock.Anything, "https://maps.example.com", entity.PermissionTypeGeolocation).
25+ Return(nil, nil)
26+
27+ var order []string
28+ dialog.EXPECT().
29+ ShowPermissionDialog(mock.Anything, "https://maps.example.com", []entity.PermissionType{entity.PermissionTypeGeolocation}, mock.Anything, mock.Anything).
30+ Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, onResult func(port.PermissionDialogResult)) {
31+ order = append(order, "dialog")
32+ onResult(port.PermissionDialogResult{Allowed: true})
33+ }).Return()
34+
35+ pane := entity.PaneID("pane-1")
36+ var gotPane entity.PaneID
37+ activityCalls := 0
38+ c := &Coordinator{
39+ permissionUC: uc,
40+ onPermissionPrompt: func(paneID entity.PaneID) {
41+ order = append(order, "bind")
42+ gotPane = paneID
43+ uc.SetDialogPresenter(dialog)
44+ },
45+ onPermissionActivity: func(entity.PaneID, string, []entity.PermissionType, PermissionActivityState) {
46+ activityCalls++
47+ },
48+ }
49+
50+ allowed, denied := 0, 0
51+ handled := c.handlePermissionRequest(context.Background(), pane, "https://maps.example.com",
52+ []string{"geolocation"}, nil, func() { allowed++ }, func() { denied++ })
53+
54+ require.True(t, handled)
55+ assert.Equal(t, pane, gotPane)
56+ assert.Equal(t, []string{"bind", "dialog"}, order)
57+ assert.Equal(t, 1, allowed)
58+ assert.Zero(t, denied)
59+ assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator")
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/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 +40 -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).
@@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() {
150150
151151 // permFlags holds parsed permission type flags.
152152 type permFlags struct {
153- mic, cam, display, dataAccess bool
153+ mic, cam, display, dataAccess, notification, geolocation bool
154154 }
155155
156156 // parsePermFlags extracts boolean flags from permission types.
@@ -166,11 +166,26 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
166166 f.display = true
167167 case entity.PermissionTypeWebsiteDataAccess:
168168 f.dataAccess = true
169+ case entity.PermissionTypeNotification:
170+ f.notification = true
171+ case entity.PermissionTypeGeolocation:
172+ f.geolocation = true
169173 }
170174 }
171175 return f
172176 }
173177
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+
174189 // joinPermissionLabels joins labels with commas and "and".
175190 func joinPermissionLabels(labels []string) string {
176191 switch len(labels) {
@@ -204,6 +219,12 @@ func (d *PermissionDialog) buildHeading(
204219 if f.dataAccess {
205220 labels = append(labels, "Data Access")
206221 }
222+ if f.notification {
223+ labels = append(labels, "Notifications")
224+ }
225+ if f.geolocation {
226+ labels = append(labels, "Location")
227+ }
207228 switch {
208229 case len(labels) == 0:
209230 return "Allow Permission?"
@@ -211,6 +232,8 @@ func (d *PermissionDialog) buildHeading(
211232 return "Allow Third-Party Data Access?"
212233 case len(labels) == 1 && f.display:
213234 return "Allow Screen Sharing?"
235+ case len(labels) == 1 && f.notification:
236+ return "Allow Notifications?"
214237 case len(labels) == 1:
215238 return "Allow " + labels[0] + " Access?"
216239 default:
@@ -232,7 +255,20 @@ func (d *PermissionDialog) buildBody(
232255 parts = append(parts, "access your camera")
233256 }
234257 if f.display {
235- parts = append(parts, "share your screen")
258+ switch {
259+ case metadata.IsUnmediatedCapture() && metadata.IsUnmediatedAudio():
260+ parts = append(parts, "share your entire screen and system audio")
261+ case metadata.IsUnmediatedCapture():
262+ parts = append(parts, "share your entire screen")
263+ default:
264+ parts = append(parts, "share your screen")
265+ }
266+ }
267+ if f.notification {
268+ parts = append(parts, "show notifications")
269+ }
270+ if f.geolocation {
271+ parts = append(parts, "access your location")
236272 }
237273 if f.dataAccess {
238274 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
@@ -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).
@@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() {
150 150
151 // permFlags holds parsed permission type flags.151 // permFlags holds parsed permission type flags.
152 type permFlags struct {152 type permFlags struct {
153- mic, cam, display, dataAccess bool153+ mic, cam, display, dataAccess, notification, geolocation bool
154 }154 }
155 155
156 // parsePermFlags extracts boolean flags from permission types.156 // parsePermFlags extracts boolean flags from permission types.
@@ -166,11 +166,26 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags {
166 f.display = true166 f.display = true
167 case entity.PermissionTypeWebsiteDataAccess:167 case entity.PermissionTypeWebsiteDataAccess:
168 f.dataAccess = true168 f.dataAccess = true
169+ case entity.PermissionTypeNotification:
170+ f.notification = true
171+ case entity.PermissionTypeGeolocation:
172+ f.geolocation = true
169 }173 }
170 }174 }
171 return f175 return f
172 }176 }
173 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+
174 // joinPermissionLabels joins labels with commas and "and".189 // joinPermissionLabels joins labels with commas and "and".
175 func joinPermissionLabels(labels []string) string {190 func joinPermissionLabels(labels []string) string {
176 switch len(labels) {191 switch len(labels) {
@@ -204,6 +219,12 @@ func (d *PermissionDialog) buildHeading(
204 if f.dataAccess {219 if f.dataAccess {
205 labels = append(labels, "Data Access")220 labels = append(labels, "Data Access")
206 }221 }
222+ if f.notification {
223+ labels = append(labels, "Notifications")
224+ }
225+ if f.geolocation {
226+ labels = append(labels, "Location")
227+ }
207 switch {228 switch {
208 case len(labels) == 0:229 case len(labels) == 0:
209 return "Allow Permission?"230 return "Allow Permission?"
@@ -211,6 +232,8 @@ func (d *PermissionDialog) buildHeading(
211 return "Allow Third-Party Data Access?"232 return "Allow Third-Party Data Access?"
212 case len(labels) == 1 && f.display:233 case len(labels) == 1 && f.display:
213 return "Allow Screen Sharing?"234 return "Allow Screen Sharing?"
235+ case len(labels) == 1 && f.notification:
236+ return "Allow Notifications?"
214 case len(labels) == 1:237 case len(labels) == 1:
215 return "Allow " + labels[0] + " Access?"238 return "Allow " + labels[0] + " Access?"
216 default:239 default:
@@ -232,7 +255,20 @@ func (d *PermissionDialog) buildBody(
232 parts = append(parts, "access your camera")255 parts = append(parts, "access your camera")
233 }256 }
234 if f.display {257 if f.display {
235- parts = append(parts, "share your screen")258+ switch {
259+ case metadata.IsUnmediatedCapture() && metadata.IsUnmediatedAudio():
260+ parts = append(parts, "share your entire screen and system audio")
261+ case metadata.IsUnmediatedCapture():
262+ parts = append(parts, "share your entire screen")
263+ default:
264+ parts = append(parts, "share your screen")
265+ }
266+ }
267+ if f.notification {
268+ parts = append(parts, "show notifications")
269+ }
270+ if f.geolocation {
271+ parts = append(parts, "access your location")
236 }272 }
237 if f.dataAccess {273 if f.dataAccess {
238 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]274 reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
M internal/ui/dialog/permission_test.go +90 -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,
@@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
142142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143143 expectedAction: "access your microphone and camera, and share your screen",
144144 },
145+ {
146+ name: "notification only",
147+ permTypes: []entity.PermissionType{entity.PermissionTypeNotification},
148+ expectHeading: "Allow Notifications?",
149+ expectedAction: "show notifications",
150+ },
151+ {
152+ name: "geolocation only",
153+ permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation},
154+ expectHeading: "Allow Location Access?",
155+ expectedAction: "access your location",
156+ },
145157 {
146158 name: "website data access only",
147159 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},
@@ -167,3 +179,79 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
167179 assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body)
168180 })
169181 }
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+}
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,
@@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143 expectedAction: "access your microphone and camera, and share your screen",143 expectedAction: "access your microphone and camera, and share your screen",
144 },144 },
145+ {
146+ name: "notification only",
147+ permTypes: []entity.PermissionType{entity.PermissionTypeNotification},
148+ expectHeading: "Allow Notifications?",
149+ expectedAction: "show notifications",
150+ },
151+ {
152+ name: "geolocation only",
153+ permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation},
154+ expectHeading: "Allow Location Access?",
155+ expectedAction: "access your location",
156+ },
145 {157 {
146 name: "website data access only",158 name: "website data access only",
147 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},159 permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},
@@ -167,3 +179,79 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
167 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)
168 })180 })
169 }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+}
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