feat(permission): add WebRTC diagnostics and remove unsafe request checks
- commit
- c51ceb8f47786a3cf55fbe60b76bceef115acc48
- author
- brice · · 2026-02-08 06:54 UTC
- parent
- 2db8eeb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +1741 -77
M assets/webui_embed.go +2 -2
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "embed" | |
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, and config pages). | |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config. | |
| 7 | + | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). | |
| 8 | + | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. | |
| 9 | 9 | // | |
| 10 | 10 | //go:embed webui/* | |
| 11 | 11 | var WebUIAssets embed.FS | |
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "embed" | 4 | "embed" | ||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, and config pages). | 7 | + | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config. | 8 | + | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. |
| 9 | // | 9 | // | ||
| 10 | //go:embed webui/* | 10 | //go:embed webui/* | ||
| 11 | var WebUIAssets embed.FS | 11 | var WebUIAssets embed.FS | ||
M go.mod +2 -2
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.10 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.12 | |
| 7 | 7 | github.com/charmbracelet/bubbles v0.21.1 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -22,7 +22,7 @@ require ( | |||
| 22 | 22 | gopkg.in/yaml.v3 v3.0.1 | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 | |
| 25 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 | |
| 26 | 26 | ||
| 27 | 27 | require ( | |
| 28 | 28 | github.com/atotto/clipboard v0.1.4 // indirect | |
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.10 | 6 | + | github.com/bnema/puregotk-webkit v0.0.12 |
| 7 | github.com/charmbracelet/bubbles v0.21.1 | 7 | github.com/charmbracelet/bubbles v0.21.1 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| @@ -22,7 +22,7 @@ require ( | |||||
| 22 | gopkg.in/yaml.v3 v3.0.1 | 22 | gopkg.in/yaml.v3 v3.0.1 | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 | 25 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 |
| 26 | 26 | ||||
| 27 | require ( | 27 | require ( | ||
| 28 | github.com/atotto/clipboard v0.1.4 // indirect | 28 | github.com/atotto/clipboard v0.1.4 // indirect | ||
M go.sum +4 -4 generated file: collapsed
M internal/application/usecase/handle_permission.go +78 -0
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -89,6 +90,13 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 89 | 90 | callback.Deny() | |
| 90 | 91 | return | |
| 91 | 92 | } | |
| 93 | + | ||
| 94 | + | if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) { | |
| 95 | + | log.Debug().Msg("manual override denied auto-allow permission request") | |
| 96 | + | callback.Deny() | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | ||
| 92 | 100 | if isAutoAllowSet(permTypes) { | |
| 93 | 101 | log.Debug().Msg("auto-allowing permission request") | |
| 94 | 102 | callback.Allow() | |
| @@ -286,3 +294,73 @@ func (uc *HandlePermissionUseCase) persistPermission( | |||
| 286 | 294 | } | |
| 287 | 295 | } | |
| 288 | 296 | } | |
| 297 | + | ||
| 298 | + | // GetManualPermissionDecision returns the stored decision (if any) for origin/type. | |
| 299 | + | func (uc *HandlePermissionUseCase) GetManualPermissionDecision( | |
| 300 | + | ctx context.Context, | |
| 301 | + | origin string, | |
| 302 | + | permType entity.PermissionType, | |
| 303 | + | ) (*entity.PermissionRecord, error) { | |
| 304 | + | if origin == "" { | |
| 305 | + | return nil, errors.New("origin is required") | |
| 306 | + | } | |
| 307 | + | return uc.permRepo.Get(ctx, origin, permType) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // SetManualPermissionDecision stores an explicit decision for origin/type. | |
| 311 | + | // This is used by the UI permission indicator as the source of truth for lock state. | |
| 312 | + | func (uc *HandlePermissionUseCase) SetManualPermissionDecision( | |
| 313 | + | ctx context.Context, | |
| 314 | + | origin string, | |
| 315 | + | permType entity.PermissionType, | |
| 316 | + | decision entity.PermissionDecision, | |
| 317 | + | ) error { | |
| 318 | + | if origin == "" { | |
| 319 | + | return errors.New("origin is required") | |
| 320 | + | } | |
| 321 | + | if decision == entity.PermissionPrompt { | |
| 322 | + | return uc.permRepo.Delete(ctx, origin, permType) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | return uc.permRepo.Set(ctx, &entity.PermissionRecord{ | |
| 326 | + | Origin: origin, | |
| 327 | + | Type: permType, | |
| 328 | + | Decision: decision, | |
| 329 | + | UpdatedAt: time.Now().Unix(), | |
| 330 | + | }) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // ResetManualPermissionDecision clears any stored decision for origin/type. | |
| 334 | + | func (uc *HandlePermissionUseCase) ResetManualPermissionDecision( | |
| 335 | + | ctx context.Context, | |
| 336 | + | origin string, | |
| 337 | + | permType entity.PermissionType, | |
| 338 | + | ) error { | |
| 339 | + | if origin == "" { | |
| 340 | + | return errors.New("origin is required") | |
| 341 | + | } | |
| 342 | + | return uc.permRepo.Delete(ctx, origin, permType) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |
| 346 | + | ctx context.Context, | |
| 347 | + | origin string, | |
| 348 | + | permTypes []entity.PermissionType, | |
| 349 | + | ) bool { | |
| 350 | + | for _, permType := range permTypes { | |
| 351 | + | if !entity.IsAutoAllow(permType) { | |
| 352 | + | continue | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | record, err := uc.permRepo.Get(ctx, origin, permType) | |
| 356 | + | if err != nil || record == nil { | |
| 357 | + | continue | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | if record.Decision == entity.PermissionDenied { | |
| 361 | + | return true | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | return false | |
| 366 | + | } | |
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -89,6 +90,13 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||||
| 89 | callback.Deny() | 90 | callback.Deny() | ||
| 90 | return | 91 | return | ||
| 91 | } | 92 | } | ||
| 93 | + | ||||
| 94 | + | if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) { | |||
| 95 | + | log.Debug().Msg("manual override denied auto-allow permission request") | |||
| 96 | + | callback.Deny() | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | ||||
| 92 | if isAutoAllowSet(permTypes) { | 100 | if isAutoAllowSet(permTypes) { | ||
| 93 | log.Debug().Msg("auto-allowing permission request") | 101 | log.Debug().Msg("auto-allowing permission request") | ||
| 94 | callback.Allow() | 102 | callback.Allow() | ||
| @@ -286,3 +294,73 @@ func (uc *HandlePermissionUseCase) persistPermission( | |||||
| 286 | } | 294 | } | ||
| 287 | } | 295 | } | ||
| 288 | } | 296 | } | ||
| 297 | + | ||||
| 298 | + | // GetManualPermissionDecision returns the stored decision (if any) for origin/type. | |||
| 299 | + | func (uc *HandlePermissionUseCase) GetManualPermissionDecision( | |||
| 300 | + | ctx context.Context, | |||
| 301 | + | origin string, | |||
| 302 | + | permType entity.PermissionType, | |||
| 303 | + | ) (*entity.PermissionRecord, error) { | |||
| 304 | + | if origin == "" { | |||
| 305 | + | return nil, errors.New("origin is required") | |||
| 306 | + | } | |||
| 307 | + | return uc.permRepo.Get(ctx, origin, permType) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // SetManualPermissionDecision stores an explicit decision for origin/type. | |||
| 311 | + | // This is used by the UI permission indicator as the source of truth for lock state. | |||
| 312 | + | func (uc *HandlePermissionUseCase) SetManualPermissionDecision( | |||
| 313 | + | ctx context.Context, | |||
| 314 | + | origin string, | |||
| 315 | + | permType entity.PermissionType, | |||
| 316 | + | decision entity.PermissionDecision, | |||
| 317 | + | ) error { | |||
| 318 | + | if origin == "" { | |||
| 319 | + | return errors.New("origin is required") | |||
| 320 | + | } | |||
| 321 | + | if decision == entity.PermissionPrompt { | |||
| 322 | + | return uc.permRepo.Delete(ctx, origin, permType) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | return uc.permRepo.Set(ctx, &entity.PermissionRecord{ | |||
| 326 | + | Origin: origin, | |||
| 327 | + | Type: permType, | |||
| 328 | + | Decision: decision, | |||
| 329 | + | UpdatedAt: time.Now().Unix(), | |||
| 330 | + | }) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // ResetManualPermissionDecision clears any stored decision for origin/type. | |||
| 334 | + | func (uc *HandlePermissionUseCase) ResetManualPermissionDecision( | |||
| 335 | + | ctx context.Context, | |||
| 336 | + | origin string, | |||
| 337 | + | permType entity.PermissionType, | |||
| 338 | + | ) error { | |||
| 339 | + | if origin == "" { | |||
| 340 | + | return errors.New("origin is required") | |||
| 341 | + | } | |||
| 342 | + | return uc.permRepo.Delete(ctx, origin, permType) | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |||
| 346 | + | ctx context.Context, | |||
| 347 | + | origin string, | |||
| 348 | + | permTypes []entity.PermissionType, | |||
| 349 | + | ) bool { | |||
| 350 | + | for _, permType := range permTypes { | |||
| 351 | + | if !entity.IsAutoAllow(permType) { | |||
| 352 | + | continue | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | record, err := uc.permRepo.Get(ctx, origin, permType) | |||
| 356 | + | if err != nil || record == nil { | |||
| 357 | + | continue | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | if record.Decision == entity.PermissionDenied { | |||
| 361 | + | return true | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | return false | |||
| 366 | + | } | |||
M internal/application/usecase/handle_permission_test.go +78 -2
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |
| @@ -21,6 +22,9 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||
| 21 | 22 | ||
| 22 | 23 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | |
| 23 | 24 | ||
| 25 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |
| 26 | + | Return(nil, nil) | |
| 27 | + | ||
| 24 | 28 | allowed := false | |
| 25 | 29 | denied := false | |
| 26 | 30 | callback := usecase.PermissionCallback{ | |
| @@ -35,7 +39,6 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||
| 35 | 39 | ||
| 36 | 40 | assert.True(t, allowed, "display capture should be auto-allowed") | |
| 37 | 41 | assert.False(t, denied) | |
| 38 | - | permRepo.AssertNotCalled(t, "Get") | |
| 39 | 42 | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |
| 40 | 43 | } | |
| 41 | 44 | ||
| @@ -46,6 +49,9 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) { | |||
| 46 | 49 | ||
| 47 | 50 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | |
| 48 | 51 | ||
| 52 | + | permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo). | |
| 53 | + | Return(nil, nil) | |
| 54 | + | ||
| 49 | 55 | allowed := false | |
| 50 | 56 | callback := usecase.PermissionCallback{ | |
| 51 | 57 | Allow: func() { allowed = true }, | |
| @@ -341,8 +347,10 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) { | |||
| 341 | 347 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | |
| 342 | 348 | ||
| 343 | 349 | // Display capture is not persistable per W3C spec | |
| 344 | - | // No Get call since it's auto-allowed | |
| 350 | + | // A stored override may exist, so a Get call is expected before auto-allow. | |
| 345 | 351 | // No Set call should happen even if we try to persist | |
| 352 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |
| 353 | + | Return(nil, nil) | |
| 346 | 354 | ||
| 347 | 355 | allowed := false | |
| 348 | 356 | callback := usecase.PermissionCallback{ | |
| @@ -381,3 +389,71 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) { | |||
| 381 | 389 | ||
| 382 | 390 | assert.True(t, denied, "should deny when no dialog presenter is available") | |
| 383 | 391 | } | |
| 392 | + | ||
| 393 | + | func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) { | |
| 394 | + | ctx := testContext() | |
| 395 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 396 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 397 | + | ||
| 398 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | |
| 399 | + | ||
| 400 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |
| 401 | + | Return(&entity.PermissionRecord{ | |
| 402 | + | Origin: "https://meet.example.com", | |
| 403 | + | Type: entity.PermissionTypeDisplay, | |
| 404 | + | Decision: entity.PermissionDenied, | |
| 405 | + | }, nil) | |
| 406 | + | ||
| 407 | + | denied := false | |
| 408 | + | callback := usecase.PermissionCallback{ | |
| 409 | + | Allow: func() {}, | |
| 410 | + | Deny: func() { denied = true }, | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |
| 414 | + | entity.PermissionTypeDisplay, | |
| 415 | + | }, callback) | |
| 416 | + | ||
| 417 | + | assert.True(t, denied, "display should be denied when manual override is denied") | |
| 418 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) { | |
| 422 | + | ctx := testContext() | |
| 423 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 424 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, nil) | |
| 425 | + | ||
| 426 | + | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |
| 427 | + | Run(func(_ context.Context, record *entity.PermissionRecord) { | |
| 428 | + | assert.Equal(t, "https://app.zoom.us", record.Origin) | |
| 429 | + | assert.Equal(t, entity.PermissionTypeMicrophone, record.Type) | |
| 430 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |
| 431 | + | }). | |
| 432 | + | Return(nil) | |
| 433 | + | ||
| 434 | + | err := uc.SetManualPermissionDecision( | |
| 435 | + | ctx, | |
| 436 | + | "https://app.zoom.us", | |
| 437 | + | entity.PermissionTypeMicrophone, | |
| 438 | + | entity.PermissionGranted, | |
| 439 | + | ) | |
| 440 | + | require.NoError(t, err) | |
| 441 | + | ||
| 442 | + | permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |
| 443 | + | Return(&entity.PermissionRecord{ | |
| 444 | + | Origin: "https://app.zoom.us", | |
| 445 | + | Type: entity.PermissionTypeMicrophone, | |
| 446 | + | Decision: entity.PermissionGranted, | |
| 447 | + | }, nil) | |
| 448 | + | ||
| 449 | + | record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | if assert.NotNil(t, record) { | |
| 452 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |
| 456 | + | Return(nil) | |
| 457 | + | err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |
| 458 | + | require.NoError(t, err) | |
| 459 | + | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | 18 | func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | ||
| @@ -21,6 +22,9 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||||
| 21 | 22 | ||||
| 22 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | 23 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | ||
| 23 | 24 | ||||
| 25 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 26 | + | Return(nil, nil) | |||
| 27 | + | ||||
| 24 | allowed := false | 28 | allowed := false | ||
| 25 | denied := false | 29 | denied := false | ||
| 26 | callback := usecase.PermissionCallback{ | 30 | callback := usecase.PermissionCallback{ | ||
| @@ -35,7 +39,6 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||||
| 35 | 39 | ||||
| 36 | assert.True(t, allowed, "display capture should be auto-allowed") | 40 | assert.True(t, allowed, "display capture should be auto-allowed") | ||
| 37 | assert.False(t, denied) | 41 | assert.False(t, denied) | ||
| 38 | - | permRepo.AssertNotCalled(t, "Get") | |||
| 39 | dialog.AssertNotCalled(t, "ShowPermissionDialog") | 42 | dialog.AssertNotCalled(t, "ShowPermissionDialog") | ||
| 40 | } | 43 | } | ||
| 41 | 44 | ||||
| @@ -46,6 +49,9 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) { | |||||
| 46 | 49 | ||||
| 47 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | 50 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | ||
| 48 | 51 | ||||
| 52 | + | permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo). | |||
| 53 | + | Return(nil, nil) | |||
| 54 | + | ||||
| 49 | allowed := false | 55 | allowed := false | ||
| 50 | callback := usecase.PermissionCallback{ | 56 | callback := usecase.PermissionCallback{ | ||
| 51 | Allow: func() { allowed = true }, | 57 | Allow: func() { allowed = true }, | ||
| @@ -341,8 +347,10 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) { | |||||
| 341 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | 347 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | ||
| 342 | 348 | ||||
| 343 | // Display capture is not persistable per W3C spec | 349 | // Display capture is not persistable per W3C spec | ||
| 344 | - | // No Get call since it's auto-allowed | 350 | + | // A stored override may exist, so a Get call is expected before auto-allow. |
| 345 | // No Set call should happen even if we try to persist | 351 | // No Set call should happen even if we try to persist | ||
| 352 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 353 | + | Return(nil, nil) | |||
| 346 | 354 | ||||
| 347 | allowed := false | 355 | allowed := false | ||
| 348 | callback := usecase.PermissionCallback{ | 356 | callback := usecase.PermissionCallback{ | ||
| @@ -381,3 +389,71 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) { | |||||
| 381 | 389 | ||||
| 382 | assert.True(t, denied, "should deny when no dialog presenter is available") | 390 | assert.True(t, denied, "should deny when no dialog presenter is available") | ||
| 383 | } | 391 | } | ||
| 392 | + | ||||
| 393 | + | func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) { | |||
| 394 | + | ctx := testContext() | |||
| 395 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 396 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 397 | + | ||||
| 398 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog) | |||
| 399 | + | ||||
| 400 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 401 | + | Return(&entity.PermissionRecord{ | |||
| 402 | + | Origin: "https://meet.example.com", | |||
| 403 | + | Type: entity.PermissionTypeDisplay, | |||
| 404 | + | Decision: entity.PermissionDenied, | |||
| 405 | + | }, nil) | |||
| 406 | + | ||||
| 407 | + | denied := false | |||
| 408 | + | callback := usecase.PermissionCallback{ | |||
| 409 | + | Allow: func() {}, | |||
| 410 | + | Deny: func() { denied = true }, | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 414 | + | entity.PermissionTypeDisplay, | |||
| 415 | + | }, callback) | |||
| 416 | + | ||||
| 417 | + | assert.True(t, denied, "display should be denied when manual override is denied") | |||
| 418 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) { | |||
| 422 | + | ctx := testContext() | |||
| 423 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 424 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, nil) | |||
| 425 | + | ||||
| 426 | + | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |||
| 427 | + | Run(func(_ context.Context, record *entity.PermissionRecord) { | |||
| 428 | + | assert.Equal(t, "https://app.zoom.us", record.Origin) | |||
| 429 | + | assert.Equal(t, entity.PermissionTypeMicrophone, record.Type) | |||
| 430 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |||
| 431 | + | }). | |||
| 432 | + | Return(nil) | |||
| 433 | + | ||||
| 434 | + | err := uc.SetManualPermissionDecision( | |||
| 435 | + | ctx, | |||
| 436 | + | "https://app.zoom.us", | |||
| 437 | + | entity.PermissionTypeMicrophone, | |||
| 438 | + | entity.PermissionGranted, | |||
| 439 | + | ) | |||
| 440 | + | require.NoError(t, err) | |||
| 441 | + | ||||
| 442 | + | permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |||
| 443 | + | Return(&entity.PermissionRecord{ | |||
| 444 | + | Origin: "https://app.zoom.us", | |||
| 445 | + | Type: entity.PermissionTypeMicrophone, | |||
| 446 | + | Decision: entity.PermissionGranted, | |||
| 447 | + | }, nil) | |||
| 448 | + | ||||
| 449 | + | record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |||
| 450 | + | require.NoError(t, err) | |||
| 451 | + | if assert.NotNil(t, record) { | |||
| 452 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |||
| 456 | + | Return(nil) | |||
| 457 | + | err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |||
| 458 | + | require.NoError(t, err) | |||
| 459 | + | } | |||
M internal/bootstrap/webkit_stack.go +11 -2
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 14 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/theme" | |
| 16 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 16 | 17 | "github.com/rs/zerolog" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -211,14 +212,22 @@ func initWebViewPool( | |||
| 211 | 212 | pool.SetFilterApplier(filterManager) | |
| 212 | 213 | } | |
| 213 | 214 | ||
| 214 | - | if err := pool.PrewarmFirst(ctx); err != nil { | |
| 215 | - | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |
| 215 | + | if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) { | |
| 216 | + | if err := pool.PrewarmFirst(ctx); err != nil { | |
| 217 | + | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |
| 218 | + | } | |
| 219 | + | } else { | |
| 220 | + | logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet") | |
| 216 | 221 | } | |
| 217 | 222 | logging.Trace().Mark("pool_prewarm_first") | |
| 218 | 223 | ||
| 219 | 224 | return pool | |
| 220 | 225 | } | |
| 221 | 226 | ||
| 227 | + | func shouldPrewarmFirstWebView(display *gdk.Display) bool { | |
| 228 | + | return display != nil | |
| 229 | + | } | |
| 230 | + | ||
| 222 | 231 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | |
| 223 | 232 | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |
| 224 | 233 | return p.WebProcessMemoryLimitMB > 0 || | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 14 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 15 | "github.com/bnema/dumber/internal/ui/theme" | 15 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 16 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 16 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -211,14 +212,22 @@ func initWebViewPool( | |||||
| 211 | pool.SetFilterApplier(filterManager) | 212 | pool.SetFilterApplier(filterManager) | ||
| 212 | } | 213 | } | ||
| 213 | 214 | ||||
| 214 | - | if err := pool.PrewarmFirst(ctx); err != nil { | 215 | + | if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) { |
| 215 | - | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | 216 | + | if err := pool.PrewarmFirst(ctx); err != nil { |
| 217 | + | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |||
| 218 | + | } | |||
| 219 | + | } else { | |||
| 220 | + | logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet") | |||
| 216 | } | 221 | } | ||
| 217 | logging.Trace().Mark("pool_prewarm_first") | 222 | logging.Trace().Mark("pool_prewarm_first") | ||
| 218 | 223 | ||||
| 219 | return pool | 224 | return pool | ||
| 220 | } | 225 | } | ||
| 221 | 226 | ||||
| 227 | + | func shouldPrewarmFirstWebView(display *gdk.Display) bool { | |||
| 228 | + | return display != nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 222 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | 231 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | ||
| 223 | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | 232 | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | ||
| 224 | return p.WebProcessMemoryLimitMB > 0 || | 233 | return p.WebProcessMemoryLimitMB > 0 || | ||
M internal/bootstrap/webkit_stack_test.go +6 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| @@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) { | |||
| 47 | 48 | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | |
| 48 | 49 | assert.True(t, opts.ITPEnabled) | |
| 49 | 50 | } | |
| 51 | + | ||
| 52 | + | func TestShouldPrewarmFirstWebView(t *testing.T) { | |
| 53 | + | assert.False(t, shouldPrewarmFirstWebView(nil)) | |
| 54 | + | assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{})) | |
| 55 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| @@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) { | |||||
| 47 | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | 48 | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | ||
| 48 | assert.True(t, opts.ITPEnabled) | 49 | assert.True(t, opts.ITPEnabled) | ||
| 49 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestShouldPrewarmFirstWebView(t *testing.T) { | |||
| 53 | + | assert.False(t, shouldPrewarmFirstWebView(nil)) | |||
| 54 | + | assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{})) | |||
| 55 | + | } | |||
A internal/infrastructure/webkit/gobject_type_helpers.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/jwijenbergh/purego" | |
| 7 | + | "github.com/jwijenbergh/puregotk/pkg/core" | |
| 8 | + | gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var ( | |
| 12 | + | loadGObjectTypeFnsOnce sync.Once | |
| 13 | + | typeCheckInstanceIsA func(uintptr, gtypes.GType) bool | |
| 14 | + | typeNameFromInstance func(uintptr) string | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func gobjectTypeCheckInstanceIsAByPtr(instancePtr uintptr, requestType gtypes.GType) bool { | |
| 18 | + | if instancePtr == 0 { | |
| 19 | + | return false | |
| 20 | + | } | |
| 21 | + | loadGObjectTypeFns() | |
| 22 | + | if typeCheckInstanceIsA == nil { | |
| 23 | + | return false | |
| 24 | + | } | |
| 25 | + | return typeCheckInstanceIsA(instancePtr, requestType) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func permissionRequestTypeName(instancePtr uintptr) string { | |
| 29 | + | if instancePtr == 0 { | |
| 30 | + | return "" | |
| 31 | + | } | |
| 32 | + | loadGObjectTypeFns() | |
| 33 | + | if typeNameFromInstance == nil { | |
| 34 | + | return "" | |
| 35 | + | } | |
| 36 | + | return typeNameFromInstance(instancePtr) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func loadGObjectTypeFns() { | |
| 40 | + | loadGObjectTypeFnsOnce.Do(func() { | |
| 41 | + | libs := make([]uintptr, 0, 2) | |
| 42 | + | for _, libPath := range core.GetPaths("GOBJECT") { | |
| 43 | + | lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL) | |
| 44 | + | if err != nil { | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | libs = append(libs, lib) | |
| 48 | + | } | |
| 49 | + | if len(libs) == 0 { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | core.PuregoSafeRegister(&typeCheckInstanceIsA, libs, "g_type_check_instance_is_a") | |
| 54 | + | core.PuregoSafeRegister(&typeNameFromInstance, libs, "g_type_name_from_instance") | |
| 55 | + | }) | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/jwijenbergh/purego" | |||
| 7 | + | "github.com/jwijenbergh/puregotk/pkg/core" | |||
| 8 | + | gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var ( | |||
| 12 | + | loadGObjectTypeFnsOnce sync.Once | |||
| 13 | + | typeCheckInstanceIsA func(uintptr, gtypes.GType) bool | |||
| 14 | + | typeNameFromInstance func(uintptr) string | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func gobjectTypeCheckInstanceIsAByPtr(instancePtr uintptr, requestType gtypes.GType) bool { | |||
| 18 | + | if instancePtr == 0 { | |||
| 19 | + | return false | |||
| 20 | + | } | |||
| 21 | + | loadGObjectTypeFns() | |||
| 22 | + | if typeCheckInstanceIsA == nil { | |||
| 23 | + | return false | |||
| 24 | + | } | |||
| 25 | + | return typeCheckInstanceIsA(instancePtr, requestType) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func permissionRequestTypeName(instancePtr uintptr) string { | |||
| 29 | + | if instancePtr == 0 { | |||
| 30 | + | return "" | |||
| 31 | + | } | |||
| 32 | + | loadGObjectTypeFns() | |||
| 33 | + | if typeNameFromInstance == nil { | |||
| 34 | + | return "" | |||
| 35 | + | } | |||
| 36 | + | return typeNameFromInstance(instancePtr) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func loadGObjectTypeFns() { | |||
| 40 | + | loadGObjectTypeFnsOnce.Do(func() { | |||
| 41 | + | libs := make([]uintptr, 0, 2) | |||
| 42 | + | for _, libPath := range core.GetPaths("GOBJECT") { | |||
| 43 | + | lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL) | |||
| 44 | + | if err != nil { | |||
| 45 | + | continue | |||
| 46 | + | } | |||
| 47 | + | libs = append(libs, lib) | |||
| 48 | + | } | |||
| 49 | + | if len(libs) == 0 { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | core.PuregoSafeRegister(&typeCheckInstanceIsA, libs, "g_type_check_instance_is_a") | |||
| 54 | + | core.PuregoSafeRegister(&typeNameFromInstance, libs, "g_type_name_from_instance") | |||
| 55 | + | }) | |||
| 56 | + | } | |||
M internal/infrastructure/webkit/injector.go +36 -5
| @@ -141,6 +141,25 @@ const autoCopySelectionScript = `(function() { | |||
| 141 | 141 | }); | |
| 142 | 142 | })();` | |
| 143 | 143 | ||
| 144 | + | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | |
| 145 | + | // Some pages gate support on window.RTCPeerConnection and report false negatives | |
| 146 | + | // when only webkit-prefixed constructors are present. | |
| 147 | + | const webRTCCompatScript = `(function() { | |
| 148 | + | if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) { | |
| 149 | + | window.RTCPeerConnection = window.webkitRTCPeerConnection; | |
| 150 | + | } | |
| 151 | + | if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) { | |
| 152 | + | window.RTCSessionDescription = window.webkitRTCSessionDescription; | |
| 153 | + | } | |
| 154 | + | if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) { | |
| 155 | + | window.RTCIceCandidate = window.webkitRTCIceCandidate; | |
| 156 | + | } | |
| 157 | + | })();` | |
| 158 | + | ||
| 159 | + | func buildWebRTCCompatScript() string { | |
| 160 | + | return webRTCCompatScript | |
| 161 | + | } | |
| 162 | + | ||
| 144 | 163 | // ContentInjector encapsulates script injection into WebViews. | |
| 145 | 164 | // It injects dark mode detection scripts for internal pages (dumb://) | |
| 146 | 165 | // and theme CSS variables for WebUI styling. | |
| @@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 217 | 236 | ||
| 218 | 237 | prefersDark := ci.PrefersDark() | |
| 219 | 238 | ||
| 220 | - | // 1. Inject WebView ID for debugging (internal pages only) | |
| 239 | + | // 1. Inject WebRTC compatibility aliases for all pages. | |
| 240 | + | addScript( | |
| 241 | + | webkit.NewUserScript( | |
| 242 | + | buildWebRTCCompatScript(), | |
| 243 | + | webkit.UserContentInjectTopFrameValue, | |
| 244 | + | webkit.UserScriptInjectAtDocumentStartValue, | |
| 245 | + | nil, | |
| 246 | + | nil, | |
| 247 | + | ), | |
| 248 | + | "webrtc-compat-shim", | |
| 249 | + | ) | |
| 250 | + | ||
| 251 | + | // 2. Inject WebView ID for debugging (internal pages only) | |
| 221 | 252 | if webviewID != 0 { | |
| 222 | 253 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 223 | 254 | addScript( | |
| @@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 235 | 266 | log.Warn().Msg("webview ID is 0, skipping ID injection") | |
| 236 | 267 | } | |
| 237 | 268 | ||
| 238 | - | // 2. Inject dark mode handler for internal pages only | |
| 269 | + | // 3. Inject dark mode handler for internal pages only | |
| 239 | 270 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 240 | 271 | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | |
| 241 | 272 | addScript( | |
| @@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 249 | 280 | "internal-dark-mode-handler", | |
| 250 | 281 | ) | |
| 251 | 282 | ||
| 252 | - | // 3. Inject theme CSS for internal pages (dumb://* only) | |
| 283 | + | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 253 | 284 | if ci.themeCSSVars != "" { | |
| 254 | 285 | // Escape for JS string literal | |
| 255 | 286 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |
| @@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 269 | 300 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | |
| 270 | 301 | } | |
| 271 | 302 | ||
| 272 | - | // 4. Inject find highlight CSS for all pages | |
| 303 | + | // 5. Inject find highlight CSS for all pages | |
| 273 | 304 | if ci.findCSS != "" { | |
| 274 | 305 | stylesheet := webkit.NewUserStyleSheet( | |
| 275 | 306 | ci.findCSS, | |
| @@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 286 | 317 | } | |
| 287 | 318 | } | |
| 288 | 319 | ||
| 289 | - | // 5. Inject auto-copy selection script for all pages (if enabled) | |
| 320 | + | // 6. Inject auto-copy selection script for all pages (if enabled) | |
| 290 | 321 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |
| 291 | 322 | if autoCopyEnabled { | |
| 292 | 323 | addScript( | |
| @@ -141,6 +141,25 @@ const autoCopySelectionScript = `(function() { | |||||
| 141 | }); | 141 | }); | ||
| 142 | })();` | 142 | })();` | ||
| 143 | 143 | ||||
| 144 | + | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | |||
| 145 | + | // Some pages gate support on window.RTCPeerConnection and report false negatives | |||
| 146 | + | // when only webkit-prefixed constructors are present. | |||
| 147 | + | const webRTCCompatScript = `(function() { | |||
| 148 | + | if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) { | |||
| 149 | + | window.RTCPeerConnection = window.webkitRTCPeerConnection; | |||
| 150 | + | } | |||
| 151 | + | if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) { | |||
| 152 | + | window.RTCSessionDescription = window.webkitRTCSessionDescription; | |||
| 153 | + | } | |||
| 154 | + | if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) { | |||
| 155 | + | window.RTCIceCandidate = window.webkitRTCIceCandidate; | |||
| 156 | + | } | |||
| 157 | + | })();` | |||
| 158 | + | ||||
| 159 | + | func buildWebRTCCompatScript() string { | |||
| 160 | + | return webRTCCompatScript | |||
| 161 | + | } | |||
| 162 | + | ||||
| 144 | // ContentInjector encapsulates script injection into WebViews. | 163 | // ContentInjector encapsulates script injection into WebViews. | ||
| 145 | // It injects dark mode detection scripts for internal pages (dumb://) | 164 | // It injects dark mode detection scripts for internal pages (dumb://) | ||
| 146 | // and theme CSS variables for WebUI styling. | 165 | // and theme CSS variables for WebUI styling. | ||
| @@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 217 | 236 | ||||
| 218 | prefersDark := ci.PrefersDark() | 237 | prefersDark := ci.PrefersDark() | ||
| 219 | 238 | ||||
| 220 | - | // 1. Inject WebView ID for debugging (internal pages only) | 239 | + | // 1. Inject WebRTC compatibility aliases for all pages. |
| 240 | + | addScript( | |||
| 241 | + | webkit.NewUserScript( | |||
| 242 | + | buildWebRTCCompatScript(), | |||
| 243 | + | webkit.UserContentInjectTopFrameValue, | |||
| 244 | + | webkit.UserScriptInjectAtDocumentStartValue, | |||
| 245 | + | nil, | |||
| 246 | + | nil, | |||
| 247 | + | ), | |||
| 248 | + | "webrtc-compat-shim", | |||
| 249 | + | ) | |||
| 250 | + | ||||
| 251 | + | // 2. Inject WebView ID for debugging (internal pages only) | |||
| 221 | if webviewID != 0 { | 252 | if webviewID != 0 { | ||
| 222 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 253 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 223 | addScript( | 254 | addScript( | ||
| @@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 235 | log.Warn().Msg("webview ID is 0, skipping ID injection") | 266 | log.Warn().Msg("webview ID is 0, skipping ID injection") | ||
| 236 | } | 267 | } | ||
| 237 | 268 | ||||
| 238 | - | // 2. Inject dark mode handler for internal pages only | 269 | + | // 3. Inject dark mode handler for internal pages only |
| 239 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 270 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | ||
| 240 | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | 271 | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | ||
| 241 | addScript( | 272 | addScript( | ||
| @@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 249 | "internal-dark-mode-handler", | 280 | "internal-dark-mode-handler", | ||
| 250 | ) | 281 | ) | ||
| 251 | 282 | ||||
| 252 | - | // 3. Inject theme CSS for internal pages (dumb://* only) | 283 | + | // 4. Inject theme CSS for internal pages (dumb://* only) |
| 253 | if ci.themeCSSVars != "" { | 284 | if ci.themeCSSVars != "" { | ||
| 254 | // Escape for JS string literal | 285 | // Escape for JS string literal | ||
| 255 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | 286 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | ||
| @@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 269 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | 300 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | ||
| 270 | } | 301 | } | ||
| 271 | 302 | ||||
| 272 | - | // 4. Inject find highlight CSS for all pages | 303 | + | // 5. Inject find highlight CSS for all pages |
| 273 | if ci.findCSS != "" { | 304 | if ci.findCSS != "" { | ||
| 274 | stylesheet := webkit.NewUserStyleSheet( | 305 | stylesheet := webkit.NewUserStyleSheet( | ||
| 275 | ci.findCSS, | 306 | ci.findCSS, | ||
| @@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 286 | } | 317 | } | ||
| 287 | } | 318 | } | ||
| 288 | 319 | ||||
| 289 | - | // 5. Inject auto-copy selection script for all pages (if enabled) | 320 | + | // 6. Inject auto-copy selection script for all pages (if enabled) |
| 290 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | 321 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | ||
| 291 | if autoCopyEnabled { | 322 | if autoCopyEnabled { | ||
| 292 | addScript( | 323 | addScript( | ||
A internal/infrastructure/webkit/injector_webrtc_compat_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestBuildWebRTCCompatScript_AddsPrefixedAliases(t *testing.T) { | |
| 10 | + | script := buildWebRTCCompatScript() | |
| 11 | + | ||
| 12 | + | assert.Contains(t, script, "window.webkitRTCPeerConnection") | |
| 13 | + | assert.Contains(t, script, "window.RTCPeerConnection = window.webkitRTCPeerConnection") | |
| 14 | + | assert.Contains(t, script, "window.RTCSessionDescription = window.webkitRTCSessionDescription") | |
| 15 | + | assert.Contains(t, script, "window.RTCIceCandidate = window.webkitRTCIceCandidate") | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildWebRTCCompatScript_AddsPrefixedAliases(t *testing.T) { | |||
| 10 | + | script := buildWebRTCCompatScript() | |||
| 11 | + | ||||
| 12 | + | assert.Contains(t, script, "window.webkitRTCPeerConnection") | |||
| 13 | + | assert.Contains(t, script, "window.RTCPeerConnection = window.webkitRTCPeerConnection") | |||
| 14 | + | assert.Contains(t, script, "window.RTCSessionDescription = window.webkitRTCSessionDescription") | |||
| 15 | + | assert.Contains(t, script, "window.RTCIceCandidate = window.webkitRTCIceCandidate") | |||
| 16 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +35 -32
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | const ( | |
| 29 | 29 | HomePath = "home" | |
| 30 | 30 | ConfigPath = "config" | |
| 31 | + | WebRTCPath = "webrtc" | |
| 31 | 32 | ErrorPath = "error" | |
| 32 | 33 | CrashPath = "crash" | |
| 33 | 34 | IndexHTML = "index.html" | |
| @@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 511 | 512 | return nil | |
| 512 | 513 | } | |
| 513 | 514 | ||
| 514 | - | // Determine the target file based on host and path | |
| 515 | - | host := u.Host | |
| 516 | - | path := strings.TrimPrefix(u.Path, "/") | |
| 517 | - | ||
| 518 | - | var relPath string | |
| 519 | - | switch { | |
| 520 | - | // Home root maps to index.html. | |
| 521 | - | case host == HomePath && (path == "" || path == "/"): | |
| 522 | - | relPath = IndexHTML | |
| 523 | - | // Home asset paths map directly to assets. | |
| 524 | - | case host == HomePath && path != "": | |
| 525 | - | relPath = path | |
| 526 | - | // Config root maps to config.html. | |
| 527 | - | case host == ConfigPath && (path == "" || path == "/"): | |
| 528 | - | relPath = "config.html" | |
| 529 | - | // Config asset paths map directly to assets. | |
| 530 | - | case host == ConfigPath && path != "": | |
| 531 | - | relPath = path | |
| 532 | - | // Error root maps to error.html. | |
| 533 | - | case host == ErrorPath && (path == "" || path == "/"): | |
| 534 | - | relPath = "error.html" | |
| 535 | - | // Error asset paths map directly to assets. | |
| 536 | - | case host == ErrorPath && path != "": | |
| 537 | - | relPath = path | |
| 538 | - | // Opaque home form maps to index.html. | |
| 539 | - | case u.Opaque == HomePath: | |
| 540 | - | relPath = IndexHTML | |
| 541 | - | // Opaque error form maps to error.html. | |
| 542 | - | case u.Opaque == ErrorPath: | |
| 543 | - | relPath = "error.html" | |
| 544 | - | default: | |
| 545 | - | // Not a recognized asset path | |
| 515 | + | relPath, ok := resolveAssetPath(u) | |
| 516 | + | if !ok { | |
| 546 | 517 | return nil | |
| 547 | 518 | } | |
| 548 | 519 | ||
| @@ -568,6 +539,38 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 568 | 539 | } | |
| 569 | 540 | } | |
| 570 | 541 | ||
| 542 | + | func resolveAssetPath(u *url.URL) (string, bool) { | |
| 543 | + | if u == nil { | |
| 544 | + | return "", false | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | rootByHost := map[string]string{ | |
| 548 | + | HomePath: IndexHTML, | |
| 549 | + | ConfigPath: "config.html", | |
| 550 | + | WebRTCPath: "webrtc.html", | |
| 551 | + | ErrorPath: "error.html", | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | if root, ok := rootByHost[u.Host]; ok { | |
| 555 | + | path := strings.TrimPrefix(u.Path, "/") | |
| 556 | + | if path == "" { | |
| 557 | + | return root, true | |
| 558 | + | } | |
| 559 | + | return path, true | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | switch u.Opaque { | |
| 563 | + | case HomePath: | |
| 564 | + | return IndexHTML, true | |
| 565 | + | case ErrorPath: | |
| 566 | + | return "error.html", true | |
| 567 | + | case WebRTCPath: | |
| 568 | + | return "webrtc.html", true | |
| 569 | + | default: | |
| 570 | + | return "", false | |
| 571 | + | } | |
| 572 | + | } | |
| 573 | + | ||
| 571 | 574 | // getMimeType determines the MIME type for a given file path. | |
| 572 | 575 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | |
| 573 | 576 | if h == nil { | |
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | const ( | 28 | const ( | ||
| 29 | HomePath = "home" | 29 | HomePath = "home" | ||
| 30 | ConfigPath = "config" | 30 | ConfigPath = "config" | ||
| 31 | + | WebRTCPath = "webrtc" | |||
| 31 | ErrorPath = "error" | 32 | ErrorPath = "error" | ||
| 32 | CrashPath = "crash" | 33 | CrashPath = "crash" | ||
| 33 | IndexHTML = "index.html" | 34 | IndexHTML = "index.html" | ||
| @@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 511 | return nil | 512 | return nil | ||
| 512 | } | 513 | } | ||
| 513 | 514 | ||||
| 514 | - | // Determine the target file based on host and path | 515 | + | relPath, ok := resolveAssetPath(u) |
| 515 | - | host := u.Host | 516 | + | if !ok { |
| 516 | - | path := strings.TrimPrefix(u.Path, "/") | |||
| 517 | - | ||||
| 518 | - | var relPath string | |||
| 519 | - | switch { | |||
| 520 | - | // Home root maps to index.html. | |||
| 521 | - | case host == HomePath && (path == "" || path == "/"): | |||
| 522 | - | relPath = IndexHTML | |||
| 523 | - | // Home asset paths map directly to assets. | |||
| 524 | - | case host == HomePath && path != "": | |||
| 525 | - | relPath = path | |||
| 526 | - | // Config root maps to config.html. | |||
| 527 | - | case host == ConfigPath && (path == "" || path == "/"): | |||
| 528 | - | relPath = "config.html" | |||
| 529 | - | // Config asset paths map directly to assets. | |||
| 530 | - | case host == ConfigPath && path != "": | |||
| 531 | - | relPath = path | |||
| 532 | - | // Error root maps to error.html. | |||
| 533 | - | case host == ErrorPath && (path == "" || path == "/"): | |||
| 534 | - | relPath = "error.html" | |||
| 535 | - | // Error asset paths map directly to assets. | |||
| 536 | - | case host == ErrorPath && path != "": | |||
| 537 | - | relPath = path | |||
| 538 | - | // Opaque home form maps to index.html. | |||
| 539 | - | case u.Opaque == HomePath: | |||
| 540 | - | relPath = IndexHTML | |||
| 541 | - | // Opaque error form maps to error.html. | |||
| 542 | - | case u.Opaque == ErrorPath: | |||
| 543 | - | relPath = "error.html" | |||
| 544 | - | default: | |||
| 545 | - | // Not a recognized asset path | |||
| 546 | return nil | 517 | return nil | ||
| 547 | } | 518 | } | ||
| 548 | 519 | ||||
| @@ -568,6 +539,38 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 568 | } | 539 | } | ||
| 569 | } | 540 | } | ||
| 570 | 541 | ||||
| 542 | + | func resolveAssetPath(u *url.URL) (string, bool) { | |||
| 543 | + | if u == nil { | |||
| 544 | + | return "", false | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | rootByHost := map[string]string{ | |||
| 548 | + | HomePath: IndexHTML, | |||
| 549 | + | ConfigPath: "config.html", | |||
| 550 | + | WebRTCPath: "webrtc.html", | |||
| 551 | + | ErrorPath: "error.html", | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | if root, ok := rootByHost[u.Host]; ok { | |||
| 555 | + | path := strings.TrimPrefix(u.Path, "/") | |||
| 556 | + | if path == "" { | |||
| 557 | + | return root, true | |||
| 558 | + | } | |||
| 559 | + | return path, true | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | switch u.Opaque { | |||
| 563 | + | case HomePath: | |||
| 564 | + | return IndexHTML, true | |||
| 565 | + | case ErrorPath: | |||
| 566 | + | return "error.html", true | |||
| 567 | + | case WebRTCPath: | |||
| 568 | + | return "webrtc.html", true | |||
| 569 | + | default: | |||
| 570 | + | return "", false | |||
| 571 | + | } | |||
| 572 | + | } | |||
| 573 | + | ||||
| 571 | // getMimeType determines the MIME type for a given file path. | 574 | // getMimeType determines the MIME type for a given file path. | ||
| 572 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | 575 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | ||
| 573 | if h == nil { | 576 | if h == nil { | ||
M internal/infrastructure/webkit/scheme_handler_test.go +28 -0
| @@ -2,8 +2,10 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "net/url" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/assets" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 8 | 10 | "github.com/stretchr/testify/require" | |
| 9 | 11 | ) | |
| @@ -87,3 +89,29 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||
| 87 | 89 | require.NotNil(t, resp) | |
| 88 | 90 | assert.Contains(t, string(resp.Data), `data-target=""`) | |
| 89 | 91 | } | |
| 92 | + | ||
| 93 | + | func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) { | |
| 94 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 95 | + | h.SetAssets(assets.WebUIAssets) | |
| 96 | + | ||
| 97 | + | u, err := url.Parse("dumb://webrtc/") | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | ||
| 100 | + | resp := h.handleAsset(u) | |
| 101 | + | require.NotNil(t, resp) | |
| 102 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |
| 103 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | |
| 107 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 108 | + | h.SetAssets(assets.WebUIAssets) | |
| 109 | + | ||
| 110 | + | u, err := url.Parse("dumb:webrtc") | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | ||
| 113 | + | resp := h.handleAsset(u) | |
| 114 | + | require.NotNil(t, resp) | |
| 115 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |
| 116 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |
| 117 | + | } | |
| @@ -2,8 +2,10 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "net/url" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/assets" | |||
| 7 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 11 | ) | ||
| @@ -87,3 +89,29 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||||
| 87 | require.NotNil(t, resp) | 89 | require.NotNil(t, resp) | ||
| 88 | assert.Contains(t, string(resp.Data), `data-target=""`) | 90 | assert.Contains(t, string(resp.Data), `data-target=""`) | ||
| 89 | } | 91 | } | ||
| 92 | + | ||||
| 93 | + | func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) { | |||
| 94 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 95 | + | h.SetAssets(assets.WebUIAssets) | |||
| 96 | + | ||||
| 97 | + | u, err := url.Parse("dumb://webrtc/") | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | resp := h.handleAsset(u) | |||
| 101 | + | require.NotNil(t, resp) | |||
| 102 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 103 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | |||
| 107 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 108 | + | h.SetAssets(assets.WebUIAssets) | |||
| 109 | + | ||||
| 110 | + | u, err := url.Parse("dumb:webrtc") | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | ||||
| 113 | + | resp := h.handleAsset(u) | |||
| 114 | + | require.NotNil(t, resp) | |||
| 115 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 116 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |||
| 117 | + | } | |||
M internal/infrastructure/webkit/settings.go +9 -0
| @@ -60,10 +60,15 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||
| 60 | 60 | applyCanvasSettings(settings) | |
| 61 | 61 | applyWebRTCSettings(settings) | |
| 62 | 62 | ||
| 63 | + | webrtcEnabled := settings.GetPropertyEnableWebrtc() | |
| 64 | + | mediaStreamEnabled := settings.GetPropertyEnableMediaStream() | |
| 65 | + | ||
| 63 | 66 | log.Debug(). | |
| 64 | 67 | Str("sans_font", cfg.Appearance.SansFont). | |
| 65 | 68 | Str("rendering_mode", string(cfg.Rendering.Mode)). | |
| 66 | 69 | Bool("developer_extras", cfg.Debug.EnableDevTools). | |
| 70 | + | Bool("webrtc_enabled", webrtcEnabled). | |
| 71 | + | Bool("media_stream_enabled", mediaStreamEnabled). | |
| 67 | 72 | Msg("settings applied") | |
| 68 | 73 | } | |
| 69 | 74 | ||
| @@ -152,6 +157,10 @@ func applyCanvasSettings(settings *webkit.Settings) { | |||
| 152 | 157 | } | |
| 153 | 158 | ||
| 154 | 159 | func applyWebRTCSettings(settings *webkit.Settings) { | |
| 160 | + | // Set both direct API and gobject properties for robustness with generated bindings. | |
| 161 | + | settings.SetPropertyEnableMediaStream(true) | |
| 162 | + | settings.SetPropertyEnableWebrtc(true) | |
| 163 | + | ||
| 155 | 164 | settings.SetEnableWebrtc(true) | |
| 156 | 165 | } | |
| 157 | 166 | ||
| @@ -60,10 +60,15 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||||
| 60 | applyCanvasSettings(settings) | 60 | applyCanvasSettings(settings) | ||
| 61 | applyWebRTCSettings(settings) | 61 | applyWebRTCSettings(settings) | ||
| 62 | 62 | ||||
| 63 | + | webrtcEnabled := settings.GetPropertyEnableWebrtc() | |||
| 64 | + | mediaStreamEnabled := settings.GetPropertyEnableMediaStream() | |||
| 65 | + | ||||
| 63 | log.Debug(). | 66 | log.Debug(). | ||
| 64 | Str("sans_font", cfg.Appearance.SansFont). | 67 | Str("sans_font", cfg.Appearance.SansFont). | ||
| 65 | Str("rendering_mode", string(cfg.Rendering.Mode)). | 68 | Str("rendering_mode", string(cfg.Rendering.Mode)). | ||
| 66 | Bool("developer_extras", cfg.Debug.EnableDevTools). | 69 | Bool("developer_extras", cfg.Debug.EnableDevTools). | ||
| 70 | + | Bool("webrtc_enabled", webrtcEnabled). | |||
| 71 | + | Bool("media_stream_enabled", mediaStreamEnabled). | |||
| 67 | Msg("settings applied") | 72 | Msg("settings applied") | ||
| 68 | } | 73 | } | ||
| 69 | 74 | ||||
| @@ -152,6 +157,10 @@ func applyCanvasSettings(settings *webkit.Settings) { | |||||
| 152 | } | 157 | } | ||
| 153 | 158 | ||||
| 154 | func applyWebRTCSettings(settings *webkit.Settings) { | 159 | func applyWebRTCSettings(settings *webkit.Settings) { | ||
| 160 | + | // Set both direct API and gobject properties for robustness with generated bindings. | |||
| 161 | + | settings.SetPropertyEnableMediaStream(true) | |||
| 162 | + | settings.SetPropertyEnableWebrtc(true) | |||
| 163 | + | ||||
| 155 | settings.SetEnableWebrtc(true) | 164 | settings.SetEnableWebrtc(true) | ||
| 156 | } | 165 | } | ||
| 157 | 166 | ||||
M internal/infrastructure/webkit/webview.go +113 -21
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 20 | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 21 | 21 | "github.com/jwijenbergh/puregotk/v4/gobject" | |
| 22 | + | gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types" | |
| 22 | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 23 | 24 | "github.com/rs/zerolog" | |
| 24 | 25 | ) | |
| @@ -414,6 +415,7 @@ func (wv *WebView) connectSignals() { | |||
| 414 | 415 | wv.connectEnterFullscreenSignal() | |
| 415 | 416 | wv.connectLeaveFullscreenSignal() | |
| 416 | 417 | wv.connectAudioStateSignal() | |
| 418 | + | wv.connectMediaCaptureStateSignals() | |
| 417 | 419 | wv.connectMouseTargetChangedSignal() | |
| 418 | 420 | wv.connectBackForwardListChangedSignal() | |
| 419 | 421 | wv.connectWebProcessTerminatedSignal() | |
| @@ -835,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() { | |||
| 835 | 837 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 836 | 838 | } | |
| 837 | 839 | ||
| 840 | + | func (wv *WebView) connectMediaCaptureStateSignals() { | |
| 841 | + | connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) { | |
| 842 | + | cb := func() { | |
| 843 | + | state := readState() | |
| 844 | + | wv.logger.Debug(). | |
| 845 | + | Uint64("id", uint64(wv.id)). | |
| 846 | + | Str("kind", kind). | |
| 847 | + | Str("state", mediaCaptureStateString(state)). | |
| 848 | + | Int("state_code", int(state)). | |
| 849 | + | Msg("media capture state changed") | |
| 850 | + | } | |
| 851 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb)) | |
| 852 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera") | |
| 856 | + | connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone") | |
| 857 | + | connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display") | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | func mediaCaptureStateString(state webkit.MediaCaptureState) string { | |
| 861 | + | switch state { | |
| 862 | + | case webkit.MediaCaptureStateNoneValue: | |
| 863 | + | return "none" | |
| 864 | + | case webkit.MediaCaptureStateActiveValue: | |
| 865 | + | return "active" | |
| 866 | + | case webkit.MediaCaptureStateMutedValue: | |
| 867 | + | return "muted" | |
| 868 | + | default: | |
| 869 | + | return "unknown" | |
| 870 | + | } | |
| 871 | + | } | |
| 872 | + | ||
| 838 | 873 | func (wv *WebView) connectMouseTargetChangedSignal() { | |
| 839 | 874 | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | |
| 840 | 875 | if wv.OnLinkHover == nil { | |
| @@ -1004,47 +1039,104 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||
| 1004 | 1039 | ||
| 1005 | 1040 | // determinePermissionTypes extracts permission types from a WebKit permission request. | |
| 1006 | 1041 | // This uses type checking to identify UserMediaPermissionRequest and its specific types. | |
| 1007 | - | // We use GObject property accessors as the primary method because the C function wrappers | |
| 1008 | - | // can hit a purego bug where bool return values are misread. | |
| 1042 | + | // We use GObject property accessors for audio/video request flags. Display detection | |
| 1043 | + | // uses the dedicated WebKit API because this WebKit build does not expose an | |
| 1044 | + | // "is-for-display-device" GObject property. | |
| 1009 | 1045 | func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string { | |
| 1010 | - | var types []string | |
| 1046 | + | requestKind := detectPermissionRequestKind(requestPtr) | |
| 1047 | + | switch requestKind { | |
| 1048 | + | case permissionRequestKindUserMedia: | |
| 1049 | + | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |
| 1050 | + | if userMediaReq == nil { | |
| 1051 | + | return nil | |
| 1052 | + | } | |
| 1011 | 1053 | ||
| 1012 | - | // Try to cast to UserMediaPermissionRequest | |
| 1013 | - | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |
| 1014 | - | if userMediaReq != nil { | |
| 1015 | 1054 | // Use GObject property accessors — more reliable than the C function wrappers | |
| 1016 | 1055 | // which can hit the purego bool return value bug. | |
| 1017 | 1056 | isAudio := userMediaReq.GetPropertyIsForAudioDevice() | |
| 1018 | 1057 | isVideo := userMediaReq.GetPropertyIsForVideoDevice() | |
| 1058 | + | isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) | |
| 1019 | 1059 | ||
| 1020 | 1060 | wv.logger.Debug(). | |
| 1021 | 1061 | Bool("is_audio", isAudio). | |
| 1022 | 1062 | Bool("is_video", isVideo). | |
| 1063 | + | Bool("is_display", isDisplay). | |
| 1023 | 1064 | Msg("permission request type detection") | |
| 1024 | 1065 | ||
| 1025 | - | if isAudio { | |
| 1026 | - | types = append(types, "microphone") | |
| 1027 | - | } | |
| 1028 | - | if isVideo { | |
| 1029 | - | // Check if this is display capture or camera | |
| 1030 | - | if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) { | |
| 1031 | - | types = append(types, "display") | |
| 1066 | + | return classifyPermissionRequestTypes(requestKind, isAudio, isVideo, isDisplay) | |
| 1067 | + | case permissionRequestKindDeviceInfo: | |
| 1068 | + | return classifyPermissionRequestTypes(requestKind, false, false, false) | |
| 1069 | + | default: | |
| 1070 | + | if requestPtr != 0 { | |
| 1071 | + | typeName := permissionRequestTypeName(requestPtr) | |
| 1072 | + | if typeName != "" { | |
| 1073 | + | wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type") | |
| 1032 | 1074 | } else { | |
| 1033 | - | types = append(types, "camera") | |
| 1075 | + | wv.logger.Warn().Msg("unknown permission request type") | |
| 1034 | 1076 | } | |
| 1035 | 1077 | } | |
| 1036 | - | return types | |
| 1078 | + | // Unknown permission type - could be clipboard, notifications, geolocation, etc. | |
| 1079 | + | // For now, return empty to trigger denial. Future phases will add these types. | |
| 1080 | + | return nil | |
| 1037 | 1081 | } | |
| 1082 | + | } | |
| 1038 | 1083 | ||
| 1039 | - | // Check for device enumeration request | |
| 1040 | - | deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr) | |
| 1041 | - | if deviceInfoReq != nil { | |
| 1084 | + | type permissionRequestKind int | |
| 1085 | + | ||
| 1086 | + | const ( | |
| 1087 | + | permissionRequestKindUnknown permissionRequestKind = iota | |
| 1088 | + | permissionRequestKindUserMedia | |
| 1089 | + | permissionRequestKindDeviceInfo | |
| 1090 | + | ) | |
| 1091 | + | ||
| 1092 | + | func detectPermissionRequestKind(requestPtr uintptr) permissionRequestKind { | |
| 1093 | + | if isPermissionRequestType(requestPtr, webkit.UserMediaPermissionRequestGLibType()) { | |
| 1094 | + | return permissionRequestKindUserMedia | |
| 1095 | + | } | |
| 1096 | + | if isPermissionRequestType(requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) { | |
| 1097 | + | return permissionRequestKindDeviceInfo | |
| 1098 | + | } | |
| 1099 | + | return permissionRequestKindUnknown | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | func isPermissionRequestType(requestPtr uintptr, requestType gtypes.GType) bool { | |
| 1103 | + | return gobjectTypeCheckInstanceIsAByPtr(requestPtr, requestType) | |
| 1104 | + | } | |
| 1105 | + | ||
| 1106 | + | func classifyPermissionRequestTypes( | |
| 1107 | + | kind permissionRequestKind, | |
| 1108 | + | isAudio, isVideo, isDisplay bool, | |
| 1109 | + | ) []string { | |
| 1110 | + | switch kind { | |
| 1111 | + | case permissionRequestKindUserMedia: | |
| 1112 | + | return classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay) | |
| 1113 | + | case permissionRequestKindDeviceInfo: | |
| 1042 | 1114 | return []string{"device_info"} | |
| 1115 | + | default: | |
| 1116 | + | return nil | |
| 1043 | 1117 | } | |
| 1118 | + | } | |
| 1044 | 1119 | ||
| 1045 | - | // Unknown permission type - could be clipboard, notifications, geolocation, etc. | |
| 1046 | - | // For now, return empty to trigger denial. Future phases will add these types. | |
| 1047 | - | return nil | |
| 1120 | + | func classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay bool) []string { | |
| 1121 | + | types := make([]string, 0, 2) | |
| 1122 | + | ||
| 1123 | + | if isAudio { | |
| 1124 | + | types = append(types, "microphone") | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | if isDisplay { | |
| 1128 | + | types = append(types, "display") | |
| 1129 | + | } else if isVideo { | |
| 1130 | + | types = append(types, "camera") | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | if len(types) == 0 { | |
| 1134 | + | // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all | |
| 1135 | + | // flags false even though the request is a screen-capture request. | |
| 1136 | + | return []string{"display"} | |
| 1137 | + | } | |
| 1138 | + | ||
| 1139 | + | return types | |
| 1048 | 1140 | } | |
| 1049 | 1141 | ||
| 1050 | 1142 | // allowPermissionRequest calls Allow() on the WebKit permission request. | |
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/jwijenbergh/puregotk/v4/gio" | 19 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 20 | "github.com/jwijenbergh/puregotk/v4/glib" | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 21 | "github.com/jwijenbergh/puregotk/v4/gobject" | 21 | "github.com/jwijenbergh/puregotk/v4/gobject" | ||
| 22 | + | gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types" | |||
| 22 | "github.com/jwijenbergh/puregotk/v4/gtk" | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 23 | "github.com/rs/zerolog" | 24 | "github.com/rs/zerolog" | ||
| 24 | ) | 25 | ) | ||
| @@ -414,6 +415,7 @@ func (wv *WebView) connectSignals() { | |||||
| 414 | wv.connectEnterFullscreenSignal() | 415 | wv.connectEnterFullscreenSignal() | ||
| 415 | wv.connectLeaveFullscreenSignal() | 416 | wv.connectLeaveFullscreenSignal() | ||
| 416 | wv.connectAudioStateSignal() | 417 | wv.connectAudioStateSignal() | ||
| 418 | + | wv.connectMediaCaptureStateSignals() | |||
| 417 | wv.connectMouseTargetChangedSignal() | 419 | wv.connectMouseTargetChangedSignal() | ||
| 418 | wv.connectBackForwardListChangedSignal() | 420 | wv.connectBackForwardListChangedSignal() | ||
| 419 | wv.connectWebProcessTerminatedSignal() | 421 | wv.connectWebProcessTerminatedSignal() | ||
| @@ -835,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() { | |||||
| 835 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 837 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 836 | } | 838 | } | ||
| 837 | 839 | ||||
| 840 | + | func (wv *WebView) connectMediaCaptureStateSignals() { | |||
| 841 | + | connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) { | |||
| 842 | + | cb := func() { | |||
| 843 | + | state := readState() | |||
| 844 | + | wv.logger.Debug(). | |||
| 845 | + | Uint64("id", uint64(wv.id)). | |||
| 846 | + | Str("kind", kind). | |||
| 847 | + | Str("state", mediaCaptureStateString(state)). | |||
| 848 | + | Int("state_code", int(state)). | |||
| 849 | + | Msg("media capture state changed") | |||
| 850 | + | } | |||
| 851 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb)) | |||
| 852 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera") | |||
| 856 | + | connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone") | |||
| 857 | + | connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display") | |||
| 858 | + | } | |||
| 859 | + | ||||
| 860 | + | func mediaCaptureStateString(state webkit.MediaCaptureState) string { | |||
| 861 | + | switch state { | |||
| 862 | + | case webkit.MediaCaptureStateNoneValue: | |||
| 863 | + | return "none" | |||
| 864 | + | case webkit.MediaCaptureStateActiveValue: | |||
| 865 | + | return "active" | |||
| 866 | + | case webkit.MediaCaptureStateMutedValue: | |||
| 867 | + | return "muted" | |||
| 868 | + | default: | |||
| 869 | + | return "unknown" | |||
| 870 | + | } | |||
| 871 | + | } | |||
| 872 | + | ||||
| 838 | func (wv *WebView) connectMouseTargetChangedSignal() { | 873 | func (wv *WebView) connectMouseTargetChangedSignal() { | ||
| 839 | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | 874 | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | ||
| 840 | if wv.OnLinkHover == nil { | 875 | if wv.OnLinkHover == nil { | ||
| @@ -1004,47 +1039,104 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||||
| 1004 | 1039 | ||||
| 1005 | // determinePermissionTypes extracts permission types from a WebKit permission request. | 1040 | // determinePermissionTypes extracts permission types from a WebKit permission request. | ||
| 1006 | // This uses type checking to identify UserMediaPermissionRequest and its specific types. | 1041 | // This uses type checking to identify UserMediaPermissionRequest and its specific types. | ||
| 1007 | - | // We use GObject property accessors as the primary method because the C function wrappers | 1042 | + | // We use GObject property accessors for audio/video request flags. Display detection |
| 1008 | - | // can hit a purego bug where bool return values are misread. | 1043 | + | // uses the dedicated WebKit API because this WebKit build does not expose an |
| 1044 | + | // "is-for-display-device" GObject property. | |||
| 1009 | func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string { | 1045 | func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string { | ||
| 1010 | - | var types []string | 1046 | + | requestKind := detectPermissionRequestKind(requestPtr) |
| 1047 | + | switch requestKind { | |||
| 1048 | + | case permissionRequestKindUserMedia: | |||
| 1049 | + | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1050 | + | if userMediaReq == nil { | |||
| 1051 | + | return nil | |||
| 1052 | + | } | |||
| 1011 | 1053 | ||||
| 1012 | - | // Try to cast to UserMediaPermissionRequest | |||
| 1013 | - | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1014 | - | if userMediaReq != nil { | |||
| 1015 | // Use GObject property accessors — more reliable than the C function wrappers | 1054 | // Use GObject property accessors — more reliable than the C function wrappers | ||
| 1016 | // which can hit the purego bool return value bug. | 1055 | // which can hit the purego bool return value bug. | ||
| 1017 | isAudio := userMediaReq.GetPropertyIsForAudioDevice() | 1056 | isAudio := userMediaReq.GetPropertyIsForAudioDevice() | ||
| 1018 | isVideo := userMediaReq.GetPropertyIsForVideoDevice() | 1057 | isVideo := userMediaReq.GetPropertyIsForVideoDevice() | ||
| 1058 | + | isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) | |||
| 1019 | 1059 | ||||
| 1020 | wv.logger.Debug(). | 1060 | wv.logger.Debug(). | ||
| 1021 | Bool("is_audio", isAudio). | 1061 | Bool("is_audio", isAudio). | ||
| 1022 | Bool("is_video", isVideo). | 1062 | Bool("is_video", isVideo). | ||
| 1063 | + | Bool("is_display", isDisplay). | |||
| 1023 | Msg("permission request type detection") | 1064 | Msg("permission request type detection") | ||
| 1024 | 1065 | ||||
| 1025 | - | if isAudio { | 1066 | + | return classifyPermissionRequestTypes(requestKind, isAudio, isVideo, isDisplay) |
| 1026 | - | types = append(types, "microphone") | 1067 | + | case permissionRequestKindDeviceInfo: |
| 1027 | - | } | 1068 | + | return classifyPermissionRequestTypes(requestKind, false, false, false) |
| 1028 | - | if isVideo { | 1069 | + | default: |
| 1029 | - | // Check if this is display capture or camera | 1070 | + | if requestPtr != 0 { |
| 1030 | - | if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) { | 1071 | + | typeName := permissionRequestTypeName(requestPtr) |
| 1031 | - | types = append(types, "display") | 1072 | + | if typeName != "" { |
| 1073 | + | wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type") | |||
| 1032 | } else { | 1074 | } else { | ||
| 1033 | - | types = append(types, "camera") | 1075 | + | wv.logger.Warn().Msg("unknown permission request type") |
| 1034 | } | 1076 | } | ||
| 1035 | } | 1077 | } | ||
| 1036 | - | return types | 1078 | + | // Unknown permission type - could be clipboard, notifications, geolocation, etc. |
| 1079 | + | // For now, return empty to trigger denial. Future phases will add these types. | |||
| 1080 | + | return nil | |||
| 1037 | } | 1081 | } | ||
| 1082 | + | } | |||
| 1038 | 1083 | ||||
| 1039 | - | // Check for device enumeration request | 1084 | + | type permissionRequestKind int |
| 1040 | - | deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr) | 1085 | + | |
| 1041 | - | if deviceInfoReq != nil { | 1086 | + | const ( |
| 1087 | + | permissionRequestKindUnknown permissionRequestKind = iota | |||
| 1088 | + | permissionRequestKindUserMedia | |||
| 1089 | + | permissionRequestKindDeviceInfo | |||
| 1090 | + | ) | |||
| 1091 | + | ||||
| 1092 | + | func detectPermissionRequestKind(requestPtr uintptr) permissionRequestKind { | |||
| 1093 | + | if isPermissionRequestType(requestPtr, webkit.UserMediaPermissionRequestGLibType()) { | |||
| 1094 | + | return permissionRequestKindUserMedia | |||
| 1095 | + | } | |||
| 1096 | + | if isPermissionRequestType(requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) { | |||
| 1097 | + | return permissionRequestKindDeviceInfo | |||
| 1098 | + | } | |||
| 1099 | + | return permissionRequestKindUnknown | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | func isPermissionRequestType(requestPtr uintptr, requestType gtypes.GType) bool { | |||
| 1103 | + | return gobjectTypeCheckInstanceIsAByPtr(requestPtr, requestType) | |||
| 1104 | + | } | |||
| 1105 | + | ||||
| 1106 | + | func classifyPermissionRequestTypes( | |||
| 1107 | + | kind permissionRequestKind, | |||
| 1108 | + | isAudio, isVideo, isDisplay bool, | |||
| 1109 | + | ) []string { | |||
| 1110 | + | switch kind { | |||
| 1111 | + | case permissionRequestKindUserMedia: | |||
| 1112 | + | return classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay) | |||
| 1113 | + | case permissionRequestKindDeviceInfo: | |||
| 1042 | return []string{"device_info"} | 1114 | return []string{"device_info"} | ||
| 1115 | + | default: | |||
| 1116 | + | return nil | |||
| 1043 | } | 1117 | } | ||
| 1118 | + | } | |||
| 1044 | 1119 | ||||
| 1045 | - | // Unknown permission type - could be clipboard, notifications, geolocation, etc. | 1120 | + | func classifyUserMediaPermissionTypes(isAudio, isVideo, isDisplay bool) []string { |
| 1046 | - | // For now, return empty to trigger denial. Future phases will add these types. | 1121 | + | types := make([]string, 0, 2) |
| 1047 | - | return nil | 1122 | + | |
| 1123 | + | if isAudio { | |||
| 1124 | + | types = append(types, "microphone") | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | if isDisplay { | |||
| 1128 | + | types = append(types, "display") | |||
| 1129 | + | } else if isVideo { | |||
| 1130 | + | types = append(types, "camera") | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | if len(types) == 0 { | |||
| 1134 | + | // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all | |||
| 1135 | + | // flags false even though the request is a screen-capture request. | |||
| 1136 | + | return []string{"display"} | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1139 | + | return types | |||
| 1048 | } | 1140 | } | ||
| 1049 | 1141 | ||||
| 1050 | // allowPermissionRequest calls Allow() on the WebKit permission request. | 1142 | // allowPermissionRequest calls Allow() on the WebKit permission request. | ||
A internal/infrastructure/webkit/webview_permission_types_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestClassifyUserMediaPermissionTypes(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | isAudio bool | |
| 13 | + | isVideo bool | |
| 14 | + | isDisplay bool | |
| 15 | + | expected []string | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "audio and camera", | |
| 19 | + | isAudio: true, | |
| 20 | + | isVideo: true, | |
| 21 | + | isDisplay: false, | |
| 22 | + | expected: []string{"microphone", "camera"}, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "screen only", | |
| 26 | + | isAudio: false, | |
| 27 | + | isVideo: false, | |
| 28 | + | isDisplay: true, | |
| 29 | + | expected: []string{"display"}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "screen with audio", | |
| 33 | + | isAudio: true, | |
| 34 | + | isVideo: false, | |
| 35 | + | isDisplay: true, | |
| 36 | + | expected: []string{"microphone", "display"}, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "webkit display fallback when all flags false", | |
| 40 | + | isAudio: false, | |
| 41 | + | isVideo: false, | |
| 42 | + | isDisplay: false, | |
| 43 | + | expected: []string{"display"}, | |
| 44 | + | }, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | for _, tt := range tests { | |
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |
| 49 | + | assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(tt.isAudio, tt.isVideo, tt.isDisplay)) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestClassifyPermissionRequestTypes(t *testing.T) { | |
| 55 | + | tests := []struct { | |
| 56 | + | name string | |
| 57 | + | kind permissionRequestKind | |
| 58 | + | isAudio bool | |
| 59 | + | isVideo bool | |
| 60 | + | isDisplay bool | |
| 61 | + | expected []string | |
| 62 | + | }{ | |
| 63 | + | { | |
| 64 | + | name: "device info request stays device_info", | |
| 65 | + | kind: permissionRequestKindDeviceInfo, | |
| 66 | + | expected: []string{"device_info"}, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "user media camera", | |
| 70 | + | kind: permissionRequestKindUserMedia, | |
| 71 | + | isAudio: false, | |
| 72 | + | isVideo: true, | |
| 73 | + | isDisplay: false, | |
| 74 | + | expected: []string{"camera"}, | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: "user media fallback display", | |
| 78 | + | kind: permissionRequestKindUserMedia, | |
| 79 | + | isAudio: false, | |
| 80 | + | isVideo: false, | |
| 81 | + | isDisplay: false, | |
| 82 | + | expected: []string{"display"}, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "unknown request denied", | |
| 86 | + | kind: permissionRequestKindUnknown, | |
| 87 | + | expected: nil, | |
| 88 | + | }, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for _, tt := range tests { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | assert.Equal(t, tt.expected, classifyPermissionRequestTypes(tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay)) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestClassifyUserMediaPermissionTypes(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | isAudio bool | |||
| 13 | + | isVideo bool | |||
| 14 | + | isDisplay bool | |||
| 15 | + | expected []string | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "audio and camera", | |||
| 19 | + | isAudio: true, | |||
| 20 | + | isVideo: true, | |||
| 21 | + | isDisplay: false, | |||
| 22 | + | expected: []string{"microphone", "camera"}, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "screen only", | |||
| 26 | + | isAudio: false, | |||
| 27 | + | isVideo: false, | |||
| 28 | + | isDisplay: true, | |||
| 29 | + | expected: []string{"display"}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "screen with audio", | |||
| 33 | + | isAudio: true, | |||
| 34 | + | isVideo: false, | |||
| 35 | + | isDisplay: true, | |||
| 36 | + | expected: []string{"microphone", "display"}, | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "webkit display fallback when all flags false", | |||
| 40 | + | isAudio: false, | |||
| 41 | + | isVideo: false, | |||
| 42 | + | isDisplay: false, | |||
| 43 | + | expected: []string{"display"}, | |||
| 44 | + | }, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | for _, tt := range tests { | |||
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 49 | + | assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(tt.isAudio, tt.isVideo, tt.isDisplay)) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestClassifyPermissionRequestTypes(t *testing.T) { | |||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | kind permissionRequestKind | |||
| 58 | + | isAudio bool | |||
| 59 | + | isVideo bool | |||
| 60 | + | isDisplay bool | |||
| 61 | + | expected []string | |||
| 62 | + | }{ | |||
| 63 | + | { | |||
| 64 | + | name: "device info request stays device_info", | |||
| 65 | + | kind: permissionRequestKindDeviceInfo, | |||
| 66 | + | expected: []string{"device_info"}, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "user media camera", | |||
| 70 | + | kind: permissionRequestKindUserMedia, | |||
| 71 | + | isAudio: false, | |||
| 72 | + | isVideo: true, | |||
| 73 | + | isDisplay: false, | |||
| 74 | + | expected: []string{"camera"}, | |||
| 75 | + | }, | |||
| 76 | + | { | |||
| 77 | + | name: "user media fallback display", | |||
| 78 | + | kind: permissionRequestKindUserMedia, | |||
| 79 | + | isAudio: false, | |||
| 80 | + | isVideo: false, | |||
| 81 | + | isDisplay: false, | |||
| 82 | + | expected: []string{"display"}, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "unknown request denied", | |||
| 86 | + | kind: permissionRequestKindUnknown, | |||
| 87 | + | expected: nil, | |||
| 88 | + | }, | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for _, tt := range tests { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | assert.Equal(t, tt.expected, classifyPermissionRequestTypes(tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay)) | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
M internal/ui/app.go +109 -2
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| @@ -94,6 +95,8 @@ type App struct { | |||
| 94 | 95 | appToaster *component.Toaster | |
| 95 | 96 | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |
| 96 | 97 | modeToaster *component.Toaster | |
| 98 | + | // Top-right WebRTC permission activity indicator | |
| 99 | + | webrtcIndicator *component.WebRTCPermissionIndicator | |
| 97 | 100 | ||
| 98 | 101 | // Session management | |
| 99 | 102 | sessionManager *component.SessionManager | |
| @@ -202,8 +205,8 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 202 | 205 | } | |
| 203 | 206 | } | |
| 204 | 207 | ||
| 205 | - | // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption) | |
| 206 | - | a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue) | |
| 208 | + | appID := AppID | |
| 209 | + | a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue) | |
| 207 | 210 | if a.gtkApp == nil { | |
| 208 | 211 | log.Error().Msg("failed to create GTK application") | |
| 209 | 212 | return 1 | |
| @@ -252,6 +255,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 252 | 255 | a.initDownloadHandler(ctx) | |
| 253 | 256 | ||
| 254 | 257 | a.initCoordinators(ctx) | |
| 258 | + | a.wireWebRTCPermissionIndicator() | |
| 255 | 259 | logging.Trace().Mark("coordinators_init") | |
| 256 | 260 | a.initKeyboardHandler(ctx) | |
| 257 | 261 | a.initOmniboxConfig(ctx) | |
| @@ -366,6 +370,15 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 366 | 370 | } | |
| 367 | 371 | } | |
| 368 | 372 | ||
| 373 | + | // Create top-right WebRTC permission activity indicator. | |
| 374 | + | indicator := component.NewWebRTCPermissionIndicator() | |
| 375 | + | if indicator != nil { | |
| 376 | + | if w := indicator.Widget(); w != nil { | |
| 377 | + | a.mainWindow.AddOverlay(w) | |
| 378 | + | a.webrtcIndicator = indicator | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 369 | 382 | // Apply GTK CSS styling from theme manager. | |
| 370 | 383 | if a.deps == nil || a.deps.Theme == nil { | |
| 371 | 384 | return nil | |
| @@ -1504,6 +1517,100 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1504 | 1517 | log.Debug().Msg("coordinators initialized") | |
| 1505 | 1518 | } | |
| 1506 | 1519 | ||
| 1520 | + | func (a *App) wireWebRTCPermissionIndicator() { | |
| 1521 | + | if a.webrtcIndicator == nil || a.contentCoord == nil { | |
| 1522 | + | return | |
| 1523 | + | } | |
| 1524 | + | ctx := context.Background() | |
| 1525 | + | if a.deps != nil && a.deps.Ctx != nil { | |
| 1526 | + | ctx = a.deps.Ctx | |
| 1527 | + | } | |
| 1528 | + | log := logging.FromContext(ctx) | |
| 1529 | + | ||
| 1530 | + | a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) { | |
| 1531 | + | a.webrtcIndicator.SetOrigin(origin) | |
| 1532 | + | ||
| 1533 | + | switch state { | |
| 1534 | + | case coordinator.PermissionActivityRequesting: | |
| 1535 | + | a.webrtcIndicator.MarkRequesting(permTypes) | |
| 1536 | + | case coordinator.PermissionActivityAllowed: | |
| 1537 | + | a.webrtcIndicator.MarkAllowed(permTypes) | |
| 1538 | + | case coordinator.PermissionActivityBlocked: | |
| 1539 | + | a.webrtcIndicator.MarkBlocked(permTypes) | |
| 1540 | + | } | |
| 1541 | + | ||
| 1542 | + | for _, permType := range permTypes { | |
| 1543 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |
| 1544 | + | } | |
| 1545 | + | }) | |
| 1546 | + | ||
| 1547 | + | a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) { | |
| 1548 | + | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 1549 | + | return | |
| 1550 | + | } | |
| 1551 | + | ||
| 1552 | + | if hasStored { | |
| 1553 | + | // Already locked: reset to prompt (will re-ask on next request). | |
| 1554 | + | if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil { | |
| 1555 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision") | |
| 1556 | + | return | |
| 1557 | + | } | |
| 1558 | + | } else { | |
| 1559 | + | // Not locked: flip the state. | |
| 1560 | + | // Allowed/requesting → deny future requests. | |
| 1561 | + | // Blocked → allow future requests. | |
| 1562 | + | decision := entity.PermissionDenied | |
| 1563 | + | if state == string(coordinator.PermissionActivityBlocked) { | |
| 1564 | + | decision = entity.PermissionGranted | |
| 1565 | + | } | |
| 1566 | + | if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil { | |
| 1567 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision") | |
| 1568 | + | return | |
| 1569 | + | } | |
| 1570 | + | } | |
| 1571 | + | ||
| 1572 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |
| 1573 | + | }) | |
| 1574 | + | ||
| 1575 | + | // Reset indicator when the active pane navigates away from the current origin. | |
| 1576 | + | a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) { | |
| 1577 | + | newOrigin, err := urlutil.ExtractOrigin(uri) | |
| 1578 | + | if err != nil { | |
| 1579 | + | // Internal pages (dumb://, about:) — clear the indicator. | |
| 1580 | + | a.webrtcIndicator.Reset() | |
| 1581 | + | return | |
| 1582 | + | } | |
| 1583 | + | ||
| 1584 | + | currentOrigin := a.webrtcIndicator.Origin() | |
| 1585 | + | if currentOrigin != "" && currentOrigin != newOrigin { | |
| 1586 | + | a.webrtcIndicator.Reset() | |
| 1587 | + | } | |
| 1588 | + | }) | |
| 1589 | + | } | |
| 1590 | + | ||
| 1591 | + | func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) { | |
| 1592 | + | if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 1593 | + | return | |
| 1594 | + | } | |
| 1595 | + | ||
| 1596 | + | record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType) | |
| 1597 | + | if err != nil { | |
| 1598 | + | logging.FromContext(ctx).Warn(). | |
| 1599 | + | Err(err). | |
| 1600 | + | Str("origin", origin). | |
| 1601 | + | Str("type", string(permType)). | |
| 1602 | + | Msg("failed to read manual permission decision") | |
| 1603 | + | return | |
| 1604 | + | } | |
| 1605 | + | ||
| 1606 | + | if record == nil { | |
| 1607 | + | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |
| 1608 | + | return | |
| 1609 | + | } | |
| 1610 | + | ||
| 1611 | + | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | |
| 1612 | + | } | |
| 1613 | + | ||
| 1507 | 1614 | // generateID generates a unique ID for tabs and panes. | |
| 1508 | 1615 | // Uses monotonically increasing counter to avoid ID collisions. | |
| 1509 | 1616 | func (a *App) generateID() string { | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 17 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| @@ -94,6 +95,8 @@ type App struct { | |||||
| 94 | appToaster *component.Toaster | 95 | appToaster *component.Toaster | ||
| 95 | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | 96 | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | ||
| 96 | modeToaster *component.Toaster | 97 | modeToaster *component.Toaster | ||
| 98 | + | // Top-right WebRTC permission activity indicator | |||
| 99 | + | webrtcIndicator *component.WebRTCPermissionIndicator | |||
| 97 | 100 | ||||
| 98 | // Session management | 101 | // Session management | ||
| 99 | sessionManager *component.SessionManager | 102 | sessionManager *component.SessionManager | ||
| @@ -202,8 +205,8 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 202 | } | 205 | } | ||
| 203 | } | 206 | } | ||
| 204 | 207 | ||||
| 205 | - | // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption) | 208 | + | appID := AppID |
| 206 | - | a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue) | 209 | + | a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue) |
| 207 | if a.gtkApp == nil { | 210 | if a.gtkApp == nil { | ||
| 208 | log.Error().Msg("failed to create GTK application") | 211 | log.Error().Msg("failed to create GTK application") | ||
| 209 | return 1 | 212 | return 1 | ||
| @@ -252,6 +255,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 252 | a.initDownloadHandler(ctx) | 255 | a.initDownloadHandler(ctx) | ||
| 253 | 256 | ||||
| 254 | a.initCoordinators(ctx) | 257 | a.initCoordinators(ctx) | ||
| 258 | + | a.wireWebRTCPermissionIndicator() | |||
| 255 | logging.Trace().Mark("coordinators_init") | 259 | logging.Trace().Mark("coordinators_init") | ||
| 256 | a.initKeyboardHandler(ctx) | 260 | a.initKeyboardHandler(ctx) | ||
| 257 | a.initOmniboxConfig(ctx) | 261 | a.initOmniboxConfig(ctx) | ||
| @@ -366,6 +370,15 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 366 | } | 370 | } | ||
| 367 | } | 371 | } | ||
| 368 | 372 | ||||
| 373 | + | // Create top-right WebRTC permission activity indicator. | |||
| 374 | + | indicator := component.NewWebRTCPermissionIndicator() | |||
| 375 | + | if indicator != nil { | |||
| 376 | + | if w := indicator.Widget(); w != nil { | |||
| 377 | + | a.mainWindow.AddOverlay(w) | |||
| 378 | + | a.webrtcIndicator = indicator | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 369 | // Apply GTK CSS styling from theme manager. | 382 | // Apply GTK CSS styling from theme manager. | ||
| 370 | if a.deps == nil || a.deps.Theme == nil { | 383 | if a.deps == nil || a.deps.Theme == nil { | ||
| 371 | return nil | 384 | return nil | ||
| @@ -1504,6 +1517,100 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1504 | log.Debug().Msg("coordinators initialized") | 1517 | log.Debug().Msg("coordinators initialized") | ||
| 1505 | } | 1518 | } | ||
| 1506 | 1519 | ||||
| 1520 | + | func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1521 | + | if a.webrtcIndicator == nil || a.contentCoord == nil { | |||
| 1522 | + | return | |||
| 1523 | + | } | |||
| 1524 | + | ctx := context.Background() | |||
| 1525 | + | if a.deps != nil && a.deps.Ctx != nil { | |||
| 1526 | + | ctx = a.deps.Ctx | |||
| 1527 | + | } | |||
| 1528 | + | log := logging.FromContext(ctx) | |||
| 1529 | + | ||||
| 1530 | + | a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) { | |||
| 1531 | + | a.webrtcIndicator.SetOrigin(origin) | |||
| 1532 | + | ||||
| 1533 | + | switch state { | |||
| 1534 | + | case coordinator.PermissionActivityRequesting: | |||
| 1535 | + | a.webrtcIndicator.MarkRequesting(permTypes) | |||
| 1536 | + | case coordinator.PermissionActivityAllowed: | |||
| 1537 | + | a.webrtcIndicator.MarkAllowed(permTypes) | |||
| 1538 | + | case coordinator.PermissionActivityBlocked: | |||
| 1539 | + | a.webrtcIndicator.MarkBlocked(permTypes) | |||
| 1540 | + | } | |||
| 1541 | + | ||||
| 1542 | + | for _, permType := range permTypes { | |||
| 1543 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |||
| 1544 | + | } | |||
| 1545 | + | }) | |||
| 1546 | + | ||||
| 1547 | + | a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) { | |||
| 1548 | + | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |||
| 1549 | + | return | |||
| 1550 | + | } | |||
| 1551 | + | ||||
| 1552 | + | if hasStored { | |||
| 1553 | + | // Already locked: reset to prompt (will re-ask on next request). | |||
| 1554 | + | if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil { | |||
| 1555 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision") | |||
| 1556 | + | return | |||
| 1557 | + | } | |||
| 1558 | + | } else { | |||
| 1559 | + | // Not locked: flip the state. | |||
| 1560 | + | // Allowed/requesting → deny future requests. | |||
| 1561 | + | // Blocked → allow future requests. | |||
| 1562 | + | decision := entity.PermissionDenied | |||
| 1563 | + | if state == string(coordinator.PermissionActivityBlocked) { | |||
| 1564 | + | decision = entity.PermissionGranted | |||
| 1565 | + | } | |||
| 1566 | + | if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil { | |||
| 1567 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision") | |||
| 1568 | + | return | |||
| 1569 | + | } | |||
| 1570 | + | } | |||
| 1571 | + | ||||
| 1572 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |||
| 1573 | + | }) | |||
| 1574 | + | ||||
| 1575 | + | // Reset indicator when the active pane navigates away from the current origin. | |||
| 1576 | + | a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) { | |||
| 1577 | + | newOrigin, err := urlutil.ExtractOrigin(uri) | |||
| 1578 | + | if err != nil { | |||
| 1579 | + | // Internal pages (dumb://, about:) — clear the indicator. | |||
| 1580 | + | a.webrtcIndicator.Reset() | |||
| 1581 | + | return | |||
| 1582 | + | } | |||
| 1583 | + | ||||
| 1584 | + | currentOrigin := a.webrtcIndicator.Origin() | |||
| 1585 | + | if currentOrigin != "" && currentOrigin != newOrigin { | |||
| 1586 | + | a.webrtcIndicator.Reset() | |||
| 1587 | + | } | |||
| 1588 | + | }) | |||
| 1589 | + | } | |||
| 1590 | + | ||||
| 1591 | + | func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) { | |||
| 1592 | + | if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |||
| 1593 | + | return | |||
| 1594 | + | } | |||
| 1595 | + | ||||
| 1596 | + | record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType) | |||
| 1597 | + | if err != nil { | |||
| 1598 | + | logging.FromContext(ctx).Warn(). | |||
| 1599 | + | Err(err). | |||
| 1600 | + | Str("origin", origin). | |||
| 1601 | + | Str("type", string(permType)). | |||
| 1602 | + | Msg("failed to read manual permission decision") | |||
| 1603 | + | return | |||
| 1604 | + | } | |||
| 1605 | + | ||||
| 1606 | + | if record == nil { | |||
| 1607 | + | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |||
| 1608 | + | return | |||
| 1609 | + | } | |||
| 1610 | + | ||||
| 1611 | + | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | |||
| 1612 | + | } | |||
| 1613 | + | ||||
| 1507 | // generateID generates a unique ID for tabs and panes. | 1614 | // generateID generates a unique ID for tabs and panes. | ||
| 1508 | // Uses monotonically increasing counter to avoid ID collisions. | 1615 | // Uses monotonically increasing counter to avoid ID collisions. | ||
| 1509 | func (a *App) generateID() string { | 1616 | func (a *App) generateID() string { | ||
A internal/ui/component/webrtc_permission_indicator.go +533 -0
| @@ -0,0 +1,533 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // revealerTransitionDuration is the animation duration in milliseconds | |
| 12 | + | // for the details panel slide-down. | |
| 13 | + | const revealerTransitionDuration = 180 | |
| 14 | + | ||
| 15 | + | var trackedWebRTCPermissionTypes = []entity.PermissionType{ | |
| 16 | + | entity.PermissionTypeMicrophone, | |
| 17 | + | entity.PermissionTypeCamera, | |
| 18 | + | entity.PermissionTypeDisplay, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // WebRTCPermissionIndicator is a discrete top-right overlay that shows | |
| 22 | + | // permission activity for microphone, camera, and display capture. | |
| 23 | + | // | |
| 24 | + | // Collapsed: a small colored dot. | |
| 25 | + | // Hovered: expands left and downward to show per-device states. | |
| 26 | + | // Uses GtkRevealer for smooth slide-down animation of the details panel. | |
| 27 | + | type WebRTCPermissionIndicator struct { | |
| 28 | + | outerBox *gtk.Box | |
| 29 | + | cardBox *gtk.Box | |
| 30 | + | dotBox *gtk.Box | |
| 31 | + | originLabel *gtk.Label | |
| 32 | + | detailsBox *gtk.Box | |
| 33 | + | revealer *gtk.Revealer | |
| 34 | + | ||
| 35 | + | rowButtons map[entity.PermissionType]*gtk.Button | |
| 36 | + | states map[entity.PermissionType]webrtcPermissionState | |
| 37 | + | stored map[entity.PermissionType]entity.PermissionDecision | |
| 38 | + | ||
| 39 | + | currentOrigin string | |
| 40 | + | onToggleLock func(origin string, permType entity.PermissionType, state string, hasStored bool) | |
| 41 | + | ||
| 42 | + | mu sync.Mutex | |
| 43 | + | expanded bool | |
| 44 | + | retainedCallbacks []interface{} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator. | |
| 48 | + | func NewWebRTCPermissionIndicator() *WebRTCPermissionIndicator { | |
| 49 | + | indicator := buildWebRTCPermissionIndicatorWidgets() | |
| 50 | + | if indicator == nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | indicator.wireRowButtons() | |
| 55 | + | indicator.attachHoverController() | |
| 56 | + | indicator.refreshLocked() | |
| 57 | + | ||
| 58 | + | return indicator | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // buildWebRTCPermissionIndicatorWidgets creates and assembles all GTK widgets | |
| 62 | + | // for the indicator: outer container, card, dot, revealer, details, and rows. | |
| 63 | + | func buildWebRTCPermissionIndicatorWidgets() *WebRTCPermissionIndicator { | |
| 64 | + | outer, card := createIndicatorContainers() | |
| 65 | + | if outer == nil || card == nil { | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | dotRow, dot, originLabel := createIndicatorDot() | |
| 70 | + | if dotRow == nil || dot == nil || originLabel == nil { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | details := createIndicatorDetails() | |
| 75 | + | if details == nil { | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | rowButtons, ok := createWebRTCPermissionRows(details) | |
| 80 | + | if !ok { | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | revealer := createIndicatorRevealer(details) | |
| 85 | + | if revealer == nil { | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | card.Append(&dotRow.Widget) | |
| 90 | + | card.Append(&revealer.Widget) | |
| 91 | + | outer.Append(&card.Widget) | |
| 92 | + | ||
| 93 | + | return &WebRTCPermissionIndicator{ | |
| 94 | + | outerBox: outer, | |
| 95 | + | cardBox: card, | |
| 96 | + | dotBox: dot, | |
| 97 | + | originLabel: originLabel, | |
| 98 | + | detailsBox: details, | |
| 99 | + | revealer: revealer, | |
| 100 | + | rowButtons: rowButtons, | |
| 101 | + | states: make(map[entity.PermissionType]webrtcPermissionState, len(trackedWebRTCPermissionTypes)), | |
| 102 | + | stored: make(map[entity.PermissionType]entity.PermissionDecision, len(trackedWebRTCPermissionTypes)), | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func createIndicatorContainers() (*gtk.Box, *gtk.Box) { | |
| 107 | + | outer := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 108 | + | if outer == nil { | |
| 109 | + | return nil, nil | |
| 110 | + | } | |
| 111 | + | outer.AddCssClass("webrtc-indicator-outer") | |
| 112 | + | outer.SetHalign(gtk.AlignEndValue) | |
| 113 | + | outer.SetValign(gtk.AlignStartValue) | |
| 114 | + | outer.SetHexpand(true) | |
| 115 | + | outer.SetVexpand(true) | |
| 116 | + | outer.SetVisible(false) | |
| 117 | + | ||
| 118 | + | card := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 119 | + | if card == nil { | |
| 120 | + | return nil, nil | |
| 121 | + | } | |
| 122 | + | card.AddCssClass("webrtc-indicator-card") | |
| 123 | + | ||
| 124 | + | return outer, card | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func createIndicatorDot() (*gtk.Box, *gtk.Box, *gtk.Label) { | |
| 128 | + | dotRow := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |
| 129 | + | if dotRow == nil { | |
| 130 | + | return nil, nil, nil | |
| 131 | + | } | |
| 132 | + | dotRow.AddCssClass("webrtc-indicator-header") | |
| 133 | + | dotRow.SetHalign(gtk.AlignEndValue) | |
| 134 | + | ||
| 135 | + | // Origin label on the left, dot on the right. | |
| 136 | + | originText := "" | |
| 137 | + | originLabel := gtk.NewLabel(&originText) | |
| 138 | + | if originLabel == nil { | |
| 139 | + | return nil, nil, nil | |
| 140 | + | } | |
| 141 | + | originLabel.AddCssClass("webrtc-indicator-origin") | |
| 142 | + | originLabel.SetHalign(gtk.AlignStartValue) | |
| 143 | + | originLabel.SetVisible(false) // Hidden when collapsed; shown on expand. | |
| 144 | + | ||
| 145 | + | dot := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 146 | + | if dot == nil { | |
| 147 | + | return nil, nil, nil | |
| 148 | + | } | |
| 149 | + | dot.AddCssClass("webrtc-indicator-dot") | |
| 150 | + | dot.SetHalign(gtk.AlignEndValue) | |
| 151 | + | dot.SetValign(gtk.AlignCenterValue) | |
| 152 | + | ||
| 153 | + | dotRow.Append(&originLabel.Widget) | |
| 154 | + | dotRow.Append(&dot.Widget) | |
| 155 | + | ||
| 156 | + | return dotRow, dot, originLabel | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func createIndicatorDetails() *gtk.Box { | |
| 160 | + | details := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 161 | + | if details == nil { | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | details.AddCssClass("webrtc-indicator-details") | |
| 165 | + | return details | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func createIndicatorRevealer(child *gtk.Box) *gtk.Revealer { | |
| 169 | + | revealer := gtk.NewRevealer() | |
| 170 | + | if revealer == nil { | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideDownValue) | |
| 174 | + | revealer.SetTransitionDuration(revealerTransitionDuration) | |
| 175 | + | revealer.SetRevealChild(false) | |
| 176 | + | revealer.AddCssClass("webrtc-indicator-revealer") | |
| 177 | + | revealer.SetChild(&child.Widget) | |
| 178 | + | ||
| 179 | + | return revealer | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func createWebRTCPermissionRows(details *gtk.Box) (map[entity.PermissionType]*gtk.Button, bool) { | |
| 183 | + | rows := make(map[entity.PermissionType]*gtk.Button, len(trackedWebRTCPermissionTypes)) | |
| 184 | + | for _, permType := range trackedWebRTCPermissionTypes { | |
| 185 | + | text := formatRowLabel(permType, webrtcPermissionStateIdle, "", false) | |
| 186 | + | row := gtk.NewButtonWithLabel(text) | |
| 187 | + | if row == nil { | |
| 188 | + | return nil, false | |
| 189 | + | } | |
| 190 | + | row.AddCssClass("webrtc-indicator-row") | |
| 191 | + | row.SetHalign(gtk.AlignFillValue) | |
| 192 | + | details.Append(&row.Widget) | |
| 193 | + | rows[permType] = row | |
| 194 | + | } | |
| 195 | + | return rows, true | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (w *WebRTCPermissionIndicator) wireRowButtons() { | |
| 199 | + | for _, permType := range trackedWebRTCPermissionTypes { | |
| 200 | + | capturedPermType := permType | |
| 201 | + | btn := w.rowButtons[capturedPermType] | |
| 202 | + | if btn == nil { | |
| 203 | + | continue | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | clickCb := func(_ gtk.Button) { | |
| 207 | + | w.mu.Lock() | |
| 208 | + | origin := w.currentOrigin | |
| 209 | + | state := w.states[capturedPermType] | |
| 210 | + | _, hasStored := w.stored[capturedPermType] | |
| 211 | + | toggle := w.onToggleLock | |
| 212 | + | w.mu.Unlock() | |
| 213 | + | ||
| 214 | + | if toggle == nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | toggle(origin, capturedPermType, string(state), hasStored) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | w.retainedCallbacks = append(w.retainedCallbacks, clickCb) | |
| 221 | + | btn.ConnectClicked(&clickCb) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Widget returns the GTK widget for adding to the main overlay. | |
| 226 | + | func (w *WebRTCPermissionIndicator) Widget() *gtk.Widget { | |
| 227 | + | if w == nil || w.outerBox == nil { | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | return &w.outerBox.Widget | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // MarkRequesting marks tracked permissions as currently requesting access. | |
| 234 | + | func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) { | |
| 235 | + | w.setState(permTypes, webrtcPermissionStateRequesting) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // MarkAllowed marks tracked permissions as allowed. | |
| 239 | + | func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) { | |
| 240 | + | w.setState(permTypes, webrtcPermissionStateAllowed) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // MarkBlocked marks tracked permissions as blocked. | |
| 244 | + | func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) { | |
| 245 | + | w.setState(permTypes, webrtcPermissionStateBlocked) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // Origin returns the current origin tracked by the indicator. | |
| 249 | + | func (w *WebRTCPermissionIndicator) Origin() string { | |
| 250 | + | if w == nil { | |
| 251 | + | return "" | |
| 252 | + | } | |
| 253 | + | w.mu.Lock() | |
| 254 | + | defer w.mu.Unlock() | |
| 255 | + | return w.currentOrigin | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // SetOrigin sets the active origin for lock/unlock actions shown in details. | |
| 259 | + | func (w *WebRTCPermissionIndicator) SetOrigin(origin string) { | |
| 260 | + | if w == nil { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | w.mu.Lock() | |
| 264 | + | defer w.mu.Unlock() | |
| 265 | + | w.currentOrigin = origin | |
| 266 | + | w.refreshLocked() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // SetStoredDecision updates whether a permission type has an explicit stored decision. | |
| 270 | + | func (w *WebRTCPermissionIndicator) SetStoredDecision( | |
| 271 | + | permType entity.PermissionType, | |
| 272 | + | decision entity.PermissionDecision, | |
| 273 | + | hasStored bool, | |
| 274 | + | ) { | |
| 275 | + | if w == nil || !isTrackedWebRTCPermissionType(permType) { | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | w.mu.Lock() | |
| 280 | + | defer w.mu.Unlock() | |
| 281 | + | ||
| 282 | + | if hasStored { | |
| 283 | + | w.stored[permType] = decision | |
| 284 | + | } else { | |
| 285 | + | delete(w.stored, permType) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | w.refreshLocked() | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Reset clears all permission states and stored decisions, hiding the indicator. | |
| 292 | + | // Call this when the active page navigates to a different origin. | |
| 293 | + | func (w *WebRTCPermissionIndicator) Reset() { | |
| 294 | + | if w == nil { | |
| 295 | + | return | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | w.mu.Lock() | |
| 299 | + | defer w.mu.Unlock() | |
| 300 | + | ||
| 301 | + | for k := range w.states { | |
| 302 | + | delete(w.states, k) | |
| 303 | + | } | |
| 304 | + | for k := range w.stored { | |
| 305 | + | delete(w.stored, k) | |
| 306 | + | } | |
| 307 | + | w.currentOrigin = "" | |
| 308 | + | ||
| 309 | + | w.refreshLocked() | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked. | |
| 313 | + | func (w *WebRTCPermissionIndicator) SetOnToggleLock( | |
| 314 | + | callback func(origin string, permType entity.PermissionType, state string, hasStored bool), | |
| 315 | + | ) { | |
| 316 | + | if w == nil { | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | w.mu.Lock() | |
| 320 | + | defer w.mu.Unlock() | |
| 321 | + | w.onToggleLock = callback | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) { | |
| 325 | + | if w == nil { | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | w.mu.Lock() | |
| 330 | + | defer w.mu.Unlock() | |
| 331 | + | ||
| 332 | + | for _, permType := range permTypes { | |
| 333 | + | if !isTrackedWebRTCPermissionType(permType) { | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | w.states[permType] = state | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | w.refreshLocked() | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (w *WebRTCPermissionIndicator) refreshLocked() { | |
| 343 | + | if w.outerBox == nil { | |
| 344 | + | return | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | visible := shouldShowWebRTCPermissionIndicator(w.states) | |
| 348 | + | w.outerBox.SetVisible(visible) | |
| 349 | + | ||
| 350 | + | summary := summarizeWebRTCPermissionState(w.states) | |
| 351 | + | if w.dotBox != nil { | |
| 352 | + | updateStateCSSClass(&w.dotBox.Widget, summary) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | if w.originLabel != nil { | |
| 356 | + | origin := w.currentOrigin | |
| 357 | + | if origin == "" { | |
| 358 | + | origin = "Current site" | |
| 359 | + | } | |
| 360 | + | w.originLabel.SetText(origin) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | for _, permType := range trackedWebRTCPermissionTypes { | |
| 364 | + | row := w.rowButtons[permType] | |
| 365 | + | if row == nil { | |
| 366 | + | continue | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | state := w.states[permType] | |
| 370 | + | if state == "" { | |
| 371 | + | state = webrtcPermissionStateIdle | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | storedDecision, hasStored := w.stored[permType] | |
| 375 | + | label := formatRowLabel(permType, state, storedDecision, hasStored) | |
| 376 | + | row.SetLabel(label) | |
| 377 | + | updateStateCSSClass(&row.Widget, state) | |
| 378 | + | updateLockedCSSClass(&row.Widget, hasStored) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | if !visible { | |
| 382 | + | w.setExpandedLocked(false) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func (w *WebRTCPermissionIndicator) attachHoverController() { | |
| 387 | + | if w == nil || w.outerBox == nil { | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | controller := gtk.NewEventControllerMotion() | |
| 392 | + | if controller == nil { | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 397 | + | w.mu.Lock() | |
| 398 | + | defer w.mu.Unlock() | |
| 399 | + | w.setExpandedLocked(true) | |
| 400 | + | } | |
| 401 | + | leaveCb := func(_ gtk.EventControllerMotion) { | |
| 402 | + | w.mu.Lock() | |
| 403 | + | defer w.mu.Unlock() | |
| 404 | + | w.setExpandedLocked(false) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb) | |
| 408 | + | controller.ConnectEnter(&enterCb) | |
| 409 | + | controller.ConnectLeave(&leaveCb) | |
| 410 | + | w.outerBox.AddController(&controller.EventController) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) { | |
| 414 | + | if w.revealer == nil || w.cardBox == nil { | |
| 415 | + | return | |
| 416 | + | } | |
| 417 | + | if w.expanded == expanded { | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | w.expanded = expanded | |
| 422 | + | w.revealer.SetRevealChild(expanded) | |
| 423 | + | ||
| 424 | + | // Show/hide origin label so it doesn't take space when collapsed to just a dot. | |
| 425 | + | // Toggle hexpand alongside visibility to prevent expand propagation when collapsed. | |
| 426 | + | if w.originLabel != nil { | |
| 427 | + | w.originLabel.SetVisible(expanded) | |
| 428 | + | w.originLabel.SetHexpand(expanded) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | if expanded { | |
| 432 | + | w.cardBox.AddCssClass("expanded") | |
| 433 | + | } else { | |
| 434 | + | w.cardBox.RemoveCssClass("expanded") | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool { | |
| 439 | + | switch permType { | |
| 440 | + | case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay: | |
| 441 | + | return true | |
| 442 | + | default: | |
| 443 | + | return false | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func permissionDisplayName(permType entity.PermissionType) string { | |
| 448 | + | switch permType { | |
| 449 | + | case entity.PermissionTypeMicrophone: | |
| 450 | + | return "\U0001F3A4 Mic" // 🎤 | |
| 451 | + | case entity.PermissionTypeCamera: | |
| 452 | + | return "\U0001F4F7 Cam" // 📷 | |
| 453 | + | case entity.PermissionTypeDisplay: | |
| 454 | + | return "\U0001F4BB Screen" // 💻 | |
| 455 | + | default: | |
| 456 | + | return "Unknown" | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func stateDisplayName(state webrtcPermissionState) string { | |
| 461 | + | switch state { | |
| 462 | + | case webrtcPermissionStateRequesting: | |
| 463 | + | return "\u26A0 requesting" // ⚠ | |
| 464 | + | case webrtcPermissionStateAllowed: | |
| 465 | + | return "\u2713 active" // ✓ | |
| 466 | + | case webrtcPermissionStateBlocked: | |
| 467 | + | return "\u2717 blocked" // ✗ | |
| 468 | + | default: | |
| 469 | + | return "idle" | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func formatRowLabel( | |
| 474 | + | permType entity.PermissionType, | |
| 475 | + | state webrtcPermissionState, | |
| 476 | + | storedDecision entity.PermissionDecision, | |
| 477 | + | hasStored bool, | |
| 478 | + | ) string { | |
| 479 | + | name := permissionDisplayName(permType) | |
| 480 | + | status := stateDisplayName(state) | |
| 481 | + | ||
| 482 | + | if hasStored { | |
| 483 | + | // Locked: show current policy and offer to reset. | |
| 484 | + | policy := "allow" | |
| 485 | + | if storedDecision == entity.PermissionDenied { | |
| 486 | + | policy = "deny" | |
| 487 | + | } | |
| 488 | + | return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // Not locked: show the flip action. | |
| 492 | + | switch state { | |
| 493 | + | case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting: | |
| 494 | + | return fmt.Sprintf("%s %s \u00B7 block", name, status) | |
| 495 | + | case webrtcPermissionStateBlocked: | |
| 496 | + | return fmt.Sprintf("%s %s \u00B7 allow", name, status) | |
| 497 | + | default: | |
| 498 | + | return fmt.Sprintf("%s %s", name, status) | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func updateLockedCSSClass(widget *gtk.Widget, locked bool) { | |
| 503 | + | if widget == nil { | |
| 504 | + | return | |
| 505 | + | } | |
| 506 | + | if locked { | |
| 507 | + | widget.AddCssClass("row-locked") | |
| 508 | + | } else { | |
| 509 | + | widget.RemoveCssClass("row-locked") | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) { | |
| 514 | + | if widget == nil { | |
| 515 | + | return | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | widget.RemoveCssClass("state-idle") | |
| 519 | + | widget.RemoveCssClass("state-requesting") | |
| 520 | + | widget.RemoveCssClass("state-allowed") | |
| 521 | + | widget.RemoveCssClass("state-blocked") | |
| 522 | + | ||
| 523 | + | switch state { | |
| 524 | + | case webrtcPermissionStateRequesting: | |
| 525 | + | widget.AddCssClass("state-requesting") | |
| 526 | + | case webrtcPermissionStateAllowed: | |
| 527 | + | widget.AddCssClass("state-allowed") | |
| 528 | + | case webrtcPermissionStateBlocked: | |
| 529 | + | widget.AddCssClass("state-blocked") | |
| 530 | + | default: | |
| 531 | + | widget.AddCssClass("state-idle") | |
| 532 | + | } | |
| 533 | + | } | |
| @@ -0,0 +1,533 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // revealerTransitionDuration is the animation duration in milliseconds | |||
| 12 | + | // for the details panel slide-down. | |||
| 13 | + | const revealerTransitionDuration = 180 | |||
| 14 | + | ||||
| 15 | + | var trackedWebRTCPermissionTypes = []entity.PermissionType{ | |||
| 16 | + | entity.PermissionTypeMicrophone, | |||
| 17 | + | entity.PermissionTypeCamera, | |||
| 18 | + | entity.PermissionTypeDisplay, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // WebRTCPermissionIndicator is a discrete top-right overlay that shows | |||
| 22 | + | // permission activity for microphone, camera, and display capture. | |||
| 23 | + | // | |||
| 24 | + | // Collapsed: a small colored dot. | |||
| 25 | + | // Hovered: expands left and downward to show per-device states. | |||
| 26 | + | // Uses GtkRevealer for smooth slide-down animation of the details panel. | |||
| 27 | + | type WebRTCPermissionIndicator struct { | |||
| 28 | + | outerBox *gtk.Box | |||
| 29 | + | cardBox *gtk.Box | |||
| 30 | + | dotBox *gtk.Box | |||
| 31 | + | originLabel *gtk.Label | |||
| 32 | + | detailsBox *gtk.Box | |||
| 33 | + | revealer *gtk.Revealer | |||
| 34 | + | ||||
| 35 | + | rowButtons map[entity.PermissionType]*gtk.Button | |||
| 36 | + | states map[entity.PermissionType]webrtcPermissionState | |||
| 37 | + | stored map[entity.PermissionType]entity.PermissionDecision | |||
| 38 | + | ||||
| 39 | + | currentOrigin string | |||
| 40 | + | onToggleLock func(origin string, permType entity.PermissionType, state string, hasStored bool) | |||
| 41 | + | ||||
| 42 | + | mu sync.Mutex | |||
| 43 | + | expanded bool | |||
| 44 | + | retainedCallbacks []interface{} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator. | |||
| 48 | + | func NewWebRTCPermissionIndicator() *WebRTCPermissionIndicator { | |||
| 49 | + | indicator := buildWebRTCPermissionIndicatorWidgets() | |||
| 50 | + | if indicator == nil { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | indicator.wireRowButtons() | |||
| 55 | + | indicator.attachHoverController() | |||
| 56 | + | indicator.refreshLocked() | |||
| 57 | + | ||||
| 58 | + | return indicator | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // buildWebRTCPermissionIndicatorWidgets creates and assembles all GTK widgets | |||
| 62 | + | // for the indicator: outer container, card, dot, revealer, details, and rows. | |||
| 63 | + | func buildWebRTCPermissionIndicatorWidgets() *WebRTCPermissionIndicator { | |||
| 64 | + | outer, card := createIndicatorContainers() | |||
| 65 | + | if outer == nil || card == nil { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | dotRow, dot, originLabel := createIndicatorDot() | |||
| 70 | + | if dotRow == nil || dot == nil || originLabel == nil { | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | details := createIndicatorDetails() | |||
| 75 | + | if details == nil { | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | rowButtons, ok := createWebRTCPermissionRows(details) | |||
| 80 | + | if !ok { | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | revealer := createIndicatorRevealer(details) | |||
| 85 | + | if revealer == nil { | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | card.Append(&dotRow.Widget) | |||
| 90 | + | card.Append(&revealer.Widget) | |||
| 91 | + | outer.Append(&card.Widget) | |||
| 92 | + | ||||
| 93 | + | return &WebRTCPermissionIndicator{ | |||
| 94 | + | outerBox: outer, | |||
| 95 | + | cardBox: card, | |||
| 96 | + | dotBox: dot, | |||
| 97 | + | originLabel: originLabel, | |||
| 98 | + | detailsBox: details, | |||
| 99 | + | revealer: revealer, | |||
| 100 | + | rowButtons: rowButtons, | |||
| 101 | + | states: make(map[entity.PermissionType]webrtcPermissionState, len(trackedWebRTCPermissionTypes)), | |||
| 102 | + | stored: make(map[entity.PermissionType]entity.PermissionDecision, len(trackedWebRTCPermissionTypes)), | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func createIndicatorContainers() (*gtk.Box, *gtk.Box) { | |||
| 107 | + | outer := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 108 | + | if outer == nil { | |||
| 109 | + | return nil, nil | |||
| 110 | + | } | |||
| 111 | + | outer.AddCssClass("webrtc-indicator-outer") | |||
| 112 | + | outer.SetHalign(gtk.AlignEndValue) | |||
| 113 | + | outer.SetValign(gtk.AlignStartValue) | |||
| 114 | + | outer.SetHexpand(true) | |||
| 115 | + | outer.SetVexpand(true) | |||
| 116 | + | outer.SetVisible(false) | |||
| 117 | + | ||||
| 118 | + | card := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 119 | + | if card == nil { | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | card.AddCssClass("webrtc-indicator-card") | |||
| 123 | + | ||||
| 124 | + | return outer, card | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func createIndicatorDot() (*gtk.Box, *gtk.Box, *gtk.Label) { | |||
| 128 | + | dotRow := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |||
| 129 | + | if dotRow == nil { | |||
| 130 | + | return nil, nil, nil | |||
| 131 | + | } | |||
| 132 | + | dotRow.AddCssClass("webrtc-indicator-header") | |||
| 133 | + | dotRow.SetHalign(gtk.AlignEndValue) | |||
| 134 | + | ||||
| 135 | + | // Origin label on the left, dot on the right. | |||
| 136 | + | originText := "" | |||
| 137 | + | originLabel := gtk.NewLabel(&originText) | |||
| 138 | + | if originLabel == nil { | |||
| 139 | + | return nil, nil, nil | |||
| 140 | + | } | |||
| 141 | + | originLabel.AddCssClass("webrtc-indicator-origin") | |||
| 142 | + | originLabel.SetHalign(gtk.AlignStartValue) | |||
| 143 | + | originLabel.SetVisible(false) // Hidden when collapsed; shown on expand. | |||
| 144 | + | ||||
| 145 | + | dot := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 146 | + | if dot == nil { | |||
| 147 | + | return nil, nil, nil | |||
| 148 | + | } | |||
| 149 | + | dot.AddCssClass("webrtc-indicator-dot") | |||
| 150 | + | dot.SetHalign(gtk.AlignEndValue) | |||
| 151 | + | dot.SetValign(gtk.AlignCenterValue) | |||
| 152 | + | ||||
| 153 | + | dotRow.Append(&originLabel.Widget) | |||
| 154 | + | dotRow.Append(&dot.Widget) | |||
| 155 | + | ||||
| 156 | + | return dotRow, dot, originLabel | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func createIndicatorDetails() *gtk.Box { | |||
| 160 | + | details := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 161 | + | if details == nil { | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | details.AddCssClass("webrtc-indicator-details") | |||
| 165 | + | return details | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func createIndicatorRevealer(child *gtk.Box) *gtk.Revealer { | |||
| 169 | + | revealer := gtk.NewRevealer() | |||
| 170 | + | if revealer == nil { | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideDownValue) | |||
| 174 | + | revealer.SetTransitionDuration(revealerTransitionDuration) | |||
| 175 | + | revealer.SetRevealChild(false) | |||
| 176 | + | revealer.AddCssClass("webrtc-indicator-revealer") | |||
| 177 | + | revealer.SetChild(&child.Widget) | |||
| 178 | + | ||||
| 179 | + | return revealer | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func createWebRTCPermissionRows(details *gtk.Box) (map[entity.PermissionType]*gtk.Button, bool) { | |||
| 183 | + | rows := make(map[entity.PermissionType]*gtk.Button, len(trackedWebRTCPermissionTypes)) | |||
| 184 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 185 | + | text := formatRowLabel(permType, webrtcPermissionStateIdle, "", false) | |||
| 186 | + | row := gtk.NewButtonWithLabel(text) | |||
| 187 | + | if row == nil { | |||
| 188 | + | return nil, false | |||
| 189 | + | } | |||
| 190 | + | row.AddCssClass("webrtc-indicator-row") | |||
| 191 | + | row.SetHalign(gtk.AlignFillValue) | |||
| 192 | + | details.Append(&row.Widget) | |||
| 193 | + | rows[permType] = row | |||
| 194 | + | } | |||
| 195 | + | return rows, true | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (w *WebRTCPermissionIndicator) wireRowButtons() { | |||
| 199 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 200 | + | capturedPermType := permType | |||
| 201 | + | btn := w.rowButtons[capturedPermType] | |||
| 202 | + | if btn == nil { | |||
| 203 | + | continue | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | clickCb := func(_ gtk.Button) { | |||
| 207 | + | w.mu.Lock() | |||
| 208 | + | origin := w.currentOrigin | |||
| 209 | + | state := w.states[capturedPermType] | |||
| 210 | + | _, hasStored := w.stored[capturedPermType] | |||
| 211 | + | toggle := w.onToggleLock | |||
| 212 | + | w.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | if toggle == nil { | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | toggle(origin, capturedPermType, string(state), hasStored) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | w.retainedCallbacks = append(w.retainedCallbacks, clickCb) | |||
| 221 | + | btn.ConnectClicked(&clickCb) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Widget returns the GTK widget for adding to the main overlay. | |||
| 226 | + | func (w *WebRTCPermissionIndicator) Widget() *gtk.Widget { | |||
| 227 | + | if w == nil || w.outerBox == nil { | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | return &w.outerBox.Widget | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // MarkRequesting marks tracked permissions as currently requesting access. | |||
| 234 | + | func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) { | |||
| 235 | + | w.setState(permTypes, webrtcPermissionStateRequesting) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // MarkAllowed marks tracked permissions as allowed. | |||
| 239 | + | func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) { | |||
| 240 | + | w.setState(permTypes, webrtcPermissionStateAllowed) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // MarkBlocked marks tracked permissions as blocked. | |||
| 244 | + | func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) { | |||
| 245 | + | w.setState(permTypes, webrtcPermissionStateBlocked) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // Origin returns the current origin tracked by the indicator. | |||
| 249 | + | func (w *WebRTCPermissionIndicator) Origin() string { | |||
| 250 | + | if w == nil { | |||
| 251 | + | return "" | |||
| 252 | + | } | |||
| 253 | + | w.mu.Lock() | |||
| 254 | + | defer w.mu.Unlock() | |||
| 255 | + | return w.currentOrigin | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // SetOrigin sets the active origin for lock/unlock actions shown in details. | |||
| 259 | + | func (w *WebRTCPermissionIndicator) SetOrigin(origin string) { | |||
| 260 | + | if w == nil { | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | w.mu.Lock() | |||
| 264 | + | defer w.mu.Unlock() | |||
| 265 | + | w.currentOrigin = origin | |||
| 266 | + | w.refreshLocked() | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // SetStoredDecision updates whether a permission type has an explicit stored decision. | |||
| 270 | + | func (w *WebRTCPermissionIndicator) SetStoredDecision( | |||
| 271 | + | permType entity.PermissionType, | |||
| 272 | + | decision entity.PermissionDecision, | |||
| 273 | + | hasStored bool, | |||
| 274 | + | ) { | |||
| 275 | + | if w == nil || !isTrackedWebRTCPermissionType(permType) { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | w.mu.Lock() | |||
| 280 | + | defer w.mu.Unlock() | |||
| 281 | + | ||||
| 282 | + | if hasStored { | |||
| 283 | + | w.stored[permType] = decision | |||
| 284 | + | } else { | |||
| 285 | + | delete(w.stored, permType) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | w.refreshLocked() | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Reset clears all permission states and stored decisions, hiding the indicator. | |||
| 292 | + | // Call this when the active page navigates to a different origin. | |||
| 293 | + | func (w *WebRTCPermissionIndicator) Reset() { | |||
| 294 | + | if w == nil { | |||
| 295 | + | return | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | w.mu.Lock() | |||
| 299 | + | defer w.mu.Unlock() | |||
| 300 | + | ||||
| 301 | + | for k := range w.states { | |||
| 302 | + | delete(w.states, k) | |||
| 303 | + | } | |||
| 304 | + | for k := range w.stored { | |||
| 305 | + | delete(w.stored, k) | |||
| 306 | + | } | |||
| 307 | + | w.currentOrigin = "" | |||
| 308 | + | ||||
| 309 | + | w.refreshLocked() | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked. | |||
| 313 | + | func (w *WebRTCPermissionIndicator) SetOnToggleLock( | |||
| 314 | + | callback func(origin string, permType entity.PermissionType, state string, hasStored bool), | |||
| 315 | + | ) { | |||
| 316 | + | if w == nil { | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | w.mu.Lock() | |||
| 320 | + | defer w.mu.Unlock() | |||
| 321 | + | w.onToggleLock = callback | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) { | |||
| 325 | + | if w == nil { | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | w.mu.Lock() | |||
| 330 | + | defer w.mu.Unlock() | |||
| 331 | + | ||||
| 332 | + | for _, permType := range permTypes { | |||
| 333 | + | if !isTrackedWebRTCPermissionType(permType) { | |||
| 334 | + | continue | |||
| 335 | + | } | |||
| 336 | + | w.states[permType] = state | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | w.refreshLocked() | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func (w *WebRTCPermissionIndicator) refreshLocked() { | |||
| 343 | + | if w.outerBox == nil { | |||
| 344 | + | return | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | visible := shouldShowWebRTCPermissionIndicator(w.states) | |||
| 348 | + | w.outerBox.SetVisible(visible) | |||
| 349 | + | ||||
| 350 | + | summary := summarizeWebRTCPermissionState(w.states) | |||
| 351 | + | if w.dotBox != nil { | |||
| 352 | + | updateStateCSSClass(&w.dotBox.Widget, summary) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | if w.originLabel != nil { | |||
| 356 | + | origin := w.currentOrigin | |||
| 357 | + | if origin == "" { | |||
| 358 | + | origin = "Current site" | |||
| 359 | + | } | |||
| 360 | + | w.originLabel.SetText(origin) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 364 | + | row := w.rowButtons[permType] | |||
| 365 | + | if row == nil { | |||
| 366 | + | continue | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | state := w.states[permType] | |||
| 370 | + | if state == "" { | |||
| 371 | + | state = webrtcPermissionStateIdle | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | storedDecision, hasStored := w.stored[permType] | |||
| 375 | + | label := formatRowLabel(permType, state, storedDecision, hasStored) | |||
| 376 | + | row.SetLabel(label) | |||
| 377 | + | updateStateCSSClass(&row.Widget, state) | |||
| 378 | + | updateLockedCSSClass(&row.Widget, hasStored) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | if !visible { | |||
| 382 | + | w.setExpandedLocked(false) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func (w *WebRTCPermissionIndicator) attachHoverController() { | |||
| 387 | + | if w == nil || w.outerBox == nil { | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | controller := gtk.NewEventControllerMotion() | |||
| 392 | + | if controller == nil { | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |||
| 397 | + | w.mu.Lock() | |||
| 398 | + | defer w.mu.Unlock() | |||
| 399 | + | w.setExpandedLocked(true) | |||
| 400 | + | } | |||
| 401 | + | leaveCb := func(_ gtk.EventControllerMotion) { | |||
| 402 | + | w.mu.Lock() | |||
| 403 | + | defer w.mu.Unlock() | |||
| 404 | + | w.setExpandedLocked(false) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb) | |||
| 408 | + | controller.ConnectEnter(&enterCb) | |||
| 409 | + | controller.ConnectLeave(&leaveCb) | |||
| 410 | + | w.outerBox.AddController(&controller.EventController) | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) { | |||
| 414 | + | if w.revealer == nil || w.cardBox == nil { | |||
| 415 | + | return | |||
| 416 | + | } | |||
| 417 | + | if w.expanded == expanded { | |||
| 418 | + | return | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | w.expanded = expanded | |||
| 422 | + | w.revealer.SetRevealChild(expanded) | |||
| 423 | + | ||||
| 424 | + | // Show/hide origin label so it doesn't take space when collapsed to just a dot. | |||
| 425 | + | // Toggle hexpand alongside visibility to prevent expand propagation when collapsed. | |||
| 426 | + | if w.originLabel != nil { | |||
| 427 | + | w.originLabel.SetVisible(expanded) | |||
| 428 | + | w.originLabel.SetHexpand(expanded) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | if expanded { | |||
| 432 | + | w.cardBox.AddCssClass("expanded") | |||
| 433 | + | } else { | |||
| 434 | + | w.cardBox.RemoveCssClass("expanded") | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool { | |||
| 439 | + | switch permType { | |||
| 440 | + | case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay: | |||
| 441 | + | return true | |||
| 442 | + | default: | |||
| 443 | + | return false | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func permissionDisplayName(permType entity.PermissionType) string { | |||
| 448 | + | switch permType { | |||
| 449 | + | case entity.PermissionTypeMicrophone: | |||
| 450 | + | return "\U0001F3A4 Mic" // 🎤 | |||
| 451 | + | case entity.PermissionTypeCamera: | |||
| 452 | + | return "\U0001F4F7 Cam" // 📷 | |||
| 453 | + | case entity.PermissionTypeDisplay: | |||
| 454 | + | return "\U0001F4BB Screen" // 💻 | |||
| 455 | + | default: | |||
| 456 | + | return "Unknown" | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func stateDisplayName(state webrtcPermissionState) string { | |||
| 461 | + | switch state { | |||
| 462 | + | case webrtcPermissionStateRequesting: | |||
| 463 | + | return "\u26A0 requesting" // ⚠ | |||
| 464 | + | case webrtcPermissionStateAllowed: | |||
| 465 | + | return "\u2713 active" // ✓ | |||
| 466 | + | case webrtcPermissionStateBlocked: | |||
| 467 | + | return "\u2717 blocked" // ✗ | |||
| 468 | + | default: | |||
| 469 | + | return "idle" | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func formatRowLabel( | |||
| 474 | + | permType entity.PermissionType, | |||
| 475 | + | state webrtcPermissionState, | |||
| 476 | + | storedDecision entity.PermissionDecision, | |||
| 477 | + | hasStored bool, | |||
| 478 | + | ) string { | |||
| 479 | + | name := permissionDisplayName(permType) | |||
| 480 | + | status := stateDisplayName(state) | |||
| 481 | + | ||||
| 482 | + | if hasStored { | |||
| 483 | + | // Locked: show current policy and offer to reset. | |||
| 484 | + | policy := "allow" | |||
| 485 | + | if storedDecision == entity.PermissionDenied { | |||
| 486 | + | policy = "deny" | |||
| 487 | + | } | |||
| 488 | + | return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // Not locked: show the flip action. | |||
| 492 | + | switch state { | |||
| 493 | + | case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting: | |||
| 494 | + | return fmt.Sprintf("%s %s \u00B7 block", name, status) | |||
| 495 | + | case webrtcPermissionStateBlocked: | |||
| 496 | + | return fmt.Sprintf("%s %s \u00B7 allow", name, status) | |||
| 497 | + | default: | |||
| 498 | + | return fmt.Sprintf("%s %s", name, status) | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func updateLockedCSSClass(widget *gtk.Widget, locked bool) { | |||
| 503 | + | if widget == nil { | |||
| 504 | + | return | |||
| 505 | + | } | |||
| 506 | + | if locked { | |||
| 507 | + | widget.AddCssClass("row-locked") | |||
| 508 | + | } else { | |||
| 509 | + | widget.RemoveCssClass("row-locked") | |||
| 510 | + | } | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) { | |||
| 514 | + | if widget == nil { | |||
| 515 | + | return | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | widget.RemoveCssClass("state-idle") | |||
| 519 | + | widget.RemoveCssClass("state-requesting") | |||
| 520 | + | widget.RemoveCssClass("state-allowed") | |||
| 521 | + | widget.RemoveCssClass("state-blocked") | |||
| 522 | + | ||||
| 523 | + | switch state { | |||
| 524 | + | case webrtcPermissionStateRequesting: | |||
| 525 | + | widget.AddCssClass("state-requesting") | |||
| 526 | + | case webrtcPermissionStateAllowed: | |||
| 527 | + | widget.AddCssClass("state-allowed") | |||
| 528 | + | case webrtcPermissionStateBlocked: | |||
| 529 | + | widget.AddCssClass("state-blocked") | |||
| 530 | + | default: | |||
| 531 | + | widget.AddCssClass("state-idle") | |||
| 532 | + | } | |||
| 533 | + | } | |||
A internal/ui/component/webrtc_permission_indicator_state.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | type webrtcPermissionState string | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | webrtcPermissionStateIdle webrtcPermissionState = "idle" | |
| 9 | + | webrtcPermissionStateRequesting webrtcPermissionState = "requesting" | |
| 10 | + | webrtcPermissionStateAllowed webrtcPermissionState = "allowed" | |
| 11 | + | webrtcPermissionStateBlocked webrtcPermissionState = "blocked" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func summarizeWebRTCPermissionState(states map[entity.PermissionType]webrtcPermissionState) webrtcPermissionState { | |
| 15 | + | if len(states) == 0 { | |
| 16 | + | return webrtcPermissionStateIdle | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | hasAllowed := false | |
| 20 | + | hasBlocked := false | |
| 21 | + | ||
| 22 | + | for _, state := range states { | |
| 23 | + | switch state { | |
| 24 | + | case webrtcPermissionStateRequesting: | |
| 25 | + | return webrtcPermissionStateRequesting | |
| 26 | + | case webrtcPermissionStateBlocked: | |
| 27 | + | hasBlocked = true | |
| 28 | + | case webrtcPermissionStateAllowed: | |
| 29 | + | hasAllowed = true | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | if hasBlocked { | |
| 34 | + | return webrtcPermissionStateBlocked | |
| 35 | + | } | |
| 36 | + | if hasAllowed { | |
| 37 | + | return webrtcPermissionStateAllowed | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return webrtcPermissionStateIdle | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func shouldShowWebRTCPermissionIndicator(states map[entity.PermissionType]webrtcPermissionState) bool { | |
| 44 | + | if len(states) == 0 { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, state := range states { | |
| 49 | + | if state != webrtcPermissionStateIdle { | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | return false | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | type webrtcPermissionState string | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | webrtcPermissionStateIdle webrtcPermissionState = "idle" | |||
| 9 | + | webrtcPermissionStateRequesting webrtcPermissionState = "requesting" | |||
| 10 | + | webrtcPermissionStateAllowed webrtcPermissionState = "allowed" | |||
| 11 | + | webrtcPermissionStateBlocked webrtcPermissionState = "blocked" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func summarizeWebRTCPermissionState(states map[entity.PermissionType]webrtcPermissionState) webrtcPermissionState { | |||
| 15 | + | if len(states) == 0 { | |||
| 16 | + | return webrtcPermissionStateIdle | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | hasAllowed := false | |||
| 20 | + | hasBlocked := false | |||
| 21 | + | ||||
| 22 | + | for _, state := range states { | |||
| 23 | + | switch state { | |||
| 24 | + | case webrtcPermissionStateRequesting: | |||
| 25 | + | return webrtcPermissionStateRequesting | |||
| 26 | + | case webrtcPermissionStateBlocked: | |||
| 27 | + | hasBlocked = true | |||
| 28 | + | case webrtcPermissionStateAllowed: | |||
| 29 | + | hasAllowed = true | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if hasBlocked { | |||
| 34 | + | return webrtcPermissionStateBlocked | |||
| 35 | + | } | |||
| 36 | + | if hasAllowed { | |||
| 37 | + | return webrtcPermissionStateAllowed | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | return webrtcPermissionStateIdle | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func shouldShowWebRTCPermissionIndicator(states map[entity.PermissionType]webrtcPermissionState) bool { | |||
| 44 | + | if len(states) == 0 { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, state := range states { | |||
| 49 | + | if state != webrtcPermissionStateIdle { | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | return false | |||
| 55 | + | } | |||
A internal/ui/component/webrtc_permission_indicator_state_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestWebRTCSummaryState_RequestingWins(t *testing.T) { | |
| 11 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |
| 12 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |
| 13 | + | entity.PermissionTypeCamera: webrtcPermissionStateRequesting, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | summary := summarizeWebRTCPermissionState(states) | |
| 17 | + | assert.Equal(t, webrtcPermissionStateRequesting, summary) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestWebRTCSummaryState_BlockedBeatsAllowed(t *testing.T) { | |
| 21 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |
| 22 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |
| 23 | + | entity.PermissionTypeCamera: webrtcPermissionStateBlocked, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | summary := summarizeWebRTCPermissionState(states) | |
| 27 | + | assert.Equal(t, webrtcPermissionStateBlocked, summary) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestWebRTCSummaryState_AllowedWhenOnlyAllowed(t *testing.T) { | |
| 31 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |
| 32 | + | entity.PermissionTypeDisplay: webrtcPermissionStateAllowed, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | summary := summarizeWebRTCPermissionState(states) | |
| 36 | + | assert.Equal(t, webrtcPermissionStateAllowed, summary) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestWebRTCSummaryState_IdleWhenEmpty(t *testing.T) { | |
| 40 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(nil)) | |
| 41 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(map[entity.PermissionType]webrtcPermissionState{})) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestWebRTCShouldShowIndicator(t *testing.T) { | |
| 45 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(nil)) | |
| 46 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |
| 47 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateIdle, | |
| 48 | + | })) | |
| 49 | + | assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |
| 50 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |
| 51 | + | })) | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWebRTCSummaryState_RequestingWins(t *testing.T) { | |||
| 11 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 12 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 13 | + | entity.PermissionTypeCamera: webrtcPermissionStateRequesting, | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 17 | + | assert.Equal(t, webrtcPermissionStateRequesting, summary) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestWebRTCSummaryState_BlockedBeatsAllowed(t *testing.T) { | |||
| 21 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 22 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 23 | + | entity.PermissionTypeCamera: webrtcPermissionStateBlocked, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 27 | + | assert.Equal(t, webrtcPermissionStateBlocked, summary) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestWebRTCSummaryState_AllowedWhenOnlyAllowed(t *testing.T) { | |||
| 31 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 32 | + | entity.PermissionTypeDisplay: webrtcPermissionStateAllowed, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 36 | + | assert.Equal(t, webrtcPermissionStateAllowed, summary) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestWebRTCSummaryState_IdleWhenEmpty(t *testing.T) { | |||
| 40 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(nil)) | |||
| 41 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(map[entity.PermissionType]webrtcPermissionState{})) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestWebRTCShouldShowIndicator(t *testing.T) { | |||
| 45 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(nil)) | |||
| 46 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |||
| 47 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateIdle, | |||
| 48 | + | })) | |||
| 49 | + | assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |||
| 50 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 51 | + | })) | |||
| 52 | + | } | |||
M internal/ui/coordinator/content.go +74 -4
| @@ -118,6 +118,12 @@ type ContentCoordinator struct { | |||
| 118 | 118 | // Callback when active pane title changes (for window title updates) | |
| 119 | 119 | onWindowTitleChanged func(title string) | |
| 120 | 120 | ||
| 121 | + | // Callback when media permission activity changes (requesting/allowed/blocked). | |
| 122 | + | onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState) | |
| 123 | + | ||
| 124 | + | // Callback when the active pane commits a navigation (new page loading). | |
| 125 | + | onActiveNavigationCommitted func(uri string) | |
| 126 | + | ||
| 121 | 127 | // Callback when the WebView becomes visible (first real commit) | |
| 122 | 128 | onWebViewShown func(paneID entity.PaneID) | |
| 123 | 129 | ||
| @@ -179,6 +185,15 @@ type popupOAuthState struct { | |||
| 179 | 185 | Seen bool | |
| 180 | 186 | } | |
| 181 | 187 | ||
| 188 | + | // PermissionActivityState represents the visible state for media permission activity. | |
| 189 | + | type PermissionActivityState string | |
| 190 | + | ||
| 191 | + | const ( | |
| 192 | + | PermissionActivityRequesting PermissionActivityState = "requesting" | |
| 193 | + | PermissionActivityAllowed PermissionActivityState = "allowed" | |
| 194 | + | PermissionActivityBlocked PermissionActivityState = "blocked" | |
| 195 | + | ) | |
| 196 | + | ||
| 182 | 197 | // NewContentCoordinator creates a new ContentCoordinator. | |
| 183 | 198 | func NewContentCoordinator( | |
| 184 | 199 | ctx context.Context, | |
| @@ -219,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan | |||
| 219 | 234 | c.onHistoryRecord = fn | |
| 220 | 235 | } | |
| 221 | 236 | ||
| 237 | + | // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. | |
| 238 | + | func (c *ContentCoordinator) SetOnPermissionActivity( | |
| 239 | + | fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState), | |
| 240 | + | ) { | |
| 241 | + | c.onPermissionActivity = fn | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation. | |
| 245 | + | func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) { | |
| 246 | + | c.onActiveNavigationCommitted = fn | |
| 247 | + | } | |
| 248 | + | ||
| 222 | 249 | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | |
| 223 | 250 | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | |
| 224 | 251 | c.onPaneURIUpdated = fn | |
| @@ -1083,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 1083 | 1110 | c.onHistoryRecord(ctx, paneID, uri) | |
| 1084 | 1111 | } | |
| 1085 | 1112 | ||
| 1113 | + | // Notify active pane navigation for permission indicator reset. | |
| 1114 | + | c.notifyActiveNavigation(paneID, uri) | |
| 1115 | + | ||
| 1086 | 1116 | // Apply zoom | |
| 1087 | 1117 | if c.zoomUC == nil { | |
| 1088 | 1118 | return | |
| @@ -1096,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 1096 | 1126 | _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) | |
| 1097 | 1127 | } | |
| 1098 | 1128 | ||
| 1129 | + | func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) { | |
| 1130 | + | if c.onActiveNavigationCommitted == nil { | |
| 1131 | + | return | |
| 1132 | + | } | |
| 1133 | + | ws, _ := c.getActiveWS() | |
| 1134 | + | if ws != nil && ws.ActivePaneID == paneID { | |
| 1135 | + | c.onActiveNavigationCommitted(uri) | |
| 1136 | + | } | |
| 1137 | + | } | |
| 1138 | + | ||
| 1099 | 1139 | func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool { | |
| 1100 | 1140 | if wv == nil || wv.IsDestroyed() { | |
| 1101 | 1141 | return false | |
| @@ -1983,6 +2023,25 @@ func (c *ContentCoordinator) handlePermissionRequest( | |||
| 1983 | 2023 | return true | |
| 1984 | 2024 | } | |
| 1985 | 2025 | ||
| 2026 | + | trackedTypes := filterWebRTCPermissionTypes(entityTypes) | |
| 2027 | + | notifyActivity := func(state PermissionActivityState) { | |
| 2028 | + | if c.onPermissionActivity == nil || len(trackedTypes) == 0 { | |
| 2029 | + | return | |
| 2030 | + | } | |
| 2031 | + | c.onPermissionActivity(origin, trackedTypes, state) | |
| 2032 | + | } | |
| 2033 | + | ||
| 2034 | + | notifyActivity(PermissionActivityRequesting) | |
| 2035 | + | ||
| 2036 | + | wrappedAllow := func() { | |
| 2037 | + | notifyActivity(PermissionActivityAllowed) | |
| 2038 | + | allow() | |
| 2039 | + | } | |
| 2040 | + | wrappedDeny := func() { | |
| 2041 | + | notifyActivity(PermissionActivityBlocked) | |
| 2042 | + | deny() | |
| 2043 | + | } | |
| 2044 | + | ||
| 1986 | 2045 | // Check if permission use case is available | |
| 1987 | 2046 | if c.permissionUC == nil { | |
| 1988 | 2047 | log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions") | |
| @@ -1995,23 +2054,34 @@ func (c *ContentCoordinator) handlePermissionRequest( | |||
| 1995 | 2054 | } | |
| 1996 | 2055 | } | |
| 1997 | 2056 | if allAutoAllow { | |
| 1998 | - | allow() | |
| 2057 | + | wrappedAllow() | |
| 1999 | 2058 | } else { | |
| 2000 | - | deny() | |
| 2059 | + | wrappedDeny() | |
| 2001 | 2060 | } | |
| 2002 | 2061 | return true | |
| 2003 | 2062 | } | |
| 2004 | 2063 | ||
| 2005 | 2064 | // Delegate to use case | |
| 2006 | 2065 | callback := usecase.PermissionCallback{ | |
| 2007 | - | Allow: allow, | |
| 2008 | - | Deny: deny, | |
| 2066 | + | Allow: wrappedAllow, | |
| 2067 | + | Deny: wrappedDeny, | |
| 2009 | 2068 | } | |
| 2010 | 2069 | ||
| 2011 | 2070 | c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback) | |
| 2012 | 2071 | return true | |
| 2013 | 2072 | } | |
| 2014 | 2073 | ||
| 2074 | + | func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType { | |
| 2075 | + | filtered := make([]entity.PermissionType, 0, len(types)) | |
| 2076 | + | for _, permType := range types { | |
| 2077 | + | switch permType { | |
| 2078 | + | case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay: | |
| 2079 | + | filtered = append(filtered, permType) | |
| 2080 | + | } | |
| 2081 | + | } | |
| 2082 | + | return filtered | |
| 2083 | + | } | |
| 2084 | + | ||
| 2015 | 2085 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | |
| 2016 | 2086 | // Idle is inhibited when: | |
| 2017 | 2087 | // - The webview enters fullscreen mode (e.g., fullscreen video) | |
| @@ -118,6 +118,12 @@ type ContentCoordinator struct { | |||||
| 118 | // Callback when active pane title changes (for window title updates) | 118 | // Callback when active pane title changes (for window title updates) | ||
| 119 | onWindowTitleChanged func(title string) | 119 | onWindowTitleChanged func(title string) | ||
| 120 | 120 | ||||
| 121 | + | // Callback when media permission activity changes (requesting/allowed/blocked). | |||
| 122 | + | onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState) | |||
| 123 | + | ||||
| 124 | + | // Callback when the active pane commits a navigation (new page loading). | |||
| 125 | + | onActiveNavigationCommitted func(uri string) | |||
| 126 | + | ||||
| 121 | // Callback when the WebView becomes visible (first real commit) | 127 | // Callback when the WebView becomes visible (first real commit) | ||
| 122 | onWebViewShown func(paneID entity.PaneID) | 128 | onWebViewShown func(paneID entity.PaneID) | ||
| 123 | 129 | ||||
| @@ -179,6 +185,15 @@ type popupOAuthState struct { | |||||
| 179 | Seen bool | 185 | Seen bool | ||
| 180 | } | 186 | } | ||
| 181 | 187 | ||||
| 188 | + | // PermissionActivityState represents the visible state for media permission activity. | |||
| 189 | + | type PermissionActivityState string | |||
| 190 | + | ||||
| 191 | + | const ( | |||
| 192 | + | PermissionActivityRequesting PermissionActivityState = "requesting" | |||
| 193 | + | PermissionActivityAllowed PermissionActivityState = "allowed" | |||
| 194 | + | PermissionActivityBlocked PermissionActivityState = "blocked" | |||
| 195 | + | ) | |||
| 196 | + | ||||
| 182 | // NewContentCoordinator creates a new ContentCoordinator. | 197 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| 183 | func NewContentCoordinator( | 198 | func NewContentCoordinator( | ||
| 184 | ctx context.Context, | 199 | ctx context.Context, | ||
| @@ -219,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan | |||||
| 219 | c.onHistoryRecord = fn | 234 | c.onHistoryRecord = fn | ||
| 220 | } | 235 | } | ||
| 221 | 236 | ||||
| 237 | + | // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. | |||
| 238 | + | func (c *ContentCoordinator) SetOnPermissionActivity( | |||
| 239 | + | fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState), | |||
| 240 | + | ) { | |||
| 241 | + | c.onPermissionActivity = fn | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation. | |||
| 245 | + | func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) { | |||
| 246 | + | c.onActiveNavigationCommitted = fn | |||
| 247 | + | } | |||
| 248 | + | ||||
| 222 | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | 249 | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | ||
| 223 | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | 250 | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | ||
| 224 | c.onPaneURIUpdated = fn | 251 | c.onPaneURIUpdated = fn | ||
| @@ -1083,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 1083 | c.onHistoryRecord(ctx, paneID, uri) | 1110 | c.onHistoryRecord(ctx, paneID, uri) | ||
| 1084 | } | 1111 | } | ||
| 1085 | 1112 | ||||
| 1113 | + | // Notify active pane navigation for permission indicator reset. | |||
| 1114 | + | c.notifyActiveNavigation(paneID, uri) | |||
| 1115 | + | ||||
| 1086 | // Apply zoom | 1116 | // Apply zoom | ||
| 1087 | if c.zoomUC == nil { | 1117 | if c.zoomUC == nil { | ||
| 1088 | return | 1118 | return | ||
| @@ -1096,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 1096 | _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) | 1126 | _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) | ||
| 1097 | } | 1127 | } | ||
| 1098 | 1128 | ||||
| 1129 | + | func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) { | |||
| 1130 | + | if c.onActiveNavigationCommitted == nil { | |||
| 1131 | + | return | |||
| 1132 | + | } | |||
| 1133 | + | ws, _ := c.getActiveWS() | |||
| 1134 | + | if ws != nil && ws.ActivePaneID == paneID { | |||
| 1135 | + | c.onActiveNavigationCommitted(uri) | |||
| 1136 | + | } | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1099 | func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool { | 1139 | func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool { | ||
| 1100 | if wv == nil || wv.IsDestroyed() { | 1140 | if wv == nil || wv.IsDestroyed() { | ||
| 1101 | return false | 1141 | return false | ||
| @@ -1983,6 +2023,25 @@ func (c *ContentCoordinator) handlePermissionRequest( | |||||
| 1983 | return true | 2023 | return true | ||
| 1984 | } | 2024 | } | ||
| 1985 | 2025 | ||||
| 2026 | + | trackedTypes := filterWebRTCPermissionTypes(entityTypes) | |||
| 2027 | + | notifyActivity := func(state PermissionActivityState) { | |||
| 2028 | + | if c.onPermissionActivity == nil || len(trackedTypes) == 0 { | |||
| 2029 | + | return | |||
| 2030 | + | } | |||
| 2031 | + | c.onPermissionActivity(origin, trackedTypes, state) | |||
| 2032 | + | } | |||
| 2033 | + | ||||
| 2034 | + | notifyActivity(PermissionActivityRequesting) | |||
| 2035 | + | ||||
| 2036 | + | wrappedAllow := func() { | |||
| 2037 | + | notifyActivity(PermissionActivityAllowed) | |||
| 2038 | + | allow() | |||
| 2039 | + | } | |||
| 2040 | + | wrappedDeny := func() { | |||
| 2041 | + | notifyActivity(PermissionActivityBlocked) | |||
| 2042 | + | deny() | |||
| 2043 | + | } | |||
| 2044 | + | ||||
| 1986 | // Check if permission use case is available | 2045 | // Check if permission use case is available | ||
| 1987 | if c.permissionUC == nil { | 2046 | if c.permissionUC == nil { | ||
| 1988 | log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions") | 2047 | log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions") | ||
| @@ -1995,23 +2054,34 @@ func (c *ContentCoordinator) handlePermissionRequest( | |||||
| 1995 | } | 2054 | } | ||
| 1996 | } | 2055 | } | ||
| 1997 | if allAutoAllow { | 2056 | if allAutoAllow { | ||
| 1998 | - | allow() | 2057 | + | wrappedAllow() |
| 1999 | } else { | 2058 | } else { | ||
| 2000 | - | deny() | 2059 | + | wrappedDeny() |
| 2001 | } | 2060 | } | ||
| 2002 | return true | 2061 | return true | ||
| 2003 | } | 2062 | } | ||
| 2004 | 2063 | ||||
| 2005 | // Delegate to use case | 2064 | // Delegate to use case | ||
| 2006 | callback := usecase.PermissionCallback{ | 2065 | callback := usecase.PermissionCallback{ | ||
| 2007 | - | Allow: allow, | 2066 | + | Allow: wrappedAllow, |
| 2008 | - | Deny: deny, | 2067 | + | Deny: wrappedDeny, |
| 2009 | } | 2068 | } | ||
| 2010 | 2069 | ||||
| 2011 | c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback) | 2070 | c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback) | ||
| 2012 | return true | 2071 | return true | ||
| 2013 | } | 2072 | } | ||
| 2014 | 2073 | ||||
| 2074 | + | func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType { | |||
| 2075 | + | filtered := make([]entity.PermissionType, 0, len(types)) | |||
| 2076 | + | for _, permType := range types { | |||
| 2077 | + | switch permType { | |||
| 2078 | + | case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay: | |||
| 2079 | + | filtered = append(filtered, permType) | |||
| 2080 | + | } | |||
| 2081 | + | } | |||
| 2082 | + | return filtered | |||
| 2083 | + | } | |||
| 2084 | + | ||||
| 2015 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | 2085 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | ||
| 2016 | // Idle is inhibited when: | 2086 | // Idle is inhibited when: | ||
| 2017 | // - The webview enters fullscreen mode (e.g., fullscreen video) | 2087 | // - The webview enters fullscreen mode (e.g., fullscreen video) | ||
M internal/ui/dialog/permission.go +67 -1
| @@ -4,6 +4,7 @@ package dialog | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -11,11 +12,26 @@ import ( | |||
| 11 | 12 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | type permissionPopup interface { | |
| 16 | + | Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type permissionDialogRequest struct { | |
| 20 | + | ctx context.Context | |
| 21 | + | origin string | |
| 22 | + | permTypes []entity.PermissionType | |
| 23 | + | callback func(result port.PermissionDialogResult) | |
| 24 | + | } | |
| 25 | + | ||
| 14 | 26 | // PermissionDialog implements the port.PermissionDialogPresenter interface. | |
| 15 | 27 | // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug | |
| 16 | 28 | // and match the app's custom UI style. | |
| 17 | 29 | type PermissionDialog struct { | |
| 18 | - | popup *component.PermissionPopup | |
| 30 | + | popup permissionPopup | |
| 31 | + | ||
| 32 | + | mu sync.Mutex | |
| 33 | + | active bool | |
| 34 | + | queue []permissionDialogRequest | |
| 19 | 35 | } | |
| 20 | 36 | ||
| 21 | 37 | // NewPermissionDialog creates a new permission dialog presenter. | |
| @@ -33,11 +49,45 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||
| 33 | 49 | permTypes []entity.PermissionType, | |
| 34 | 50 | callback func(result port.PermissionDialogResult), | |
| 35 | 51 | ) { | |
| 52 | + | req := permissionDialogRequest{ | |
| 53 | + | ctx: ctx, | |
| 54 | + | origin: origin, | |
| 55 | + | permTypes: permTypes, | |
| 56 | + | callback: callback, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if !d.enqueueOrStart(req) { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | d.showRequest(req) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool { | |
| 67 | + | d.mu.Lock() | |
| 68 | + | defer d.mu.Unlock() | |
| 69 | + | ||
| 70 | + | if d.active { | |
| 71 | + | d.queue = append(d.queue, req) | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | d.active = true | |
| 76 | + | return true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |
| 80 | + | ctx := req.ctx | |
| 81 | + | origin := req.origin | |
| 82 | + | permTypes := req.permTypes | |
| 83 | + | callback := req.callback | |
| 84 | + | ||
| 36 | 85 | log := logging.FromContext(ctx) | |
| 37 | 86 | ||
| 38 | 87 | if d.popup == nil { | |
| 39 | 88 | log.Error().Msg("permission popup not available") | |
| 40 | 89 | callback(port.PermissionDialogResult{Allowed: false, Persistent: false}) | |
| 90 | + | d.showNextQueuedRequest() | |
| 41 | 91 | return | |
| 42 | 92 | } | |
| 43 | 93 | ||
| @@ -51,6 +101,7 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||
| 51 | 101 | Bool("persistent", persistent). | |
| 52 | 102 | Msg("permission popup response") | |
| 53 | 103 | callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent}) | |
| 104 | + | d.showNextQueuedRequest() | |
| 54 | 105 | }) | |
| 55 | 106 | ||
| 56 | 107 | log.Debug(). | |
| @@ -59,6 +110,21 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||
| 59 | 110 | Msg("showing permission popup") | |
| 60 | 111 | } | |
| 61 | 112 | ||
| 113 | + | func (d *PermissionDialog) showNextQueuedRequest() { | |
| 114 | + | d.mu.Lock() | |
| 115 | + | if len(d.queue) == 0 { | |
| 116 | + | d.active = false | |
| 117 | + | d.mu.Unlock() | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | next := d.queue[0] | |
| 122 | + | d.queue = d.queue[1:] | |
| 123 | + | d.mu.Unlock() | |
| 124 | + | ||
| 125 | + | d.showRequest(next) | |
| 126 | + | } | |
| 127 | + | ||
| 62 | 128 | // buildHeading creates the dialog heading based on permission types. | |
| 63 | 129 | func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string { | |
| 64 | 130 | hasMic := false | |
| @@ -4,6 +4,7 @@ package dialog | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "sync" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -11,11 +12,26 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/ui/component" | 12 | "github.com/bnema/dumber/internal/ui/component" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | type permissionPopup interface { | |||
| 16 | + | Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type permissionDialogRequest struct { | |||
| 20 | + | ctx context.Context | |||
| 21 | + | origin string | |||
| 22 | + | permTypes []entity.PermissionType | |||
| 23 | + | callback func(result port.PermissionDialogResult) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 14 | // PermissionDialog implements the port.PermissionDialogPresenter interface. | 26 | // PermissionDialog implements the port.PermissionDialogPresenter interface. | ||
| 15 | // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug | 27 | // It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug | ||
| 16 | // and match the app's custom UI style. | 28 | // and match the app's custom UI style. | ||
| 17 | type PermissionDialog struct { | 29 | type PermissionDialog struct { | ||
| 18 | - | popup *component.PermissionPopup | 30 | + | popup permissionPopup |
| 31 | + | ||||
| 32 | + | mu sync.Mutex | |||
| 33 | + | active bool | |||
| 34 | + | queue []permissionDialogRequest | |||
| 19 | } | 35 | } | ||
| 20 | 36 | ||||
| 21 | // NewPermissionDialog creates a new permission dialog presenter. | 37 | // NewPermissionDialog creates a new permission dialog presenter. | ||
| @@ -33,11 +49,45 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||||
| 33 | permTypes []entity.PermissionType, | 49 | permTypes []entity.PermissionType, | ||
| 34 | callback func(result port.PermissionDialogResult), | 50 | callback func(result port.PermissionDialogResult), | ||
| 35 | ) { | 51 | ) { | ||
| 52 | + | req := permissionDialogRequest{ | |||
| 53 | + | ctx: ctx, | |||
| 54 | + | origin: origin, | |||
| 55 | + | permTypes: permTypes, | |||
| 56 | + | callback: callback, | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if !d.enqueueOrStart(req) { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | d.showRequest(req) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool { | |||
| 67 | + | d.mu.Lock() | |||
| 68 | + | defer d.mu.Unlock() | |||
| 69 | + | ||||
| 70 | + | if d.active { | |||
| 71 | + | d.queue = append(d.queue, req) | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | d.active = true | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 80 | + | ctx := req.ctx | |||
| 81 | + | origin := req.origin | |||
| 82 | + | permTypes := req.permTypes | |||
| 83 | + | callback := req.callback | |||
| 84 | + | ||||
| 36 | log := logging.FromContext(ctx) | 85 | log := logging.FromContext(ctx) | ||
| 37 | 86 | ||||
| 38 | if d.popup == nil { | 87 | if d.popup == nil { | ||
| 39 | log.Error().Msg("permission popup not available") | 88 | log.Error().Msg("permission popup not available") | ||
| 40 | callback(port.PermissionDialogResult{Allowed: false, Persistent: false}) | 89 | callback(port.PermissionDialogResult{Allowed: false, Persistent: false}) | ||
| 90 | + | d.showNextQueuedRequest() | |||
| 41 | return | 91 | return | ||
| 42 | } | 92 | } | ||
| 43 | 93 | ||||
| @@ -51,6 +101,7 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||||
| 51 | Bool("persistent", persistent). | 101 | Bool("persistent", persistent). | ||
| 52 | Msg("permission popup response") | 102 | Msg("permission popup response") | ||
| 53 | callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent}) | 103 | callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent}) | ||
| 104 | + | d.showNextQueuedRequest() | |||
| 54 | }) | 105 | }) | ||
| 55 | 106 | ||||
| 56 | log.Debug(). | 107 | log.Debug(). | ||
| @@ -59,6 +110,21 @@ func (d *PermissionDialog) ShowPermissionDialog( | |||||
| 59 | Msg("showing permission popup") | 110 | Msg("showing permission popup") | ||
| 60 | } | 111 | } | ||
| 61 | 112 | ||||
| 113 | + | func (d *PermissionDialog) showNextQueuedRequest() { | |||
| 114 | + | d.mu.Lock() | |||
| 115 | + | if len(d.queue) == 0 { | |||
| 116 | + | d.active = false | |||
| 117 | + | d.mu.Unlock() | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | next := d.queue[0] | |||
| 122 | + | d.queue = d.queue[1:] | |||
| 123 | + | d.mu.Unlock() | |||
| 124 | + | ||||
| 125 | + | d.showRequest(next) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 62 | // buildHeading creates the dialog heading based on permission types. | 128 | // buildHeading creates the dialog heading based on permission types. | ||
| 63 | func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string { | 129 | func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string { | ||
| 64 | hasMic := false | 130 | hasMic := false | ||
A internal/ui/dialog/permission_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package dialog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type fakePermissionPopup struct { | |
| 13 | + | showCalls []fakePopupShowCall | |
| 14 | + | callback func(allowed, persistent bool) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type fakePopupShowCall struct { | |
| 18 | + | heading string | |
| 19 | + | body string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (f *fakePermissionPopup) Show( | |
| 23 | + | _ context.Context, | |
| 24 | + | heading string, | |
| 25 | + | body string, | |
| 26 | + | callback func(allowed, persistent bool), | |
| 27 | + | ) { | |
| 28 | + | f.showCalls = append(f.showCalls, fakePopupShowCall{ | |
| 29 | + | heading: heading, | |
| 30 | + | body: body, | |
| 31 | + | }) | |
| 32 | + | f.callback = callback | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (f *fakePermissionPopup) Respond(allowed, persistent bool) { | |
| 36 | + | if f.callback == nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | cb := f.callback | |
| 40 | + | f.callback = nil | |
| 41 | + | cb(allowed, persistent) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) { | |
| 45 | + | popup := &fakePermissionPopup{} | |
| 46 | + | d := &PermissionDialog{popup: popup} | |
| 47 | + | ||
| 48 | + | var firstResult port.PermissionDialogResult | |
| 49 | + | firstCalled := false | |
| 50 | + | d.ShowPermissionDialog( | |
| 51 | + | context.Background(), | |
| 52 | + | "https://example.com", | |
| 53 | + | []entity.PermissionType{entity.PermissionTypeMicrophone}, | |
| 54 | + | func(result port.PermissionDialogResult) { | |
| 55 | + | firstCalled = true | |
| 56 | + | firstResult = result | |
| 57 | + | }, | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | secondCalled := false | |
| 61 | + | var secondResult port.PermissionDialogResult | |
| 62 | + | d.ShowPermissionDialog( | |
| 63 | + | context.Background(), | |
| 64 | + | "https://example.com", | |
| 65 | + | []entity.PermissionType{entity.PermissionTypeCamera}, | |
| 66 | + | func(result port.PermissionDialogResult) { | |
| 67 | + | secondCalled = true | |
| 68 | + | secondResult = result | |
| 69 | + | }, | |
| 70 | + | ) | |
| 71 | + | ||
| 72 | + | if assert.Len(t, popup.showCalls, 1) { | |
| 73 | + | assert.Equal(t, "Allow Microphone Access?", popup.showCalls[0].heading) | |
| 74 | + | } | |
| 75 | + | assert.False(t, firstCalled) | |
| 76 | + | assert.False(t, secondCalled) | |
| 77 | + | ||
| 78 | + | popup.Respond(true, false) | |
| 79 | + | assert.True(t, firstCalled) | |
| 80 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: true, Persistent: false}, firstResult) | |
| 81 | + | ||
| 82 | + | if assert.Len(t, popup.showCalls, 2) { | |
| 83 | + | assert.Equal(t, "Allow Camera Access?", popup.showCalls[1].heading) | |
| 84 | + | } | |
| 85 | + | assert.False(t, secondCalled) | |
| 86 | + | ||
| 87 | + | popup.Respond(false, true) | |
| 88 | + | assert.True(t, secondCalled) | |
| 89 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: true}, secondResult) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestPermissionDialog_NoPopup_DeniesRequest(t *testing.T) { | |
| 93 | + | d := &PermissionDialog{popup: nil} | |
| 94 | + | ||
| 95 | + | called := false | |
| 96 | + | result := port.PermissionDialogResult{} | |
| 97 | + | d.ShowPermissionDialog( | |
| 98 | + | context.Background(), | |
| 99 | + | "https://example.com", | |
| 100 | + | []entity.PermissionType{entity.PermissionTypeMicrophone}, | |
| 101 | + | func(r port.PermissionDialogResult) { | |
| 102 | + | called = true | |
| 103 | + | result = r | |
| 104 | + | }, | |
| 105 | + | ) | |
| 106 | + | ||
| 107 | + | assert.True(t, called) | |
| 108 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: false}, result) | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package dialog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type fakePermissionPopup struct { | |||
| 13 | + | showCalls []fakePopupShowCall | |||
| 14 | + | callback func(allowed, persistent bool) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | type fakePopupShowCall struct { | |||
| 18 | + | heading string | |||
| 19 | + | body string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (f *fakePermissionPopup) Show( | |||
| 23 | + | _ context.Context, | |||
| 24 | + | heading string, | |||
| 25 | + | body string, | |||
| 26 | + | callback func(allowed, persistent bool), | |||
| 27 | + | ) { | |||
| 28 | + | f.showCalls = append(f.showCalls, fakePopupShowCall{ | |||
| 29 | + | heading: heading, | |||
| 30 | + | body: body, | |||
| 31 | + | }) | |||
| 32 | + | f.callback = callback | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f *fakePermissionPopup) Respond(allowed, persistent bool) { | |||
| 36 | + | if f.callback == nil { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | cb := f.callback | |||
| 40 | + | f.callback = nil | |||
| 41 | + | cb(allowed, persistent) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestPermissionDialog_QueuesRequestsWhilePopupVisible(t *testing.T) { | |||
| 45 | + | popup := &fakePermissionPopup{} | |||
| 46 | + | d := &PermissionDialog{popup: popup} | |||
| 47 | + | ||||
| 48 | + | var firstResult port.PermissionDialogResult | |||
| 49 | + | firstCalled := false | |||
| 50 | + | d.ShowPermissionDialog( | |||
| 51 | + | context.Background(), | |||
| 52 | + | "https://example.com", | |||
| 53 | + | []entity.PermissionType{entity.PermissionTypeMicrophone}, | |||
| 54 | + | func(result port.PermissionDialogResult) { | |||
| 55 | + | firstCalled = true | |||
| 56 | + | firstResult = result | |||
| 57 | + | }, | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | secondCalled := false | |||
| 61 | + | var secondResult port.PermissionDialogResult | |||
| 62 | + | d.ShowPermissionDialog( | |||
| 63 | + | context.Background(), | |||
| 64 | + | "https://example.com", | |||
| 65 | + | []entity.PermissionType{entity.PermissionTypeCamera}, | |||
| 66 | + | func(result port.PermissionDialogResult) { | |||
| 67 | + | secondCalled = true | |||
| 68 | + | secondResult = result | |||
| 69 | + | }, | |||
| 70 | + | ) | |||
| 71 | + | ||||
| 72 | + | if assert.Len(t, popup.showCalls, 1) { | |||
| 73 | + | assert.Equal(t, "Allow Microphone Access?", popup.showCalls[0].heading) | |||
| 74 | + | } | |||
| 75 | + | assert.False(t, firstCalled) | |||
| 76 | + | assert.False(t, secondCalled) | |||
| 77 | + | ||||
| 78 | + | popup.Respond(true, false) | |||
| 79 | + | assert.True(t, firstCalled) | |||
| 80 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: true, Persistent: false}, firstResult) | |||
| 81 | + | ||||
| 82 | + | if assert.Len(t, popup.showCalls, 2) { | |||
| 83 | + | assert.Equal(t, "Allow Camera Access?", popup.showCalls[1].heading) | |||
| 84 | + | } | |||
| 85 | + | assert.False(t, secondCalled) | |||
| 86 | + | ||||
| 87 | + | popup.Respond(false, true) | |||
| 88 | + | assert.True(t, secondCalled) | |||
| 89 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: true}, secondResult) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestPermissionDialog_NoPopup_DeniesRequest(t *testing.T) { | |||
| 93 | + | d := &PermissionDialog{popup: nil} | |||
| 94 | + | ||||
| 95 | + | called := false | |||
| 96 | + | result := port.PermissionDialogResult{} | |||
| 97 | + | d.ShowPermissionDialog( | |||
| 98 | + | context.Background(), | |||
| 99 | + | "https://example.com", | |||
| 100 | + | []entity.PermissionType{entity.PermissionTypeMicrophone}, | |||
| 101 | + | func(r port.PermissionDialogResult) { | |||
| 102 | + | called = true | |||
| 103 | + | result = r | |||
| 104 | + | }, | |||
| 105 | + | ) | |||
| 106 | + | ||||
| 107 | + | assert.True(t, called) | |||
| 108 | + | assert.Equal(t, port.PermissionDialogResult{Allowed: false, Persistent: false}, result) | |||
| 109 | + | } | |||
M internal/ui/theme/css.go +4 -0
| @@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||
| 132 | 132 | sb.WriteString(generatePermissionPopupCSS(p)) | |
| 133 | 133 | sb.WriteString("\n") | |
| 134 | 134 | ||
| 135 | + | // WebRTC permission activity indicator styling | |
| 136 | + | sb.WriteString(generateWebRTCPermissionIndicatorCSS(p)) | |
| 137 | + | sb.WriteString("\n") | |
| 138 | + | ||
| 135 | 139 | // Accent picker styling | |
| 136 | 140 | sb.WriteString(generateAccentPickerCSS(p)) | |
| 137 | 141 | ||
| @@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||||
| 132 | sb.WriteString(generatePermissionPopupCSS(p)) | 132 | sb.WriteString(generatePermissionPopupCSS(p)) | ||
| 133 | sb.WriteString("\n") | 133 | sb.WriteString("\n") | ||
| 134 | 134 | ||||
| 135 | + | // WebRTC permission activity indicator styling | |||
| 136 | + | sb.WriteString(generateWebRTCPermissionIndicatorCSS(p)) | |||
| 137 | + | sb.WriteString("\n") | |||
| 138 | + | ||||
| 135 | // Accent picker styling | 139 | // Accent picker styling | ||
| 136 | sb.WriteString(generateAccentPickerCSS(p)) | 140 | sb.WriteString(generateAccentPickerCSS(p)) | ||
| 137 | 141 | ||||
A internal/ui/theme/webrtc_indicator_css.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | // generateWebRTCPermissionIndicatorCSS creates styles for the top-right | |
| 4 | + | // WebRTC permission activity indicator. | |
| 5 | + | // The dot anchors top-right; on hover the card expands left and down | |
| 6 | + | // using a GtkRevealer (slide-down) for the details panel. | |
| 7 | + | func generateWebRTCPermissionIndicatorCSS(p Palette) string { | |
| 8 | + | _ = p | |
| 9 | + | return `/* ===== WebRTC Permission Indicator ===== */ | |
| 10 | + | ||
| 11 | + | .webrtc-indicator-outer { | |
| 12 | + | margin-top: 0.125em; | |
| 13 | + | margin-right: 0.125em; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /* Card container: transparent when collapsed, styled when expanded. | |
| 17 | + | Right-aligned so the dot stays top-right and content grows leftward. */ | |
| 18 | + | .webrtc-indicator-card { | |
| 19 | + | background-color: transparent; | |
| 20 | + | border: 0.0625em solid transparent; | |
| 21 | + | border-radius: 999px; | |
| 22 | + | padding: 0; | |
| 23 | + | transition: background-color 180ms ease-in-out, | |
| 24 | + | border-color 180ms ease-in-out, | |
| 25 | + | box-shadow 180ms ease-in-out; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | .webrtc-indicator-card.expanded { | |
| 29 | + | background-color: alpha(var(--surface-variant), 0.96); | |
| 30 | + | border-color: alpha(var(--border), 0.8); | |
| 31 | + | border-radius: 0.375em; | |
| 32 | + | box-shadow: 0 0.25em 1em alpha(black, 0.28), | |
| 33 | + | 0 0.0625em 0.25em alpha(black, 0.12); | |
| 34 | + | padding: 0.4em 0.5em; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /* Summary dot - small colored circle, right-aligned */ | |
| 38 | + | .webrtc-indicator-dot { | |
| 39 | + | min-width: 0.5em; | |
| 40 | + | min-height: 0.5em; | |
| 41 | + | border-radius: 999px; | |
| 42 | + | background-color: var(--muted); | |
| 43 | + | border: 0.0625em solid alpha(var(--border), 0.6); | |
| 44 | + | transition: background-color 200ms ease-in-out, | |
| 45 | + | border-color 200ms ease-in-out, | |
| 46 | + | box-shadow 200ms ease-in-out; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /* Header row: origin label (left) + dot (right) on the same line */ | |
| 50 | + | .webrtc-indicator-header { | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /* Origin label - shown next to the dot, hidden when collapsed (no text) */ | |
| 54 | + | .webrtc-indicator-origin { | |
| 55 | + | font-size: 0.6875em; | |
| 56 | + | font-weight: 600; | |
| 57 | + | letter-spacing: 0.02em; | |
| 58 | + | padding: 0 0.4em 0 0; | |
| 59 | + | color: var(--muted); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /* GtkRevealer wrapper */ | |
| 63 | + | .webrtc-indicator-revealer { | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /* Details section inside the revealer (permission rows only) */ | |
| 67 | + | .webrtc-indicator-details { | |
| 68 | + | margin-top: 0.3em; | |
| 69 | + | padding-top: 0.3em; | |
| 70 | + | border-top: 0.0625em solid alpha(var(--border), 0.5); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /* Permission row buttons */ | |
| 74 | + | .webrtc-indicator-row { | |
| 75 | + | font-size: 0.6875em; | |
| 76 | + | font-weight: 500; | |
| 77 | + | padding: 0.25em 0.4em; | |
| 78 | + | margin-top: 0.15em; | |
| 79 | + | border-radius: 0.25em; | |
| 80 | + | border-left: 0.1875em solid transparent; | |
| 81 | + | border-top: none; | |
| 82 | + | border-right: none; | |
| 83 | + | border-bottom: none; | |
| 84 | + | background-color: alpha(var(--bg), 0.35); | |
| 85 | + | background-image: none; | |
| 86 | + | color: var(--muted); | |
| 87 | + | text-align: left; | |
| 88 | + | transition: background-color 150ms ease-in-out, | |
| 89 | + | border-color 150ms ease-in-out, | |
| 90 | + | color 150ms ease-in-out; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | .webrtc-indicator-row:hover { | |
| 94 | + | background-color: alpha(var(--bg), 0.55); | |
| 95 | + | background-image: none; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /* ---- State: idle ---- */ | |
| 99 | + | .webrtc-indicator-dot.state-idle { | |
| 100 | + | background-color: var(--muted); | |
| 101 | + | border-color: alpha(var(--border), 0.6); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | .webrtc-indicator-row.state-idle { | |
| 105 | + | color: var(--muted); | |
| 106 | + | border-left-color: alpha(var(--muted), 0.4); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /* ---- State: requesting (amber) ---- */ | |
| 110 | + | .webrtc-indicator-dot.state-requesting { | |
| 111 | + | background-color: var(--warning); | |
| 112 | + | border-color: alpha(var(--warning), 0.6); | |
| 113 | + | box-shadow: 0 0 0.4em alpha(var(--warning), 0.5); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | .webrtc-indicator-row.state-requesting { | |
| 117 | + | color: var(--warning); | |
| 118 | + | border-left-color: var(--warning); | |
| 119 | + | background-color: alpha(var(--warning), 0.08); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | .webrtc-indicator-row.state-requesting:hover { | |
| 123 | + | background-color: alpha(var(--warning), 0.14); | |
| 124 | + | background-image: none; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /* ---- State: allowed (green) ---- */ | |
| 128 | + | .webrtc-indicator-dot.state-allowed { | |
| 129 | + | background-color: var(--success); | |
| 130 | + | border-color: alpha(var(--success), 0.6); | |
| 131 | + | box-shadow: 0 0 0.35em alpha(var(--success), 0.35); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | .webrtc-indicator-row.state-allowed { | |
| 135 | + | color: var(--success); | |
| 136 | + | border-left-color: var(--success); | |
| 137 | + | background-color: alpha(var(--success), 0.06); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | .webrtc-indicator-row.state-allowed:hover { | |
| 141 | + | background-color: alpha(var(--success), 0.12); | |
| 142 | + | background-image: none; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /* ---- State: blocked (red) ---- */ | |
| 146 | + | .webrtc-indicator-dot.state-blocked { | |
| 147 | + | background-color: var(--destructive); | |
| 148 | + | border-color: alpha(var(--destructive), 0.6); | |
| 149 | + | box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | .webrtc-indicator-row.state-blocked { | |
| 153 | + | color: var(--destructive); | |
| 154 | + | border-left-color: var(--destructive); | |
| 155 | + | background-color: alpha(var(--destructive), 0.06); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | .webrtc-indicator-row.state-blocked:hover { | |
| 159 | + | background-color: alpha(var(--destructive), 0.12); | |
| 160 | + | background-image: none; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /* ---- Locked row modifier ---- */ | |
| 164 | + | .webrtc-indicator-row.row-locked { | |
| 165 | + | font-weight: 600; | |
| 166 | + | } | |
| 167 | + | ` | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generateWebRTCPermissionIndicatorCSS creates styles for the top-right | |||
| 4 | + | // WebRTC permission activity indicator. | |||
| 5 | + | // The dot anchors top-right; on hover the card expands left and down | |||
| 6 | + | // using a GtkRevealer (slide-down) for the details panel. | |||
| 7 | + | func generateWebRTCPermissionIndicatorCSS(p Palette) string { | |||
| 8 | + | _ = p | |||
| 9 | + | return `/* ===== WebRTC Permission Indicator ===== */ | |||
| 10 | + | ||||
| 11 | + | .webrtc-indicator-outer { | |||
| 12 | + | margin-top: 0.125em; | |||
| 13 | + | margin-right: 0.125em; | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | /* Card container: transparent when collapsed, styled when expanded. | |||
| 17 | + | Right-aligned so the dot stays top-right and content grows leftward. */ | |||
| 18 | + | .webrtc-indicator-card { | |||
| 19 | + | background-color: transparent; | |||
| 20 | + | border: 0.0625em solid transparent; | |||
| 21 | + | border-radius: 999px; | |||
| 22 | + | padding: 0; | |||
| 23 | + | transition: background-color 180ms ease-in-out, | |||
| 24 | + | border-color 180ms ease-in-out, | |||
| 25 | + | box-shadow 180ms ease-in-out; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | .webrtc-indicator-card.expanded { | |||
| 29 | + | background-color: alpha(var(--surface-variant), 0.96); | |||
| 30 | + | border-color: alpha(var(--border), 0.8); | |||
| 31 | + | border-radius: 0.375em; | |||
| 32 | + | box-shadow: 0 0.25em 1em alpha(black, 0.28), | |||
| 33 | + | 0 0.0625em 0.25em alpha(black, 0.12); | |||
| 34 | + | padding: 0.4em 0.5em; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | /* Summary dot - small colored circle, right-aligned */ | |||
| 38 | + | .webrtc-indicator-dot { | |||
| 39 | + | min-width: 0.5em; | |||
| 40 | + | min-height: 0.5em; | |||
| 41 | + | border-radius: 999px; | |||
| 42 | + | background-color: var(--muted); | |||
| 43 | + | border: 0.0625em solid alpha(var(--border), 0.6); | |||
| 44 | + | transition: background-color 200ms ease-in-out, | |||
| 45 | + | border-color 200ms ease-in-out, | |||
| 46 | + | box-shadow 200ms ease-in-out; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | /* Header row: origin label (left) + dot (right) on the same line */ | |||
| 50 | + | .webrtc-indicator-header { | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | /* Origin label - shown next to the dot, hidden when collapsed (no text) */ | |||
| 54 | + | .webrtc-indicator-origin { | |||
| 55 | + | font-size: 0.6875em; | |||
| 56 | + | font-weight: 600; | |||
| 57 | + | letter-spacing: 0.02em; | |||
| 58 | + | padding: 0 0.4em 0 0; | |||
| 59 | + | color: var(--muted); | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | /* GtkRevealer wrapper */ | |||
| 63 | + | .webrtc-indicator-revealer { | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | /* Details section inside the revealer (permission rows only) */ | |||
| 67 | + | .webrtc-indicator-details { | |||
| 68 | + | margin-top: 0.3em; | |||
| 69 | + | padding-top: 0.3em; | |||
| 70 | + | border-top: 0.0625em solid alpha(var(--border), 0.5); | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | /* Permission row buttons */ | |||
| 74 | + | .webrtc-indicator-row { | |||
| 75 | + | font-size: 0.6875em; | |||
| 76 | + | font-weight: 500; | |||
| 77 | + | padding: 0.25em 0.4em; | |||
| 78 | + | margin-top: 0.15em; | |||
| 79 | + | border-radius: 0.25em; | |||
| 80 | + | border-left: 0.1875em solid transparent; | |||
| 81 | + | border-top: none; | |||
| 82 | + | border-right: none; | |||
| 83 | + | border-bottom: none; | |||
| 84 | + | background-color: alpha(var(--bg), 0.35); | |||
| 85 | + | background-image: none; | |||
| 86 | + | color: var(--muted); | |||
| 87 | + | text-align: left; | |||
| 88 | + | transition: background-color 150ms ease-in-out, | |||
| 89 | + | border-color 150ms ease-in-out, | |||
| 90 | + | color 150ms ease-in-out; | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | .webrtc-indicator-row:hover { | |||
| 94 | + | background-color: alpha(var(--bg), 0.55); | |||
| 95 | + | background-image: none; | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | /* ---- State: idle ---- */ | |||
| 99 | + | .webrtc-indicator-dot.state-idle { | |||
| 100 | + | background-color: var(--muted); | |||
| 101 | + | border-color: alpha(var(--border), 0.6); | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | .webrtc-indicator-row.state-idle { | |||
| 105 | + | color: var(--muted); | |||
| 106 | + | border-left-color: alpha(var(--muted), 0.4); | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | /* ---- State: requesting (amber) ---- */ | |||
| 110 | + | .webrtc-indicator-dot.state-requesting { | |||
| 111 | + | background-color: var(--warning); | |||
| 112 | + | border-color: alpha(var(--warning), 0.6); | |||
| 113 | + | box-shadow: 0 0 0.4em alpha(var(--warning), 0.5); | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | .webrtc-indicator-row.state-requesting { | |||
| 117 | + | color: var(--warning); | |||
| 118 | + | border-left-color: var(--warning); | |||
| 119 | + | background-color: alpha(var(--warning), 0.08); | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | .webrtc-indicator-row.state-requesting:hover { | |||
| 123 | + | background-color: alpha(var(--warning), 0.14); | |||
| 124 | + | background-image: none; | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | /* ---- State: allowed (green) ---- */ | |||
| 128 | + | .webrtc-indicator-dot.state-allowed { | |||
| 129 | + | background-color: var(--success); | |||
| 130 | + | border-color: alpha(var(--success), 0.6); | |||
| 131 | + | box-shadow: 0 0 0.35em alpha(var(--success), 0.35); | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | .webrtc-indicator-row.state-allowed { | |||
| 135 | + | color: var(--success); | |||
| 136 | + | border-left-color: var(--success); | |||
| 137 | + | background-color: alpha(var(--success), 0.06); | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | .webrtc-indicator-row.state-allowed:hover { | |||
| 141 | + | background-color: alpha(var(--success), 0.12); | |||
| 142 | + | background-image: none; | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | /* ---- State: blocked (red) ---- */ | |||
| 146 | + | .webrtc-indicator-dot.state-blocked { | |||
| 147 | + | background-color: var(--destructive); | |||
| 148 | + | border-color: alpha(var(--destructive), 0.6); | |||
| 149 | + | box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35); | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | .webrtc-indicator-row.state-blocked { | |||
| 153 | + | color: var(--destructive); | |||
| 154 | + | border-left-color: var(--destructive); | |||
| 155 | + | background-color: alpha(var(--destructive), 0.06); | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | .webrtc-indicator-row.state-blocked:hover { | |||
| 159 | + | background-color: alpha(var(--destructive), 0.12); | |||
| 160 | + | background-image: none; | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | /* ---- Locked row modifier ---- */ | |||
| 164 | + | .webrtc-indicator-row.row-locked { | |||
| 165 | + | font-weight: 600; | |||
| 166 | + | } | |||
| 167 | + | ` | |||
| 168 | + | } | |||