fix(cef): require user approval for unmediated screen capture
CEF grants the full screen on Cont() for desktop video requests without any picker or portal, so display auto-allow must not apply there. Add the unmediated_capture permission metadata flag, set by the CEF media access handler for desktop video; the use case and the coordinator's no-use-case fallback no longer auto-allow display when it is set, so the request goes to the dialog (or is denied). Display remains non-persistable. The dialog body says the entire screen will be shared.
- commit
- 45bd51f070ee5086f4d72b367c8144e41899246d
- author
- brice · · 2026-10-03 06:57 UTC
- committer
- brice · · 2026-10-03 07:50 UTC
- parent
- 75e8c77
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +243 -14
M internal/application/usecase/handle_permission.go +9 -7
| @@ -133,20 +133,20 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 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) { | |
| @@ -212,9 +212,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState( | |||
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | // isAutoAllowSet returns true if all permission types in the set are auto-allow. | |
| 215 | - | func isAutoAllowSet(permTypes []entity.PermissionType) bool { | |
| 215 | + | func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool { | |
| 216 | 216 | for _, pt := range permTypes { | |
| 217 | - | if !entity.IsAutoAllow(pt) { | |
| 217 | + | if !entity.IsAutoAllowFor(pt, metadata) { | |
| 218 | 218 | return false | |
| 219 | 219 | } | |
| 220 | 220 | } | |
| @@ -227,6 +227,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 227 | 227 | ctx context.Context, | |
| 228 | 228 | origin string, | |
| 229 | 229 | permTypes []entity.PermissionType, | |
| 230 | + | metadata entity.PermissionMetadata, | |
| 230 | 231 | ) entity.PermissionDecision { | |
| 231 | 232 | log := uc.logger(ctx) | |
| 232 | 233 | ||
| @@ -234,7 +235,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 234 | 235 | ||
| 235 | 236 | for _, permType := range permTypes { | |
| 236 | 237 | // Skip auto-allow types - they're handled before this function | |
| 237 | - | if entity.IsAutoAllow(permType) { | |
| 238 | + | if entity.IsAutoAllowFor(permType, metadata) { | |
| 238 | 239 | continue | |
| 239 | 240 | } | |
| 240 | 241 | ||
| @@ -414,9 +415,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |||
| 414 | 415 | ctx context.Context, | |
| 415 | 416 | origin string, | |
| 416 | 417 | permTypes []entity.PermissionType, | |
| 418 | + | metadata entity.PermissionMetadata, | |
| 417 | 419 | ) bool { | |
| 418 | 420 | for _, permType := range permTypes { | |
| 419 | - | if !entity.IsAutoAllow(permType) { | |
| 421 | + | if !entity.IsAutoAllowFor(permType, metadata) { | |
| 420 | 422 | continue | |
| 421 | 423 | } | |
| 422 | 424 | ||
| @@ -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) { | ||
| @@ -212,9 +212,9 @@ func (uc *HandlePermissionUseCase) QueryPermissionState( | |||||
| 212 | } | 212 | } | ||
| 213 | 213 | ||||
| 214 | // isAutoAllowSet returns true if all permission types in the set are auto-allow. | 214 | // isAutoAllowSet returns true if all permission types in the set are auto-allow. | ||
| 215 | - | func isAutoAllowSet(permTypes []entity.PermissionType) bool { | 215 | + | func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool { |
| 216 | for _, pt := range permTypes { | 216 | for _, pt := range permTypes { | ||
| 217 | - | if !entity.IsAutoAllow(pt) { | 217 | + | if !entity.IsAutoAllowFor(pt, metadata) { |
| 218 | return false | 218 | return false | ||
| 219 | } | 219 | } | ||
| 220 | } | 220 | } | ||
| @@ -227,6 +227,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||||
| 227 | ctx context.Context, | 227 | ctx context.Context, | ||
| 228 | origin string, | 228 | origin string, | ||
| 229 | permTypes []entity.PermissionType, | 229 | permTypes []entity.PermissionType, | ||
| 230 | + | metadata entity.PermissionMetadata, | |||
| 230 | ) entity.PermissionDecision { | 231 | ) entity.PermissionDecision { | ||
| 231 | log := uc.logger(ctx) | 232 | log := uc.logger(ctx) | ||
| 232 | 233 | ||||
| @@ -234,7 +235,7 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||||
| 234 | 235 | ||||
| 235 | for _, permType := range permTypes { | 236 | for _, permType := range permTypes { | ||
| 236 | // Skip auto-allow types - they're handled before this function | 237 | // Skip auto-allow types - they're handled before this function | ||
| 237 | - | if entity.IsAutoAllow(permType) { | 238 | + | if entity.IsAutoAllowFor(permType, metadata) { |
| 238 | continue | 239 | continue | ||
| 239 | } | 240 | } | ||
| 240 | 241 | ||||
| @@ -414,9 +415,10 @@ func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |||||
| 414 | ctx context.Context, | 415 | ctx context.Context, | ||
| 415 | origin string, | 416 | origin string, | ||
| 416 | permTypes []entity.PermissionType, | 417 | permTypes []entity.PermissionType, | ||
| 418 | + | metadata entity.PermissionMetadata, | |||
| 417 | ) bool { | 419 | ) bool { | ||
| 418 | for _, permType := range permTypes { | 420 | for _, permType := range permTypes { | ||
| 419 | - | if !entity.IsAutoAllow(permType) { | 421 | + | if !entity.IsAutoAllowFor(permType, metadata) { |
| 420 | continue | 422 | continue | ||
| 421 | } | 423 | } | ||
| 422 | 424 | ||||
M internal/application/usecase/handle_permission_test.go +105 -0
| @@ -610,3 +610,108 @@ func TestHandlePermissionUseCase_WebsiteDataAccess_UsesStoredGrant(t *testing.T) | |||
| 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 | + | for _, decision := range []entity.PermissionDecision{entity.PermissionGranted, entity.PermissionDenied} { | |
| 659 | + | t.Run(string(decision), func(t *testing.T) { | |
| 660 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 661 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 662 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| 663 | + | ||
| 664 | + | // A stale stored record must neither auto-allow nor auto-deny. | |
| 665 | + | permRepo.EXPECT().Get(mock.Anything, mock.Anything, entity.PermissionTypeDisplay). | |
| 666 | + | Return(&entity.PermissionRecord{Type: entity.PermissionTypeDisplay, Decision: decision}, nil).Maybe() | |
| 667 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", | |
| 668 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything). | |
| 669 | + | Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) { | |
| 670 | + | cb(port.PermissionDialogResult{Allowed: false}) | |
| 671 | + | }).Once() | |
| 672 | + | ||
| 673 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", | |
| 674 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata, | |
| 675 | + | usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}}) | |
| 676 | + | }) | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) { | |
| 681 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 682 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 683 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| 684 | + | ||
| 685 | + | types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay} | |
| 686 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil) | |
| 687 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything). | |
| 688 | + | Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) { | |
| 689 | + | cb(port.PermissionDialogResult{Allowed: true, Persistent: true}) | |
| 690 | + | }) | |
| 691 | + | permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool { | |
| 692 | + | return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted | |
| 693 | + | })).Return(nil).Once() | |
| 694 | + | ||
| 695 | + | allowed := false | |
| 696 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata, | |
| 697 | + | usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}}) | |
| 698 | + | ||
| 699 | + | assert.True(t, allowed) | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) { | |
| 703 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 704 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 705 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| 706 | + | ||
| 707 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil) | |
| 708 | + | ||
| 709 | + | allowed := false | |
| 710 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", | |
| 711 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{}, | |
| 712 | + | usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}}) | |
| 713 | + | ||
| 714 | + | assert.True(t, allowed) | |
| 715 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |
| 716 | + | permRepo.AssertNotCalled(t, "Set") | |
| 717 | + | } | |
| @@ -610,3 +610,108 @@ func TestHandlePermissionUseCase_WebsiteDataAccess_UsesStoredGrant(t *testing.T) | |||||
| 610 | assert.True(t, allowed, "should use stored granted permission without dialog") | 610 | assert.True(t, allowed, "should use stored granted permission without dialog") | ||
| 611 | dialog.AssertNotCalled(t, "ShowPermissionDialog") | 611 | dialog.AssertNotCalled(t, "ShowPermissionDialog") | ||
| 612 | } | 612 | } | ||
| 613 | + | ||||
| 614 | + | var unmediatedDisplayMetadata = entity.PermissionMetadata{ | |||
| 615 | + | entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue, | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | func TestHandlePermissionUseCase_UnmediatedDisplayShowsDialog(t *testing.T) { | |||
| 619 | + | tests := []struct { | |||
| 620 | + | name string | |||
| 621 | + | result port.PermissionDialogResult | |||
| 622 | + | wantAllow bool | |||
| 623 | + | wantDenied bool | |||
| 624 | + | }{ | |||
| 625 | + | {"allow", port.PermissionDialogResult{Allowed: true}, true, false}, | |||
| 626 | + | {"always allow is never persisted", port.PermissionDialogResult{Allowed: true, Persistent: true}, true, false}, | |||
| 627 | + | {"deny", port.PermissionDialogResult{Allowed: false}, false, true}, | |||
| 628 | + | {"always deny is never persisted", port.PermissionDialogResult{Allowed: false, Persistent: true}, false, true}, | |||
| 629 | + | } | |||
| 630 | + | for _, tt := range tests { | |||
| 631 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 632 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |||
| 633 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 634 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 635 | + | ||||
| 636 | + | var gotMeta entity.PermissionMetadata | |||
| 637 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", | |||
| 638 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything). | |||
| 639 | + | Run(func(_ context.Context, _ string, _ []entity.PermissionType, meta entity.PermissionMetadata, cb func(port.PermissionDialogResult)) { | |||
| 640 | + | gotMeta = meta | |||
| 641 | + | cb(tt.result) | |||
| 642 | + | }) | |||
| 643 | + | ||||
| 644 | + | allowed, denied := false, false | |||
| 645 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", | |||
| 646 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata, | |||
| 647 | + | usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() { denied = true }}) | |||
| 648 | + | ||||
| 649 | + | assert.Equal(t, tt.wantAllow, allowed) | |||
| 650 | + | assert.Equal(t, tt.wantDenied, denied) | |||
| 651 | + | assert.True(t, gotMeta.IsUnmediatedCapture(), "metadata must reach the dialog") | |||
| 652 | + | permRepo.AssertNotCalled(t, "Set") | |||
| 653 | + | }) | |||
| 654 | + | } | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | func TestHandlePermissionUseCase_UnmediatedDisplayIgnoresStoredDecision(t *testing.T) { | |||
| 658 | + | for _, decision := range []entity.PermissionDecision{entity.PermissionGranted, entity.PermissionDenied} { | |||
| 659 | + | t.Run(string(decision), func(t *testing.T) { | |||
| 660 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |||
| 661 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 662 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 663 | + | ||||
| 664 | + | // A stale stored record must neither auto-allow nor auto-deny. | |||
| 665 | + | permRepo.EXPECT().Get(mock.Anything, mock.Anything, entity.PermissionTypeDisplay). | |||
| 666 | + | Return(&entity.PermissionRecord{Type: entity.PermissionTypeDisplay, Decision: decision}, nil).Maybe() | |||
| 667 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", | |||
| 668 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, mock.Anything, mock.Anything). | |||
| 669 | + | Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) { | |||
| 670 | + | cb(port.PermissionDialogResult{Allowed: false}) | |||
| 671 | + | }).Once() | |||
| 672 | + | ||||
| 673 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", | |||
| 674 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, unmediatedDisplayMetadata, | |||
| 675 | + | usecase.PermissionCallback{Allow: func() { t.Fatal("must not auto-allow") }, Deny: func() {}}) | |||
| 676 | + | }) | |||
| 677 | + | } | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | func TestHandlePermissionUseCase_UnmediatedDisplayWithMicrophoneDoesNotPersistDisplay(t *testing.T) { | |||
| 681 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |||
| 682 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 683 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 684 | + | ||||
| 685 | + | types := []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay} | |||
| 686 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).Return(nil, nil) | |||
| 687 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", types, mock.Anything, mock.Anything). | |||
| 688 | + | Run(func(_ context.Context, _ string, _ []entity.PermissionType, _ entity.PermissionMetadata, cb func(port.PermissionDialogResult)) { | |||
| 689 | + | cb(port.PermissionDialogResult{Allowed: true, Persistent: true}) | |||
| 690 | + | }) | |||
| 691 | + | permRepo.EXPECT().Set(mock.Anything, mock.MatchedBy(func(r *entity.PermissionRecord) bool { | |||
| 692 | + | return r.Type == entity.PermissionTypeMicrophone && r.Decision == entity.PermissionGranted | |||
| 693 | + | })).Return(nil).Once() | |||
| 694 | + | ||||
| 695 | + | allowed := false | |||
| 696 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", types, unmediatedDisplayMetadata, | |||
| 697 | + | usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}}) | |||
| 698 | + | ||||
| 699 | + | assert.True(t, allowed) | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | func TestHandlePermissionUseCase_DisplayWithoutFlagStillAutoAllowed(t *testing.T) { | |||
| 703 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |||
| 704 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 705 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 706 | + | ||||
| 707 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).Return(nil, nil) | |||
| 708 | + | ||||
| 709 | + | allowed := false | |||
| 710 | + | uc.HandlePermissionRequest(testContext(), "https://meet.example.com", | |||
| 711 | + | []entity.PermissionType{entity.PermissionTypeDisplay}, entity.PermissionMetadata{}, | |||
| 712 | + | usecase.PermissionCallback{Allow: func() { allowed = true }, Deny: func() {}}) | |||
| 713 | + | ||||
| 714 | + | assert.True(t, allowed) | |||
| 715 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 716 | + | permRepo.AssertNotCalled(t, "Set") | |||
| 717 | + | } | |||
M internal/domain/entity/permission.go +26 -0
| @@ -47,6 +47,14 @@ const ( | |||
| 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 | + | // PermissionMetadataValueTrue is the metadata value for boolean flags. | |
| 57 | + | PermissionMetadataValueTrue = "true" | |
| 50 | 58 | ) | |
| 51 | 59 | ||
| 52 | 60 | // PermissionMetadata carries extra context for specific permission types. | |
| @@ -107,7 +115,25 @@ func CanPersist(permType PermissionType) bool { | |||
| 107 | 115 | } | |
| 108 | 116 | } | |
| 109 | 117 | ||
| 118 | + | // IsUnmediatedCapture reports whether the metadata flags a display capture that no | |
| 119 | + | // system picker/portal mediates. | |
| 120 | + | func (m PermissionMetadata) IsUnmediatedCapture() bool { | |
| 121 | + | return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an | |
| 125 | + | // unmediated display capture is never auto-allowed. | |
| 126 | + | func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool { | |
| 127 | + | if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | return IsAutoAllow(permType) | |
| 131 | + | } | |
| 132 | + | ||
| 110 | 133 | // IsAutoAllow returns true if this permission type should be auto-allowed. | |
| 134 | + | // Exception: display capture is only auto-allowed when a system picker/portal | |
| 135 | + | // mediates it (WebKit); use IsAutoAllowFor for requests that may carry | |
| 136 | + | // PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user. | |
| 111 | 137 | func IsAutoAllow(permType PermissionType) bool { | |
| 112 | 138 | switch permType { | |
| 113 | 139 | case PermissionTypeDisplay: | |
| @@ -47,6 +47,14 @@ 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 | + | // PermissionMetadataValueTrue is the metadata value for boolean flags. | |||
| 57 | + | PermissionMetadataValueTrue = "true" | |||
| 50 | ) | 58 | ) | ||
| 51 | 59 | ||||
| 52 | // PermissionMetadata carries extra context for specific permission types. | 60 | // PermissionMetadata carries extra context for specific permission types. | ||
| @@ -107,7 +115,25 @@ func CanPersist(permType PermissionType) bool { | |||||
| 107 | } | 115 | } | ||
| 108 | } | 116 | } | ||
| 109 | 117 | ||||
| 118 | + | // IsUnmediatedCapture reports whether the metadata flags a display capture that no | |||
| 119 | + | // system picker/portal mediates. | |||
| 120 | + | func (m PermissionMetadata) IsUnmediatedCapture() bool { | |||
| 121 | + | return m[PermissionMetadataKeyUnmediatedCapture] == PermissionMetadataValueTrue | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // IsAutoAllowFor is IsAutoAllow with request metadata taken into account: an | |||
| 125 | + | // unmediated display capture is never auto-allowed. | |||
| 126 | + | func IsAutoAllowFor(permType PermissionType, metadata PermissionMetadata) bool { | |||
| 127 | + | if permType == PermissionTypeDisplay && metadata.IsUnmediatedCapture() { | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | return IsAutoAllow(permType) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 110 | // IsAutoAllow returns true if this permission type should be auto-allowed. | 133 | // IsAutoAllow returns true if this permission type should be auto-allowed. | ||
| 134 | + | // Exception: display capture is only auto-allowed when a system picker/portal | |||
| 135 | + | // mediates it (WebKit); use IsAutoAllowFor for requests that may carry | |||
| 136 | + | // PermissionMetadataKeyUnmediatedCapture (CEF), which must prompt the user. | |||
| 111 | func IsAutoAllow(permType PermissionType) bool { | 137 | func IsAutoAllow(permType PermissionType) bool { | ||
| 112 | switch permType { | 138 | switch permType { | ||
| 113 | case PermissionTypeDisplay: | 139 | case PermissionTypeDisplay: | ||
M internal/infrastructure/cef/permission_handler.go +17 -3
| @@ -51,6 +51,17 @@ func mediaPermissionTypes(requested uint32) []string { | |||
| 51 | 51 | return types | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | // mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants | |
| 55 | + | // the full screen on Cont() without any picker or portal, so Dumber must ask the | |
| 56 | + | // user explicitly. | |
| 57 | + | func mediaPermissionMetadata(requested uint32) map[string]string { | |
| 58 | + | metadata := map[string]string{} | |
| 59 | + | if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 { | |
| 60 | + | metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue | |
| 61 | + | } | |
| 62 | + | return metadata | |
| 63 | + | } | |
| 64 | + | ||
| 54 | 65 | // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber | |
| 55 | 66 | // permission types. It returns nil when the mask is empty or contains any bit | |
| 56 | 67 | // Dumber does not support. | |
| @@ -186,6 +197,7 @@ func (h *handlerSet) OnRequestMediaAccessPermission( | |||
| 186 | 197 | h.beginPermissionRequest( | |
| 187 | 198 | h.permissions.nextMediaKey(), browser, requestingOrigin, | |
| 188 | 199 | mediaPermissionTypes(requestedPermissions), | |
| 200 | + | mediaPermissionMetadata(requestedPermissions), | |
| 189 | 201 | func(allow bool) { | |
| 190 | 202 | if allow { | |
| 191 | 203 | callback.Cont(requestedPermissions) | |
| @@ -213,7 +225,7 @@ func (h *handlerSet) OnShowPermissionPrompt( | |||
| 213 | 225 | if len(types) == 0 { | |
| 214 | 226 | return 0 | |
| 215 | 227 | } | |
| 216 | - | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, func(allow bool) { | |
| 228 | + | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) { | |
| 217 | 229 | if allow { | |
| 218 | 230 | callback.Cont(purecef.PermissionRequestResultPermissionResultAccept) | |
| 219 | 231 | return | |
| @@ -269,6 +281,7 @@ func (h *handlerSet) beginPermissionRequest( | |||
| 269 | 281 | browser purecef.Browser, | |
| 270 | 282 | requestingOrigin string, | |
| 271 | 283 | types []string, | |
| 284 | + | metadata map[string]string, | |
| 272 | 285 | finish func(allow bool), | |
| 273 | 286 | ) { | |
| 274 | 287 | wv := h.wv | |
| @@ -303,7 +316,7 @@ func (h *handlerSet) beginPermissionRequest( | |||
| 303 | 316 | return | |
| 304 | 317 | } | |
| 305 | 318 | ||
| 306 | - | wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types) }) | |
| 319 | + | wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) }) | |
| 307 | 320 | } | |
| 308 | 321 | ||
| 309 | 322 | // deliverPermissionRequest runs on the GTK thread. It skips requests that were | |
| @@ -314,6 +327,7 @@ func (h *handlerSet) deliverPermissionRequest( | |||
| 314 | 327 | cb *port.WebViewCallbacks, | |
| 315 | 328 | origin string, | |
| 316 | 329 | types []string, | |
| 330 | + | metadata map[string]string, | |
| 317 | 331 | ) { | |
| 318 | 332 | if req.isDone() { | |
| 319 | 333 | return | |
| @@ -324,7 +338,7 @@ func (h *handlerSet) deliverPermissionRequest( | |||
| 324 | 338 | } | |
| 325 | 339 | allow := func() { req.resolve(true) } | |
| 326 | 340 | deny := func() { req.resolve(false) } | |
| 327 | - | if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) { | |
| 341 | + | if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) { | |
| 328 | 342 | req.resolve(false) | |
| 329 | 343 | } | |
| 330 | 344 | } | |
| @@ -51,6 +51,17 @@ func mediaPermissionTypes(requested uint32) []string { | |||||
| 51 | return types | 51 | return types | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | // mediaPermissionMetadata flags desktop video capture as unmediated: CEF grants | |||
| 55 | + | // the full screen on Cont() without any picker or portal, so Dumber must ask the | |||
| 56 | + | // user explicitly. | |||
| 57 | + | func mediaPermissionMetadata(requested uint32) map[string]string { | |||
| 58 | + | metadata := map[string]string{} | |||
| 59 | + | if requested&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) != 0 { | |||
| 60 | + | metadata[entity.PermissionMetadataKeyUnmediatedCapture] = entity.PermissionMetadataValueTrue | |||
| 61 | + | } | |||
| 62 | + | return metadata | |||
| 63 | + | } | |||
| 64 | + | ||||
| 54 | // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber | 65 | // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber | ||
| 55 | // permission types. It returns nil when the mask is empty or contains any bit | 66 | // permission types. It returns nil when the mask is empty or contains any bit | ||
| 56 | // Dumber does not support. | 67 | // Dumber does not support. | ||
| @@ -186,6 +197,7 @@ func (h *handlerSet) OnRequestMediaAccessPermission( | |||||
| 186 | h.beginPermissionRequest( | 197 | h.beginPermissionRequest( | ||
| 187 | h.permissions.nextMediaKey(), browser, requestingOrigin, | 198 | h.permissions.nextMediaKey(), browser, requestingOrigin, | ||
| 188 | mediaPermissionTypes(requestedPermissions), | 199 | mediaPermissionTypes(requestedPermissions), | ||
| 200 | + | mediaPermissionMetadata(requestedPermissions), | |||
| 189 | func(allow bool) { | 201 | func(allow bool) { | ||
| 190 | if allow { | 202 | if allow { | ||
| 191 | callback.Cont(requestedPermissions) | 203 | callback.Cont(requestedPermissions) | ||
| @@ -213,7 +225,7 @@ func (h *handlerSet) OnShowPermissionPrompt( | |||||
| 213 | if len(types) == 0 { | 225 | if len(types) == 0 { | ||
| 214 | return 0 | 226 | return 0 | ||
| 215 | } | 227 | } | ||
| 216 | - | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, func(allow bool) { | 228 | + | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, browser, requestingOrigin, types, nil, func(allow bool) { |
| 217 | if allow { | 229 | if allow { | ||
| 218 | callback.Cont(purecef.PermissionRequestResultPermissionResultAccept) | 230 | callback.Cont(purecef.PermissionRequestResultPermissionResultAccept) | ||
| 219 | return | 231 | return | ||
| @@ -269,6 +281,7 @@ func (h *handlerSet) beginPermissionRequest( | |||||
| 269 | browser purecef.Browser, | 281 | browser purecef.Browser, | ||
| 270 | requestingOrigin string, | 282 | requestingOrigin string, | ||
| 271 | types []string, | 283 | types []string, | ||
| 284 | + | metadata map[string]string, | |||
| 272 | finish func(allow bool), | 285 | finish func(allow bool), | ||
| 273 | ) { | 286 | ) { | ||
| 274 | wv := h.wv | 287 | wv := h.wv | ||
| @@ -303,7 +316,7 @@ func (h *handlerSet) beginPermissionRequest( | |||||
| 303 | return | 316 | return | ||
| 304 | } | 317 | } | ||
| 305 | 318 | ||||
| 306 | - | wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types) }) | 319 | + | wv.runOnGTK(func() { h.deliverPermissionRequest(req, cb, origin, types, metadata) }) |
| 307 | } | 320 | } | ||
| 308 | 321 | ||||
| 309 | // deliverPermissionRequest runs on the GTK thread. It skips requests that were | 322 | // deliverPermissionRequest runs on the GTK thread. It skips requests that were | ||
| @@ -314,6 +327,7 @@ func (h *handlerSet) deliverPermissionRequest( | |||||
| 314 | cb *port.WebViewCallbacks, | 327 | cb *port.WebViewCallbacks, | ||
| 315 | origin string, | 328 | origin string, | ||
| 316 | types []string, | 329 | types []string, | ||
| 330 | + | metadata map[string]string, | |||
| 317 | ) { | 331 | ) { | ||
| 318 | if req.isDone() { | 332 | if req.isDone() { | ||
| 319 | return | 333 | return | ||
| @@ -324,7 +338,7 @@ func (h *handlerSet) deliverPermissionRequest( | |||||
| 324 | } | 338 | } | ||
| 325 | allow := func() { req.resolve(true) } | 339 | allow := func() { req.resolve(true) } | ||
| 326 | deny := func() { req.resolve(false) } | 340 | deny := func() { req.resolve(false) } | ||
| 327 | - | if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) { | 341 | + | if !cb.OnPermissionRequest(origin, types, metadata, allow, deny) { |
| 328 | req.resolve(false) | 342 | req.resolve(false) | ||
| 329 | } | 343 | } | ||
| 330 | } | 344 | } | ||
M internal/infrastructure/cef/permission_handler_test.go +32 -2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | type stubMediaAccessCallback struct { | |
| @@ -33,6 +34,7 @@ type permissionCapture struct { | |||
| 33 | 34 | types []string | |
| 34 | 35 | allow func() | |
| 35 | 36 | deny func() | |
| 37 | + | meta map[string]string | |
| 36 | 38 | calls int | |
| 37 | 39 | } | |
| 38 | 40 | ||
| @@ -276,7 +278,7 @@ func TestDeliverPermissionRequest(t *testing.T) { | |||
| 276 | 278 | req := newReq(&results) | |
| 277 | 279 | req.resolve(false) | |
| 278 | 280 | handlerCalls = 0 | |
| 279 | - | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}) | |
| 281 | + | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil) | |
| 280 | 282 | assert.Zero(t, handlerCalls) | |
| 281 | 283 | assert.Equal(t, []bool{false}, results) | |
| 282 | 284 | }) | |
| @@ -286,7 +288,7 @@ func TestDeliverPermissionRequest(t *testing.T) { | |||
| 286 | 288 | var results []bool | |
| 287 | 289 | req := newReq(&results) | |
| 288 | 290 | handlerCalls = 0 | |
| 289 | - | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}) | |
| 291 | + | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil) | |
| 290 | 292 | assert.Zero(t, handlerCalls) | |
| 291 | 293 | assert.Equal(t, []bool{false}, results) | |
| 292 | 294 | }) | |
| @@ -413,3 +415,31 @@ func TestDispatchPermissionResultPostsToUIThread(t *testing.T) { | |||
| 413 | 415 | posted.Execute() | |
| 414 | 416 | assert.Equal(t, 1, ran) | |
| 415 | 417 | } | |
| 418 | + | ||
| 419 | + | func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) { | |
| 420 | + | tests := []struct { | |
| 421 | + | name string | |
| 422 | + | requested uint32 | |
| 423 | + | flagged bool | |
| 424 | + | }{ | |
| 425 | + | {"desktop video", mediaDeskV, true}, | |
| 426 | + | {"desktop audio and video", mediaDeskA | mediaDeskV, true}, | |
| 427 | + | {"camera and desktop video", mediaCamera | mediaDeskV, true}, | |
| 428 | + | {"camera only", mediaCamera, false}, | |
| 429 | + | {"microphone and camera", mediaMic | mediaCamera, false}, | |
| 430 | + | } | |
| 431 | + | for _, tt := range tests { | |
| 432 | + | t.Run(tt.name, func(t *testing.T) { | |
| 433 | + | capture := &permissionCapture{} | |
| 434 | + | h, _, browser := newPermissionTestHandler(t, true) | |
| 435 | + | h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool { | |
| 436 | + | capture.types, capture.meta = types, meta | |
| 437 | + | return true | |
| 438 | + | } | |
| 439 | + | cb := &stubMediaAccessCallback{} | |
| 440 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb)) | |
| 441 | + | require.NotNil(t, capture.types) | |
| 442 | + | assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture()) | |
| 443 | + | }) | |
| 444 | + | } | |
| 445 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | type stubMediaAccessCallback struct { | 16 | type stubMediaAccessCallback struct { | ||
| @@ -33,6 +34,7 @@ type permissionCapture struct { | |||||
| 33 | types []string | 34 | types []string | ||
| 34 | allow func() | 35 | allow func() | ||
| 35 | deny func() | 36 | deny func() | ||
| 37 | + | meta map[string]string | |||
| 36 | calls int | 38 | calls int | ||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| @@ -276,7 +278,7 @@ func TestDeliverPermissionRequest(t *testing.T) { | |||||
| 276 | req := newReq(&results) | 278 | req := newReq(&results) | ||
| 277 | req.resolve(false) | 279 | req.resolve(false) | ||
| 278 | handlerCalls = 0 | 280 | handlerCalls = 0 | ||
| 279 | - | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}) | 281 | + | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil) |
| 280 | assert.Zero(t, handlerCalls) | 282 | assert.Zero(t, handlerCalls) | ||
| 281 | assert.Equal(t, []bool{false}, results) | 283 | assert.Equal(t, []bool{false}, results) | ||
| 282 | }) | 284 | }) | ||
| @@ -286,7 +288,7 @@ func TestDeliverPermissionRequest(t *testing.T) { | |||||
| 286 | var results []bool | 288 | var results []bool | ||
| 287 | req := newReq(&results) | 289 | req := newReq(&results) | ||
| 288 | handlerCalls = 0 | 290 | handlerCalls = 0 | ||
| 289 | - | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}) | 291 | + | h.deliverPermissionRequest(req, cb, "https://a.example", []string{"camera"}, nil) |
| 290 | assert.Zero(t, handlerCalls) | 292 | assert.Zero(t, handlerCalls) | ||
| 291 | assert.Equal(t, []bool{false}, results) | 293 | assert.Equal(t, []bool{false}, results) | ||
| 292 | }) | 294 | }) | ||
| @@ -413,3 +415,31 @@ func TestDispatchPermissionResultPostsToUIThread(t *testing.T) { | |||||
| 413 | posted.Execute() | 415 | posted.Execute() | ||
| 414 | assert.Equal(t, 1, ran) | 416 | assert.Equal(t, 1, ran) | ||
| 415 | } | 417 | } | ||
| 418 | + | ||||
| 419 | + | func TestOnRequestMediaAccessPermissionFlagsDesktopVideoAsUnmediated(t *testing.T) { | |||
| 420 | + | tests := []struct { | |||
| 421 | + | name string | |||
| 422 | + | requested uint32 | |||
| 423 | + | flagged bool | |||
| 424 | + | }{ | |||
| 425 | + | {"desktop video", mediaDeskV, true}, | |||
| 426 | + | {"desktop audio and video", mediaDeskA | mediaDeskV, true}, | |||
| 427 | + | {"camera and desktop video", mediaCamera | mediaDeskV, true}, | |||
| 428 | + | {"camera only", mediaCamera, false}, | |||
| 429 | + | {"microphone and camera", mediaMic | mediaCamera, false}, | |||
| 430 | + | } | |||
| 431 | + | for _, tt := range tests { | |||
| 432 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 433 | + | capture := &permissionCapture{} | |||
| 434 | + | h, _, browser := newPermissionTestHandler(t, true) | |||
| 435 | + | h.wv.callbacks.OnPermissionRequest = func(_ string, types []string, meta map[string]string, _, _ func()) bool { | |||
| 436 | + | capture.types, capture.meta = types, meta | |||
| 437 | + | return true | |||
| 438 | + | } | |||
| 439 | + | cb := &stubMediaAccessCallback{} | |||
| 440 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(browser, nil, "https://meet.example.com", tt.requested, cb)) | |||
| 441 | + | require.NotNil(t, capture.types) | |||
| 442 | + | assert.Equal(t, tt.flagged, entity.PermissionMetadata(capture.meta).IsUnmediatedCapture()) | |||
| 443 | + | }) | |||
| 444 | + | } | |||
| 445 | + | } | |||
M internal/ui/coordinator/content/callbacks.go +1 -1
| @@ -236,7 +236,7 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 236 | 236 | // Auto-allow display and device_info, deny others | |
| 237 | 237 | allAutoAllow := true | |
| 238 | 238 | for _, pt := range entityTypes { | |
| 239 | - | if !entity.IsAutoAllow(pt) { | |
| 239 | + | if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) { | |
| 240 | 240 | allAutoAllow = false | |
| 241 | 241 | break | |
| 242 | 242 | } | |
| @@ -236,7 +236,7 @@ func (c *Coordinator) handlePermissionRequest( | |||||
| 236 | // Auto-allow display and device_info, deny others | 236 | // Auto-allow display and device_info, deny others | ||
| 237 | allAutoAllow := true | 237 | allAutoAllow := true | ||
| 238 | for _, pt := range entityTypes { | 238 | for _, pt := range entityTypes { | ||
| 239 | - | if !entity.IsAutoAllow(pt) { | 239 | + | if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) { |
| 240 | allAutoAllow = false | 240 | allAutoAllow = false | ||
| 241 | break | 241 | break | ||
| 242 | } | 242 | } | ||
M internal/ui/coordinator/content/permission_prompt_test.go +32 -0
| @@ -58,3 +58,35 @@ func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t * | |||
| 58 | 58 | assert.Zero(t, denied) | |
| 59 | 59 | assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator") | |
| 60 | 60 | } | |
| 61 | + | ||
| 62 | + | func TestHandlePermissionRequest_NoUseCaseFallbackDeniesUnmediatedDisplay(t *testing.T) { | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | metadata map[string]string | |
| 66 | + | wantAllow bool | |
| 67 | + | }{ | |
| 68 | + | {"mediated display is auto-allowed", nil, true}, | |
| 69 | + | { | |
| 70 | + | "unmediated display is denied", | |
| 71 | + | map[string]string{entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue}, | |
| 72 | + | false, | |
| 73 | + | }, | |
| 74 | + | } | |
| 75 | + | for _, tt := range tests { | |
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |
| 77 | + | c := &Coordinator{} | |
| 78 | + | allowed, denied := 0, 0 | |
| 79 | + | handled := c.handlePermissionRequest(context.Background(), "pane-1", "https://meet.example.com", | |
| 80 | + | []string{"display"}, tt.metadata, func() { allowed++ }, func() { denied++ }) | |
| 81 | + | ||
| 82 | + | require.True(t, handled) | |
| 83 | + | if tt.wantAllow { | |
| 84 | + | assert.Equal(t, 1, allowed) | |
| 85 | + | assert.Zero(t, denied) | |
| 86 | + | } else { | |
| 87 | + | assert.Zero(t, allowed) | |
| 88 | + | assert.Equal(t, 1, denied) | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| @@ -58,3 +58,35 @@ func TestHandlePermissionRequest_BindsDialogBeforeUseCaseForNonWebRTCRequest(t * | |||||
| 58 | assert.Zero(t, denied) | 58 | assert.Zero(t, denied) | ||
| 59 | assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator") | 59 | assert.Zero(t, activityCalls, "geolocation is not tracked by the WebRTC indicator") | ||
| 60 | } | 60 | } | ||
| 61 | + | ||||
| 62 | + | func TestHandlePermissionRequest_NoUseCaseFallbackDeniesUnmediatedDisplay(t *testing.T) { | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | metadata map[string]string | |||
| 66 | + | wantAllow bool | |||
| 67 | + | }{ | |||
| 68 | + | {"mediated display is auto-allowed", nil, true}, | |||
| 69 | + | { | |||
| 70 | + | "unmediated display is denied", | |||
| 71 | + | map[string]string{entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue}, | |||
| 72 | + | false, | |||
| 73 | + | }, | |||
| 74 | + | } | |||
| 75 | + | for _, tt := range tests { | |||
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 77 | + | c := &Coordinator{} | |||
| 78 | + | allowed, denied := 0, 0 | |||
| 79 | + | handled := c.handlePermissionRequest(context.Background(), "pane-1", "https://meet.example.com", | |||
| 80 | + | []string{"display"}, tt.metadata, func() { allowed++ }, func() { denied++ }) | |||
| 81 | + | ||||
| 82 | + | require.True(t, handled) | |||
| 83 | + | if tt.wantAllow { | |||
| 84 | + | assert.Equal(t, 1, allowed) | |||
| 85 | + | assert.Zero(t, denied) | |||
| 86 | + | } else { | |||
| 87 | + | assert.Zero(t, allowed) | |||
| 88 | + | assert.Equal(t, 1, denied) | |||
| 89 | + | } | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
M internal/ui/dialog/permission.go +5 -1
| @@ -244,7 +244,11 @@ func (d *PermissionDialog) buildBody( | |||
| 244 | 244 | parts = append(parts, "access your camera") | |
| 245 | 245 | } | |
| 246 | 246 | if f.display { | |
| 247 | - | parts = append(parts, "share your screen") | |
| 247 | + | if metadata.IsUnmediatedCapture() { | |
| 248 | + | parts = append(parts, "share your entire screen") | |
| 249 | + | } else { | |
| 250 | + | parts = append(parts, "share your screen") | |
| 251 | + | } | |
| 248 | 252 | } | |
| 249 | 253 | if f.notification { | |
| 250 | 254 | parts = append(parts, "show notifications") | |
| @@ -244,7 +244,11 @@ func (d *PermissionDialog) buildBody( | |||||
| 244 | parts = append(parts, "access your camera") | 244 | parts = append(parts, "access your camera") | ||
| 245 | } | 245 | } | ||
| 246 | if f.display { | 246 | if f.display { | ||
| 247 | - | parts = append(parts, "share your screen") | 247 | + | if metadata.IsUnmediatedCapture() { |
| 248 | + | parts = append(parts, "share your entire screen") | |||
| 249 | + | } else { | |||
| 250 | + | parts = append(parts, "share your screen") | |||
| 251 | + | } | |||
| 248 | } | 252 | } | ||
| 249 | if f.notification { | 253 | if f.notification { | ||
| 250 | parts = append(parts, "show notifications") | 254 | parts = append(parts, "show notifications") | ||
M internal/ui/dialog/permission_test.go +16 -0
| @@ -179,3 +179,19 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||
| 179 | 179 | assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body) | |
| 180 | 180 | }) | |
| 181 | 181 | } | |
| 182 | + | ||
| 183 | + | func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) { | |
| 184 | + | d := &PermissionDialog{} | |
| 185 | + | origin := "https://meet.example.com" | |
| 186 | + | meta := entity.PermissionMetadata{ | |
| 187 | + | entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue, | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | assert.Equal(t, origin+" wants to share your entire screen.", | |
| 191 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta)) | |
| 192 | + | assert.Equal(t, origin+" wants to access your camera and share your entire screen.", | |
| 193 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeCamera, entity.PermissionTypeDisplay}, meta)) | |
| 194 | + | // Mediated display (WebKit portal) keeps the generic wording. | |
| 195 | + | assert.Equal(t, origin+" wants to share your screen.", | |
| 196 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil)) | |
| 197 | + | } | |
| @@ -179,3 +179,19 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||||
| 179 | assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body) | 179 | assert.Equal(t, origin+" wants to allow accounts.google.com to access its data (including cookies) while you browse shop.example.com.", body) | ||
| 180 | }) | 180 | }) | ||
| 181 | } | 181 | } | ||
| 182 | + | ||||
| 183 | + | func TestPermissionDialog_BuildBody_UnmediatedDisplay(t *testing.T) { | |||
| 184 | + | d := &PermissionDialog{} | |||
| 185 | + | origin := "https://meet.example.com" | |||
| 186 | + | meta := entity.PermissionMetadata{ | |||
| 187 | + | entity.PermissionMetadataKeyUnmediatedCapture: entity.PermissionMetadataValueTrue, | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | assert.Equal(t, origin+" wants to share your entire screen.", | |||
| 191 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, meta)) | |||
| 192 | + | assert.Equal(t, origin+" wants to access your camera and share your entire screen.", | |||
| 193 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeCamera, entity.PermissionTypeDisplay}, meta)) | |||
| 194 | + | // Mediated display (WebKit portal) keeps the generic wording. | |||
| 195 | + | assert.Equal(t, origin+" wants to share your screen.", | |||
| 196 | + | d.buildBody(origin, []entity.PermissionType{entity.PermissionTypeDisplay}, nil)) | |||
| 197 | + | } | |||