detached 72ba574

Merge pull request #158 from bnema/feat/permission-system

feat: implement media permission system
commit
72ba574a6d4f81532e956abb10307eecb28f1bd7
author
bnema · · 2026-02-08 13:20 UTC
committer
GitHub · · 2026-02-08 13:20 UTC
merge of
d90bc72 + 23cba99 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
55 files · +4869 -62
M .mockery.yaml +2 -0
@@ -31,6 +31,7 @@ packages:
31 KeybindingsProvider: {}31 KeybindingsProvider: {}
32 KeybindingsSaver: {}32 KeybindingsSaver: {}
33 DownloadResponse: {}33 DownloadResponse: {}
34+ PermissionDialogPresenter: {}
34 github.com/bnema/dumber/internal/domain/repository:35 github.com/bnema/dumber/internal/domain/repository:
35 config:36 config:
36 dir: '{{.InterfaceDir}}/mocks'37 dir: '{{.InterfaceDir}}/mocks'
@@ -45,6 +46,7 @@ packages:
45 SessionStateRepository: {}46 SessionStateRepository: {}
46 TagRepository: {}47 TagRepository: {}
47 ZoomRepository: {}48 ZoomRepository: {}
49+ PermissionRepository: {}
48 github.com/bnema/dumber/internal/infrastructure/filtering:50 github.com/bnema/dumber/internal/infrastructure/filtering:
49 config:51 config:
50 dir: '{{.InterfaceDir}}/mocks'52 dir: '{{.InterfaceDir}}/mocks'
M AGENTS.md +10 -0
@@ -31,3 +31,13 @@
31 31
32 ## Commands32 ## Commands
33 33
34+Run before committing:
35+- `make lint` - golangci-lint
36+- `make test` - unit tests
37+- `make check` - verify build, tests, tools
38+- `go fmt ./...` - format code
39+
40+## Misc
41+
42+- vendor dir is gitignored
43+
M assets/webui_embed.go +2 -2
@@ -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.FS11 var WebUIAssets embed.FS
M cmd/dumber/main.go +10 -0
@@ -398,6 +398,7 @@ type repositories struct {
398 folder repository.FolderRepository398 folder repository.FolderRepository
399 tag repository.TagRepository399 tag repository.TagRepository
400 zoom repository.ZoomRepository400 zoom repository.ZoomRepository
401+ permission port.PermissionRepository
401 session repository.SessionRepository402 session repository.SessionRepository
402 sessionState repository.SessionStateRepository403 sessionState repository.SessionStateRepository
403 }404 }
@@ -409,6 +410,7 @@ func createRepositories(db *sql.DB) *repositories {
409 folder: sqlite.NewFolderRepository(db),410 folder: sqlite.NewFolderRepository(db),
410 tag: sqlite.NewTagRepository(db),411 tag: sqlite.NewTagRepository(db),
411 zoom: sqlite.NewZoomRepository(db),412 zoom: sqlite.NewZoomRepository(db),
413+ permission: sqlite.NewPermissionRepository(db),
412 session: sqlite.NewSessionRepository(db),414 session: sqlite.NewSessionRepository(db),
413 sessionState: sqlite.NewSessionStateRepository(db),415 sessionState: sqlite.NewSessionStateRepository(db),
414 }416 }
@@ -421,6 +423,7 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
421 folder: sqlite.NewLazyFolderRepository(provider),423 folder: sqlite.NewLazyFolderRepository(provider),
422 tag: sqlite.NewLazyTagRepository(provider),424 tag: sqlite.NewLazyTagRepository(provider),
423 zoom: sqlite.NewLazyZoomRepository(provider),425 zoom: sqlite.NewLazyZoomRepository(provider),
426+ permission: sqlite.NewLazyPermissionRepository(provider),
424 session: sqlite.NewLazySessionRepository(provider),427 session: sqlite.NewLazySessionRepository(provider),
425 sessionState: sqlite.NewLazySessionStateRepository(provider),428 sessionState: sqlite.NewLazySessionStateRepository(provider),
426 }429 }
@@ -433,6 +436,7 @@ type useCases struct {
433 history *usecase.SearchHistoryUseCase436 history *usecase.SearchHistoryUseCase
434 favorites *usecase.ManageFavoritesUseCase437 favorites *usecase.ManageFavoritesUseCase
435 zoom *usecase.ManageZoomUseCase438 zoom *usecase.ManageZoomUseCase
439+ permission *usecase.HandlePermissionUseCase
436 navigate *usecase.NavigateUseCase440 navigate *usecase.NavigateUseCase
437 copyURL *usecase.CopyURLUseCase441 copyURL *usecase.CopyURLUseCase
438 snapshot *usecase.SnapshotSessionUseCase442 snapshot *usecase.SnapshotSessionUseCase
@@ -468,12 +472,16 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
468 updateDownloader := updater.NewGitHubDownloader()472 updateDownloader := updater.NewGitHubDownloader()
469 updateApplier := updater.NewApplier(stateDir)473 updateApplier := updater.NewApplier(stateDir)
470 474
475+ // Permission use case will be initialized later with dialog presenter
476+ permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
477+
471 return &useCases{478 return &useCases{
472 tabs: usecase.NewManageTabsUseCase(idGenerator),479 tabs: usecase.NewManageTabsUseCase(idGenerator),
473 panes: usecase.NewManagePanesUseCase(idGenerator),480 panes: usecase.NewManagePanesUseCase(idGenerator),
474 history: usecase.NewSearchHistoryUseCase(repos.history),481 history: usecase.NewSearchHistoryUseCase(repos.history),
475 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),482 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
476 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),483 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
484+ permission: permissionUC,
477 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),485 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
478 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),486 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
479 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),487 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
@@ -517,11 +525,13 @@ func buildUIDependencies(
517 HistoryRepo: repos.history,525 HistoryRepo: repos.history,
518 FavoriteRepo: repos.favorite,526 FavoriteRepo: repos.favorite,
519 ZoomRepo: repos.zoom,527 ZoomRepo: repos.zoom,
528+ PermissionRepo: repos.permission,
520 TabsUC: uc.tabs,529 TabsUC: uc.tabs,
521 PanesUC: uc.panes,530 PanesUC: uc.panes,
522 HistoryUC: uc.history,531 HistoryUC: uc.history,
523 FavoritesUC: uc.favorites,532 FavoritesUC: uc.favorites,
524 ZoomUC: uc.zoom,533 ZoomUC: uc.zoom,
534+ PermissionUC: uc.permission,
525 NavigateUC: uc.navigate,535 NavigateUC: uc.navigate,
526 CopyURLUC: uc.copyURL,536 CopyURLUC: uc.copyURL,
527 Clipboard: uc.clipboard,537 Clipboard: uc.clipboard,
M docs/reference/index.md +1 -0
@@ -3,6 +3,7 @@
3 ## Quick Links3 ## Quick Links
4 4
5 - [Configuration](./configuration.md) - All config keys5 - [Configuration](./configuration.md) - All config keys
6+- [Permissions](./permissions.md) - Website permission handling (mic, camera, screen sharing)
6 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu7 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
7 - [Keybindings](./keybindings.md) - All keyboard shortcuts8 - [Keybindings](./keybindings.md) - All keyboard shortcuts
8 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions9 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
A docs/reference/permissions.md +92 -0
@@ -0,0 +1,92 @@
1+# Website Permissions
2+
3+Dumber handles website permission requests for media access (microphone, camera, screen sharing) following W3C standards.
4+
5+## Permission Types
6+
7+### Auto-Allowed
8+
9+These permissions are automatically granted without user interaction:
10+
11+- **Display Capture** (screen sharing) - The XDG Desktop Portal handles the UI
12+- **Device Enumeration** (listing microphones/cameras) - Low security risk
13+
14+### User-Confirmed
15+
16+These permissions require explicit user approval:
17+
18+- **Microphone** - Websites can request audio capture access
19+- **Camera** - Websites can request video capture access
20+
21+When both microphone and camera are requested together, a combined dialog is shown.
22+
23+## Permission Dialog
24+
25+When a site requests mic/camera access, a dialog appears with four options:
26+
27+| Button | Action | Persistence |
28+|--------|--------|-------------|
29+| **Allow** | Grant permission once | Not saved |
30+| **Always Allow** | Grant and remember | Saved per origin |
31+| **Deny** | Block permission once | Not saved |
32+| **Always Deny** | Block and remember | Saved per origin |
33+
34+## How Permissions Work
35+
36+### Origin-Based Storage
37+
38+Permissions are stored per-origin (scheme + host + port), not per-page.
39+Default ports are normalized away (`:80` for `http`, `:443` for `https`). This means:
40+
41+- `https://meet.google.com/room1` and `https://meet.google.com/room2` share the same permission (same origin)
42+- `https://meet.google.com:443/room1` and `https://meet.google.com/room1` share the same permission (default `https` port)
43+- `https://meet.google.com:8443/room1` and `https://meet.google.com/room1` have different permissions (different port)
44+- `https://github.com` and `https://gist.github.com` have different permissions (different hosts)
45+
46+### Permission States
47+
48+- **Granted** - Permission was allowed (manually or via "Always Allow")
49+- **Denied** - Permission was blocked (manually or via "Always Deny")
50+- **Prompt** - No decision made yet, will show dialog on next request
51+
52+### W3C Compliance
53+
54+The permission system follows W3C specifications:
55+
56+- Display capture permissions are **never persisted** (always prompt via portal)
57+- Mic/camera permissions **can be persisted** with user consent
58+- Permissions are scoped to origins (scheme + host + port)
59+
60+## Managing Stored Permissions
61+
62+Currently, stored permissions can only be cleared by:
63+
64+1. **Site-specific reset**: `navigator.permissions.revoke()` was removed from the Permissions API and is not supported in modern browsers; direct users to browser/site permission settings (or account-level controls when available) to reset access.
65+2. **Full reset**: Remove `~/.local/share/dumber/dumber.db` (deletes all data including permissions)
66+
67+A permission management UI is planned for a future release.
68+
69+## Privacy Notes
70+
71+- Permissions are stored in your local SQLite database (`~/.local/share/dumber/dumber.db`)
72+- No permission data is sent to any server
73+- "Always Deny" is useful for blocking persistent permission requests from unwanted sites
74+- The default response is "Deny" (conservative security posture)
75+
76+## Troubleshooting
77+
78+### Permission dialog not appearing
79+
80+1. Check if the site uses HTTPS (required for media permissions)
81+2. Verify the site isn't already in your stored permissions
82+3. Check browser logs: `dumber --log-level=debug`
83+
84+### Permission keeps prompting
85+
86+- Display capture always prompts (W3C requirement)
87+- Check if database is writable: `ls -la ~/.local/share/dumber/`
88+
89+### Site says permission denied but I clicked Allow
90+
91+- Check if you clicked "Deny" or "Always Deny" previously
92+- Clear permissions by removing the database (see above)
M go.mod +2 -2
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.106+ github.com/bnema/puregotk-webkit v0.0.12
7 github.com/charmbracelet/bubbles v0.21.17 github.com/charmbracelet/bubbles v0.21.1
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
22 gopkg.in/yaml.v3 v3.0.122 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-c591276fa9d325+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 // indirect28 github.com/atotto/clipboard v0.1.4 // indirect
M go.sum +4 -4 generated file: collapsed
A internal/application/port/logger.go +11 -0
@@ -0,0 +1,11 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/rs/zerolog"
7+)
8+
9+// LoggerFromContext resolves the current request/session logger from context.
10+// Use cases depend on this boundary instead of importing infrastructure logging.
11+type LoggerFromContext func(ctx context.Context) *zerolog.Logger
M internal/application/port/mocks/mocks.go +85 -0
@@ -2140,6 +2140,91 @@ func (_c *MockKeybindingsSaver_SetKeybinding_Call) RunAndReturn(run func(ctx con
2140 return _c2140 return _c
2141 }2141 }
2142 2142
2143+// NewMockPermissionDialogPresenter creates a new instance of MockPermissionDialogPresenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2144+// The first argument is typically a *testing.T value.
2145+func NewMockPermissionDialogPresenter(t interface {
2146+ mock.TestingT
2147+ Cleanup(func())
2148+}) *MockPermissionDialogPresenter {
2149+ mock := &MockPermissionDialogPresenter{}
2150+ mock.Mock.Test(t)
2151+
2152+ t.Cleanup(func() { mock.AssertExpectations(t) })
2153+
2154+ return mock
2155+}
2156+
2157+// MockPermissionDialogPresenter is an autogenerated mock type for the PermissionDialogPresenter type
2158+type MockPermissionDialogPresenter struct {
2159+ mock.Mock
2160+}
2161+
2162+type MockPermissionDialogPresenter_Expecter struct {
2163+ mock *mock.Mock
2164+}
2165+
2166+func (_m *MockPermissionDialogPresenter) EXPECT() *MockPermissionDialogPresenter_Expecter {
2167+ return &MockPermissionDialogPresenter_Expecter{mock: &_m.Mock}
2168+}
2169+
2170+// ShowPermissionDialog provides a mock function for the type MockPermissionDialogPresenter
2171+func (_mock *MockPermissionDialogPresenter) ShowPermissionDialog(ctx context.Context, origin string, permTypes []entity.PermissionType, callback func(result port.PermissionDialogResult)) {
2172+ _mock.Called(ctx, origin, permTypes, callback)
2173+ return
2174+}
2175+
2176+// MockPermissionDialogPresenter_ShowPermissionDialog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShowPermissionDialog'
2177+type MockPermissionDialogPresenter_ShowPermissionDialog_Call struct {
2178+ *mock.Call
2179+}
2180+
2181+// ShowPermissionDialog is a helper method to define mock.On call
2182+// - ctx context.Context
2183+// - origin string
2184+// - permTypes []entity.PermissionType
2185+// - callback func(result port.PermissionDialogResult)
2186+func (_e *MockPermissionDialogPresenter_Expecter) ShowPermissionDialog(ctx interface{}, origin interface{}, permTypes interface{}, callback interface{}) *MockPermissionDialogPresenter_ShowPermissionDialog_Call {
2187+ return &MockPermissionDialogPresenter_ShowPermissionDialog_Call{Call: _e.mock.On("ShowPermissionDialog", ctx, origin, permTypes, callback)}
2188+}
2189+
2190+func (_c *MockPermissionDialogPresenter_ShowPermissionDialog_Call) Run(run func(ctx context.Context, origin string, permTypes []entity.PermissionType, callback func(result port.PermissionDialogResult))) *MockPermissionDialogPresenter_ShowPermissionDialog_Call {
2191+ _c.Call.Run(func(args mock.Arguments) {
2192+ var arg0 context.Context
2193+ if args[0] != nil {
2194+ arg0 = args[0].(context.Context)
2195+ }
2196+ var arg1 string
2197+ if args[1] != nil {
2198+ arg1 = args[1].(string)
2199+ }
2200+ var arg2 []entity.PermissionType
2201+ if args[2] != nil {
2202+ arg2 = args[2].([]entity.PermissionType)
2203+ }
2204+ var arg3 func(result port.PermissionDialogResult)
2205+ if args[3] != nil {
2206+ arg3 = args[3].(func(result port.PermissionDialogResult))
2207+ }
2208+ run(
2209+ arg0,
2210+ arg1,
2211+ arg2,
2212+ arg3,
2213+ )
2214+ })
2215+ return _c
2216+}
2217+
2218+func (_c *MockPermissionDialogPresenter_ShowPermissionDialog_Call) Return() *MockPermissionDialogPresenter_ShowPermissionDialog_Call {
2219+ _c.Call.Return()
2220+ return _c
2221+}
2222+
2223+func (_c *MockPermissionDialogPresenter_ShowPermissionDialog_Call) RunAndReturn(run func(ctx context.Context, origin string, permTypes []entity.PermissionType, callback func(result port.PermissionDialogResult))) *MockPermissionDialogPresenter_ShowPermissionDialog_Call {
2224+ _c.Run(run)
2225+ return _c
2226+}
2227+
2143 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.2228 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2144 // The first argument is typically a *testing.T value.2229 // The first argument is typically a *testing.T value.
2145 func NewMockSessionLogger(t interface {2230 func NewMockSessionLogger(t interface {
A internal/application/port/permission.go +36 -0
@@ -0,0 +1,36 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionDialogResult represents the user's response from a permission dialog.
10+type PermissionDialogResult struct {
11+ // Allowed is true if the user clicked "Allow" or "Always Allow".
12+ Allowed bool
13+
14+ // Persistent is true if the user clicked "Always Allow" (save this decision).
15+ // This should only be true for mic/camera permissions, not display capture.
16+ Persistent bool
17+}
18+
19+// PermissionDialogPresenter defines the interface for showing permission dialogs.
20+// This is implemented by the UI layer (Adwaita dialogs).
21+type PermissionDialogPresenter interface {
22+ // ShowPermissionDialog displays a permission request dialog to the user.
23+ // The callback is invoked with the user's decision.
24+ //
25+ // Parameters:
26+ // - ctx: context for cancellation and logging
27+ // - origin: the website origin requesting permission (e.g., "https://meet.example.com")
28+ // - permTypes: the types of permissions being requested
29+ // - callback: invoked when the user makes a decision or dialog is dismissed
30+ ShowPermissionDialog(
31+ ctx context.Context,
32+ origin string,
33+ permTypes []entity.PermissionType,
34+ callback func(result PermissionDialogResult),
35+ )
36+}
A internal/application/port/permission_repository.go +24 -0
@@ -0,0 +1,24 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionRepository defines operations for permission persistence.
10+// Only microphone and camera permissions are persisted per W3C spec.
11+type PermissionRepository interface {
12+ // Get retrieves the permission record for a specific origin and permission type.
13+ // Returns nil if no record exists (treat as "prompt" state).
14+ Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error)
15+
16+ // Set saves or updates a permission record.
17+ Set(ctx context.Context, record *entity.PermissionRecord) error
18+
19+ // Delete removes a permission record for a specific origin and type.
20+ Delete(ctx context.Context, origin string, permType entity.PermissionType) error
21+
22+ // GetAll retrieves all permission records for an origin.
23+ GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error)
24+}
M internal/application/port/webview.go +5 -0
@@ -104,6 +104,11 @@ type WebViewCallbacks struct {
104 OnLinkHover func(uri string)104 OnLinkHover func(uri string)
105 // OnWebProcessTerminated is called when the web process exits unexpectedly.105 // OnWebProcessTerminated is called when the web process exits unexpectedly.
106 OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string)106 OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string)
107+
108+ // OnPermissionRequest is called when a site requests permission (mic, camera, screen sharing).
109+ // Return true to indicate the request was handled. Call allow()/deny() to respond.
110+ // The permission types are passed as strings ("microphone", "camera", "display", etc.).
111+ OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
107 }112 }
108 113
109 // FindOptions configures search behavior.114 // FindOptions configures search behavior.
A internal/application/usecase/handle_permission.go +389 -0
@@ -0,0 +1,389 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "errors"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/rs/zerolog"
13+)
14+
15+// PermissionCallback provides allow/deny functions for the permission request.
16+type PermissionCallback struct {
17+ Allow func()
18+ Deny func()
19+}
20+
21+// HandlePermissionUseCase handles media permission requests from WebKit.
22+// It implements the permission strategy defined in the architecture:
23+// - Display capture: auto-allow (XDG portal handles UI)
24+// - Device enumeration: auto-allow (low risk)
25+// - Mic/Camera: check stored → dialog → persist if "Always"
26+type HandlePermissionUseCase struct {
27+ permRepo port.PermissionRepository
28+ dialog port.PermissionDialogPresenter
29+ loggerFromContext port.LoggerFromContext
30+ dialogMu sync.RWMutex
31+}
32+
33+// NewHandlePermissionUseCase creates a new permission handling use case.
34+func NewHandlePermissionUseCase(
35+ permRepo port.PermissionRepository,
36+ dialog port.PermissionDialogPresenter,
37+ loggerFromContext port.LoggerFromContext,
38+) *HandlePermissionUseCase {
39+ return &HandlePermissionUseCase{
40+ permRepo: permRepo,
41+ dialog: dialog,
42+ loggerFromContext: loggerFromContext,
43+ }
44+}
45+
46+func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
47+ if ctx == nil {
48+ ctx = context.Background()
49+ }
50+ if uc == nil || uc.loggerFromContext == nil {
51+ return zerolog.Ctx(ctx)
52+ }
53+ log := uc.loggerFromContext(ctx)
54+ if log == nil {
55+ return zerolog.Ctx(ctx)
56+ }
57+ return log
58+}
59+
60+// SetDialogPresenter sets the dialog presenter. This can be called after
61+// initialization when the UI window is available.
62+func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
63+ uc.dialogMu.Lock()
64+ defer uc.dialogMu.Unlock()
65+ uc.dialog = dialog
66+}
67+
68+// getDialog safely returns the current dialog presenter.
69+func (uc *HandlePermissionUseCase) getDialog() port.PermissionDialogPresenter {
70+ uc.dialogMu.RLock()
71+ defer uc.dialogMu.RUnlock()
72+ return uc.dialog
73+}
74+
75+// HandlePermissionRequest processes a permission request from WebKit.
76+// This is the main entry point for the permission use case.
77+//
78+// Parameters:
79+// - ctx: context for cancellation and logging
80+// - origin: the website origin (URI) requesting permission
81+// - permTypes: the types of permissions being requested
82+// - callback: callbacks to allow or deny the request (must call one)
83+func (uc *HandlePermissionUseCase) HandlePermissionRequest(
84+ ctx context.Context,
85+ origin string,
86+ permTypes []entity.PermissionType,
87+ callback PermissionCallback,
88+) {
89+ log := uc.logger(ctx).With().
90+ Str("component", "permission").
91+ Str("origin", origin).
92+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
93+ Logger()
94+
95+ // Validate origin
96+ if origin == "" {
97+ log.Warn().Msg("permission request with empty origin, denying")
98+ callback.Deny()
99+ return
100+ }
101+
102+ // Check if all types are auto-allow
103+ // Also handle empty permTypes as a deny (shouldn't happen but defensive)
104+ if len(permTypes) == 0 {
105+ log.Warn().Msg("permission request with empty types, denying")
106+ callback.Deny()
107+ return
108+ }
109+
110+ if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
111+ log.Debug().Msg("manual override denied auto-allow permission request")
112+ callback.Deny()
113+ return
114+ }
115+
116+ if isAutoAllowSet(permTypes) {
117+ log.Debug().Msg("auto-allowing permission request")
118+ callback.Allow()
119+ return
120+ }
121+
122+ // For mic/camera: check stored permissions first
123+ decision := uc.checkStoredPermissions(ctx, origin, permTypes)
124+ switch decision {
125+ case entity.PermissionGranted:
126+ log.Debug().Msg("using stored permission: granted")
127+ callback.Allow()
128+ return
129+ case entity.PermissionDenied:
130+ log.Debug().Msg("using stored permission: denied")
131+ callback.Deny()
132+ return
133+ case entity.PermissionPrompt:
134+ // Fall through to show dialog
135+ }
136+
137+ // Show dialog for undecided permissions
138+ uc.showPermissionDialog(ctx, origin, permTypes, callback)
139+}
140+
141+// QueryPermissionState returns the current permission state for the W3C Permissions API.
142+// This is used by websites to check if they already have permission before calling getUserMedia().
143+func (uc *HandlePermissionUseCase) QueryPermissionState(
144+ ctx context.Context,
145+ origin string,
146+ permType entity.PermissionType,
147+) entity.PermissionDecision {
148+ log := uc.logger(ctx).With().
149+ Str("component", "permission").
150+ Str("origin", origin).
151+ Str("type", string(permType)).
152+ Logger()
153+
154+ if origin == "" {
155+ log.Warn().Msg("query: empty origin, returning prompt")
156+ return entity.PermissionPrompt
157+ }
158+
159+ // Check stored permission first (including manual overrides on auto-allow types).
160+ record, err := uc.permRepo.Get(ctx, origin, permType)
161+ if err != nil {
162+ log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
163+ return entity.PermissionPrompt
164+ }
165+
166+ if record != nil {
167+ log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
168+ return record.Decision
169+ }
170+
171+ if entity.IsAutoAllow(permType) {
172+ log.Debug().Msg("query: no stored permission for auto-allow type, returning granted")
173+ return entity.PermissionGranted
174+ }
175+
176+ log.Debug().Msg("query: no stored permission, returning prompt")
177+ return entity.PermissionPrompt
178+}
179+
180+// isAutoAllowSet returns true if all permission types in the set are auto-allow.
181+func isAutoAllowSet(permTypes []entity.PermissionType) bool {
182+ for _, pt := range permTypes {
183+ if !entity.IsAutoAllow(pt) {
184+ return false
185+ }
186+ }
187+ return true
188+}
189+
190+// checkStoredPermissions checks if all permissions in the set have stored decisions.
191+// Returns granted if all are granted, denied if any are denied, prompt otherwise.
192+func (uc *HandlePermissionUseCase) checkStoredPermissions(
193+ ctx context.Context,
194+ origin string,
195+ permTypes []entity.PermissionType,
196+) entity.PermissionDecision {
197+ log := uc.logger(ctx)
198+
199+ hasPrompt := false
200+
201+ for _, permType := range permTypes {
202+ // Skip auto-allow types - they're handled before this function
203+ if entity.IsAutoAllow(permType) {
204+ continue
205+ }
206+
207+ // Non-persistable types that aren't auto-allow should prompt
208+ // This shouldn't happen in practice, but we handle it defensively
209+ if !entity.CanPersist(permType) {
210+ hasPrompt = true
211+ continue
212+ }
213+
214+ record, err := uc.permRepo.Get(ctx, origin, permType)
215+ if err != nil {
216+ log.Warn().
217+ Err(err).
218+ Str("perm_type", string(permType)).
219+ Msg("failed to get stored permission")
220+ // On error, treat as prompt (fall back to dialog)
221+ hasPrompt = true
222+ continue
223+ }
224+
225+ if record == nil {
226+ hasPrompt = true
227+ continue
228+ }
229+
230+ switch record.Decision {
231+ case entity.PermissionDenied:
232+ // If any permission is denied, deny the whole request (conservative)
233+ return entity.PermissionDenied
234+ case entity.PermissionPrompt:
235+ hasPrompt = true
236+ }
237+ }
238+
239+ if hasPrompt {
240+ return entity.PermissionPrompt
241+ }
242+
243+ // All permissions are granted
244+ return entity.PermissionGranted
245+}
246+
247+// showPermissionDialog shows the permission dialog and handles the result.
248+func (uc *HandlePermissionUseCase) showPermissionDialog(
249+ ctx context.Context,
250+ origin string,
251+ permTypes []entity.PermissionType,
252+ callback PermissionCallback,
253+) {
254+ log := uc.logger(ctx)
255+
256+ dialog := uc.getDialog()
257+
258+ // If no dialog presenter is set, deny the permission
259+ if dialog == nil {
260+ log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
261+ callback.Deny()
262+ return
263+ }
264+
265+ dialog.ShowPermissionDialog(ctx, origin, permTypes, func(result port.PermissionDialogResult) {
266+ if result.Allowed {
267+ log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
268+ callback.Allow()
269+
270+ // Persist if "Always Allow" was chosen
271+ if result.Persistent {
272+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
273+ }
274+ } else {
275+ log.Debug().Msg("user denied permission")
276+ callback.Deny()
277+
278+ // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
279+ if result.Persistent {
280+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
281+ }
282+ }
283+ })
284+}
285+
286+// persistPermission saves the permission decision to the repository.
287+func (uc *HandlePermissionUseCase) persistPermission(
288+ ctx context.Context,
289+ origin string,
290+ permTypes []entity.PermissionType,
291+ decision entity.PermissionDecision,
292+) {
293+ log := uc.logger(ctx)
294+
295+ for _, permType := range permTypes {
296+ // Only persist allowed types
297+ if !entity.CanPersist(permType) {
298+ continue
299+ }
300+
301+ record := &entity.PermissionRecord{
302+ Origin: origin,
303+ Type: permType,
304+ Decision: decision,
305+ UpdatedAt: time.Now().Unix(),
306+ }
307+
308+ if err := uc.permRepo.Set(ctx, record); err != nil {
309+ log.Warn().
310+ Err(err).
311+ Str("perm_type", string(permType)).
312+ Str("decision", string(decision)).
313+ Msg("failed to persist permission")
314+ }
315+ }
316+}
317+
318+// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
319+func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
320+ ctx context.Context,
321+ origin string,
322+ permType entity.PermissionType,
323+) (*entity.PermissionRecord, error) {
324+ if origin == "" {
325+ return nil, errors.New("origin is required")
326+ }
327+ return uc.permRepo.Get(ctx, origin, permType)
328+}
329+
330+// SetManualPermissionDecision stores an explicit decision for origin/type.
331+// This is used by the UI permission indicator as the source of truth for lock state.
332+func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
333+ ctx context.Context,
334+ origin string,
335+ permType entity.PermissionType,
336+ decision entity.PermissionDecision,
337+) error {
338+ if origin == "" {
339+ return errors.New("origin is required")
340+ }
341+ if !entity.CanPersist(permType) {
342+ return errors.New("permission type not persistable")
343+ }
344+ if decision == entity.PermissionPrompt {
345+ return uc.permRepo.Delete(ctx, origin, permType)
346+ }
347+
348+ return uc.permRepo.Set(ctx, &entity.PermissionRecord{
349+ Origin: origin,
350+ Type: permType,
351+ Decision: decision,
352+ UpdatedAt: time.Now().Unix(),
353+ })
354+}
355+
356+// ResetManualPermissionDecision clears any stored decision for origin/type.
357+func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
358+ ctx context.Context,
359+ origin string,
360+ permType entity.PermissionType,
361+) error {
362+ if origin == "" {
363+ return errors.New("origin is required")
364+ }
365+ return uc.permRepo.Delete(ctx, origin, permType)
366+}
367+
368+func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
369+ ctx context.Context,
370+ origin string,
371+ permTypes []entity.PermissionType,
372+) bool {
373+ for _, permType := range permTypes {
374+ if !entity.IsAutoAllow(permType) {
375+ continue
376+ }
377+
378+ record, err := uc.permRepo.Get(ctx, origin, permType)
379+ if err != nil || record == nil {
380+ continue
381+ }
382+
383+ if record.Decision == entity.PermissionDenied {
384+ return true
385+ }
386+ }
387+
388+ return false
389+}
A internal/application/usecase/handle_permission_test.go +536 -0
@@ -0,0 +1,536 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13+ "github.com/rs/zerolog"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func permissionLoggerFromContext(ctx context.Context) *zerolog.Logger {
20+ return zerolog.Ctx(ctx)
21+}
22+
23+func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
24+ ctx := testContext()
25+ permRepo := repomocks.NewMockPermissionRepository(t)
26+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
27+
28+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
29+
30+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
31+ Return(nil, nil)
32+
33+ allowed := false
34+ denied := false
35+ callback := usecase.PermissionCallback{
36+ Allow: func() { allowed = true },
37+ Deny: func() { denied = true },
38+ }
39+
40+ // Display capture should be auto-allowed (portal handles UI)
41+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
42+ entity.PermissionTypeDisplay,
43+ }, callback)
44+
45+ assert.True(t, allowed, "display capture should be auto-allowed")
46+ assert.False(t, denied)
47+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
48+}
49+
50+func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
51+ ctx := testContext()
52+ permRepo := repomocks.NewMockPermissionRepository(t)
53+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
54+
55+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
56+
57+ permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo).
58+ Return(nil, nil)
59+
60+ allowed := false
61+ callback := usecase.PermissionCallback{
62+ Allow: func() { allowed = true },
63+ Deny: func() {},
64+ }
65+
66+ // Device info should be auto-allowed
67+ uc.HandlePermissionRequest(ctx, "https://example.com", []entity.PermissionType{
68+ entity.PermissionTypeDeviceInfo,
69+ }, callback)
70+
71+ assert.True(t, allowed, "device info should be auto-allowed")
72+}
73+
74+func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
75+ ctx := testContext()
76+ permRepo := repomocks.NewMockPermissionRepository(t)
77+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
78+
79+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
80+
81+ // Return stored granted permission
82+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
83+ Return(&entity.PermissionRecord{
84+ Origin: "https://meet.example.com",
85+ Type: entity.PermissionTypeMicrophone,
86+ Decision: entity.PermissionGranted,
87+ UpdatedAt: time.Now().Unix(),
88+ }, nil)
89+
90+ allowed := false
91+ callback := usecase.PermissionCallback{
92+ Allow: func() { allowed = true },
93+ Deny: func() {},
94+ }
95+
96+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
97+ entity.PermissionTypeMicrophone,
98+ }, callback)
99+
100+ assert.True(t, allowed, "should use stored granted permission")
101+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
102+}
103+
104+func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
105+ ctx := testContext()
106+ permRepo := repomocks.NewMockPermissionRepository(t)
107+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
108+
109+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
110+
111+ // Return stored denied permission
112+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
113+ Return(&entity.PermissionRecord{
114+ Origin: "https://meet.example.com",
115+ Type: entity.PermissionTypeCamera,
116+ Decision: entity.PermissionDenied,
117+ UpdatedAt: time.Now().Unix(),
118+ }, nil)
119+
120+ denied := false
121+ callback := usecase.PermissionCallback{
122+ Allow: func() {},
123+ Deny: func() { denied = true },
124+ }
125+
126+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
127+ entity.PermissionTypeCamera,
128+ }, callback)
129+
130+ assert.True(t, denied, "should use stored denied permission")
131+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
132+}
133+
134+func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
135+ ctx := testContext()
136+ permRepo := repomocks.NewMockPermissionRepository(t)
137+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
138+
139+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
140+
141+ // No stored permission (returns nil)
142+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
143+ Return(nil, nil)
144+
145+ allowed := false
146+ callback := usecase.PermissionCallback{
147+ Allow: func() { allowed = true },
148+ Deny: func() {},
149+ }
150+
151+ // Dialog shows and user clicks Allow (not persistent)
152+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
153+ entity.PermissionTypeMicrophone,
154+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, callback func(port.PermissionDialogResult)) {
155+ callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
156+ })
157+
158+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
159+ entity.PermissionTypeMicrophone,
160+ }, callback)
161+
162+ assert.True(t, allowed, "user allowed permission")
163+ // Should not persist since Persistent=false
164+ permRepo.AssertNotCalled(t, "Set")
165+}
166+
167+func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
168+ ctx := testContext()
169+ permRepo := repomocks.NewMockPermissionRepository(t)
170+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
171+
172+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
173+
174+ // No stored permission
175+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
176+ Return(nil, nil)
177+
178+ allowed := false
179+ callback := usecase.PermissionCallback{
180+ Allow: func() { allowed = true },
181+ Deny: func() {},
182+ }
183+
184+ // Dialog shows and user clicks "Always Allow"
185+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
186+ entity.PermissionTypeMicrophone,
187+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
188+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
189+ })
190+
191+ // Should persist the granted permission
192+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
193+ Run(func(_ context.Context, r *entity.PermissionRecord) {
194+ assert.Equal(t, "https://meet.example.com", r.Origin)
195+ assert.Equal(t, entity.PermissionTypeMicrophone, r.Type)
196+ assert.Equal(t, entity.PermissionGranted, r.Decision)
197+ }).Return(nil)
198+
199+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
200+ entity.PermissionTypeMicrophone,
201+ }, callback)
202+
203+ assert.True(t, allowed)
204+}
205+
206+func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
207+ ctx := testContext()
208+ permRepo := repomocks.NewMockPermissionRepository(t)
209+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
210+
211+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
212+
213+ // No stored permission
214+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
215+ Return(nil, nil)
216+
217+ denied := false
218+ callback := usecase.PermissionCallback{
219+ Allow: func() {},
220+ Deny: func() { denied = true },
221+ }
222+
223+ // Dialog shows and user clicks "Always Deny"
224+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
225+ entity.PermissionTypeCamera,
226+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
227+ // User denied but wants to persist this decision
228+ cb(port.PermissionDialogResult{Allowed: false, Persistent: true})
229+ })
230+
231+ // Should persist the denied permission
232+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
233+ Run(func(_ context.Context, r *entity.PermissionRecord) {
234+ assert.Equal(t, entity.PermissionDenied, r.Decision)
235+ }).Return(nil)
236+
237+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
238+ entity.PermissionTypeCamera,
239+ }, callback)
240+
241+ assert.True(t, denied)
242+}
243+
244+func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
245+ ctx := testContext()
246+ permRepo := repomocks.NewMockPermissionRepository(t)
247+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
248+
249+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
250+
251+ denied := false
252+ callback := usecase.PermissionCallback{
253+ Allow: func() {},
254+ Deny: func() { denied = true },
255+ }
256+
257+ // Empty origin should be denied
258+ uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{
259+ entity.PermissionTypeMicrophone,
260+ }, callback)
261+
262+ assert.True(t, denied, "empty origin should be denied")
263+}
264+
265+func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
266+ ctx := testContext()
267+ permRepo := repomocks.NewMockPermissionRepository(t)
268+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
269+
270+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
271+
272+ // Both mic and camera - one denied means whole request denied
273+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
274+ Return(&entity.PermissionRecord{
275+ Origin: "https://meet.example.com",
276+ Type: entity.PermissionTypeMicrophone,
277+ Decision: entity.PermissionGranted,
278+ }, nil)
279+
280+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
281+ Return(&entity.PermissionRecord{
282+ Origin: "https://meet.example.com",
283+ Type: entity.PermissionTypeCamera,
284+ Decision: entity.PermissionDenied,
285+ }, nil)
286+
287+ denied := false
288+ callback := usecase.PermissionCallback{
289+ Allow: func() {},
290+ Deny: func() { denied = true },
291+ }
292+
293+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
294+ entity.PermissionTypeMicrophone,
295+ entity.PermissionTypeCamera,
296+ }, callback)
297+
298+ assert.True(t, denied, "if any permission is denied, whole request is denied")
299+}
300+
301+func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
302+ ctx := testContext()
303+ permRepo := repomocks.NewMockPermissionRepository(t)
304+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
305+
306+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
307+ permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay).
308+ Return(nil, nil)
309+
310+ state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
311+
312+ assert.Equal(t, entity.PermissionGranted, state, "display capture should auto-return granted")
313+}
314+
315+func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t *testing.T) {
316+ ctx := testContext()
317+ permRepo := repomocks.NewMockPermissionRepository(t)
318+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
319+
320+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
321+
322+ permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay).
323+ Return(&entity.PermissionRecord{
324+ Origin: "https://example.com",
325+ Type: entity.PermissionTypeDisplay,
326+ Decision: entity.PermissionDenied,
327+ }, nil)
328+
329+ state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
330+
331+ assert.Equal(t, entity.PermissionDenied, state, "stored manual deny should override auto-allow")
332+}
333+
334+func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
335+ ctx := testContext()
336+ permRepo := repomocks.NewMockPermissionRepository(t)
337+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
338+
339+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
340+
341+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
342+ Return(&entity.PermissionRecord{
343+ Origin: "https://meet.example.com",
344+ Type: entity.PermissionTypeMicrophone,
345+ Decision: entity.PermissionDenied,
346+ }, nil)
347+
348+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeMicrophone)
349+
350+ assert.Equal(t, entity.PermissionDenied, state)
351+}
352+
353+func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
354+ ctx := testContext()
355+ permRepo := repomocks.NewMockPermissionRepository(t)
356+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
357+
358+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
359+
360+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
361+ Return(nil, nil)
362+
363+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeCamera)
364+
365+ assert.Equal(t, entity.PermissionPrompt, state, "no record should return prompt")
366+}
367+
368+func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t *testing.T) {
369+ ctx := testContext()
370+ permRepo := repomocks.NewMockPermissionRepository(t)
371+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
372+
373+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
374+
375+ state := uc.QueryPermissionState(ctx, "", entity.PermissionTypeDisplay)
376+
377+ assert.Equal(t, entity.PermissionPrompt, state, "empty origin should return prompt")
378+ permRepo.AssertNotCalled(t, "Get")
379+}
380+
381+func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
382+ ctx := testContext()
383+ permRepo := repomocks.NewMockPermissionRepository(t)
384+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
385+
386+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
387+
388+ // Display capture is not persistable per W3C spec
389+ // A stored override may exist, so a Get call is expected before auto-allow.
390+ // No Set call should happen even if we try to persist
391+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
392+ Return(nil, nil)
393+
394+ allowed := false
395+ callback := usecase.PermissionCallback{
396+ Allow: func() { allowed = true },
397+ Deny: func() {},
398+ }
399+
400+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
401+ entity.PermissionTypeDisplay,
402+ }, callback)
403+
404+ assert.True(t, allowed)
405+ permRepo.AssertNotCalled(t, "Set")
406+}
407+
408+func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
409+ ctx := testContext()
410+ permRepo := repomocks.NewMockPermissionRepository(t)
411+
412+ // Create use case with nil dialog presenter
413+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
414+
415+ denied := false
416+ callback := usecase.PermissionCallback{
417+ Allow: func() {},
418+ Deny: func() { denied = true },
419+ }
420+
421+ // Mic request with no stored permission and no dialog should be denied
422+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
423+ Return(nil, nil)
424+
425+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
426+ entity.PermissionTypeMicrophone,
427+ }, callback)
428+
429+ assert.True(t, denied, "should deny when no dialog presenter is available")
430+}
431+
432+func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
433+ ctx := testContext()
434+ permRepo := repomocks.NewMockPermissionRepository(t)
435+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
436+
437+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
438+
439+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
440+ Return(&entity.PermissionRecord{
441+ Origin: "https://meet.example.com",
442+ Type: entity.PermissionTypeDisplay,
443+ Decision: entity.PermissionDenied,
444+ }, nil)
445+
446+ denied := false
447+ callback := usecase.PermissionCallback{
448+ Allow: func() {},
449+ Deny: func() { denied = true },
450+ }
451+
452+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
453+ entity.PermissionTypeDisplay,
454+ }, callback)
455+
456+ assert.True(t, denied, "display should be denied when manual override is denied")
457+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
458+}
459+
460+func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
461+ ctx := testContext()
462+ permRepo := repomocks.NewMockPermissionRepository(t)
463+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
464+
465+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
466+ Run(func(_ context.Context, record *entity.PermissionRecord) {
467+ assert.Equal(t, "https://app.zoom.us", record.Origin)
468+ assert.Equal(t, entity.PermissionTypeMicrophone, record.Type)
469+ assert.Equal(t, entity.PermissionGranted, record.Decision)
470+ }).
471+ Return(nil)
472+
473+ err := uc.SetManualPermissionDecision(
474+ ctx,
475+ "https://app.zoom.us",
476+ entity.PermissionTypeMicrophone,
477+ entity.PermissionGranted,
478+ )
479+ require.NoError(t, err)
480+
481+ permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
482+ Return(&entity.PermissionRecord{
483+ Origin: "https://app.zoom.us",
484+ Type: entity.PermissionTypeMicrophone,
485+ Decision: entity.PermissionGranted,
486+ }, nil)
487+
488+ record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
489+ require.NoError(t, err)
490+ if assert.NotNil(t, record) {
491+ assert.Equal(t, entity.PermissionGranted, record.Decision)
492+ }
493+
494+ permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
495+ Return(nil)
496+ err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
497+ require.NoError(t, err)
498+}
499+
500+func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *testing.T) {
501+ ctx := testContext()
502+ permRepo := repomocks.NewMockPermissionRepository(t)
503+ uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
504+
505+ err := uc.SetManualPermissionDecision(
506+ ctx,
507+ "https://app.zoom.us",
508+ entity.PermissionTypeDisplay,
509+ entity.PermissionDenied,
510+ )
511+
512+ require.Error(t, err)
513+ require.ErrorContains(t, err, "permission type not persistable")
514+ permRepo.AssertNotCalled(t, "Set")
515+ permRepo.AssertNotCalled(t, "Delete")
516+}
517+
518+func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) {
519+ ctx := testContext()
520+ permRepo := repomocks.NewMockPermissionRepository(t)
521+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
522+
523+ called := false
524+ loggerFactory := func(ctx context.Context) *zerolog.Logger {
525+ called = true
526+ return zerolog.Ctx(ctx)
527+ }
528+
529+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, loggerFactory)
530+ uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{entity.PermissionTypeMicrophone}, usecase.PermissionCallback{
531+ Allow: func() {},
532+ Deny: func() {},
533+ })
534+
535+ assert.True(t, called)
536+}
M internal/bootstrap/webkit_stack.go +11 -2
@@ -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 pool224 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 "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/domain/entity/permission.go +114 -0
@@ -0,0 +1,114 @@
1+package entity
2+
3+// DomainTimestamp represents a Unix timestamp in seconds.
4+// This is a domain-owned type to avoid importing time in the domain layer.
5+
6+// PermissionType represents the type of permission being requested.
7+type PermissionType string
8+
9+const (
10+ // PermissionTypeMicrophone represents microphone access permission.
11+ PermissionTypeMicrophone PermissionType = "microphone"
12+
13+ // PermissionTypeCamera represents camera access permission.
14+ PermissionTypeCamera PermissionType = "camera"
15+
16+ // PermissionTypeDisplay represents screen sharing/display capture permission.
17+ PermissionTypeDisplay PermissionType = "display"
18+
19+ // PermissionTypeDeviceInfo represents device enumeration permission.
20+ PermissionTypeDeviceInfo PermissionType = "device_info"
21+
22+ // PermissionTypeClipboard represents clipboard access permission.
23+ PermissionTypeClipboard PermissionType = "clipboard"
24+
25+ // PermissionTypeNotification represents notification permission.
26+ PermissionTypeNotification PermissionType = "notification"
27+
28+ // PermissionTypeGeolocation represents geolocation permission.
29+ PermissionTypeGeolocation PermissionType = "geolocation"
30+
31+ // PermissionTypePointerLock represents pointer lock permission.
32+ PermissionTypePointerLock PermissionType = "pointer_lock"
33+
34+ // PermissionTypeMediaKeySystem represents DRM/media key system permission.
35+ PermissionTypeMediaKeySystem PermissionType = "media_key_system"
36+
37+ // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission.
38+ PermissionTypeWebsiteDataAccess PermissionType = "website_data_access"
39+)
40+
41+// PermissionDecision represents the user's decision for a permission.
42+type PermissionDecision string
43+
44+const (
45+ // PermissionGranted means the permission was allowed.
46+ PermissionGranted PermissionDecision = "granted"
47+
48+ // PermissionDenied means the permission was denied.
49+ PermissionDenied PermissionDecision = "denied"
50+
51+ // PermissionPrompt means no decision has been made yet (default state).
52+ PermissionPrompt PermissionDecision = "prompt"
53+)
54+
55+// PermissionRecord stores a permission decision for a specific origin and type.
56+type PermissionRecord struct {
57+ Origin string // The origin (domain) this permission applies to
58+ Type PermissionType // The type of permission
59+ Decision PermissionDecision // The decision: granted, denied, or prompt
60+ UpdatedAt int64 // Unix timestamp in seconds when this record was last updated
61+}
62+
63+// IsGranted returns true if the permission is granted.
64+func (p *PermissionRecord) IsGranted() bool {
65+ return p.Decision == PermissionGranted
66+}
67+
68+// IsDenied returns true if the permission is denied.
69+func (p *PermissionRecord) IsDenied() bool {
70+ return p.Decision == PermissionDenied
71+}
72+
73+// PermissionSet represents multiple permission decisions for a single request.
74+// This is used when handling combined permission requests (e.g., mic + camera).
75+type PermissionSet struct {
76+ Types []PermissionType
77+ Decision PermissionDecision
78+}
79+
80+// CanPersist returns true if this permission type can be persisted.
81+// Per W3C spec, display capture permissions cannot be persisted.
82+func CanPersist(permType PermissionType) bool {
83+ switch permType {
84+ case PermissionTypeDisplay:
85+ return false // W3C forbids persisting display capture permissions
86+ case PermissionTypeDeviceInfo, PermissionTypePointerLock:
87+ return false // Auto-allowed, no need to persist
88+ default:
89+ return true
90+ }
91+}
92+
93+// IsAutoAllow returns true if this permission type should be auto-allowed.
94+func IsAutoAllow(permType PermissionType) bool {
95+ switch permType {
96+ case PermissionTypeDisplay:
97+ return true // Portal handles the UI
98+ case PermissionTypeDeviceInfo:
99+ return true // Low risk, just lists devices
100+ case PermissionTypePointerLock:
101+ return true // Low risk, default WebKit behavior
102+ default:
103+ return false
104+ }
105+}
106+
107+// PermissionTypesToStrings converts permission types to strings for logging.
108+func PermissionTypesToStrings(types []PermissionType) []string {
109+ result := make([]string, len(types))
110+ for i, t := range types {
111+ result[i] = string(t)
112+ }
113+ return result
114+}
A internal/domain/entity/permission_test.go +164 -0
@@ -0,0 +1,164 @@
1+package entity_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPermissionType_Constants(t *testing.T) {
12+ tests := []struct {
13+ permType entity.PermissionType
14+ expected string
15+ }{
16+ {entity.PermissionTypeMicrophone, "microphone"},
17+ {entity.PermissionTypeCamera, "camera"},
18+ {entity.PermissionTypeDisplay, "display"},
19+ {entity.PermissionTypeDeviceInfo, "device_info"},
20+ {entity.PermissionTypeClipboard, "clipboard"},
21+ {entity.PermissionTypeNotification, "notification"},
22+ {entity.PermissionTypeGeolocation, "geolocation"},
23+ {entity.PermissionTypePointerLock, "pointer_lock"},
24+ {entity.PermissionTypeMediaKeySystem, "media_key_system"},
25+ {entity.PermissionTypeWebsiteDataAccess, "website_data_access"},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(string(tt.permType), func(t *testing.T) {
30+ assert.Equal(t, tt.expected, string(tt.permType))
31+ })
32+ }
33+}
34+
35+func TestPermissionDecision_Constants(t *testing.T) {
36+ tests := []struct {
37+ decision entity.PermissionDecision
38+ expected string
39+ }{
40+ {entity.PermissionGranted, "granted"},
41+ {entity.PermissionDenied, "denied"},
42+ {entity.PermissionPrompt, "prompt"},
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(string(tt.decision), func(t *testing.T) {
47+ assert.Equal(t, tt.expected, string(tt.decision))
48+ })
49+ }
50+}
51+
52+func TestPermissionRecord_IsGranted(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ decision entity.PermissionDecision
56+ expected bool
57+ }{
58+ {"granted", entity.PermissionGranted, true},
59+ {"denied", entity.PermissionDenied, false},
60+ {"prompt", entity.PermissionPrompt, false},
61+ }
62+
63+ for _, tt := range tests {
64+ t.Run(tt.name, func(t *testing.T) {
65+ record := &entity.PermissionRecord{
66+ Origin: "https://example.com",
67+ Type: entity.PermissionTypeMicrophone,
68+ Decision: tt.decision,
69+ UpdatedAt: time.Now().Unix(),
70+ }
71+ assert.Equal(t, tt.expected, record.IsGranted())
72+ })
73+ }
74+}
75+
76+func TestPermissionRecord_IsDenied(t *testing.T) {
77+ tests := []struct {
78+ name string
79+ decision entity.PermissionDecision
80+ expected bool
81+ }{
82+ {"granted", entity.PermissionGranted, false},
83+ {"denied", entity.PermissionDenied, true},
84+ {"prompt", entity.PermissionPrompt, false},
85+ }
86+
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ record := &entity.PermissionRecord{
90+ Origin: "https://example.com",
91+ Type: entity.PermissionTypeCamera,
92+ Decision: tt.decision,
93+ UpdatedAt: time.Now().Unix(),
94+ }
95+ assert.Equal(t, tt.expected, record.IsDenied())
96+ })
97+ }
98+}
99+
100+func TestCanPersist(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ permType entity.PermissionType
104+ expected bool
105+ }{
106+ // Non-persistable per W3C spec
107+ {"display capture", entity.PermissionTypeDisplay, false},
108+ {"device info", entity.PermissionTypeDeviceInfo, false},
109+ {"pointer lock", entity.PermissionTypePointerLock, false},
110+
111+ // Persistable
112+ {"microphone", entity.PermissionTypeMicrophone, true},
113+ {"camera", entity.PermissionTypeCamera, true},
114+ {"clipboard", entity.PermissionTypeClipboard, true},
115+ {"notification", entity.PermissionTypeNotification, true},
116+ {"geolocation", entity.PermissionTypeGeolocation, true},
117+ {"media key system", entity.PermissionTypeMediaKeySystem, true},
118+ {"website data access", entity.PermissionTypeWebsiteDataAccess, true},
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ assert.Equal(t, tt.expected, entity.CanPersist(tt.permType))
124+ })
125+ }
126+}
127+
128+func TestIsAutoAllow(t *testing.T) {
129+ tests := []struct {
130+ name string
131+ permType entity.PermissionType
132+ expected bool
133+ }{
134+ // Auto-allowed (portal handles UI or low risk)
135+ {"display capture", entity.PermissionTypeDisplay, true},
136+ {"device info", entity.PermissionTypeDeviceInfo, true},
137+ {"pointer lock", entity.PermissionTypePointerLock, true},
138+
139+ // Not auto-allowed (require user consent)
140+ {"microphone", entity.PermissionTypeMicrophone, false},
141+ {"camera", entity.PermissionTypeCamera, false},
142+ {"clipboard", entity.PermissionTypeClipboard, false},
143+ {"notification", entity.PermissionTypeNotification, false},
144+ {"geolocation", entity.PermissionTypeGeolocation, false},
145+ }
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType))
150+ })
151+ }
152+}
153+
154+func TestPermissionSet(t *testing.T) {
155+ set := entity.PermissionSet{
156+ Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera},
157+ Decision: entity.PermissionGranted,
158+ }
159+
160+ assert.Len(t, set.Types, 2)
161+ assert.Contains(t, set.Types, entity.PermissionTypeMicrophone)
162+ assert.Contains(t, set.Types, entity.PermissionTypeCamera)
163+ assert.Equal(t, entity.PermissionGranted, set.Decision)
164+}
M internal/domain/repository/mocks/mocks.go +289 -0
@@ -2979,6 +2979,295 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C
2979 return _c2979 return _c
2980 }2980 }
2981 2981
2982+// NewMockPermissionRepository creates a new instance of MockPermissionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2983+// The first argument is typically a *testing.T value.
2984+func NewMockPermissionRepository(t interface {
2985+ mock.TestingT
2986+ Cleanup(func())
2987+}) *MockPermissionRepository {
2988+ mock := &MockPermissionRepository{}
2989+ mock.Mock.Test(t)
2990+
2991+ t.Cleanup(func() { mock.AssertExpectations(t) })
2992+
2993+ return mock
2994+}
2995+
2996+// MockPermissionRepository is an autogenerated mock type for the PermissionRepository type
2997+type MockPermissionRepository struct {
2998+ mock.Mock
2999+}
3000+
3001+type MockPermissionRepository_Expecter struct {
3002+ mock *mock.Mock
3003+}
3004+
3005+func (_m *MockPermissionRepository) EXPECT() *MockPermissionRepository_Expecter {
3006+ return &MockPermissionRepository_Expecter{mock: &_m.Mock}
3007+}
3008+
3009+// Delete provides a mock function for the type MockPermissionRepository
3010+func (_mock *MockPermissionRepository) Delete(ctx context.Context, origin string, permType entity.PermissionType) error {
3011+ ret := _mock.Called(ctx, origin, permType)
3012+
3013+ if len(ret) == 0 {
3014+ panic("no return value specified for Delete")
3015+ }
3016+
3017+ var r0 error
3018+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) error); ok {
3019+ r0 = returnFunc(ctx, origin, permType)
3020+ } else {
3021+ r0 = ret.Error(0)
3022+ }
3023+ return r0
3024+}
3025+
3026+// MockPermissionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
3027+type MockPermissionRepository_Delete_Call struct {
3028+ *mock.Call
3029+}
3030+
3031+// Delete is a helper method to define mock.On call
3032+// - ctx context.Context
3033+// - origin string
3034+// - permType entity.PermissionType
3035+func (_e *MockPermissionRepository_Expecter) Delete(ctx interface{}, origin interface{}, permType interface{}) *MockPermissionRepository_Delete_Call {
3036+ return &MockPermissionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, origin, permType)}
3037+}
3038+
3039+func (_c *MockPermissionRepository_Delete_Call) Run(run func(ctx context.Context, origin string, permType entity.PermissionType)) *MockPermissionRepository_Delete_Call {
3040+ _c.Call.Run(func(args mock.Arguments) {
3041+ var arg0 context.Context
3042+ if args[0] != nil {
3043+ arg0 = args[0].(context.Context)
3044+ }
3045+ var arg1 string
3046+ if args[1] != nil {
3047+ arg1 = args[1].(string)
3048+ }
3049+ var arg2 entity.PermissionType
3050+ if args[2] != nil {
3051+ arg2 = args[2].(entity.PermissionType)
3052+ }
3053+ run(
3054+ arg0,
3055+ arg1,
3056+ arg2,
3057+ )
3058+ })
3059+ return _c
3060+}
3061+
3062+func (_c *MockPermissionRepository_Delete_Call) Return(err error) *MockPermissionRepository_Delete_Call {
3063+ _c.Call.Return(err)
3064+ return _c
3065+}
3066+
3067+func (_c *MockPermissionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, origin string, permType entity.PermissionType) error) *MockPermissionRepository_Delete_Call {
3068+ _c.Call.Return(run)
3069+ return _c
3070+}
3071+
3072+// Get provides a mock function for the type MockPermissionRepository
3073+func (_mock *MockPermissionRepository) Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) {
3074+ ret := _mock.Called(ctx, origin, permType)
3075+
3076+ if len(ret) == 0 {
3077+ panic("no return value specified for Get")
3078+ }
3079+
3080+ var r0 *entity.PermissionRecord
3081+ var r1 error
3082+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) (*entity.PermissionRecord, error)); ok {
3083+ return returnFunc(ctx, origin, permType)
3084+ }
3085+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) *entity.PermissionRecord); ok {
3086+ r0 = returnFunc(ctx, origin, permType)
3087+ } else {
3088+ if ret.Get(0) != nil {
3089+ r0 = ret.Get(0).(*entity.PermissionRecord)
3090+ }
3091+ }
3092+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, entity.PermissionType) error); ok {
3093+ r1 = returnFunc(ctx, origin, permType)
3094+ } else {
3095+ r1 = ret.Error(1)
3096+ }
3097+ return r0, r1
3098+}
3099+
3100+// MockPermissionRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
3101+type MockPermissionRepository_Get_Call struct {
3102+ *mock.Call
3103+}
3104+
3105+// Get is a helper method to define mock.On call
3106+// - ctx context.Context
3107+// - origin string
3108+// - permType entity.PermissionType
3109+func (_e *MockPermissionRepository_Expecter) Get(ctx interface{}, origin interface{}, permType interface{}) *MockPermissionRepository_Get_Call {
3110+ return &MockPermissionRepository_Get_Call{Call: _e.mock.On("Get", ctx, origin, permType)}
3111+}
3112+
3113+func (_c *MockPermissionRepository_Get_Call) Run(run func(ctx context.Context, origin string, permType entity.PermissionType)) *MockPermissionRepository_Get_Call {
3114+ _c.Call.Run(func(args mock.Arguments) {
3115+ var arg0 context.Context
3116+ if args[0] != nil {
3117+ arg0 = args[0].(context.Context)
3118+ }
3119+ var arg1 string
3120+ if args[1] != nil {
3121+ arg1 = args[1].(string)
3122+ }
3123+ var arg2 entity.PermissionType
3124+ if args[2] != nil {
3125+ arg2 = args[2].(entity.PermissionType)
3126+ }
3127+ run(
3128+ arg0,
3129+ arg1,
3130+ arg2,
3131+ )
3132+ })
3133+ return _c
3134+}
3135+
3136+func (_c *MockPermissionRepository_Get_Call) Return(permissionRecord *entity.PermissionRecord, err error) *MockPermissionRepository_Get_Call {
3137+ _c.Call.Return(permissionRecord, err)
3138+ return _c
3139+}
3140+
3141+func (_c *MockPermissionRepository_Get_Call) RunAndReturn(run func(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error)) *MockPermissionRepository_Get_Call {
3142+ _c.Call.Return(run)
3143+ return _c
3144+}
3145+
3146+// GetAll provides a mock function for the type MockPermissionRepository
3147+func (_mock *MockPermissionRepository) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) {
3148+ ret := _mock.Called(ctx, origin)
3149+
3150+ if len(ret) == 0 {
3151+ panic("no return value specified for GetAll")
3152+ }
3153+
3154+ var r0 []*entity.PermissionRecord
3155+ var r1 error
3156+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*entity.PermissionRecord, error)); ok {
3157+ return returnFunc(ctx, origin)
3158+ }
3159+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []*entity.PermissionRecord); ok {
3160+ r0 = returnFunc(ctx, origin)
3161+ } else {
3162+ if ret.Get(0) != nil {
3163+ r0 = ret.Get(0).([]*entity.PermissionRecord)
3164+ }
3165+ }
3166+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
3167+ r1 = returnFunc(ctx, origin)
3168+ } else {
3169+ r1 = ret.Error(1)
3170+ }
3171+ return r0, r1
3172+}
3173+
3174+// MockPermissionRepository_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
3175+type MockPermissionRepository_GetAll_Call struct {
3176+ *mock.Call
3177+}
3178+
3179+// GetAll is a helper method to define mock.On call
3180+// - ctx context.Context
3181+// - origin string
3182+func (_e *MockPermissionRepository_Expecter) GetAll(ctx interface{}, origin interface{}) *MockPermissionRepository_GetAll_Call {
3183+ return &MockPermissionRepository_GetAll_Call{Call: _e.mock.On("GetAll", ctx, origin)}
3184+}
3185+
3186+func (_c *MockPermissionRepository_GetAll_Call) Run(run func(ctx context.Context, origin string)) *MockPermissionRepository_GetAll_Call {
3187+ _c.Call.Run(func(args mock.Arguments) {
3188+ var arg0 context.Context
3189+ if args[0] != nil {
3190+ arg0 = args[0].(context.Context)
3191+ }
3192+ var arg1 string
3193+ if args[1] != nil {
3194+ arg1 = args[1].(string)
3195+ }
3196+ run(
3197+ arg0,
3198+ arg1,
3199+ )
3200+ })
3201+ return _c
3202+}
3203+
3204+func (_c *MockPermissionRepository_GetAll_Call) Return(permissionRecords []*entity.PermissionRecord, err error) *MockPermissionRepository_GetAll_Call {
3205+ _c.Call.Return(permissionRecords, err)
3206+ return _c
3207+}
3208+
3209+func (_c *MockPermissionRepository_GetAll_Call) RunAndReturn(run func(ctx context.Context, origin string) ([]*entity.PermissionRecord, error)) *MockPermissionRepository_GetAll_Call {
3210+ _c.Call.Return(run)
3211+ return _c
3212+}
3213+
3214+// Set provides a mock function for the type MockPermissionRepository
3215+func (_mock *MockPermissionRepository) Set(ctx context.Context, record *entity.PermissionRecord) error {
3216+ ret := _mock.Called(ctx, record)
3217+
3218+ if len(ret) == 0 {
3219+ panic("no return value specified for Set")
3220+ }
3221+
3222+ var r0 error
3223+ if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.PermissionRecord) error); ok {
3224+ r0 = returnFunc(ctx, record)
3225+ } else {
3226+ r0 = ret.Error(0)
3227+ }
3228+ return r0
3229+}
3230+
3231+// MockPermissionRepository_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
3232+type MockPermissionRepository_Set_Call struct {
3233+ *mock.Call
3234+}
3235+
3236+// Set is a helper method to define mock.On call
3237+// - ctx context.Context
3238+// - record *entity.PermissionRecord
3239+func (_e *MockPermissionRepository_Expecter) Set(ctx interface{}, record interface{}) *MockPermissionRepository_Set_Call {
3240+ return &MockPermissionRepository_Set_Call{Call: _e.mock.On("Set", ctx, record)}
3241+}
3242+
3243+func (_c *MockPermissionRepository_Set_Call) Run(run func(ctx context.Context, record *entity.PermissionRecord)) *MockPermissionRepository_Set_Call {
3244+ _c.Call.Run(func(args mock.Arguments) {
3245+ var arg0 context.Context
3246+ if args[0] != nil {
3247+ arg0 = args[0].(context.Context)
3248+ }
3249+ var arg1 *entity.PermissionRecord
3250+ if args[1] != nil {
3251+ arg1 = args[1].(*entity.PermissionRecord)
3252+ }
3253+ run(
3254+ arg0,
3255+ arg1,
3256+ )
3257+ })
3258+ return _c
3259+}
3260+
3261+func (_c *MockPermissionRepository_Set_Call) Return(err error) *MockPermissionRepository_Set_Call {
3262+ _c.Call.Return(err)
3263+ return _c
3264+}
3265+
3266+func (_c *MockPermissionRepository_Set_Call) RunAndReturn(run func(ctx context.Context, record *entity.PermissionRecord) error) *MockPermissionRepository_Set_Call {
3267+ _c.Call.Return(run)
3268+ return _c
3269+}
3270+
2982 // NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3271 // NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2983 // The first argument is typically a *testing.T value.3272 // The first argument is typically a *testing.T value.
2984 func NewMockSessionRepository(t interface {3273 func NewMockSessionRepository(t interface {
M internal/domain/url/normalize.go +40 -0
@@ -203,3 +203,43 @@ func IsExternalScheme(uri string) bool {
203 return true203 return true
204 }204 }
205 }205 }
206+
207+// ExtractOrigin extracts the origin (scheme://host) from a URI.
208+// This normalizes URIs to origins for permission storage and comparison.
209+// Canonicalizes by lowercasing scheme and host, and omitting default ports.
210+// Example: "https://example.com/path?query" -> "https://example.com"
211+// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
212+func ExtractOrigin(uri string) (string, error) {
213+ parsed, err := url.Parse(uri)
214+ if err != nil {
215+ return "", fmt.Errorf("invalid URI: %w", err)
216+ }
217+
218+ if parsed.Scheme == "" || parsed.Host == "" {
219+ return "", fmt.Errorf("URI missing scheme or host: %s", uri)
220+ }
221+
222+ // Canonicalize: lowercase scheme and hostname
223+ scheme := strings.ToLower(parsed.Scheme)
224+ hostname := strings.ToLower(parsed.Hostname())
225+ if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
226+ hostname = "[" + hostname + "]"
227+ }
228+
229+ // Determine if we should include the port
230+ port := parsed.Port()
231+ if port != "" {
232+ // Omit default ports
233+ if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
234+ port = ""
235+ }
236+ }
237+
238+ // Build canonical origin
239+ origin := scheme + "://" + hostname
240+ if port != "" {
241+ origin = origin + ":" + port
242+ }
243+
244+ return origin, nil
245+}
M internal/domain/url/normalize_test.go +92 -0
@@ -509,3 +509,95 @@ func TestIsExternalScheme(t *testing.T) {
509 })509 })
510 }510 }
511 }511 }
512+
513+func TestExtractOrigin_ValidURI(t *testing.T) {
514+ tests := []struct {
515+ name string
516+ uri string
517+ expected string
518+ }{
519+ {
520+ name: "https with port",
521+ uri: "https://example.com:8443/path?query=1",
522+ expected: "https://example.com:8443",
523+ },
524+ {
525+ name: "https without port",
526+ uri: "https://example.com/path",
527+ expected: "https://example.com",
528+ },
529+ {
530+ name: "http",
531+ uri: "http://localhost:8080/app",
532+ expected: "http://localhost:8080",
533+ },
534+ {
535+ name: "uppercase host",
536+ uri: "https://EXAMPLE.COM/path",
537+ expected: "https://example.com",
538+ },
539+ {
540+ name: "uppercase scheme",
541+ uri: "HTTPS://example.com/path",
542+ expected: "https://example.com",
543+ },
544+ {
545+ name: "https default port omitted",
546+ uri: "https://example.com:443/path",
547+ expected: "https://example.com",
548+ },
549+ {
550+ name: "http default port omitted",
551+ uri: "http://example.com:80/path",
552+ expected: "http://example.com",
553+ },
554+ {
555+ name: "https explicit non-default port kept",
556+ uri: "https://example.com:8080/path",
557+ expected: "https://example.com:8080",
558+ },
559+ {
560+ name: "mixed case with default port",
561+ uri: "HTTPS://EXAMPLE.COM:443/path",
562+ expected: "https://example.com",
563+ },
564+ {
565+ name: "ipv6 with explicit port",
566+ uri: "https://[::1]:8443/path",
567+ expected: "https://[::1]:8443",
568+ },
569+ }
570+
571+ for _, tt := range tests {
572+ t.Run(tt.name, func(t *testing.T) {
573+ origin, err := ExtractOrigin(tt.uri)
574+ if err != nil {
575+ t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
576+ return
577+ }
578+ if origin != tt.expected {
579+ t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
580+ }
581+ })
582+ }
583+}
584+
585+func TestExtractOrigin_InvalidURI(t *testing.T) {
586+ tests := []struct {
587+ name string
588+ uri string
589+ }{
590+ {name: "empty", uri: ""},
591+ {name: "no scheme", uri: "example.com"},
592+ {name: "no host", uri: "https://"},
593+ }
594+
595+ for _, tt := range tests {
596+ t.Run(tt.name, func(t *testing.T) {
597+ origin, err := ExtractOrigin(tt.uri)
598+ if err == nil {
599+ t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
600+ }
601+ })
602+ }
603+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +59 -0
@@ -40,6 +40,63 @@ func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.History
40 return &LazyHistoryRepository{provider: provider}40 return &LazyHistoryRepository{provider: provider}
41 }41 }
42 42
43+// LazyPermissionRepository wraps a permission repository with lazy database initialization.
44+type LazyPermissionRepository struct {
45+ provider port.DatabaseProvider
46+ repo port.PermissionRepository
47+ once sync.Once
48+ initErr error
49+}
50+
51+// NewLazyPermissionRepository creates a lazy-loading permission repository.
52+func NewLazyPermissionRepository(provider port.DatabaseProvider) port.PermissionRepository {
53+ return &LazyPermissionRepository{provider: provider}
54+}
55+
56+func (r *LazyPermissionRepository) init(ctx context.Context) error {
57+ r.once.Do(func() {
58+ db, err := r.provider.DB(ctx)
59+ if err != nil {
60+ r.initErr = err
61+ return
62+ }
63+ r.repo = NewPermissionRepository(db)
64+ })
65+ return r.initErr
66+}
67+
68+func (r *LazyPermissionRepository) Get(
69+ ctx context.Context,
70+ origin string,
71+ permType entity.PermissionType,
72+) (*entity.PermissionRecord, error) {
73+ if err := r.init(ctx); err != nil {
74+ return nil, err
75+ }
76+ return r.repo.Get(ctx, origin, permType)
77+}
78+
79+func (r *LazyPermissionRepository) Set(ctx context.Context, record *entity.PermissionRecord) error {
80+ if err := r.init(ctx); err != nil {
81+ return err
82+ }
83+ return r.repo.Set(ctx, record)
84+}
85+
86+func (r *LazyPermissionRepository) Delete(ctx context.Context, origin string, permType entity.PermissionType) error {
87+ if err := r.init(ctx); err != nil {
88+ return err
89+ }
90+ return r.repo.Delete(ctx, origin, permType)
91+}
92+
93+func (r *LazyPermissionRepository) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) {
94+ if err := r.init(ctx); err != nil {
95+ return nil, err
96+ }
97+ return r.repo.GetAll(ctx, origin)
98+}
99+
43 func (r *LazyHistoryRepository) init(ctx context.Context) error {100 func (r *LazyHistoryRepository) init(ctx context.Context) error {
44 r.once.Do(func() {101 r.once.Do(func() {
45 db, err := r.provider.DB(ctx)102 db, err := r.provider.DB(ctx)
@@ -696,6 +753,7 @@ type LazyRepositories struct {
696 Session repository.SessionRepository753 Session repository.SessionRepository
697 SessionState repository.SessionStateRepository754 SessionState repository.SessionStateRepository
698 Filter repository.ContentWhitelistRepository755 Filter repository.ContentWhitelistRepository
756+ Permission port.PermissionRepository
699 }757 }
700 758
701 // NewLazyRepositories creates all lazy repositories from a database provider.759 // NewLazyRepositories creates all lazy repositories from a database provider.
@@ -709,6 +767,7 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
709 Session: NewLazySessionRepository(provider),767 Session: NewLazySessionRepository(provider),
710 SessionState: NewLazySessionStateRepository(provider),768 SessionState: NewLazySessionStateRepository(provider),
711 Filter: NewLazyContentWhitelistRepository(provider),769 Filter: NewLazyContentWhitelistRepository(provider),
770+ Permission: NewLazyPermissionRepository(provider),
712 }771 }
713 }772 }
714 773
A internal/infrastructure/persistence/sqlite/migrations/007_permissions.sql +16 -0
@@ -0,0 +1,16 @@
1+-- +goose Up
2+-- Permission storage for media access (mic, camera, etc.)
3+-- Per W3C spec: display capture permissions cannot be persisted
4+
5+CREATE TABLE IF NOT EXISTS permissions (
6+ origin TEXT NOT NULL,
7+ permission_type TEXT NOT NULL,
8+ decision TEXT NOT NULL CHECK(decision IN ('granted', 'denied')),
9+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
10+ PRIMARY KEY (origin, permission_type)
11+);
12+
13+CREATE INDEX IF NOT EXISTS idx_permissions_updated_at ON permissions(updated_at);
14+
15+-- +goose Down
16+DROP TABLE IF EXISTS permissions;
A internal/infrastructure/persistence/sqlite/permission_repo.go +102 -0
@@ -0,0 +1,102 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+type permissionRepo struct {
15+ queries *sqlc.Queries
16+}
17+
18+// NewPermissionRepository creates a new SQLite-backed permission repository.
19+func NewPermissionRepository(db *sql.DB) port.PermissionRepository {
20+ return &permissionRepo{queries: sqlc.New(db)}
21+}
22+
23+func (r *permissionRepo) Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) {
24+ log := logging.FromContext(ctx)
25+ log.Debug().Str("origin", origin).Str("type", string(permType)).Msg("getting permission")
26+
27+ row, err := r.queries.GetPermission(ctx, sqlc.GetPermissionParams{
28+ Origin: origin,
29+ PermissionType: string(permType),
30+ })
31+ if err != nil {
32+ if errors.Is(err, sql.ErrNoRows) {
33+ return nil, nil
34+ }
35+ return nil, err
36+ }
37+
38+ return permissionFromRow(row), nil
39+}
40+
41+func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error {
42+ log := logging.FromContext(ctx)
43+
44+ if record == nil {
45+ log.Error().Msg("cannot set nil permission record")
46+ return errors.New("cannot set nil permission record")
47+ }
48+
49+ log.Debug().
50+ Str("origin", record.Origin).
51+ Str("type", string(record.Type)).
52+ Str("decision", string(record.Decision)).
53+ Msg("setting permission")
54+
55+ return r.queries.SetPermission(ctx, sqlc.SetPermissionParams{
56+ Origin: record.Origin,
57+ PermissionType: string(record.Type),
58+ Decision: string(record.Decision),
59+ })
60+}
61+
62+func (r *permissionRepo) Delete(ctx context.Context, origin string, permType entity.PermissionType) error {
63+ log := logging.FromContext(ctx)
64+ log.Debug().Str("origin", origin).Str("type", string(permType)).Msg("deleting permission")
65+
66+ return r.queries.DeletePermission(ctx, sqlc.DeletePermissionParams{
67+ Origin: origin,
68+ PermissionType: string(permType),
69+ })
70+}
71+
72+func (r *permissionRepo) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) {
73+ log := logging.FromContext(ctx)
74+ log.Debug().Str("origin", origin).Msg("getting all permissions for origin")
75+
76+ // sqlc returns an empty slice (not sql.ErrNoRows) when nothing matches.
77+ rows, err := r.queries.ListPermissionsByOrigin(ctx, origin)
78+ if err != nil {
79+ return nil, err
80+ }
81+ if len(rows) == 0 {
82+ return []*entity.PermissionRecord{}, nil
83+ }
84+
85+ records := make([]*entity.PermissionRecord, len(rows))
86+ for i, row := range rows {
87+ records[i] = permissionFromRow(row)
88+ }
89+ return records, nil
90+}
91+
92+func permissionFromRow(row sqlc.Permission) *entity.PermissionRecord {
93+ record := &entity.PermissionRecord{
94+ Origin: row.Origin,
95+ Type: entity.PermissionType(row.PermissionType),
96+ Decision: entity.PermissionDecision(row.Decision),
97+ }
98+ if row.UpdatedAt.Valid {
99+ record.UpdatedAt = row.UpdatedAt.Time.Unix()
100+ }
101+ return record
102+}
A internal/infrastructure/persistence/sqlite/queries/permissions.sql +18 -0
@@ -0,0 +1,18 @@
1+-- name: GetPermission :one
2+SELECT * FROM permissions WHERE origin = ? AND permission_type = ? LIMIT 1;
3+
4+-- name: SetPermission :exec
5+INSERT INTO permissions (origin, permission_type, decision, updated_at)
6+VALUES (?, ?, ?, CURRENT_TIMESTAMP)
7+ON CONFLICT(origin, permission_type) DO UPDATE SET
8+ decision = excluded.decision,
9+ updated_at = CURRENT_TIMESTAMP;
10+
11+-- name: DeletePermission :exec
12+DELETE FROM permissions WHERE origin = ? AND permission_type = ?;
13+
14+-- name: ListPermissionsByOrigin :many
15+SELECT * FROM permissions WHERE origin = ? ORDER BY updated_at DESC;
16+
17+-- name: ListAllPermissions :many
18+SELECT * FROM permissions ORDER BY origin, permission_type;
M internal/infrastructure/persistence/sqlite/sqlc/models.go +7 -0
@@ -63,6 +63,13 @@ type HistoryFt struct {
63 Title string `json:"title"`63 Title string `json:"title"`
64 }64 }
65 65
66+type Permission struct {
67+ Origin string `json:"origin"`
68+ PermissionType string `json:"permission_type"`
69+ Decision string `json:"decision"`
70+ UpdatedAt sql.NullTime `json:"updated_at"`
71+}
72+
66 type Session struct {73 type Session struct {
67 ID string `json:"id"`74 ID string `json:"id"`
68 Type string `json:"type"`75 Type string `json:"type"`
A internal/infrastructure/persistence/sqlite/sqlc/permissions.sql.go +128 -0
@@ -0,0 +1,128 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: permissions.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const DeletePermission = `-- name: DeletePermission :exec
13+DELETE FROM permissions WHERE origin = ? AND permission_type = ?
14+`
15+
16+type DeletePermissionParams struct {
17+ Origin string `json:"origin"`
18+ PermissionType string `json:"permission_type"`
19+}
20+
21+func (q *Queries) DeletePermission(ctx context.Context, arg DeletePermissionParams) error {
22+ _, err := q.db.ExecContext(ctx, DeletePermission, arg.Origin, arg.PermissionType)
23+ return err
24+}
25+
26+const GetPermission = `-- name: GetPermission :one
27+SELECT origin, permission_type, decision, updated_at FROM permissions WHERE origin = ? AND permission_type = ? LIMIT 1
28+`
29+
30+type GetPermissionParams struct {
31+ Origin string `json:"origin"`
32+ PermissionType string `json:"permission_type"`
33+}
34+
35+func (q *Queries) GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) {
36+ row := q.db.QueryRowContext(ctx, GetPermission, arg.Origin, arg.PermissionType)
37+ var i Permission
38+ err := row.Scan(
39+ &i.Origin,
40+ &i.PermissionType,
41+ &i.Decision,
42+ &i.UpdatedAt,
43+ )
44+ return i, err
45+}
46+
47+const ListAllPermissions = `-- name: ListAllPermissions :many
48+SELECT origin, permission_type, decision, updated_at FROM permissions ORDER BY origin, permission_type
49+`
50+
51+func (q *Queries) ListAllPermissions(ctx context.Context) ([]Permission, error) {
52+ rows, err := q.db.QueryContext(ctx, ListAllPermissions)
53+ if err != nil {
54+ return nil, err
55+ }
56+ defer rows.Close()
57+ items := []Permission{}
58+ for rows.Next() {
59+ var i Permission
60+ if err := rows.Scan(
61+ &i.Origin,
62+ &i.PermissionType,
63+ &i.Decision,
64+ &i.UpdatedAt,
65+ ); err != nil {
66+ return nil, err
67+ }
68+ items = append(items, i)
69+ }
70+ if err := rows.Close(); err != nil {
71+ return nil, err
72+ }
73+ if err := rows.Err(); err != nil {
74+ return nil, err
75+ }
76+ return items, nil
77+}
78+
79+const ListPermissionsByOrigin = `-- name: ListPermissionsByOrigin :many
80+SELECT origin, permission_type, decision, updated_at FROM permissions WHERE origin = ? ORDER BY updated_at DESC
81+`
82+
83+func (q *Queries) ListPermissionsByOrigin(ctx context.Context, origin string) ([]Permission, error) {
84+ rows, err := q.db.QueryContext(ctx, ListPermissionsByOrigin, origin)
85+ if err != nil {
86+ return nil, err
87+ }
88+ defer rows.Close()
89+ items := []Permission{}
90+ for rows.Next() {
91+ var i Permission
92+ if err := rows.Scan(
93+ &i.Origin,
94+ &i.PermissionType,
95+ &i.Decision,
96+ &i.UpdatedAt,
97+ ); err != nil {
98+ return nil, err
99+ }
100+ items = append(items, i)
101+ }
102+ if err := rows.Close(); err != nil {
103+ return nil, err
104+ }
105+ if err := rows.Err(); err != nil {
106+ return nil, err
107+ }
108+ return items, nil
109+}
110+
111+const SetPermission = `-- name: SetPermission :exec
112+INSERT INTO permissions (origin, permission_type, decision, updated_at)
113+VALUES (?, ?, ?, CURRENT_TIMESTAMP)
114+ON CONFLICT(origin, permission_type) DO UPDATE SET
115+ decision = excluded.decision,
116+ updated_at = CURRENT_TIMESTAMP
117+`
118+
119+type SetPermissionParams struct {
120+ Origin string `json:"origin"`
121+ PermissionType string `json:"permission_type"`
122+ Decision string `json:"decision"`
123+}
124+
125+func (q *Queries) SetPermission(ctx context.Context, arg SetPermissionParams) error {
126+ _, err := q.db.ExecContext(ctx, SetPermission, arg.Origin, arg.PermissionType, arg.Decision)
127+ return err
128+}
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
@@ -26,6 +26,7 @@ type Querier interface {
26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
28 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)28 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
29+ DeletePermission(ctx context.Context, arg DeletePermissionParams) error
29 DeleteSession(ctx context.Context, id string) error30 DeleteSession(ctx context.Context, id string) error
30 DeleteSessionState(ctx context.Context, sessionID string) error31 DeleteSessionState(ctx context.Context, sessionID string) error
31 DeleteTag(ctx context.Context, id int64) error32 DeleteTag(ctx context.Context, id int64) error
@@ -51,6 +52,7 @@ type Querier interface {
51 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)52 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
52 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)53 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
53 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)54 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
55+ GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
54 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)56 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
55 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)57 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
56 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)58 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
@@ -67,6 +69,8 @@ type Querier interface {
67 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error69 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
68 InsertSession(ctx context.Context, arg InsertSessionParams) error70 InsertSession(ctx context.Context, arg InsertSessionParams) error
69 IsWhitelisted(ctx context.Context, domain string) (int64, error)71 IsWhitelisted(ctx context.Context, domain string) (int64, error)
72+ ListAllPermissions(ctx context.Context) ([]Permission, error)
73+ ListPermissionsByOrigin(ctx context.Context, origin string) ([]Permission, error)
70 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)74 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
71 MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error75 MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error
72 RemoveFromWhitelist(ctx context.Context, domain string) error76 RemoveFromWhitelist(ctx context.Context, domain string) error
@@ -77,6 +81,7 @@ type Querier interface {
77 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)81 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
78 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error82 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
79 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error83 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
84+ SetPermission(ctx context.Context, arg SetPermissionParams) error
80 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error85 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
81 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error86 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
82 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error87 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
A internal/infrastructure/webkit/gobject_type_helpers.go +77 -0
@@ -0,0 +1,77 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/purego"
9+ "github.com/jwijenbergh/puregotk/pkg/core"
10+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
11+)
12+
13+var (
14+ loadGObjectTypeFnsOnce sync.Once
15+ typeCheckInstanceIsA func(uintptr, gtypes.GType) bool
16+ typeNameFromInstance func(uintptr) string
17+)
18+
19+func gobjectTypeCheckInstanceIsAByPtr(ctx context.Context, instancePtr uintptr, requestType gtypes.GType) bool {
20+ if instancePtr == 0 {
21+ return false
22+ }
23+ if ctx == nil {
24+ ctx = context.Background()
25+ }
26+ loadGObjectTypeFns(ctx)
27+ if typeCheckInstanceIsA == nil {
28+ return false
29+ }
30+ return typeCheckInstanceIsA(instancePtr, requestType)
31+}
32+
33+func permissionRequestTypeName(ctx context.Context, instancePtr uintptr) string {
34+ if instancePtr == 0 {
35+ return ""
36+ }
37+ if ctx == nil {
38+ ctx = context.Background()
39+ }
40+ loadGObjectTypeFns(ctx)
41+ if typeNameFromInstance == nil {
42+ return ""
43+ }
44+ return typeNameFromInstance(instancePtr)
45+}
46+
47+func loadGObjectTypeFns(ctx context.Context) {
48+ if ctx == nil {
49+ ctx = context.Background()
50+ }
51+
52+ loadGObjectTypeFnsOnce.Do(func() {
53+ log := logging.FromContext(ctx)
54+ libs := make([]uintptr, 0, 2)
55+ for _, libPath := range core.GetPaths("GOBJECT") {
56+ lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
57+ if err != nil {
58+ log.Debug().Str("path", libPath).Err(err).Msg("failed to load GObject library")
59+ continue
60+ }
61+ libs = append(libs, lib)
62+ }
63+ if len(libs) == 0 {
64+ log.Debug().Msg("no GObject libraries loaded; permission type helpers unavailable")
65+ return
66+ }
67+
68+ core.PuregoSafeRegister(&typeCheckInstanceIsA, libs, "g_type_check_instance_is_a")
69+ if typeCheckInstanceIsA == nil {
70+ log.Debug().Msg("failed to register g_type_check_instance_is_a")
71+ }
72+ core.PuregoSafeRegister(&typeNameFromInstance, libs, "g_type_name_from_instance")
73+ if typeNameFromInstance == nil {
74+ log.Debug().Msg("failed to register g_type_name_from_instance")
75+ }
76+ })
77+}
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 // 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 only269+ // 3. Inject dark mode handler for internal pages only
239 // This sets .dark/.light class on <html> and patches matchMedia for WebUI270 // 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 literal285 // 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 pages303+ // 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.Regexp(t, `window\.RTCPeerConnection\s*=\s*window\.webkitRTCPeerConnection`, script)
14+ assert.Regexp(t, `window\.RTCSessionDescription\s*=\s*window\.webkitRTCSessionDescription`, script)
15+ assert.Regexp(t, `window\.RTCIceCandidate\s*=\s*window\.webkitRTCIceCandidate`, script)
16+}
M internal/infrastructure/webkit/scheme_handler.go +37 -32
@@ -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 nil512 return nil
512 }513 }
513 514
514- // Determine the target file based on host and path515+ relPath, ok := resolveAssetPath(u)
515- host := u.Host516+ 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 nil517 return nil
547 }518 }
548 519
@@ -568,6 +539,40 @@ 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 ConfigPath:
566+ return "config.html", true
567+ case ErrorPath:
568+ return "error.html", true
569+ case WebRTCPath:
570+ return "webrtc.html", true
571+ default:
572+ return "", false
573+ }
574+}
575+
571 // getMimeType determines the MIME type for a given file path.576 // getMimeType determines the MIME type for a given file path.
572 func (h *DumbSchemeHandler) getMimeType(filename string) string {577 func (h *DumbSchemeHandler) getMimeType(filename string) string {
573 if h == nil {578 if h == nil {
M internal/infrastructure/webkit/scheme_handler_test.go +41 -0
@@ -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,42 @@ 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+}
118+
119+func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {
120+ h := NewDumbSchemeHandler(context.Background())
121+ h.SetAssets(assets.WebUIAssets)
122+
123+ u, err := url.Parse("dumb:config")
124+ require.NoError(t, err)
125+
126+ resp := h.handleAsset(u)
127+ require.NotNil(t, resp)
128+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
129+ assert.Contains(t, string(resp.Data), "config")
130+}
M internal/infrastructure/webkit/settings.go +10 -1
@@ -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
@@ -117,7 +122,6 @@ func applyMediaSettings(settings *webkit.Settings, cfg *config.Config, log *zero
117 settings.SetEnableMedia(true)122 settings.SetEnableMedia(true)
118 settings.SetEnableMediasource(true)123 settings.SetEnableMediasource(true)
119 settings.SetEnableMediaCapabilities(true)124 settings.SetEnableMediaCapabilities(true)
120- settings.SetEnableMediaStream(true)
121 settings.SetEnableEncryptedMedia(true)125 settings.SetEnableEncryptedMedia(true)
122 settings.SetMediaPlaybackRequiresUserGesture(true)126 settings.SetMediaPlaybackRequiresUserGesture(true)
123 settings.SetMediaPlaybackAllowsInline(true)127 settings.SetMediaPlaybackAllowsInline(true)
@@ -152,6 +156,11 @@ func applyCanvasSettings(settings *webkit.Settings) {
152 }156 }
153 157
154 func applyWebRTCSettings(settings *webkit.Settings) {158 func applyWebRTCSettings(settings *webkit.Settings) {
159+ // Set both direct API and gobject properties for robustness with generated bindings.
160+ settings.SetPropertyEnableMediaStream(true)
161+ settings.SetPropertyEnableWebrtc(true)
162+
163+ settings.SetEnableMediaStream(true)
155 settings.SetEnableWebrtc(true)164 settings.SetEnableWebrtc(true)
156 }165 }
157 166
M internal/infrastructure/webkit/webview.go +262 -0
@@ -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 )
@@ -154,6 +155,11 @@ type WebView struct {
154 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)155 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
155 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)156 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
156 157
158+ // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
159+ // Return true to indicate the request was handled. Call allow()/deny() to respond.
160+ // The permission types are determined from the request object.
161+ OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
162+
157 logger zerolog.Logger163 logger zerolog.Logger
158 mu sync.RWMutex164 mu sync.RWMutex
159 165
@@ -409,9 +415,11 @@ func (wv *WebView) connectSignals() {
409 wv.connectEnterFullscreenSignal()415 wv.connectEnterFullscreenSignal()
410 wv.connectLeaveFullscreenSignal()416 wv.connectLeaveFullscreenSignal()
411 wv.connectAudioStateSignal()417 wv.connectAudioStateSignal()
418+ wv.connectMediaCaptureStateSignals()
412 wv.connectMouseTargetChangedSignal()419 wv.connectMouseTargetChangedSignal()
413 wv.connectBackForwardListChangedSignal()420 wv.connectBackForwardListChangedSignal()
414 wv.connectWebProcessTerminatedSignal()421 wv.connectWebProcessTerminatedSignal()
422+ wv.connectPermissionRequestSignal()
415 }423 }
416 424
417 func (wv *WebView) connectLoadChangedSignal() {425 func (wv *WebView) connectLoadChangedSignal() {
@@ -829,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() {
829 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))837 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
830 }838 }
831 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+
832 func (wv *WebView) connectMouseTargetChangedSignal() {873 func (wv *WebView) connectMouseTargetChangedSignal() {
833 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {874 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
834 if wv.OnLinkHover == nil {875 if wv.OnLinkHover == nil {
@@ -930,6 +971,224 @@ func (wv *WebView) connectWebProcessTerminatedSignal() {
930 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))971 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
931 }972 }
932 973
974+// connectPermissionRequestSignal sets up the permission-request signal handler.
975+// This is emitted when a site calls getUserMedia() or getDisplayMedia().
976+func (wv *WebView) connectPermissionRequestSignal() {
977+ permissionCb := func(_ webkit.WebView, requestPtr uintptr) bool {
978+ ctx := logging.WithContext(context.Background(), wv.logger)
979+
980+ if wv.OnPermissionRequest == nil {
981+ return false // Not handled, WebKit will deny by default
982+ }
983+
984+ // Extract and normalize origin from current URI
985+ uri := wv.URI()
986+ if uri == "" {
987+ wv.logger.Debug().Msg("permission request with empty origin, denying")
988+ return false
989+ }
990+ origin, err := urlutil.ExtractOrigin(uri)
991+ if err != nil {
992+ wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying")
993+ return false
994+ }
995+
996+ // Determine permission types from the request
997+ permTypes := wv.determinePermissionTypes(ctx, requestPtr)
998+ if len(permTypes) == 0 {
999+ wv.logger.Warn().Msg("permission request with unknown type, denying")
1000+ return false
1001+ }
1002+
1003+ // Ref the request object to prevent use-after-free
1004+ // The request may outlive the signal handler if we show a dialog
1005+ requestObj := gobject.ObjectNewFromInternalPtr(requestPtr)
1006+ if requestObj == nil {
1007+ wv.logger.Warn().Msg("permission request: failed to wrap request object")
1008+ return false
1009+ }
1010+ requestObj.Ref()
1011+
1012+ // Create allow/deny callbacks that wrap the WebKit permission request
1013+ allowCalled := false
1014+ denyCalled := false
1015+
1016+ allow := func() {
1017+ if allowCalled || denyCalled {
1018+ return
1019+ }
1020+ allowCalled = true
1021+ wv.allowPermissionRequest(requestPtr)
1022+ requestObj.Unref()
1023+ }
1024+
1025+ deny := func() {
1026+ if allowCalled || denyCalled {
1027+ return
1028+ }
1029+ denyCalled = true
1030+ wv.denyPermissionRequest(requestPtr)
1031+ requestObj.Unref()
1032+ }
1033+
1034+ // Call the handler
1035+ return wv.OnPermissionRequest(origin, permTypes, allow, deny)
1036+ }
1037+
1038+ sigID := wv.inner.ConnectPermissionRequest(&permissionCb)
1039+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1040+}
1041+
1042+// determinePermissionTypes extracts permission types from a WebKit permission request.
1043+// This uses type checking to identify UserMediaPermissionRequest and its specific types.
1044+// We use GObject property accessors for audio/video request flags. Display detection
1045+// uses the dedicated WebKit API because this WebKit build does not expose an
1046+// "is-for-display-device" GObject property.
1047+func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) []string {
1048+ requestKind := detectPermissionRequestKind(ctx, requestPtr)
1049+ switch requestKind {
1050+ case permissionRequestKindUserMedia:
1051+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1052+ if userMediaReq == nil {
1053+ return nil
1054+ }
1055+
1056+ // Use GObject property accessors — more reliable than the C function wrappers
1057+ // which can hit the purego bool return value bug.
1058+ isAudio := userMediaReq.GetPropertyIsForAudioDevice()
1059+ isVideo := userMediaReq.GetPropertyIsForVideoDevice()
1060+ isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq)
1061+
1062+ wv.logger.Debug().
1063+ Bool("is_audio", isAudio).
1064+ Bool("is_video", isVideo).
1065+ Bool("is_display", isDisplay).
1066+ Msg("permission request type detection")
1067+
1068+ return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay)
1069+ case permissionRequestKindDeviceInfo:
1070+ return classifyPermissionRequestTypes(ctx, requestKind, false, false, false)
1071+ default:
1072+ if requestPtr != 0 {
1073+ typeName := permissionRequestTypeName(ctx, requestPtr)
1074+ if typeName != "" {
1075+ wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type")
1076+ } else {
1077+ wv.logger.Warn().Msg("unknown permission request type")
1078+ }
1079+ }
1080+ // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1081+ // For now, return empty to trigger denial. Future phases will add these types.
1082+ return nil
1083+ }
1084+}
1085+
1086+type permissionRequestKind int
1087+
1088+const (
1089+ permissionRequestKindUnknown permissionRequestKind = iota
1090+ permissionRequestKindUserMedia
1091+ permissionRequestKindDeviceInfo
1092+)
1093+
1094+func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind {
1095+ if isPermissionRequestType(ctx, requestPtr, webkit.UserMediaPermissionRequestGLibType()) {
1096+ return permissionRequestKindUserMedia
1097+ }
1098+ if isPermissionRequestType(ctx, requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) {
1099+ return permissionRequestKindDeviceInfo
1100+ }
1101+ return permissionRequestKindUnknown
1102+}
1103+
1104+func isPermissionRequestType(ctx context.Context, requestPtr uintptr, requestType gtypes.GType) bool {
1105+ return gobjectTypeCheckInstanceIsAByPtr(ctx, requestPtr, requestType)
1106+}
1107+
1108+func classifyPermissionRequestTypes(
1109+ ctx context.Context,
1110+ kind permissionRequestKind,
1111+ isAudio, isVideo, isDisplay bool,
1112+) []string {
1113+ switch kind {
1114+ case permissionRequestKindUserMedia:
1115+ return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay)
1116+ case permissionRequestKindDeviceInfo:
1117+ return []string{"device_info"}
1118+ default:
1119+ return nil
1120+ }
1121+}
1122+
1123+func classifyUserMediaPermissionTypes(ctx context.Context, isAudio, isVideo, isDisplay bool) []string {
1124+ types := make([]string, 0, 2)
1125+
1126+ if isAudio {
1127+ types = append(types, "microphone")
1128+ }
1129+
1130+ if isDisplay {
1131+ types = append(types, "display")
1132+ } else if isVideo {
1133+ types = append(types, "camera")
1134+ }
1135+
1136+ if len(types) == 0 {
1137+ // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all
1138+ // flags false even though the request is a screen-capture request.
1139+ logging.FromContext(ctx).Debug().
1140+ Bool("is_audio", isAudio).
1141+ Bool("is_video", isVideo).
1142+ Bool("is_display", isDisplay).
1143+ Msg("user media request had no flags; using display fallback")
1144+ return []string{"display"}
1145+ }
1146+
1147+ return types
1148+}
1149+
1150+// allowPermissionRequest calls Allow() on the WebKit permission request.
1151+func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
1152+ // Try UserMediaPermissionRequest first
1153+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1154+ if userMediaReq != nil {
1155+ userMediaReq.Allow()
1156+ wv.logger.Debug().Msg("permission request allowed")
1157+ return
1158+ }
1159+
1160+ // Try DeviceInfoPermissionRequest
1161+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1162+ if deviceInfoReq != nil {
1163+ deviceInfoReq.Allow()
1164+ wv.logger.Debug().Msg("permission request allowed")
1165+ return
1166+ }
1167+
1168+ wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow")
1169+}
1170+
1171+// denyPermissionRequest calls Deny() on the WebKit permission request.
1172+func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
1173+ // Try UserMediaPermissionRequest first
1174+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1175+ if userMediaReq != nil {
1176+ userMediaReq.Deny()
1177+ wv.logger.Debug().Msg("permission request denied")
1178+ return
1179+ }
1180+
1181+ // Try DeviceInfoPermissionRequest
1182+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1183+ if deviceInfoReq != nil {
1184+ deviceInfoReq.Deny()
1185+ wv.logger.Debug().Msg("permission request denied")
1186+ return
1187+ }
1188+
1189+ wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny")
1190+}
1191+
933 // ID returns the unique identifier for this WebView.1192 // ID returns the unique identifier for this WebView.
934 func (wv *WebView) ID() WebViewID {1193 func (wv *WebView) ID() WebViewID {
935 return wv.id1194 return wv.id
@@ -1214,6 +1473,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1214 wv.OnCreate = nil1473 wv.OnCreate = nil
1215 wv.OnLinkHover = nil1474 wv.OnLinkHover = nil
1216 wv.OnWebProcessTerminated = nil1475 wv.OnWebProcessTerminated = nil
1476+ wv.OnPermissionRequest = nil
1217 return1477 return
1218 }1478 }
1219 1479
@@ -1260,6 +1520,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1260 } else {1520 } else {
1261 wv.OnWebProcessTerminated = nil1521 wv.OnWebProcessTerminated = nil
1262 }1522 }
1523+ wv.OnPermissionRequest = callbacks.OnPermissionRequest
1263 }1524 }
1264 1525
1265 // ShowDevTools opens the WebKit inspector/developer tools.1526 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1374,6 +1635,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1374 wv.OnAudioStateChanged = nil1635 wv.OnAudioStateChanged = nil
1375 wv.OnLinkHover = nil1636 wv.OnLinkHover = nil
1376 wv.OnWebProcessTerminated = nil1637 wv.OnWebProcessTerminated = nil
1638+ wv.OnPermissionRequest = nil
1377 1639
1378 // 3. Clear async callback references1640 // 3. Clear async callback references
1379 wv.mu.Lock()1641 wv.mu.Lock()
A internal/infrastructure/webkit/webview_permission_types_test.go +97 -0
@@ -0,0 +1,97 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestClassifyUserMediaPermissionTypes(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ isAudio bool
14+ isVideo bool
15+ isDisplay bool
16+ expected []string
17+ }{
18+ {
19+ name: "audio and camera",
20+ isAudio: true,
21+ isVideo: true,
22+ isDisplay: false,
23+ expected: []string{"microphone", "camera"},
24+ },
25+ {
26+ name: "screen only",
27+ isAudio: false,
28+ isVideo: false,
29+ isDisplay: true,
30+ expected: []string{"display"},
31+ },
32+ {
33+ name: "screen with audio",
34+ isAudio: true,
35+ isVideo: false,
36+ isDisplay: true,
37+ expected: []string{"microphone", "display"},
38+ },
39+ {
40+ name: "webkit display fallback when all flags false",
41+ isAudio: false,
42+ isVideo: false,
43+ isDisplay: false,
44+ expected: []string{"display"},
45+ },
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(context.Background(), tt.isAudio, tt.isVideo, tt.isDisplay))
51+ })
52+ }
53+}
54+
55+func TestClassifyPermissionRequestTypes(t *testing.T) {
56+ tests := []struct {
57+ name string
58+ kind permissionRequestKind
59+ isAudio bool
60+ isVideo bool
61+ isDisplay bool
62+ expected []string
63+ }{
64+ {
65+ name: "device info request stays device_info",
66+ kind: permissionRequestKindDeviceInfo,
67+ expected: []string{"device_info"},
68+ },
69+ {
70+ name: "user media camera",
71+ kind: permissionRequestKindUserMedia,
72+ isAudio: false,
73+ isVideo: true,
74+ isDisplay: false,
75+ expected: []string{"camera"},
76+ },
77+ {
78+ name: "user media fallback display",
79+ kind: permissionRequestKindUserMedia,
80+ isAudio: false,
81+ isVideo: false,
82+ isDisplay: false,
83+ expected: []string{"display"},
84+ },
85+ {
86+ name: "unknown request denied",
87+ kind: permissionRequestKindUnknown,
88+ expected: nil,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ assert.Equal(t, tt.expected, classifyPermissionRequestTypes(context.Background(), tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay))
95+ })
96+ }
97+}
M internal/ui/app.go +128 -2
@@ -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"
@@ -24,6 +25,7 @@ import (
24 "github.com/bnema/dumber/internal/ui/adapter"25 "github.com/bnema/dumber/internal/ui/adapter"
25 "github.com/bnema/dumber/internal/ui/component"26 "github.com/bnema/dumber/internal/ui/component"
26 "github.com/bnema/dumber/internal/ui/coordinator"27 "github.com/bnema/dumber/internal/ui/coordinator"
28+ "github.com/bnema/dumber/internal/ui/dialog"
27 "github.com/bnema/dumber/internal/ui/dispatcher"29 "github.com/bnema/dumber/internal/ui/dispatcher"
28 "github.com/bnema/dumber/internal/ui/focus"30 "github.com/bnema/dumber/internal/ui/focus"
29 "github.com/bnema/dumber/internal/ui/input"31 "github.com/bnema/dumber/internal/ui/input"
@@ -95,6 +97,8 @@ type App struct {
95 appToaster *component.Toaster97 appToaster *component.Toaster
96 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)98 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
97 modeToaster *component.Toaster99 modeToaster *component.Toaster
100+ // Top-right WebRTC permission activity indicator
101+ webrtcIndicator *component.WebRTCPermissionIndicator
98 102
99 // Session management103 // Session management
100 sessionManager *component.SessionManager104 sessionManager *component.SessionManager
@@ -203,8 +207,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
203 }207 }
204 }208 }
205 209
206- // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)210+ appID := AppID
207- a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)211+ a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)
208 if a.gtkApp == nil {212 if a.gtkApp == nil {
209 log.Error().Msg("failed to create GTK application")213 log.Error().Msg("failed to create GTK application")
210 return 1214 return 1
@@ -254,6 +258,7 @@ func (a *App) onActivate(ctx context.Context) {
254 a.initDownloadHandler(ctx)258 a.initDownloadHandler(ctx)
255 259
256 a.initCoordinators(ctx)260 a.initCoordinators(ctx)
261+ a.wireWebRTCPermissionIndicator()
257 logging.Trace().Mark("coordinators_init")262 logging.Trace().Mark("coordinators_init")
258 a.initKeyboardHandler(ctx)263 a.initKeyboardHandler(ctx)
259 a.initOmniboxConfig(ctx)264 a.initOmniboxConfig(ctx)
@@ -351,6 +356,32 @@ func (a *App) createMainWindow(ctx context.Context) error {
351 }356 }
352 a.mainWindow = mainWindow357 a.mainWindow = mainWindow
353 358
359+ // Create permission popup and dialog presenter
360+ if a.deps != nil && a.deps.PermissionUC != nil {
361+ uiScale := 1.0
362+ if a.deps.Config != nil {
363+ uiScale = a.deps.Config.DefaultUIScale
364+ }
365+ permPopup := component.NewPermissionPopup(nil, uiScale)
366+ if permPopup != nil {
367+ // Add popup to the main window's content overlay
368+ if w := permPopup.Widget(); w != nil {
369+ a.mainWindow.AddOverlay(w)
370+ }
371+ permDialog := dialog.NewPermissionDialog(permPopup)
372+ a.deps.PermissionUC.SetDialogPresenter(permDialog)
373+ }
374+ }
375+
376+ // Create top-right WebRTC permission activity indicator.
377+ indicator := component.NewWebRTCPermissionIndicator()
378+ if indicator != nil {
379+ if w := indicator.Widget(); w != nil {
380+ a.mainWindow.AddOverlay(w)
381+ a.webrtcIndicator = indicator
382+ }
383+ }
384+
354 // Apply GTK CSS styling from theme manager.385 // Apply GTK CSS styling from theme manager.
355 if a.deps == nil || a.deps.Theme == nil {386 if a.deps == nil || a.deps.Theme == nil {
356 return nil387 return nil
@@ -1316,6 +1347,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1316 a.faviconAdapter,1347 a.faviconAdapter,
1317 getActiveWS,1348 getActiveWS,
1318 a.deps.ZoomUC,1349 a.deps.ZoomUC,
1350+ a.deps.PermissionUC,
1319 )1351 )
1320 1352
1321 // Set idle inhibitor for fullscreen video playback1353 // Set idle inhibitor for fullscreen video playback
@@ -1522,6 +1554,100 @@ func (a *App) initCoordinators(ctx context.Context) {
1522 log.Debug().Msg("coordinators initialized")1554 log.Debug().Msg("coordinators initialized")
1523 }1555 }
1524 1556
1557+func (a *App) wireWebRTCPermissionIndicator() {
1558+ if a.webrtcIndicator == nil || a.contentCoord == nil {
1559+ return
1560+ }
1561+ ctx := context.Background()
1562+ if a.deps != nil && a.deps.Ctx != nil {
1563+ ctx = a.deps.Ctx
1564+ }
1565+ log := logging.FromContext(ctx)
1566+
1567+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1568+ a.webrtcIndicator.SetOrigin(origin)
1569+
1570+ switch state {
1571+ case coordinator.PermissionActivityRequesting:
1572+ a.webrtcIndicator.MarkRequesting(permTypes)
1573+ case coordinator.PermissionActivityAllowed:
1574+ a.webrtcIndicator.MarkAllowed(permTypes)
1575+ case coordinator.PermissionActivityBlocked:
1576+ a.webrtcIndicator.MarkBlocked(permTypes)
1577+ }
1578+
1579+ for _, permType := range permTypes {
1580+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1581+ }
1582+ })
1583+
1584+ a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1585+ if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1586+ return
1587+ }
1588+
1589+ if hasStored {
1590+ // Already locked: reset to prompt (will re-ask on next request).
1591+ if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1592+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1593+ return
1594+ }
1595+ } else {
1596+ // Not locked: flip the state.
1597+ // Allowed/requesting → deny future requests.
1598+ // Blocked → allow future requests.
1599+ decision := entity.PermissionDenied
1600+ if state == string(coordinator.PermissionActivityBlocked) {
1601+ decision = entity.PermissionGranted
1602+ }
1603+ if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1604+ log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1605+ return
1606+ }
1607+ }
1608+
1609+ a.syncWebRTCPermissionLockState(ctx, origin, permType)
1610+ })
1611+
1612+ // Reset indicator when the active pane navigates away from the current origin.
1613+ a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1614+ newOrigin, err := urlutil.ExtractOrigin(uri)
1615+ if err != nil {
1616+ // Internal pages (dumb://, about:) — clear the indicator.
1617+ a.webrtcIndicator.Reset()
1618+ return
1619+ }
1620+
1621+ currentOrigin := a.webrtcIndicator.Origin()
1622+ if currentOrigin != "" && currentOrigin != newOrigin {
1623+ a.webrtcIndicator.Reset()
1624+ }
1625+ })
1626+}
1627+
1628+func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1629+ if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1630+ return
1631+ }
1632+
1633+ record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1634+ if err != nil {
1635+ logging.FromContext(ctx).Warn().
1636+ Err(err).
1637+ Str("origin", origin).
1638+ Str("type", string(permType)).
1639+ Msg("failed to read manual permission decision")
1640+ return
1641+ }
1642+
1643+ if record == nil {
1644+ a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1645+ return
1646+ }
1647+
1648+ a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1649+}
1650+
1525 // generateID generates a unique ID for tabs and panes.1651 // generateID generates a unique ID for tabs and panes.
1526 // Uses monotonically increasing counter to avoid ID collisions.1652 // Uses monotonically increasing counter to avoid ID collisions.
1527 func (a *App) generateID() string {1653 func (a *App) generateID() string {
M internal/ui/component/modal_sizing.go +9 -0
@@ -82,6 +82,15 @@ var TabPickerListDefaults = ListDisplayDefaults{
82 MaxResults: 20,82 MaxResults: 20,
83 }83 }
84 84
85+// PermissionPopupSizeDefaults provides default sizing for permission popup modal.
86+var PermissionPopupSizeDefaults = ModalSizeConfig{
87+ WidthPct: 0.4,
88+ MaxWidth: 400,
89+ TopMarginPct: 0.3,
90+ FallbackWidth: 400,
91+ FallbackHeight: 300,
92+}
93+
85 // EffectiveMaxRows returns the maximum number of visible rows that fit94 // EffectiveMaxRows returns the maximum number of visible rows that fit
86 // in the available space. It computes available height by subtracting the95 // in the available space. It computes available height by subtracting the
87 // top margin (TopMarginPct of parentHeight) and chrome height (header tabs +96 // top margin (TopMarginPct of parentHeight) and chrome height (header tabs +
M internal/ui/component/modal_sizing_test.go +9 -0
@@ -137,6 +137,15 @@ func TestSessionManagerSizeDefaults_Values(t *testing.T) {
137 assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)137 assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)
138 }138 }
139 139
140+func TestPermissionPopupSizeDefaults_Values(t *testing.T) {
141+ assert.Greater(t, PermissionPopupSizeDefaults.WidthPct, 0.0)
142+ assert.LessOrEqual(t, PermissionPopupSizeDefaults.WidthPct, 1.0)
143+ assert.Positive(t, PermissionPopupSizeDefaults.MaxWidth)
144+ assert.Greater(t, PermissionPopupSizeDefaults.TopMarginPct, 0.0)
145+ assert.LessOrEqual(t, PermissionPopupSizeDefaults.TopMarginPct, 1.0)
146+ assert.Equal(t, 300, PermissionPopupSizeDefaults.FallbackHeight)
147+}
148+
140 func TestListDisplayDefaults_Values(t *testing.T) {149 func TestListDisplayDefaults_Values(t *testing.T) {
141 assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)150 assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)
142 assert.Positive(t, OmniboxListDefaults.MaxResults)151 assert.Positive(t, OmniboxListDefaults.MaxResults)
A internal/ui/component/permission_popup.go +292 -0
@@ -0,0 +1,292 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+// buttonSpacing is the spacing between buttons in the permission popup.
14+const buttonSpacing = 6
15+
16+// PermissionPopup is a custom overlay component for permission prompts.
17+// It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug
18+// and match the app's custom UI style.
19+type PermissionPopup struct {
20+ outerBox *gtk.Box
21+ mainBox *gtk.Box
22+
23+ headingLabel *gtk.Label
24+ bodyLabel *gtk.Label
25+
26+ btnAllow *gtk.Button
27+ btnAlwaysAllow *gtk.Button
28+ btnDeny *gtk.Button
29+ btnAlwaysDeny *gtk.Button
30+
31+ parentOverlay layout.OverlayWidget
32+ uiScale float64
33+
34+ mu sync.Mutex
35+ visible bool
36+ callback func(allowed, persistent bool)
37+
38+ retainedCallbacks []interface{}
39+}
40+
41+// NewPermissionPopup creates a new permission popup component.
42+func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup {
43+ if uiScale <= 0 {
44+ uiScale = 1.0
45+ }
46+
47+ pp := &PermissionPopup{
48+ parentOverlay: parentOverlay,
49+ uiScale: uiScale,
50+ }
51+
52+ if err := pp.createWidgets(); err != nil {
53+ return nil
54+ }
55+ pp.attachKeyController()
56+ return pp
57+}
58+
59+// Widget returns the outer GTK widget for overlay registration.
60+func (pp *PermissionPopup) Widget() *gtk.Widget {
61+ if pp.outerBox == nil {
62+ return nil
63+ }
64+ return &pp.outerBox.Widget
65+}
66+
67+// Show displays the permission popup with the given heading and body text.
68+// The callback receives (allowed, persistent) when the user makes a choice.
69+func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
70+ log := logging.FromContext(ctx)
71+
72+ pp.mu.Lock()
73+ if pp.visible {
74+ pp.mu.Unlock()
75+ log.Warn().Msg("permission popup already visible, ignoring Show")
76+ return
77+ }
78+ pp.visible = true
79+ pp.callback = callback
80+ pp.mu.Unlock()
81+
82+ if pp.headingLabel != nil {
83+ pp.headingLabel.SetText(heading)
84+ }
85+ if pp.bodyLabel != nil {
86+ pp.bodyLabel.SetText(body)
87+ }
88+
89+ pp.resizeAndCenter()
90+ if pp.outerBox != nil {
91+ pp.outerBox.SetVisible(true)
92+ }
93+ // Focus the Deny button as the conservative default
94+ if pp.btnDeny != nil {
95+ pp.btnDeny.GrabFocus()
96+ }
97+}
98+
99+// Hide hides the popup without invoking the callback.
100+func (pp *PermissionPopup) Hide() {
101+ pp.mu.Lock()
102+ if !pp.visible {
103+ pp.mu.Unlock()
104+ return
105+ }
106+ pp.visible = false
107+ pp.callback = nil
108+ pp.mu.Unlock()
109+
110+ if pp.outerBox != nil {
111+ pp.outerBox.SetVisible(false)
112+ }
113+}
114+
115+// IsVisible returns whether the popup is currently displayed.
116+func (pp *PermissionPopup) IsVisible() bool {
117+ pp.mu.Lock()
118+ defer pp.mu.Unlock()
119+ return pp.visible
120+}
121+
122+func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
123+ pp.mu.Lock()
124+ if !pp.visible {
125+ pp.mu.Unlock()
126+ return
127+ }
128+ pp.visible = false
129+ cb := pp.callback
130+ pp.callback = nil
131+ pp.mu.Unlock()
132+
133+ if pp.outerBox != nil {
134+ pp.outerBox.SetVisible(false)
135+ }
136+ if cb != nil {
137+ cb(allowed, persistent)
138+ }
139+}
140+
141+// setupContainers creates and configures the outer and main boxes.
142+func (pp *PermissionPopup) setupContainers() error {
143+ pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
144+ if pp.outerBox == nil {
145+ return errNilWidget("permissionPopupOuterBox")
146+ }
147+ pp.outerBox.AddCssClass("permission-popup-outer")
148+ pp.outerBox.SetHalign(gtk.AlignCenterValue)
149+ pp.outerBox.SetValign(gtk.AlignStartValue)
150+ pp.outerBox.SetVisible(false)
151+
152+ pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153+ if pp.mainBox == nil {
154+ return errNilWidget("permissionPopupMainBox")
155+ }
156+ pp.mainBox.AddCssClass("permission-popup-container")
157+ return nil
158+}
159+
160+// setupLabels creates and configures the heading and body labels.
161+func (pp *PermissionPopup) setupLabels() error {
162+ emptyText := ""
163+ pp.headingLabel = gtk.NewLabel(&emptyText)
164+ if pp.headingLabel == nil {
165+ return errNilWidget("permissionPopupHeadingLabel")
166+ }
167+ pp.headingLabel.AddCssClass("permission-popup-heading")
168+ pp.headingLabel.SetHalign(gtk.AlignStartValue)
169+
170+ pp.bodyLabel = gtk.NewLabel(&emptyText)
171+ if pp.bodyLabel == nil {
172+ return errNilWidget("permissionPopupBodyLabel")
173+ }
174+ pp.bodyLabel.AddCssClass("permission-popup-body")
175+ pp.bodyLabel.SetHalign(gtk.AlignStartValue)
176+ pp.bodyLabel.SetWrap(true)
177+ return nil
178+}
179+
180+func (pp *PermissionPopup) createWidgets() error {
181+ if err := pp.setupContainers(); err != nil {
182+ return err
183+ }
184+
185+ if err := pp.setupLabels(); err != nil {
186+ return err
187+ }
188+
189+ // Button row
190+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
191+ if btnRow == nil {
192+ return errNilWidget("permissionPopupBtnRow")
193+ }
194+ btnRow.AddCssClass("permission-popup-btn-row")
195+ btnRow.SetHalign(gtk.AlignEndValue)
196+
197+ // Create buttons: Always Deny | Deny | Allow | Always Allow
198+ var err error
199+ pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"})
200+ if err != nil {
201+ return err
202+ }
203+
204+ pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"})
205+ if err != nil {
206+ return err
207+ }
208+
209+ pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
210+ if err != nil {
211+ return err
212+ }
213+
214+ pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
215+ if err != nil {
216+ return err
217+ }
218+
219+ // Wire button clicks
220+ pp.wireButton(pp.btnAlwaysDeny, false, true)
221+ pp.wireButton(pp.btnDeny, false, false)
222+ pp.wireButton(pp.btnAllow, true, false)
223+ pp.wireButton(pp.btnAlwaysAllow, true, true)
224+
225+ // Assemble button row
226+ btnRow.Append(&pp.btnAlwaysDeny.Widget)
227+ btnRow.Append(&pp.btnDeny.Widget)
228+ btnRow.Append(&pp.btnAllow.Widget)
229+ btnRow.Append(&pp.btnAlwaysAllow.Widget)
230+
231+ // Assemble main box
232+ pp.mainBox.Append(&pp.headingLabel.Widget)
233+ pp.mainBox.Append(&pp.bodyLabel.Widget)
234+ pp.mainBox.Append(&btnRow.Widget)
235+
236+ // Assemble outer box
237+ pp.outerBox.Append(&pp.mainBox.Widget)
238+
239+ return nil
240+}
241+
242+// createPermissionButton creates a permission button with the given label and CSS classes.
243+func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) {
244+ btn := gtk.NewButtonWithLabel(label)
245+ if btn == nil {
246+ return nil, errNilWidget("permissionPopupBtn" + label)
247+ }
248+ for _, class := range cssClasses {
249+ btn.AddCssClass(class)
250+ }
251+ return btn, nil
252+}
253+
254+// wireButton connects a button click to the dismiss callback.
255+func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) {
256+ cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) }
257+ pp.retainedCallbacks = append(pp.retainedCallbacks, cb)
258+ btn.ConnectClicked(&cb)
259+}
260+
261+func (pp *PermissionPopup) attachKeyController() {
262+ if pp.outerBox == nil {
263+ return
264+ }
265+ controller := gtk.NewEventControllerKey()
266+ if controller == nil {
267+ return
268+ }
269+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
270+
271+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
272+ if keyval == uint(gdk.KEY_Escape) {
273+ // Escape = deny (conservative default)
274+ pp.dismiss(false, false)
275+ return true
276+ }
277+ return false
278+ }
279+ pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
280+ controller.ConnectKeyPressed(&keyPressedCb)
281+ pp.outerBox.AddController(&controller.EventController)
282+}
283+
284+func (pp *PermissionPopup) resizeAndCenter() {
285+ if pp.outerBox == nil || pp.mainBox == nil {
286+ return
287+ }
288+
289+ width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
290+ pp.mainBox.SetSizeRequest(width, -1)
291+ pp.outerBox.SetMarginTop(marginTop)
292+}
A internal/ui/component/webrtc_permission_indicator.go +529 -0
@@ -0,0 +1,529 @@
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+ clear(w.states)
302+ clear(w.stored)
303+ w.currentOrigin = ""
304+
305+ w.refreshLocked()
306+}
307+
308+// SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked.
309+func (w *WebRTCPermissionIndicator) SetOnToggleLock(
310+ callback func(origin string, permType entity.PermissionType, state string, hasStored bool),
311+) {
312+ if w == nil {
313+ return
314+ }
315+ w.mu.Lock()
316+ defer w.mu.Unlock()
317+ w.onToggleLock = callback
318+}
319+
320+func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) {
321+ if w == nil {
322+ return
323+ }
324+
325+ w.mu.Lock()
326+ defer w.mu.Unlock()
327+
328+ for _, permType := range permTypes {
329+ if !isTrackedWebRTCPermissionType(permType) {
330+ continue
331+ }
332+ w.states[permType] = state
333+ }
334+
335+ w.refreshLocked()
336+}
337+
338+func (w *WebRTCPermissionIndicator) refreshLocked() {
339+ if w.outerBox == nil {
340+ return
341+ }
342+
343+ visible := shouldShowWebRTCPermissionIndicator(w.states)
344+ w.outerBox.SetVisible(visible)
345+
346+ summary := summarizeWebRTCPermissionState(w.states)
347+ if w.dotBox != nil {
348+ updateStateCSSClass(&w.dotBox.Widget, summary)
349+ }
350+
351+ if w.originLabel != nil {
352+ origin := w.currentOrigin
353+ if origin == "" {
354+ origin = "Current site"
355+ }
356+ w.originLabel.SetText(origin)
357+ }
358+
359+ for _, permType := range trackedWebRTCPermissionTypes {
360+ row := w.rowButtons[permType]
361+ if row == nil {
362+ continue
363+ }
364+
365+ state := w.states[permType]
366+ if state == "" {
367+ state = webrtcPermissionStateIdle
368+ }
369+
370+ storedDecision, hasStored := w.stored[permType]
371+ label := formatRowLabel(permType, state, storedDecision, hasStored)
372+ row.SetLabel(label)
373+ updateStateCSSClass(&row.Widget, state)
374+ updateLockedCSSClass(&row.Widget, hasStored)
375+ }
376+
377+ if !visible {
378+ w.setExpandedLocked(false)
379+ }
380+}
381+
382+func (w *WebRTCPermissionIndicator) attachHoverController() {
383+ if w == nil || w.outerBox == nil {
384+ return
385+ }
386+
387+ controller := gtk.NewEventControllerMotion()
388+ if controller == nil {
389+ return
390+ }
391+
392+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
393+ w.mu.Lock()
394+ defer w.mu.Unlock()
395+ w.setExpandedLocked(true)
396+ }
397+ leaveCb := func(_ gtk.EventControllerMotion) {
398+ w.mu.Lock()
399+ defer w.mu.Unlock()
400+ w.setExpandedLocked(false)
401+ }
402+
403+ w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb)
404+ controller.ConnectEnter(&enterCb)
405+ controller.ConnectLeave(&leaveCb)
406+ w.outerBox.AddController(&controller.EventController)
407+}
408+
409+func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) {
410+ if w.revealer == nil || w.cardBox == nil {
411+ return
412+ }
413+ if w.expanded == expanded {
414+ return
415+ }
416+
417+ w.expanded = expanded
418+ w.revealer.SetRevealChild(expanded)
419+
420+ // Show/hide origin label so it doesn't take space when collapsed to just a dot.
421+ // Toggle hexpand alongside visibility to prevent expand propagation when collapsed.
422+ if w.originLabel != nil {
423+ w.originLabel.SetVisible(expanded)
424+ w.originLabel.SetHexpand(expanded)
425+ }
426+
427+ if expanded {
428+ w.cardBox.AddCssClass("expanded")
429+ } else {
430+ w.cardBox.RemoveCssClass("expanded")
431+ }
432+}
433+
434+func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool {
435+ switch permType {
436+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
437+ return true
438+ default:
439+ return false
440+ }
441+}
442+
443+func permissionDisplayName(permType entity.PermissionType) string {
444+ switch permType {
445+ case entity.PermissionTypeMicrophone:
446+ return "\U0001F3A4 Mic" // 🎤
447+ case entity.PermissionTypeCamera:
448+ return "\U0001F4F7 Cam" // 📷
449+ case entity.PermissionTypeDisplay:
450+ return "\U0001F4BB Screen" // 💻
451+ default:
452+ return "Unknown"
453+ }
454+}
455+
456+func stateDisplayName(state webrtcPermissionState) string {
457+ switch state {
458+ case webrtcPermissionStateRequesting:
459+ return "\u26A0 requesting" // ⚠
460+ case webrtcPermissionStateAllowed:
461+ return "\u2713 active" // ✓
462+ case webrtcPermissionStateBlocked:
463+ return "\u2717 blocked" // ✗
464+ default:
465+ return "idle"
466+ }
467+}
468+
469+func formatRowLabel(
470+ permType entity.PermissionType,
471+ state webrtcPermissionState,
472+ storedDecision entity.PermissionDecision,
473+ hasStored bool,
474+) string {
475+ name := permissionDisplayName(permType)
476+ status := stateDisplayName(state)
477+
478+ if hasStored {
479+ // Locked: show current policy and offer to reset.
480+ policy := "allow"
481+ if storedDecision == entity.PermissionDenied {
482+ policy = "deny"
483+ }
484+ return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy)
485+ }
486+
487+ // Not locked: show the flip action.
488+ switch state {
489+ case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting:
490+ return fmt.Sprintf("%s %s \u00B7 block", name, status)
491+ case webrtcPermissionStateBlocked:
492+ return fmt.Sprintf("%s %s \u00B7 allow", name, status)
493+ default:
494+ return fmt.Sprintf("%s %s", name, status)
495+ }
496+}
497+
498+func updateLockedCSSClass(widget *gtk.Widget, locked bool) {
499+ if widget == nil {
500+ return
501+ }
502+ if locked {
503+ widget.AddCssClass("row-locked")
504+ } else {
505+ widget.RemoveCssClass("row-locked")
506+ }
507+}
508+
509+func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) {
510+ if widget == nil {
511+ return
512+ }
513+
514+ widget.RemoveCssClass("state-idle")
515+ widget.RemoveCssClass("state-requesting")
516+ widget.RemoveCssClass("state-allowed")
517+ widget.RemoveCssClass("state-blocked")
518+
519+ switch state {
520+ case webrtcPermissionStateRequesting:
521+ widget.AddCssClass("state-requesting")
522+ case webrtcPermissionStateAllowed:
523+ widget.AddCssClass("state-allowed")
524+ case webrtcPermissionStateBlocked:
525+ widget.AddCssClass("state-blocked")
526+ default:
527+ widget.AddCssClass("state-idle")
528+ }
529+}
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+}
A internal/ui/component/webrtc_permission_indicator_state_test.go +61 -0
@@ -0,0 +1,61 @@
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 TestWebRTCSummaryState_IdleWhenAllIdle(t *testing.T) {
45+ states := map[entity.PermissionType]webrtcPermissionState{
46+ entity.PermissionTypeMicrophone: webrtcPermissionStateIdle,
47+ entity.PermissionTypeCamera: webrtcPermissionStateIdle,
48+ }
49+
50+ assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(states))
51+}
52+
53+func TestWebRTCShouldShowIndicator(t *testing.T) {
54+ assert.False(t, shouldShowWebRTCPermissionIndicator(nil))
55+ assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
56+ entity.PermissionTypeMicrophone: webrtcPermissionStateIdle,
57+ }))
58+ assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{
59+ entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed,
60+ }))
61+}
M internal/ui/coordinator/content.go +139 -0
@@ -90,6 +90,7 @@ type ContentCoordinator struct {
90 widgetFactory layout.WidgetFactory90 widgetFactory layout.WidgetFactory
91 faviconAdapter *adapter.FaviconAdapter91 faviconAdapter *adapter.FaviconAdapter
92 zoomUC *usecase.ManageZoomUseCase92 zoomUC *usecase.ManageZoomUseCase
93+ permissionUC *usecase.HandlePermissionUseCase
93 injector *webkit.ContentInjector94 injector *webkit.ContentInjector
94 95
95 webViews map[entity.PaneID]*webkit.WebView96 webViews map[entity.PaneID]*webkit.WebView
@@ -117,6 +118,12 @@ type ContentCoordinator struct {
117 // Callback when active pane title changes (for window title updates)118 // Callback when active pane title changes (for window title updates)
118 onWindowTitleChanged func(title string)119 onWindowTitleChanged func(title string)
119 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+
120 // Callback when the WebView becomes visible (first real commit)127 // Callback when the WebView becomes visible (first real commit)
121 onWebViewShown func(paneID entity.PaneID)128 onWebViewShown func(paneID entity.PaneID)
122 129
@@ -178,6 +185,15 @@ type popupOAuthState struct {
178 Seen bool185 Seen bool
179 }186 }
180 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+
181 // NewContentCoordinator creates a new ContentCoordinator.197 // NewContentCoordinator creates a new ContentCoordinator.
182 func NewContentCoordinator(198 func NewContentCoordinator(
183 ctx context.Context,199 ctx context.Context,
@@ -186,6 +202,7 @@ func NewContentCoordinator(
186 faviconAdapter *adapter.FaviconAdapter,202 faviconAdapter *adapter.FaviconAdapter,
187 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),203 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
188 zoomUC *usecase.ManageZoomUseCase,204 zoomUC *usecase.ManageZoomUseCase,
205+ permissionUC *usecase.HandlePermissionUseCase,
189 ) *ContentCoordinator {206 ) *ContentCoordinator {
190 log := logging.FromContext(ctx)207 log := logging.FromContext(ctx)
191 log.Debug().Msg("creating content coordinator")208 log.Debug().Msg("creating content coordinator")
@@ -195,6 +212,7 @@ func NewContentCoordinator(
195 widgetFactory: widgetFactory,212 widgetFactory: widgetFactory,
196 faviconAdapter: faviconAdapter,213 faviconAdapter: faviconAdapter,
197 zoomUC: zoomUC,214 zoomUC: zoomUC,
215+ permissionUC: permissionUC,
198 webViews: make(map[entity.PaneID]*webkit.WebView),216 webViews: make(map[entity.PaneID]*webkit.WebView),
199 paneTitles: make(map[entity.PaneID]string),217 paneTitles: make(map[entity.PaneID]string),
200 navOrigins: make(map[entity.PaneID]string),218 navOrigins: make(map[entity.PaneID]string),
@@ -216,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
216 c.onHistoryRecord = fn234 c.onHistoryRecord = fn
217 }235 }
218 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+
219 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).249 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
220 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {250 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
221 c.onPaneURIUpdated = fn251 c.onPaneURIUpdated = fn
@@ -1080,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1080 c.onHistoryRecord(ctx, paneID, uri)1110 c.onHistoryRecord(ctx, paneID, uri)
1081 }1111 }
1082 1112
1113+ // Notify active pane navigation for permission indicator reset.
1114+ c.notifyActiveNavigation(paneID, uri)
1115+
1083 // Apply zoom1116 // Apply zoom
1084 if c.zoomUC == nil {1117 if c.zoomUC == nil {
1085 return1118 return
@@ -1093,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1093 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)1126 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
1094 }1127 }
1095 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+
1096 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {1139 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
1097 if wv == nil || wv.IsDestroyed() {1140 if wv == nil || wv.IsDestroyed() {
1098 return false1141 return false
@@ -1646,6 +1689,10 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1646 }1689 }
1647 }1690 }
1648 1691
1692+ // Permission request handler
1693+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
1694+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
1695+ }
1649 // Fullscreen handlers for idle inhibition1696 // Fullscreen handlers for idle inhibition
1650 c.setupIdleInhibitionHandlers(ctx, paneID, wv)1697 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1651 1698
@@ -1943,6 +1990,98 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1943 return true1990 return true
1944 }1991 }
1945 1992
1993+// handlePermissionRequest processes media permission requests from WebKit.
1994+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
1995+func (c *ContentCoordinator) handlePermissionRequest(
1996+ ctx context.Context,
1997+ origin string,
1998+ permTypes []string,
1999+ allow, deny func(),
2000+) bool {
2001+ log := logging.FromContext(ctx)
2002+
2003+ // Convert string permission types to entity types
2004+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
2005+ for _, pt := range permTypes {
2006+ switch pt {
2007+ case "microphone":
2008+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
2009+ case "camera":
2010+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
2011+ case "display":
2012+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
2013+ case "device_info":
2014+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
2015+ default:
2016+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
2017+ }
2018+ }
2019+
2020+ if len(entityTypes) == 0 {
2021+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
2022+ deny()
2023+ return true
2024+ }
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+
2045+ // Check if permission use case is available
2046+ if c.permissionUC == nil {
2047+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
2048+ // Auto-allow display and device_info, deny others
2049+ allAutoAllow := true
2050+ for _, pt := range entityTypes {
2051+ if !entity.IsAutoAllow(pt) {
2052+ allAutoAllow = false
2053+ break
2054+ }
2055+ }
2056+ if allAutoAllow {
2057+ wrappedAllow()
2058+ } else {
2059+ wrappedDeny()
2060+ }
2061+ return true
2062+ }
2063+
2064+ // Delegate to use case
2065+ callback := usecase.PermissionCallback{
2066+ Allow: wrappedAllow,
2067+ Deny: wrappedDeny,
2068+ }
2069+
2070+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2071+ return true
2072+}
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+
1946 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.2085 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1947 // Idle is inhibited when:2086 // Idle is inhibited when:
1948 // - The webview enters fullscreen mode (e.g., fullscreen video)2087 // - The webview enters fullscreen mode (e.g., fullscreen video)
M internal/ui/deps.go +13 -11
@@ -46,19 +46,21 @@ type Dependencies struct {
46 MessageRouter *webkit.MessageRouter46 MessageRouter *webkit.MessageRouter
47 47
48 // Repositories48 // Repositories
49- HistoryRepo repository.HistoryRepository49+ HistoryRepo repository.HistoryRepository
50- FavoriteRepo repository.FavoriteRepository50+ FavoriteRepo repository.FavoriteRepository
51- ZoomRepo repository.ZoomRepository51+ ZoomRepo repository.ZoomRepository
52- FilterRepo repository.ContentWhitelistRepository52+ PermissionRepo port.PermissionRepository
53+ FilterRepo repository.ContentWhitelistRepository
53 54
54 // Use Cases55 // Use Cases
55- TabsUC *usecase.ManageTabsUseCase56+ TabsUC *usecase.ManageTabsUseCase
56- PanesUC *usecase.ManagePanesUseCase57+ PanesUC *usecase.ManagePanesUseCase
57- NavigateUC *usecase.NavigateUseCase58+ NavigateUC *usecase.NavigateUseCase
58- ZoomUC *usecase.ManageZoomUseCase59+ ZoomUC *usecase.ManageZoomUseCase
59- FavoritesUC *usecase.ManageFavoritesUseCase60+ PermissionUC *usecase.HandlePermissionUseCase
60- HistoryUC *usecase.SearchHistoryUseCase61+ FavoritesUC *usecase.ManageFavoritesUseCase
61- CopyURLUC *usecase.CopyURLUseCase62+ HistoryUC *usecase.SearchHistoryUseCase
63+ CopyURLUC *usecase.CopyURLUseCase
62 64
63 // Infrastructure Adapters65 // Infrastructure Adapters
64 Clipboard port.Clipboard66 Clipboard port.Clipboard
A internal/ui/dialog/permission.go +206 -0
@@ -0,0 +1,206 @@
1+// Package dialog provides UI dialog implementations for the application layer.
2+package dialog
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
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+
26+// PermissionDialog implements the port.PermissionDialogPresenter interface.
27+// It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
28+// and match the app's custom UI style.
29+type PermissionDialog struct {
30+ popup permissionPopup
31+
32+ mu sync.Mutex
33+ active bool
34+ queue []permissionDialogRequest
35+}
36+
37+// NewPermissionDialog creates a new permission dialog presenter.
38+// The popup is created once and reused for each permission request.
39+func NewPermissionDialog(popup *component.PermissionPopup) *PermissionDialog {
40+ return &PermissionDialog{
41+ popup: popup,
42+ }
43+}
44+
45+// ShowPermissionDialog displays a permission request dialog to the user.
46+func (d *PermissionDialog) ShowPermissionDialog(
47+ ctx context.Context,
48+ origin string,
49+ permTypes []entity.PermissionType,
50+ callback func(result port.PermissionDialogResult),
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+
85+ log := logging.FromContext(ctx)
86+
87+ if d.popup == nil {
88+ log.Error().Msg("permission popup not available")
89+ callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
90+ d.showNextQueuedRequest()
91+ return
92+ }
93+
94+ // Build dialog text
95+ heading := d.buildHeading(permTypes)
96+ body := d.buildBody(origin, permTypes)
97+
98+ d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
99+ log.Debug().
100+ Bool("allowed", allowed).
101+ Bool("persistent", persistent).
102+ Msg("permission popup response")
103+ callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
104+ d.showNextQueuedRequest()
105+ })
106+
107+ log.Debug().
108+ Str("origin", origin).
109+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
110+ Msg("showing permission popup")
111+}
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+
128+// buildHeading creates the dialog heading based on permission types.
129+func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
130+ hasMic := false
131+ hasCam := false
132+ hasDisplay := false
133+
134+ for _, pt := range permTypes {
135+ switch pt {
136+ case entity.PermissionTypeMicrophone:
137+ hasMic = true
138+ case entity.PermissionTypeCamera:
139+ hasCam = true
140+ case entity.PermissionTypeDisplay:
141+ hasDisplay = true
142+ }
143+ }
144+
145+ switch {
146+ case hasMic && hasCam && hasDisplay:
147+ return "Allow Microphone, Camera, and Screen Sharing?"
148+ case hasMic && hasDisplay:
149+ return "Allow Microphone and Screen Sharing?"
150+ case hasCam && hasDisplay:
151+ return "Allow Camera and Screen Sharing?"
152+ case hasMic && hasCam:
153+ return "Allow Microphone and Camera?"
154+ case hasMic:
155+ return "Allow Microphone Access?"
156+ case hasCam:
157+ return "Allow Camera Access?"
158+ case hasDisplay:
159+ return "Allow Screen Sharing?"
160+ default:
161+ return "Allow Permission?"
162+ }
163+}
164+
165+// buildBody creates the dialog body text.
166+func (d *PermissionDialog) buildBody(origin string, permTypes []entity.PermissionType) string {
167+ hasMic := false
168+ hasCam := false
169+ hasDisplay := false
170+
171+ for _, pt := range permTypes {
172+ switch pt {
173+ case entity.PermissionTypeMicrophone:
174+ hasMic = true
175+ case entity.PermissionTypeCamera:
176+ hasCam = true
177+ case entity.PermissionTypeDisplay:
178+ hasDisplay = true
179+ }
180+ }
181+
182+ var action string
183+ switch {
184+ case hasMic && hasCam && hasDisplay:
185+ action = "access your microphone and camera, and share your screen"
186+ case hasMic && hasDisplay:
187+ action = "access your microphone and share your screen"
188+ case hasCam && hasDisplay:
189+ action = "access your camera and share your screen"
190+ case hasMic && hasCam:
191+ action = "access your microphone and camera"
192+ case hasMic:
193+ action = "access your microphone"
194+ case hasCam:
195+ action = "access your camera"
196+ case hasDisplay:
197+ action = "share your screen"
198+ default:
199+ action = "access your device"
200+ }
201+
202+ return fmt.Sprintf("%s wants to %s.", origin, action)
203+}
204+
205+// Ensure PermissionDialog implements the interface.
206+var _ port.PermissionDialogPresenter = (*PermissionDialog)(nil)
A internal/ui/dialog/permission_test.go +153 -0
@@ -0,0 +1,153 @@
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+}
110+
111+func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) {
112+ d := &PermissionDialog{}
113+ origin := "https://meet.example.com"
114+
115+ tests := []struct {
116+ name string
117+ permTypes []entity.PermissionType
118+ expectHeading string
119+ expectedAction string
120+ }{
121+ {
122+ name: "display only",
123+ permTypes: []entity.PermissionType{entity.PermissionTypeDisplay},
124+ expectHeading: "Allow Screen Sharing?",
125+ expectedAction: "share your screen",
126+ },
127+ {
128+ name: "microphone and display",
129+ permTypes: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeDisplay},
130+ expectHeading: "Allow Microphone and Screen Sharing?",
131+ expectedAction: "access your microphone and share your screen",
132+ },
133+ {
134+ name: "camera and display",
135+ permTypes: []entity.PermissionType{entity.PermissionTypeCamera, entity.PermissionTypeDisplay},
136+ expectHeading: "Allow Camera and Screen Sharing?",
137+ expectedAction: "access your camera and share your screen",
138+ },
139+ {
140+ name: "all three",
141+ permTypes: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay},
142+ expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143+ expectedAction: "access your microphone and camera, and share your screen",
144+ },
145+ }
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ assert.Equal(t, tt.expectHeading, d.buildHeading(tt.permTypes))
150+ assert.Equal(t, origin+" wants to "+tt.expectedAction+".", d.buildBody(origin, tt.permTypes))
151+ })
152+ }
153+}
M internal/ui/theme/css.go +8 -0
@@ -128,6 +128,14 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
128 sb.WriteString(generateTabPickerCSS(p))128 sb.WriteString(generateTabPickerCSS(p))
129 sb.WriteString("\n")129 sb.WriteString("\n")
130 130
131+ // Permission popup styling
132+ sb.WriteString(generatePermissionPopupCSS(p))
133+ sb.WriteString("\n")
134+
135+ // WebRTC permission activity indicator styling
136+ sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137+ sb.WriteString("\n")
138+
131 // Accent picker styling139 // Accent picker styling
132 sb.WriteString(generateAccentPickerCSS(p))140 sb.WriteString(generateAccentPickerCSS(p))
133 141
A internal/ui/theme/permission_popup_css.go +85 -0
@@ -0,0 +1,85 @@
1+package theme
2+
3+// generatePermissionPopupCSS creates permission popup overlay styles.
4+// Uses em units for scalable UI, matches omnibox/session manager styling patterns.
5+func generatePermissionPopupCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Permission Popup Styling ===== */
8+
9+.permission-popup-outer {
10+ /* Positioning handled via SetHalign/SetValign in Go */
11+}
12+
13+.permission-popup-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 20em;
19+}
20+
21+.permission-popup-heading {
22+ font-size: 0.9375em;
23+ font-weight: 600;
24+ color: var(--text);
25+ padding: 0.75em 1em 0.25em 1em;
26+}
27+
28+.permission-popup-body {
29+ font-size: 0.8125em;
30+ font-weight: 400;
31+ color: var(--muted);
32+ padding: 0.25em 1em 0.75em 1em;
33+}
34+
35+.permission-popup-btn-row {
36+ border-top: 0.0625em solid var(--border);
37+ padding: 0.5em 0.75em;
38+}
39+
40+/* Shared button base */
41+.permission-popup-btn {
42+ background-image: none;
43+ border: 0.0625em solid var(--border);
44+ border-radius: 0.1875em;
45+ padding: 0.375em 0.75em;
46+ font-size: 0.8125em;
47+ font-weight: 500;
48+ transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
49+}
50+
51+/* Allow buttons - accent styled */
52+.permission-popup-btn-allow {
53+ background-color: alpha(var(--accent), 0.15);
54+ color: var(--accent);
55+ border-color: alpha(var(--accent), 0.3);
56+}
57+
58+.permission-popup-btn-allow:hover {
59+ background-color: alpha(var(--accent), 0.25);
60+ border-color: var(--accent);
61+}
62+
63+/* Deny button - subtle default */
64+.permission-popup-btn-deny {
65+ background-color: var(--surface);
66+ color: var(--text);
67+}
68+
69+.permission-popup-btn-deny:hover {
70+ background-color: shade(var(--surface), 1.15);
71+}
72+
73+/* Always Deny - destructive */
74+.permission-popup-btn-destructive {
75+ background-color: alpha(var(--destructive), 0.15);
76+ color: var(--destructive);
77+ border-color: alpha(var(--destructive), 0.3);
78+}
79+
80+.permission-popup-btn-destructive:hover {
81+ background-color: alpha(var(--destructive), 0.25);
82+ border-color: var(--destructive);
83+}
84+`
85+}
A internal/ui/theme/webrtc_indicator_css.go +160 -0
@@ -0,0 +1,160 @@
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+/* Origin label shown next to the dot. */
50+.webrtc-indicator-origin {
51+ font-size: 0.6875em;
52+ font-weight: 600;
53+ letter-spacing: 0.02em;
54+ padding: 0 0.4em 0 0;
55+ color: var(--muted);
56+}
57+
58+/* Details section inside the revealer (permission rows only) */
59+.webrtc-indicator-details {
60+ margin-top: 0.3em;
61+ padding-top: 0.3em;
62+ border-top: 0.0625em solid alpha(var(--border), 0.5);
63+}
64+
65+/* Permission row buttons */
66+.webrtc-indicator-row {
67+ font-size: 0.6875em;
68+ font-weight: 500;
69+ padding: 0.25em 0.4em;
70+ margin-top: 0.15em;
71+ border-radius: 0.25em;
72+ border-left: 0.1875em solid transparent;
73+ border-top: none;
74+ border-right: none;
75+ border-bottom: none;
76+ background-color: alpha(var(--bg), 0.35);
77+ background-image: none;
78+ color: var(--muted);
79+ text-align: left;
80+ transition: background-color 150ms ease-in-out,
81+ border-color 150ms ease-in-out,
82+ color 150ms ease-in-out;
83+}
84+
85+.webrtc-indicator-row:hover {
86+ background-color: alpha(var(--bg), 0.55);
87+ background-image: none;
88+}
89+
90+/* ---- State: idle ---- */
91+.webrtc-indicator-dot.state-idle {
92+ background-color: var(--muted);
93+ border-color: alpha(var(--border), 0.6);
94+}
95+
96+.webrtc-indicator-row.state-idle {
97+ color: var(--muted);
98+ border-left-color: alpha(var(--muted), 0.4);
99+}
100+
101+/* ---- State: requesting (amber) ---- */
102+.webrtc-indicator-dot.state-requesting {
103+ background-color: var(--warning);
104+ border-color: alpha(var(--warning), 0.6);
105+ box-shadow: 0 0 0.4em alpha(var(--warning), 0.5);
106+}
107+
108+.webrtc-indicator-row.state-requesting {
109+ color: var(--warning);
110+ border-left-color: var(--warning);
111+ background-color: alpha(var(--warning), 0.08);
112+}
113+
114+.webrtc-indicator-row.state-requesting:hover {
115+ background-color: alpha(var(--warning), 0.14);
116+ background-image: none;
117+}
118+
119+/* ---- State: allowed (green) ---- */
120+.webrtc-indicator-dot.state-allowed {
121+ background-color: var(--success);
122+ border-color: alpha(var(--success), 0.6);
123+ box-shadow: 0 0 0.35em alpha(var(--success), 0.35);
124+}
125+
126+.webrtc-indicator-row.state-allowed {
127+ color: var(--success);
128+ border-left-color: var(--success);
129+ background-color: alpha(var(--success), 0.06);
130+}
131+
132+.webrtc-indicator-row.state-allowed:hover {
133+ background-color: alpha(var(--success), 0.12);
134+ background-image: none;
135+}
136+
137+/* ---- State: blocked (red) ---- */
138+.webrtc-indicator-dot.state-blocked {
139+ background-color: var(--destructive);
140+ border-color: alpha(var(--destructive), 0.6);
141+ box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35);
142+}
143+
144+.webrtc-indicator-row.state-blocked {
145+ color: var(--destructive);
146+ border-left-color: var(--destructive);
147+ background-color: alpha(var(--destructive), 0.06);
148+}
149+
150+.webrtc-indicator-row.state-blocked:hover {
151+ background-color: alpha(var(--destructive), 0.12);
152+ background-image: none;
153+}
154+
155+/* ---- Locked row modifier ---- */
156+.webrtc-indicator-row.row-locked {
157+ font-weight: 600;
158+}
159+`
160+}
M internal/ui/window/main_window.go +6 -0
@@ -185,6 +185,12 @@ func (mw *MainWindow) SetTitle(title string) {
185 mw.window.SetTitle(&title)185 mw.window.SetTitle(&title)
186 }186 }
187 187
188+// ContentOverlay returns the content overlay widget.
189+// Used by components that need the overlay dimensions for sizing calculations.
190+func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191+ return mw.contentOverlay
192+}
193+
188 // AddOverlay adds a widget to the content overlay.194 // AddOverlay adds a widget to the content overlay.
189 // The widget will be displayed on top of the workspace content.195 // The widget will be displayed on top of the workspace content.
190 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {196 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
M webui/package.json +3 -1
@@ -4,14 +4,16 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build",7+ "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build",
8 "build:homepage": "VITE_PAGE=homepage vite build",8 "build:homepage": "VITE_PAGE=homepage vite build",
9 "build:error": "VITE_PAGE=error vite build",9 "build:error": "VITE_PAGE=error vite build",
10 "build:config": "VITE_PAGE=config vite build",10 "build:config": "VITE_PAGE=config vite build",
11+ "build:webrtc": "VITE_PAGE=webrtc vite build",
11 "dev": "VITE_PAGE=homepage vite build --watch",12 "dev": "VITE_PAGE=homepage vite build --watch",
12 "dev:homepage": "VITE_PAGE=homepage vite build --watch",13 "dev:homepage": "VITE_PAGE=homepage vite build --watch",
13 "dev:error": "VITE_PAGE=error vite build --watch",14 "dev:error": "VITE_PAGE=error vite build --watch",
14 "dev:config": "VITE_PAGE=config vite build --watch",15 "dev:config": "VITE_PAGE=config vite build --watch",
16+ "dev:webrtc": "VITE_PAGE=webrtc vite build --watch",
15 "check": "svelte-check",17 "check": "svelte-check",
16 "lint": "eslint src --fix",18 "lint": "eslint src --fix",
17 "fmt": "prettier --write ."19 "fmt": "prettier --write ."
A webui/src/pages/webrtc.ts +165 -0
@@ -0,0 +1,165 @@
1+import "../styles/app.css";
2+
3+const activeStreams: MediaStream[] = [];
4+
5+function now(): string {
6+ return new Date().toLocaleTimeString();
7+}
8+
9+function appendLog(output: HTMLPreElement, message: string): void {
10+ output.textContent = `${output.textContent}[${now()}] ${message}\n`;
11+ output.scrollTop = output.scrollHeight;
12+}
13+
14+function setStatus(status: HTMLElement, value: string): void {
15+ status.textContent = value;
16+}
17+
18+function stopAllStreams(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): void {
19+ for (const stream of activeStreams.splice(0)) {
20+ for (const track of stream.getTracks()) {
21+ track.stop();
22+ }
23+ }
24+
25+ video.pause();
26+ video.srcObject = null;
27+ setStatus(status, "idle");
28+ appendLog(output, "Stopped all active tracks");
29+}
30+
31+async function probeDevices(output: HTMLPreElement): Promise<void> {
32+ if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.enumerateDevices !== "function") {
33+ appendLog(output, "enumerateDevices is unavailable");
34+ return;
35+ }
36+
37+ try {
38+ const devices = await navigator.mediaDevices.enumerateDevices();
39+ const summary = devices
40+ .map((device) => `${device.kind} ${device.label || "(label hidden until permission)"}`)
41+ .join(" | ");
42+
43+ appendLog(output, `Devices: ${summary || "none"}`);
44+ } catch (err) {
45+ appendLog(output, `enumerateDevices failed: ${String(err)}`);
46+ appendLog(output, "Devices: none");
47+ }
48+}
49+
50+async function requestUserMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> {
51+ if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getUserMedia !== "function") {
52+ appendLog(output, "getUserMedia is unavailable");
53+ return;
54+ }
55+
56+ try {
57+ setStatus(status, "requesting camera + microphone");
58+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
59+ activeStreams.push(stream);
60+ video.srcObject = stream;
61+ await video.play();
62+ setStatus(status, "camera + microphone granted");
63+ appendLog(output, "camera + microphone permission granted");
64+ await probeDevices(output);
65+ } catch (err) {
66+ setStatus(status, "camera + microphone blocked");
67+ appendLog(output, `camera + microphone request failed: ${String(err)}`);
68+ }
69+}
70+
71+async function requestDisplayMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> {
72+ if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getDisplayMedia !== "function") {
73+ appendLog(output, "getDisplayMedia is unavailable");
74+ return;
75+ }
76+
77+ try {
78+ setStatus(status, "requesting screen capture");
79+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
80+ activeStreams.push(stream);
81+ video.srcObject = stream;
82+ await video.play();
83+ setStatus(status, "screen capture granted");
84+ appendLog(output, "screen capture permission granted");
85+ } catch (err) {
86+ setStatus(status, "screen capture blocked");
87+ appendLog(output, `screen capture request failed: ${String(err)}`);
88+ }
89+}
90+
91+function mount(): void {
92+ document.title = "WebRTC Diagnostics";
93+ document.body.innerHTML = `
94+ <main class="page-shell" style="padding: 24px; max-width: 920px; margin: 0 auto; display: grid; gap: 16px;">
95+ <header style="display: grid; gap: 8px;">
96+ <h1 style="margin: 0; font-size: 1.8rem;">WebRTC Diagnostics</h1>
97+ <p style="margin: 0; opacity: 0.85;">Use this page to validate camera, microphone, and screen permission behavior.</p>
98+ </header>
99+
100+ <section style="display: flex; flex-wrap: wrap; gap: 10px;">
101+ <button id="btn-av" type="button">Request camera + microphone</button>
102+ <button id="btn-screen" type="button">Request screen capture</button>
103+ <button id="btn-stop" type="button">Stop all streams</button>
104+ </section>
105+
106+ <section style="display: grid; gap: 8px;">
107+ <strong>Status: <span id="status">idle</span></strong>
108+ <video id="preview" autoplay playsinline muted style="width: 100%; max-height: 320px; border-radius: 10px; background: #0c1522;"></video>
109+ </section>
110+
111+ <section style="display: grid; gap: 8px;">
112+ <strong>Event Log</strong>
113+ <pre id="output" style="margin: 0; padding: 12px; border-radius: 10px; background: #0d1727; color: #dbe8f4; min-height: 180px; max-height: 280px; overflow: auto;">[${now()}] Ready\n</pre>
114+ </section>
115+ </main>
116+ `;
117+
118+ const status = document.getElementById("status");
119+ const preview = document.getElementById("preview");
120+ const output = document.getElementById("output");
121+ const btnAV = document.getElementById("btn-av");
122+ const btnScreen = document.getElementById("btn-screen");
123+ const btnStop = document.getElementById("btn-stop");
124+
125+ if (!(status instanceof HTMLElement)) {
126+ return;
127+ }
128+ if (!(preview instanceof HTMLVideoElement)) {
129+ return;
130+ }
131+ if (!(output instanceof HTMLPreElement)) {
132+ return;
133+ }
134+ if (!(btnAV instanceof HTMLButtonElement)) {
135+ return;
136+ }
137+ if (!(btnScreen instanceof HTMLButtonElement)) {
138+ return;
139+ }
140+ if (!(btnStop instanceof HTMLButtonElement)) {
141+ return;
142+ }
143+
144+ btnAV.addEventListener("click", () => {
145+ requestUserMedia(preview, output, status).catch((err: unknown) => {
146+ appendLog(output, `unexpected user media error: ${String(err)}`);
147+ });
148+ });
149+
150+ btnScreen.addEventListener("click", () => {
151+ requestDisplayMedia(preview, output, status).catch((err: unknown) => {
152+ appendLog(output, `unexpected display media error: ${String(err)}`);
153+ });
154+ });
155+
156+ btnStop.addEventListener("click", () => {
157+ stopAllStreams(preview, output, status);
158+ });
159+
160+ window.addEventListener("beforeunload", () => {
161+ stopAllStreams(preview, output, status);
162+ });
163+}
164+
165+mount();
M webui/vite.config.ts +12 -0
@@ -42,6 +42,18 @@ const pages = {
42 filename: "config.html",42 filename: "config.html",
43 },43 },
44 },44 },
45+ webrtc: {
46+ entry: "src/pages/webrtc.ts",
47+ output: "webrtc.min.js",
48+ global: "DumberWebRTC",
49+ html: {
50+ name: "webrtc",
51+ title: "WebRTC Diagnostics",
52+ script: "webrtc.min.js",
53+ css: "style.css",
54+ filename: "webrtc.html",
55+ },
56+ },
45 } as const;57 } as const;
46 58
47 // Get page from VITE_PAGE env var, default to building all59 // Get page from VITE_PAGE env var, default to building all

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close