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:
3131 KeybindingsProvider: {}
3232 KeybindingsSaver: {}
3333 DownloadResponse: {}
34+ PermissionDialogPresenter: {}
3435 github.com/bnema/dumber/internal/domain/repository:
3536 config:
3637 dir: '{{.InterfaceDir}}/mocks'
@@ -45,6 +46,7 @@ packages:
4546 SessionStateRepository: {}
4647 TagRepository: {}
4748 ZoomRepository: {}
49+ PermissionRepository: {}
4850 github.com/bnema/dumber/internal/infrastructure/filtering:
4951 config:
5052 dir: '{{.InterfaceDir}}/mocks'
M AGENTS.md +10 -0
@@ -31,3 +31,13 @@
3131
3232 ## Commands
3333
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 (
44 "embed"
55 )
66
7-// WebUIAssets contains the embedded webui build output (homepage, error, and config pages).
8-// Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config.
7+// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).
8+// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.
99 //
1010 //go:embed webui/*
1111 var WebUIAssets embed.FS
M cmd/dumber/main.go +10 -0
@@ -398,6 +398,7 @@ type repositories struct {
398398 folder repository.FolderRepository
399399 tag repository.TagRepository
400400 zoom repository.ZoomRepository
401+ permission port.PermissionRepository
401402 session repository.SessionRepository
402403 sessionState repository.SessionStateRepository
403404 }
@@ -409,6 +410,7 @@ func createRepositories(db *sql.DB) *repositories {
409410 folder: sqlite.NewFolderRepository(db),
410411 tag: sqlite.NewTagRepository(db),
411412 zoom: sqlite.NewZoomRepository(db),
413+ permission: sqlite.NewPermissionRepository(db),
412414 session: sqlite.NewSessionRepository(db),
413415 sessionState: sqlite.NewSessionStateRepository(db),
414416 }
@@ -421,6 +423,7 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
421423 folder: sqlite.NewLazyFolderRepository(provider),
422424 tag: sqlite.NewLazyTagRepository(provider),
423425 zoom: sqlite.NewLazyZoomRepository(provider),
426+ permission: sqlite.NewLazyPermissionRepository(provider),
424427 session: sqlite.NewLazySessionRepository(provider),
425428 sessionState: sqlite.NewLazySessionStateRepository(provider),
426429 }
@@ -433,6 +436,7 @@ type useCases struct {
433436 history *usecase.SearchHistoryUseCase
434437 favorites *usecase.ManageFavoritesUseCase
435438 zoom *usecase.ManageZoomUseCase
439+ permission *usecase.HandlePermissionUseCase
436440 navigate *usecase.NavigateUseCase
437441 copyURL *usecase.CopyURLUseCase
438442 snapshot *usecase.SnapshotSessionUseCase
@@ -468,12 +472,16 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
468472 updateDownloader := updater.NewGitHubDownloader()
469473 updateApplier := updater.NewApplier(stateDir)
470474
475+ // Permission use case will be initialized later with dialog presenter
476+ permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
477+
471478 return &useCases{
472479 tabs: usecase.NewManageTabsUseCase(idGenerator),
473480 panes: usecase.NewManagePanesUseCase(idGenerator),
474481 history: usecase.NewSearchHistoryUseCase(repos.history),
475482 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
476483 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
484+ permission: permissionUC,
477485 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
478486 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
479487 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
@@ -517,11 +525,13 @@ func buildUIDependencies(
517525 HistoryRepo: repos.history,
518526 FavoriteRepo: repos.favorite,
519527 ZoomRepo: repos.zoom,
528+ PermissionRepo: repos.permission,
520529 TabsUC: uc.tabs,
521530 PanesUC: uc.panes,
522531 HistoryUC: uc.history,
523532 FavoritesUC: uc.favorites,
524533 ZoomUC: uc.zoom,
534+ PermissionUC: uc.permission,
525535 NavigateUC: uc.navigate,
526536 CopyURLUC: uc.copyURL,
527537 Clipboard: uc.clipboard,
M docs/reference/index.md +1 -0
@@ -3,6 +3,7 @@
33 ## Quick Links
44
55 - [Configuration](./configuration.md) - All config keys
6+- [Permissions](./permissions.md) - Website permission handling (mic, camera, screen sharing)
67 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
78 - [Keybindings](./keybindings.md) - All keyboard shortcuts
89 - [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
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.10
6+ github.com/bnema/puregotk-webkit v0.0.12
77 github.com/charmbracelet/bubbles v0.21.1
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
2222 gopkg.in/yaml.v3 v3.0.1
2323 )
2424
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3
25+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92
2626
2727 require (
2828 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
21402140 return _c
21412141 }
21422142
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+
21432228 // 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.
21442229 // The first argument is typically a *testing.T value.
21452230 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 {
104104 OnLinkHover func(uri string)
105105 // OnWebProcessTerminated is called when the web process exits unexpectedly.
106106 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
107112 }
108113
109114 // 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 (
1313 "github.com/bnema/dumber/internal/infrastructure/webkit"
1414 "github.com/bnema/dumber/internal/logging"
1515 "github.com/bnema/dumber/internal/ui/theme"
16+ "github.com/jwijenbergh/puregotk/v4/gdk"
1617 "github.com/rs/zerolog"
1718 )
1819
@@ -211,14 +212,22 @@ func initWebViewPool(
211212 pool.SetFilterApplier(filterManager)
212213 }
213214
214- if err := pool.PrewarmFirst(ctx); err != nil {
215- logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
215+ if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) {
216+ if err := pool.PrewarmFirst(ctx); err != nil {
217+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
218+ }
219+ } else {
220+ logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
216221 }
217222 logging.Trace().Mark("pool_prewarm_first")
218223
219224 return pool
220225 }
221226
227+func shouldPrewarmFirstWebView(display *gdk.Display) bool {
228+ return display != nil
229+}
230+
222231 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
223232 func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
224233 return p.WebProcessMemoryLimitMB > 0 ||
M internal/bootstrap/webkit_stack_test.go +6 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
1011 )
@@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
4748 assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
4849 assert.True(t, opts.ITPEnabled)
4950 }
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
29792979 return _c
29802980 }
29812981
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+
29823271 // 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.
29833272 // The first argument is typically a *testing.T value.
29843273 func NewMockSessionRepository(t interface {
M internal/domain/url/normalize.go +40 -0
@@ -203,3 +203,43 @@ func IsExternalScheme(uri string) bool {
203203 return true
204204 }
205205 }
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) {
509509 })
510510 }
511511 }
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
4040 return &LazyHistoryRepository{provider: provider}
4141 }
4242
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+
43100 func (r *LazyHistoryRepository) init(ctx context.Context) error {
44101 r.once.Do(func() {
45102 db, err := r.provider.DB(ctx)
@@ -696,6 +753,7 @@ type LazyRepositories struct {
696753 Session repository.SessionRepository
697754 SessionState repository.SessionStateRepository
698755 Filter repository.ContentWhitelistRepository
756+ Permission port.PermissionRepository
699757 }
700758
701759 // NewLazyRepositories creates all lazy repositories from a database provider.
@@ -709,6 +767,7 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
709767 Session: NewLazySessionRepository(provider),
710768 SessionState: NewLazySessionStateRepository(provider),
711769 Filter: NewLazyContentWhitelistRepository(provider),
770+ Permission: NewLazyPermissionRepository(provider),
712771 }
713772 }
714773
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 {
6363 Title string `json:"title"`
6464 }
6565
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+
6673 type Session struct {
6774 ID string `json:"id"`
6875 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 {
2626 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
2727 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
2828 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
29+ DeletePermission(ctx context.Context, arg DeletePermissionParams) error
2930 DeleteSession(ctx context.Context, id string) error
3031 DeleteSessionState(ctx context.Context, sessionID string) error
3132 DeleteTag(ctx context.Context, id int64) error
@@ -51,6 +52,7 @@ type Querier interface {
5152 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
5253 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
5354 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
55+ GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
5456 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
5557 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
5658 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
@@ -67,6 +69,8 @@ type Querier interface {
6769 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
6870 InsertSession(ctx context.Context, arg InsertSessionParams) error
6971 IsWhitelisted(ctx context.Context, domain string) (int64, error)
72+ ListAllPermissions(ctx context.Context) ([]Permission, error)
73+ ListPermissionsByOrigin(ctx context.Context, origin string) ([]Permission, error)
7074 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
7175 MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error
7276 RemoveFromWhitelist(ctx context.Context, domain string) error
@@ -77,6 +81,7 @@ type Querier interface {
7781 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
7882 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
7983 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
84+ SetPermission(ctx context.Context, arg SetPermissionParams) error
8085 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
8186 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
8287 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() {
141141 });
142142 })();`
143143
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+
144163 // ContentInjector encapsulates script injection into WebViews.
145164 // It injects dark mode detection scripts for internal pages (dumb://)
146165 // and theme CSS variables for WebUI styling.
@@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
217236
218237 prefersDark := ci.PrefersDark()
219238
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)
221252 if webviewID != 0 {
222253 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
223254 addScript(
@@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
235266 log.Warn().Msg("webview ID is 0, skipping ID injection")
236267 }
237268
238- // 2. Inject dark mode handler for internal pages only
269+ // 3. Inject dark mode handler for internal pages only
239270 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
240271 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
241272 addScript(
@@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
249280 "internal-dark-mode-handler",
250281 )
251282
252- // 3. Inject theme CSS for internal pages (dumb://* only)
283+ // 4. Inject theme CSS for internal pages (dumb://* only)
253284 if ci.themeCSSVars != "" {
254285 // Escape for JS string literal
255286 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
269300 log.Debug().Msg("theme CSS vars injection configured for internal pages")
270301 }
271302
272- // 4. Inject find highlight CSS for all pages
303+ // 5. Inject find highlight CSS for all pages
273304 if ci.findCSS != "" {
274305 stylesheet := webkit.NewUserStyleSheet(
275306 ci.findCSS,
@@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
286317 }
287318 }
288319
289- // 5. Inject auto-copy selection script for all pages (if enabled)
320+ // 6. Inject auto-copy selection script for all pages (if enabled)
290321 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291322 if autoCopyEnabled {
292323 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 (
2828 const (
2929 HomePath = "home"
3030 ConfigPath = "config"
31+ WebRTCPath = "webrtc"
3132 ErrorPath = "error"
3233 CrashPath = "crash"
3334 IndexHTML = "index.html"
@@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
511512 return nil
512513 }
513514
514- // Determine the target file based on host and path
515- host := u.Host
516- path := strings.TrimPrefix(u.Path, "/")
517-
518- var relPath string
519- switch {
520- // Home root maps to index.html.
521- case host == HomePath && (path == "" || path == "/"):
522- relPath = IndexHTML
523- // Home asset paths map directly to assets.
524- case host == HomePath && path != "":
525- relPath = path
526- // Config root maps to config.html.
527- case host == ConfigPath && (path == "" || path == "/"):
528- relPath = "config.html"
529- // Config asset paths map directly to assets.
530- case host == ConfigPath && path != "":
531- relPath = path
532- // Error root maps to error.html.
533- case host == ErrorPath && (path == "" || path == "/"):
534- relPath = "error.html"
535- // Error asset paths map directly to assets.
536- case host == ErrorPath && path != "":
537- relPath = path
538- // Opaque home form maps to index.html.
539- case u.Opaque == HomePath:
540- relPath = IndexHTML
541- // Opaque error form maps to error.html.
542- case u.Opaque == ErrorPath:
543- relPath = "error.html"
544- default:
545- // Not a recognized asset path
515+ relPath, ok := resolveAssetPath(u)
516+ if !ok {
546517 return nil
547518 }
548519
@@ -568,6 +539,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
568539 }
569540 }
570541
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+
571576 // getMimeType determines the MIME type for a given file path.
572577 func (h *DumbSchemeHandler) getMimeType(filename string) string {
573578 if h == nil {
M internal/infrastructure/webkit/scheme_handler_test.go +41 -0
@@ -2,8 +2,10 @@ package webkit
22
33 import (
44 "context"
5+ "net/url"
56 "testing"
67
8+ "github.com/bnema/dumber/assets"
79 "github.com/stretchr/testify/assert"
810 "github.com/stretchr/testify/require"
911 )
@@ -87,3 +89,42 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
8789 require.NotNil(t, resp)
8890 assert.Contains(t, string(resp.Data), `data-target=""`)
8991 }
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
6060 applyCanvasSettings(settings)
6161 applyWebRTCSettings(settings)
6262
63+ webrtcEnabled := settings.GetPropertyEnableWebrtc()
64+ mediaStreamEnabled := settings.GetPropertyEnableMediaStream()
65+
6366 log.Debug().
6467 Str("sans_font", cfg.Appearance.SansFont).
6568 Str("rendering_mode", string(cfg.Rendering.Mode)).
6669 Bool("developer_extras", cfg.Debug.EnableDevTools).
70+ Bool("webrtc_enabled", webrtcEnabled).
71+ Bool("media_stream_enabled", mediaStreamEnabled).
6772 Msg("settings applied")
6873 }
6974
@@ -117,7 +122,6 @@ func applyMediaSettings(settings *webkit.Settings, cfg *config.Config, log *zero
117122 settings.SetEnableMedia(true)
118123 settings.SetEnableMediasource(true)
119124 settings.SetEnableMediaCapabilities(true)
120- settings.SetEnableMediaStream(true)
121125 settings.SetEnableEncryptedMedia(true)
122126 settings.SetMediaPlaybackRequiresUserGesture(true)
123127 settings.SetMediaPlaybackAllowsInline(true)
@@ -152,6 +156,11 @@ func applyCanvasSettings(settings *webkit.Settings) {
152156 }
153157
154158 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)
155164 settings.SetEnableWebrtc(true)
156165 }
157166
M internal/infrastructure/webkit/webview.go +262 -0
@@ -19,6 +19,7 @@ import (
1919 "github.com/jwijenbergh/puregotk/v4/gio"
2020 "github.com/jwijenbergh/puregotk/v4/glib"
2121 "github.com/jwijenbergh/puregotk/v4/gobject"
22+ gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
2223 "github.com/jwijenbergh/puregotk/v4/gtk"
2324 "github.com/rs/zerolog"
2425 )
@@ -154,6 +155,11 @@ type WebView struct {
154155 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
155156 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
156157
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+
157163 logger zerolog.Logger
158164 mu sync.RWMutex
159165
@@ -409,9 +415,11 @@ func (wv *WebView) connectSignals() {
409415 wv.connectEnterFullscreenSignal()
410416 wv.connectLeaveFullscreenSignal()
411417 wv.connectAudioStateSignal()
418+ wv.connectMediaCaptureStateSignals()
412419 wv.connectMouseTargetChangedSignal()
413420 wv.connectBackForwardListChangedSignal()
414421 wv.connectWebProcessTerminatedSignal()
422+ wv.connectPermissionRequestSignal()
415423 }
416424
417425 func (wv *WebView) connectLoadChangedSignal() {
@@ -829,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() {
829837 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
830838 }
831839
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+
832873 func (wv *WebView) connectMouseTargetChangedSignal() {
833874 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
834875 if wv.OnLinkHover == nil {
@@ -930,6 +971,224 @@ func (wv *WebView) connectWebProcessTerminatedSignal() {
930971 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
931972 }
932973
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+
9331192 // ID returns the unique identifier for this WebView.
9341193 func (wv *WebView) ID() WebViewID {
9351194 return wv.id
@@ -1214,6 +1473,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
12141473 wv.OnCreate = nil
12151474 wv.OnLinkHover = nil
12161475 wv.OnWebProcessTerminated = nil
1476+ wv.OnPermissionRequest = nil
12171477 return
12181478 }
12191479
@@ -1260,6 +1520,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
12601520 } else {
12611521 wv.OnWebProcessTerminated = nil
12621522 }
1523+ wv.OnPermissionRequest = callbacks.OnPermissionRequest
12631524 }
12641525
12651526 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1374,6 +1635,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
13741635 wv.OnAudioStateChanged = nil
13751636 wv.OnLinkHover = nil
13761637 wv.OnWebProcessTerminated = nil
1638+ wv.OnPermissionRequest = nil
13771639
13781640 // 3. Clear async callback references
13791641 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 (
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
1516 "github.com/bnema/dumber/internal/infrastructure/desktop"
1617 "github.com/bnema/dumber/internal/infrastructure/filesystem"
@@ -24,6 +25,7 @@ import (
2425 "github.com/bnema/dumber/internal/ui/adapter"
2526 "github.com/bnema/dumber/internal/ui/component"
2627 "github.com/bnema/dumber/internal/ui/coordinator"
28+ "github.com/bnema/dumber/internal/ui/dialog"
2729 "github.com/bnema/dumber/internal/ui/dispatcher"
2830 "github.com/bnema/dumber/internal/ui/focus"
2931 "github.com/bnema/dumber/internal/ui/input"
@@ -95,6 +97,8 @@ type App struct {
9597 appToaster *component.Toaster
9698 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
9799 modeToaster *component.Toaster
100+ // Top-right WebRTC permission activity indicator
101+ webrtcIndicator *component.WebRTCPermissionIndicator
98102
99103 // Session management
100104 sessionManager *component.SessionManager
@@ -203,8 +207,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
203207 }
204208 }
205209
206- // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
207- a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
210+ appID := AppID
211+ a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)
208212 if a.gtkApp == nil {
209213 log.Error().Msg("failed to create GTK application")
210214 return 1
@@ -254,6 +258,7 @@ func (a *App) onActivate(ctx context.Context) {
254258 a.initDownloadHandler(ctx)
255259
256260 a.initCoordinators(ctx)
261+ a.wireWebRTCPermissionIndicator()
257262 logging.Trace().Mark("coordinators_init")
258263 a.initKeyboardHandler(ctx)
259264 a.initOmniboxConfig(ctx)
@@ -351,6 +356,32 @@ func (a *App) createMainWindow(ctx context.Context) error {
351356 }
352357 a.mainWindow = mainWindow
353358
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+
354385 // Apply GTK CSS styling from theme manager.
355386 if a.deps == nil || a.deps.Theme == nil {
356387 return nil
@@ -1316,6 +1347,7 @@ func (a *App) initCoordinators(ctx context.Context) {
13161347 a.faviconAdapter,
13171348 getActiveWS,
13181349 a.deps.ZoomUC,
1350+ a.deps.PermissionUC,
13191351 )
13201352
13211353 // Set idle inhibitor for fullscreen video playback
@@ -1522,6 +1554,100 @@ func (a *App) initCoordinators(ctx context.Context) {
15221554 log.Debug().Msg("coordinators initialized")
15231555 }
15241556
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+
15251651 // generateID generates a unique ID for tabs and panes.
15261652 // Uses monotonically increasing counter to avoid ID collisions.
15271653 func (a *App) generateID() string {
M internal/ui/component/modal_sizing.go +9 -0
@@ -82,6 +82,15 @@ var TabPickerListDefaults = ListDisplayDefaults{
8282 MaxResults: 20,
8383 }
8484
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+
8594 // EffectiveMaxRows returns the maximum number of visible rows that fit
8695 // in the available space. It computes available height by subtracting the
8796 // 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) {
137137 assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)
138138 }
139139
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+
140149 func TestListDisplayDefaults_Values(t *testing.T) {
141150 assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)
142151 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 {
9090 widgetFactory layout.WidgetFactory
9191 faviconAdapter *adapter.FaviconAdapter
9292 zoomUC *usecase.ManageZoomUseCase
93+ permissionUC *usecase.HandlePermissionUseCase
9394 injector *webkit.ContentInjector
9495
9596 webViews map[entity.PaneID]*webkit.WebView
@@ -117,6 +118,12 @@ type ContentCoordinator struct {
117118 // Callback when active pane title changes (for window title updates)
118119 onWindowTitleChanged func(title string)
119120
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+
120127 // Callback when the WebView becomes visible (first real commit)
121128 onWebViewShown func(paneID entity.PaneID)
122129
@@ -178,6 +185,15 @@ type popupOAuthState struct {
178185 Seen bool
179186 }
180187
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+
181197 // NewContentCoordinator creates a new ContentCoordinator.
182198 func NewContentCoordinator(
183199 ctx context.Context,
@@ -186,6 +202,7 @@ func NewContentCoordinator(
186202 faviconAdapter *adapter.FaviconAdapter,
187203 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
188204 zoomUC *usecase.ManageZoomUseCase,
205+ permissionUC *usecase.HandlePermissionUseCase,
189206 ) *ContentCoordinator {
190207 log := logging.FromContext(ctx)
191208 log.Debug().Msg("creating content coordinator")
@@ -195,6 +212,7 @@ func NewContentCoordinator(
195212 widgetFactory: widgetFactory,
196213 faviconAdapter: faviconAdapter,
197214 zoomUC: zoomUC,
215+ permissionUC: permissionUC,
198216 webViews: make(map[entity.PaneID]*webkit.WebView),
199217 paneTitles: make(map[entity.PaneID]string),
200218 navOrigins: make(map[entity.PaneID]string),
@@ -216,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
216234 c.onHistoryRecord = fn
217235 }
218236
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+
219249 // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
220250 func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
221251 c.onPaneURIUpdated = fn
@@ -1080,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10801110 c.onHistoryRecord(ctx, paneID, uri)
10811111 }
10821112
1113+ // Notify active pane navigation for permission indicator reset.
1114+ c.notifyActiveNavigation(paneID, uri)
1115+
10831116 // Apply zoom
10841117 if c.zoomUC == nil {
10851118 return
@@ -1093,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10931126 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
10941127 }
10951128
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+
10961139 func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
10971140 if wv == nil || wv.IsDestroyed() {
10981141 return false
@@ -1646,6 +1689,10 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
16461689 }
16471690 }
16481691
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+ }
16491696 // Fullscreen handlers for idle inhibition
16501697 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
16511698
@@ -1943,6 +1990,98 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
19431990 return true
19441991 }
19451992
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+
19462085 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
19472086 // Idle is inhibited when:
19482087 // - The webview enters fullscreen mode (e.g., fullscreen video)
M internal/ui/deps.go +13 -11
@@ -46,19 +46,21 @@ type Dependencies struct {
4646 MessageRouter *webkit.MessageRouter
4747
4848 // Repositories
49- HistoryRepo repository.HistoryRepository
50- FavoriteRepo repository.FavoriteRepository
51- ZoomRepo repository.ZoomRepository
52- FilterRepo repository.ContentWhitelistRepository
49+ HistoryRepo repository.HistoryRepository
50+ FavoriteRepo repository.FavoriteRepository
51+ ZoomRepo repository.ZoomRepository
52+ PermissionRepo port.PermissionRepository
53+ FilterRepo repository.ContentWhitelistRepository
5354
5455 // Use Cases
55- TabsUC *usecase.ManageTabsUseCase
56- PanesUC *usecase.ManagePanesUseCase
57- NavigateUC *usecase.NavigateUseCase
58- ZoomUC *usecase.ManageZoomUseCase
59- FavoritesUC *usecase.ManageFavoritesUseCase
60- HistoryUC *usecase.SearchHistoryUseCase
61- CopyURLUC *usecase.CopyURLUseCase
56+ TabsUC *usecase.ManageTabsUseCase
57+ PanesUC *usecase.ManagePanesUseCase
58+ NavigateUC *usecase.NavigateUseCase
59+ ZoomUC *usecase.ManageZoomUseCase
60+ PermissionUC *usecase.HandlePermissionUseCase
61+ FavoritesUC *usecase.ManageFavoritesUseCase
62+ HistoryUC *usecase.SearchHistoryUseCase
63+ CopyURLUC *usecase.CopyURLUseCase
6264
6365 // Infrastructure Adapters
6466 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
128128 sb.WriteString(generateTabPickerCSS(p))
129129 sb.WriteString("\n")
130130
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+
131139 // Accent picker styling
132140 sb.WriteString(generateAccentPickerCSS(p))
133141
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) {
185185 mw.window.SetTitle(&title)
186186 }
187187
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+
188194 // AddOverlay adds a widget to the content overlay.
189195 // The widget will be displayed on top of the workspace content.
190196 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
M webui/package.json +3 -1
@@ -4,14 +4,16 @@
44 "private": true,
55 "type": "module",
66 "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",
88 "build:homepage": "VITE_PAGE=homepage vite build",
99 "build:error": "VITE_PAGE=error vite build",
1010 "build:config": "VITE_PAGE=config vite build",
11+ "build:webrtc": "VITE_PAGE=webrtc vite build",
1112 "dev": "VITE_PAGE=homepage vite build --watch",
1213 "dev:homepage": "VITE_PAGE=homepage vite build --watch",
1314 "dev:error": "VITE_PAGE=error vite build --watch",
1415 "dev:config": "VITE_PAGE=config vite build --watch",
16+ "dev:webrtc": "VITE_PAGE=webrtc vite build --watch",
1517 "check": "svelte-check",
1618 "lint": "eslint src --fix",
1719 "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 = {
4242 filename: "config.html",
4343 },
4444 },
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+ },
4557 } as const;
4658
4759 // 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