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 Unverified
B5690EEEBB952194The key is unknown
15 files · +1379 -62
M internal/application/usecase/handle_permission.go +11 -7
| @@ -133,20 +133,20 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 133 | 133 | return | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | - | if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) { | |
| 136 | + | if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes, metadata) { | |
| 137 | 137 | log.Debug().Msg("manual override denied auto-allow permission request") | |
| 138 | 138 | callback.Deny() | |
| 139 | 139 | return | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | - | if isAutoAllowSet(permTypes) { | |
| 142 | + | if isAutoAllowSet(permTypes, metadata) { | |
| 143 | 143 | log.Debug().Msg("auto-allowing permission request") | |
| 144 | 144 | callback.Allow() | |
| 145 | 145 | return | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | // For mic/camera: check stored permissions first | |
| 149 | - | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | |
| 149 | + | decision := uc.checkStoredPermissions(ctx, origin, permTypes, metadata) | |
| 150 | 150 | switch decision { | |
| 151 | 151 | case entity.PermissionGranted: | |
| 152 | 152 | if isWebsiteDataAccessPermission(permTypes) { | |
| @@ -173,6 +173,8 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | 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 | 178 | // This is used by websites to check if they already have permission before calling getUserMedia(). | |
| 177 | 179 | func (uc *HandlePermissionUseCase) QueryPermissionState( | |
| 178 | 180 | ctx context.Context, | |
| @@ -212,9 +214,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState( | |||
| 212 | 214 | } | |
| 213 | 215 | ||
| 214 | 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 | 218 | for _, pt := range permTypes { | |
| 217 | - | if !entity.IsAutoAllow(pt) { | |
| 219 | + | if !entity.IsAutoAllowFor(pt, metadata) { | |
| 218 | 220 | return false | |
| 219 | 221 | } | |
| 220 | 222 | } | |
| @@ -227,6 +229,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 227 | 229 | ctx context.Context, | |
| 228 | 230 | origin string, | |
| 229 | 231 | permTypes []entity.PermissionType, | |
| 232 | + | metadata entity.PermissionMetadata, | |
| 230 | 233 | ) entity.PermissionDecision { | |
| 231 | 234 | log := uc.logger(ctx) | |
| 232 | 235 | ||
| @@ -234,7 +237,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 234 | 237 | ||
| 235 | 238 | for _, permType := range permTypes { | |
| 236 | 239 | // Skip auto-allow types - they're handled before this function | |
| 237 | - | if entity.IsAutoAllow(permType) { | |
| 240 | + | if entity.IsAutoAllowFor(permType, metadata) { | |
| 238 | 241 | continue | |
| 239 | 242 | } | |
| 240 | 243 | ||
| @@ -414,9 +417,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |||
| 414 | 417 | ctx context.Context, | |
| 415 | 418 | origin string, | |
| 416 | 419 | permTypes []entity.PermissionType, | |
| 420 | + | metadata entity.PermissionMetadata, | |
| 417 | 421 | ) bool { | |
| 418 | 422 | for _, permType := range permTypes { | |
| 419 | - | if !entity.IsAutoAllow(permType) { | |
| 423 | + | if !entity.IsAutoAllowFor(permType, metadata) { | |
| 420 | 424 | continue | |
| 421 | 425 | } | |
| 422 | 426 | ||
| @@ -133,20 +133,20 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||||
| 133 | return | 133 | 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 | return | 139 | 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 | return | 145 | return | ||
| 146 | } | 146 | } | ||
| 147 | 147 | ||||
| 148 | // For mic/camera: check stored permissions first | 148 | // 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 false | 220 | 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 function | 239 | // Skip auto-allow types - they're handled before this function | ||
| 237 | - | if entity.IsAutoAllow(permType) { | 240 | + | if entity.IsAutoAllowFor(permType, metadata) { |
| 238 | continue | 241 | 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 | continue | 424 | 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) | |||
| 610 | 610 | assert.True(t, allowed, "should use stored granted permission without dialog") | |
| 611 | 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 | + | } | |
| @@ -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 ( | |||
| 47 | 47 | // PermissionMetadataKeyCurrentDomain is the first-party domain currently loaded in | |
| 48 | 48 | // the WebView — the context in which the cross-site request is happening. | |
| 49 | 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 | 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 | 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 | 146 | func IsAutoAllow(permType PermissionType) bool { | |
| 112 | 147 | switch permType { | |
| 113 | 148 | case PermissionTypeDisplay: | |
| @@ -47,6 +47,18 @@ const ( | |||||
| 47 | // PermissionMetadataKeyCurrentDomain is the first-party domain currently loaded in | 47 | // 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¬ifications != 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¬ifications != 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 { | |||
| 30 | 30 | fileDialogPresenter fileDialogPresenter | |
| 31 | 31 | renderHandlerOnce sync.Once | |
| 32 | 32 | renderHandler purecef.RenderHandler | |
| 33 | + | permissions permissionTracker | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | // Compile-time interface checks. | |
| @@ -44,6 +45,7 @@ var ( | |||
| 44 | 45 | _ purecef.DialogHandler = (*handlerSet)(nil) | |
| 45 | 46 | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 46 | 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 | 73 | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | |
| 72 | 74 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 73 | 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 | 77 | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h } | |
| 76 | 78 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 77 | 79 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| @@ -1116,6 +1118,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||
| 1116 | 1118 | ||
| 1117 | 1119 | // OnBeforeClose fires the OnClose callback. | |
| 1118 | 1120 | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |
| 1121 | + | h.denyPendingPermissions() | |
| 1119 | 1122 | if h.wv.engine != nil { | |
| 1120 | 1123 | browserID := int32(0) | |
| 1121 | 1124 | if browser != nil { | |
| @@ -30,6 +30,7 @@ type handlerSet struct { | |||||
| 30 | fileDialogPresenter fileDialogPresenter | 30 | fileDialogPresenter fileDialogPresenter | ||
| 31 | renderHandlerOnce sync.Once | 31 | renderHandlerOnce sync.Once | ||
| 32 | renderHandler purecef.RenderHandler | 32 | 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() { | |||
| 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 | 3603 | // paneViewForPane finds a pane's view in any tab (including background tabs). | |
| 3594 | 3604 | func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView { | |
| 3595 | 3605 | for _, wsView := range a.workspaceViews { | |
| @@ -3612,19 +3622,25 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 3612 | 3622 | 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 | 3634 | a.contentCoord.SetOnPermissionActivity(func( | |
| 3616 | 3635 | paneID entity.PaneID, | |
| 3617 | 3636 | origin string, | |
| 3618 | 3637 | permTypes []entity.PermissionType, | |
| 3619 | 3638 | state content.PermissionActivityState, | |
| 3620 | 3639 | ) { | |
| 3621 | - | bw := a.browserWindowForPane(paneID) | |
| 3640 | + | bw := a.browserWindowForAnyPane(paneID) | |
| 3622 | 3641 | if bw == nil || bw.webrtcIndicator == nil { | |
| 3623 | 3642 | 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 | 3644 | bw.webrtcIndicator.SetOrigin(origin) | |
| 3629 | 3645 | ||
| 3630 | 3646 | switch state { | |
| @@ -3642,22 +3658,26 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 3642 | 3658 | }) | |
| 3643 | 3659 | ||
| 3644 | 3660 | // 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 | + | } | |
| 3655 | 3663 | ||
| 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 | + | } | |
| 3661 | 3681 | } | |
| 3662 | 3682 | ||
| 3663 | 3683 | 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.Ctx | 3622 | 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 | return | 3642 | 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) { | |||
| 1159 | 1159 | assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID, | |
| 1160 | 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 | + | } | |
| @@ -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 { | |||
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 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 | 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 | 71 | log := logging.FromContext(ctx) | |
| 71 | 72 | ||
| 72 | 73 | pp.mu.Lock() | |
| @@ -85,6 +86,12 @@ func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callb | |||
| 85 | 86 | if pp.bodyLabel != nil { | |
| 86 | 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 | 96 | pp.resizeAndCenter() | |
| 90 | 97 | 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( | |||
| 205 | 205 | ) bool { | |
| 206 | 206 | log := logging.FromContext(ctx) | |
| 207 | 207 | ||
| 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) | |
| 226 | 209 | ||
| 227 | 210 | if len(entityTypes) == 0 { | |
| 228 | 211 | log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying") | |
| @@ -255,7 +238,7 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 255 | 238 | // Auto-allow display and device_info, deny others | |
| 256 | 239 | allAutoAllow := true | |
| 257 | 240 | for _, pt := range entityTypes { | |
| 258 | - | if !entity.IsAutoAllow(pt) { | |
| 241 | + | if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) { | |
| 259 | 242 | allAutoAllow = false | |
| 260 | 243 | break | |
| 261 | 244 | } | |
| @@ -268,6 +251,11 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 268 | 251 | 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 | 259 | // Delegate to use case | |
| 272 | 260 | callback := usecase.PermissionCallback{ | |
| 273 | 261 | Allow: wrappedAllow, | |
| @@ -278,6 +266,34 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 278 | 266 | 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 | 297 | func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType { | |
| 282 | 298 | filtered := make([]entity.PermissionType, 0, len(types)) | |
| 283 | 299 | 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 types | 208 | + | 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 others | 238 | // Auto-allow display and device_info, deny others | ||
| 256 | allAutoAllow := true | 239 | 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 = false | 242 | allAutoAllow = false | ||
| 260 | break | 243 | break | ||
| 261 | } | 244 | } | ||
| @@ -268,6 +251,11 @@ func (c *Coordinator) handlePermissionRequest( | |||||
| 268 | return true | 251 | 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 case | 259 | // 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 true | 266 | 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 { | |||
| 67 | 67 | // Callback when media permission activity changes (requesting/allowed/blocked). | |
| 68 | 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 | 74 | // Callback when the active pane commits a navigation (new page loading). | |
| 71 | 75 | onActiveNavigationCommitted func(paneID entity.PaneID, uri string) | |
| 72 | 76 | ||
| @@ -206,6 +210,12 @@ func (c *Coordinator) SetOnPermissionActivity( | |||
| 206 | 210 | 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 | 219 | // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation. | |
| 210 | 220 | func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) { | |
| 211 | 221 | 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 = fn | 210 | 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 = fn | 221 | 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 { | |||
| 16 | 16 | mock.TestingT | |
| 17 | 17 | Cleanup(func()) | |
| 18 | 18 | }) *MockPermissionPopup { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 19 | 23 | mock := &MockPermissionPopup{} | |
| 20 | 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 | 33 | return mock | |
| 25 | 34 | } | |
| @@ -38,8 +47,8 @@ func (_m *MockPermissionPopup) EXPECT() *MockPermissionPopup_Expecter { | |||
| 38 | 47 | } | |
| 39 | 48 | ||
| 40 | 49 | // 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) | |
| 43 | 52 | return | |
| 44 | 53 | } | |
| 45 | 54 | ||
| @@ -52,12 +61,13 @@ type MockPermissionPopup_Show_Call struct { | |||
| 52 | 61 | // - ctx context.Context | |
| 53 | 62 | // - heading string | |
| 54 | 63 | // - body string | |
| 64 | + | // - persistable bool | |
| 55 | 65 | // - 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)} | |
| 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 | 71 | _c.Call.Run(func(args mock.Arguments) { | |
| 62 | 72 | var arg0 context.Context | |
| 63 | 73 | if args[0] != nil { | |
| @@ -71,15 +81,20 @@ func (_c *MockPermissionPopup_Show_Call) Run(run func(ctx context.Context, headi | |||
| 71 | 81 | if args[2] != nil { | |
| 72 | 82 | arg2 = args[2].(string) | |
| 73 | 83 | } | |
| 74 | - | var arg3 func(allowed bool, persistent bool) | |
| 84 | + | var arg3 bool | |
| 75 | 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 | 92 | run( | |
| 79 | 93 | arg0, | |
| 80 | 94 | arg1, | |
| 81 | 95 | arg2, | |
| 82 | 96 | arg3, | |
| 97 | + | arg4, | |
| 83 | 98 | ) | |
| 84 | 99 | }) | |
| 85 | 100 | return _c | |
| @@ -90,7 +105,7 @@ func (_c *MockPermissionPopup_Show_Call) Return() *MockPermissionPopup_Show_Call | |||
| 90 | 105 | 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 | 109 | _c.Run(run) | |
| 95 | 110 | return _c | |
| 96 | 111 | } | |
| @@ -16,10 +16,19 @@ func NewMockPermissionPopup(t interface { | |||||
| 16 | mock.TestingT | 16 | 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 mock | 33 | 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 MockPermissionPopup | 49 | // 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 | return | 52 | return | ||
| 44 | } | 53 | } | ||
| 45 | 54 | ||||
| @@ -52,12 +61,13 @@ type MockPermissionPopup_Show_Call struct { | |||||
| 52 | // - ctx context.Context | 61 | // - ctx context.Context | ||
| 53 | // - heading string | 62 | // - heading string | ||
| 54 | // - body string | 63 | // - 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.Context | 72 | 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 _c | 100 | return _c | ||
| @@ -90,7 +105,7 @@ func (_c *MockPermissionPopup_Show_Call) Return() *MockPermissionPopup_Show_Call | |||||
| 90 | return _c | 105 | 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 _c | 110 | return _c | ||
| 96 | } | 111 | } | ||
M internal/ui/dialog/permission.go +40 -4
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 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 | 20 | type permissionDialogRequest struct { | |
| @@ -109,7 +109,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 109 | 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 | 113 | if isDataAccess { | |
| 114 | 114 | log.Info(). | |
| 115 | 115 | Str("origin", origin). | |
| @@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() { | |||
| 150 | 150 | ||
| 151 | 151 | // permFlags holds parsed permission type flags. | |
| 152 | 152 | type permFlags struct { | |
| 153 | - | mic, cam, display, dataAccess bool | |
| 153 | + | mic, cam, display, dataAccess, notification, geolocation bool | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | 156 | // parsePermFlags extracts boolean flags from permission types. | |
| @@ -166,11 +166,26 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags { | |||
| 166 | 166 | f.display = true | |
| 167 | 167 | case entity.PermissionTypeWebsiteDataAccess: | |
| 168 | 168 | f.dataAccess = true | |
| 169 | + | case entity.PermissionTypeNotification: | |
| 170 | + | f.notification = true | |
| 171 | + | case entity.PermissionTypeGeolocation: | |
| 172 | + | f.geolocation = true | |
| 169 | 173 | } | |
| 170 | 174 | } | |
| 171 | 175 | 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 | 189 | // joinPermissionLabels joins labels with commas and "and". | |
| 175 | 190 | func joinPermissionLabels(labels []string) string { | |
| 176 | 191 | switch len(labels) { | |
| @@ -204,6 +219,12 @@ func (d *PermissionDialog) buildHeading( | |||
| 204 | 219 | if f.dataAccess { | |
| 205 | 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 | 228 | switch { | |
| 208 | 229 | case len(labels) == 0: | |
| 209 | 230 | return "Allow Permission?" | |
| @@ -211,6 +232,8 @@ func (d *PermissionDialog) buildHeading( | |||
| 211 | 232 | return "Allow Third-Party Data Access?" | |
| 212 | 233 | case len(labels) == 1 && f.display: | |
| 213 | 234 | return "Allow Screen Sharing?" | |
| 235 | + | case len(labels) == 1 && f.notification: | |
| 236 | + | return "Allow Notifications?" | |
| 214 | 237 | case len(labels) == 1: | |
| 215 | 238 | return "Allow " + labels[0] + " Access?" | |
| 216 | 239 | default: | |
| @@ -232,7 +255,20 @@ func (d *PermissionDialog) buildBody( | |||
| 232 | 255 | parts = append(parts, "access your camera") | |
| 233 | 256 | } | |
| 234 | 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 | 273 | if f.dataAccess { | |
| 238 | 274 | 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 bool | 153 | + | 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 = true | 166 | f.display = true | ||
| 167 | case entity.PermissionTypeWebsiteDataAccess: | 167 | case entity.PermissionTypeWebsiteDataAccess: | ||
| 168 | f.dataAccess = true | 168 | 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 f | 175 | 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) { | |||
| 21 | 21 | var showCalls []permissionPopupShowCall | |
| 22 | 22 | var callback func(allowed, persistent bool) | |
| 23 | 23 | 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)) { | |
| 26 | 26 | showCalls = append(showCalls, permissionPopupShowCall{ | |
| 27 | 27 | heading: heading, | |
| 28 | 28 | body: body, | |
| @@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||
| 142 | 142 | expectHeading: "Allow Microphone, Camera, and Screen Sharing?", | |
| 143 | 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 | 158 | name: "website data access only", | |
| 147 | 159 | permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess}, | |
| @@ -167,3 +179,79 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||
| 167 | 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 | + | } | |
| @@ -21,8 +21,8 @@ func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) { | |||||
| 21 | var showCalls []permissionPopupShowCall | 21 | 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 | + | } | |||