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