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 | KeybindingsProvider: {} | 31 | KeybindingsProvider: {} | ||
| 32 | KeybindingsSaver: {} | 32 | KeybindingsSaver: {} | ||
| 33 | DownloadResponse: {} | 33 | DownloadResponse: {} | ||
| 34 | + | PermissionDialogPresenter: {} | |||
| 34 | github.com/bnema/dumber/internal/domain/repository: | 35 | github.com/bnema/dumber/internal/domain/repository: | ||
| 35 | config: | 36 | config: | ||
| 36 | dir: '{{.InterfaceDir}}/mocks' | 37 | dir: '{{.InterfaceDir}}/mocks' | ||
| @@ -45,6 +46,7 @@ packages: | |||||
| 45 | SessionStateRepository: {} | 46 | SessionStateRepository: {} | ||
| 46 | TagRepository: {} | 47 | TagRepository: {} | ||
| 47 | ZoomRepository: {} | 48 | ZoomRepository: {} | ||
| 49 | + | PermissionRepository: {} | |||
| 48 | github.com/bnema/dumber/internal/infrastructure/filtering: | 50 | github.com/bnema/dumber/internal/infrastructure/filtering: | ||
| 49 | config: | 51 | config: | ||
| 50 | dir: '{{.InterfaceDir}}/mocks' | 52 | dir: '{{.InterfaceDir}}/mocks' | ||
M AGENTS.md +10 -0
| @@ -31,3 +31,13 @@ | |||||
| 31 | 31 | ||||
| 32 | ## Commands | 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 | "embed" | 4 | "embed" | ||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, and config pages). | 7 | + | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, and dumb://config. | 8 | + | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. |
| 9 | // | 9 | // | ||
| 10 | //go:embed webui/* | 10 | //go:embed webui/* | ||
| 11 | var WebUIAssets embed.FS | 11 | var WebUIAssets embed.FS | ||
M cmd/dumber/main.go +10 -0
| @@ -398,6 +398,7 @@ type repositories struct { | |||||
| 398 | folder repository.FolderRepository | 398 | folder repository.FolderRepository | ||
| 399 | tag repository.TagRepository | 399 | tag repository.TagRepository | ||
| 400 | zoom repository.ZoomRepository | 400 | zoom repository.ZoomRepository | ||
| 401 | + | permission port.PermissionRepository | |||
| 401 | session repository.SessionRepository | 402 | session repository.SessionRepository | ||
| 402 | sessionState repository.SessionStateRepository | 403 | sessionState repository.SessionStateRepository | ||
| 403 | } | 404 | } | ||
| @@ -409,6 +410,7 @@ func createRepositories(db *sql.DB) *repositories { | |||||
| 409 | folder: sqlite.NewFolderRepository(db), | 410 | folder: sqlite.NewFolderRepository(db), | ||
| 410 | tag: sqlite.NewTagRepository(db), | 411 | tag: sqlite.NewTagRepository(db), | ||
| 411 | zoom: sqlite.NewZoomRepository(db), | 412 | zoom: sqlite.NewZoomRepository(db), | ||
| 413 | + | permission: sqlite.NewPermissionRepository(db), | |||
| 412 | session: sqlite.NewSessionRepository(db), | 414 | session: sqlite.NewSessionRepository(db), | ||
| 413 | sessionState: sqlite.NewSessionStateRepository(db), | 415 | sessionState: sqlite.NewSessionStateRepository(db), | ||
| 414 | } | 416 | } | ||
| @@ -421,6 +423,7 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories { | |||||
| 421 | folder: sqlite.NewLazyFolderRepository(provider), | 423 | folder: sqlite.NewLazyFolderRepository(provider), | ||
| 422 | tag: sqlite.NewLazyTagRepository(provider), | 424 | tag: sqlite.NewLazyTagRepository(provider), | ||
| 423 | zoom: sqlite.NewLazyZoomRepository(provider), | 425 | zoom: sqlite.NewLazyZoomRepository(provider), | ||
| 426 | + | permission: sqlite.NewLazyPermissionRepository(provider), | |||
| 424 | session: sqlite.NewLazySessionRepository(provider), | 427 | session: sqlite.NewLazySessionRepository(provider), | ||
| 425 | sessionState: sqlite.NewLazySessionStateRepository(provider), | 428 | sessionState: sqlite.NewLazySessionStateRepository(provider), | ||
| 426 | } | 429 | } | ||
| @@ -433,6 +436,7 @@ type useCases struct { | |||||
| 433 | history *usecase.SearchHistoryUseCase | 436 | history *usecase.SearchHistoryUseCase | ||
| 434 | favorites *usecase.ManageFavoritesUseCase | 437 | favorites *usecase.ManageFavoritesUseCase | ||
| 435 | zoom *usecase.ManageZoomUseCase | 438 | zoom *usecase.ManageZoomUseCase | ||
| 439 | + | permission *usecase.HandlePermissionUseCase | |||
| 436 | navigate *usecase.NavigateUseCase | 440 | navigate *usecase.NavigateUseCase | ||
| 437 | copyURL *usecase.CopyURLUseCase | 441 | copyURL *usecase.CopyURLUseCase | ||
| 438 | snapshot *usecase.SnapshotSessionUseCase | 442 | snapshot *usecase.SnapshotSessionUseCase | ||
| @@ -468,12 +472,16 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||||
| 468 | updateDownloader := updater.NewGitHubDownloader() | 472 | updateDownloader := updater.NewGitHubDownloader() | ||
| 469 | updateApplier := updater.NewApplier(stateDir) | 473 | updateApplier := updater.NewApplier(stateDir) | ||
| 470 | 474 | ||||
| 475 | + | // Permission use case will be initialized later with dialog presenter | |||
| 476 | + | permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext) | |||
| 477 | + | ||||
| 471 | return &useCases{ | 478 | return &useCases{ | ||
| 472 | tabs: usecase.NewManageTabsUseCase(idGenerator), | 479 | tabs: usecase.NewManageTabsUseCase(idGenerator), | ||
| 473 | panes: usecase.NewManagePanesUseCase(idGenerator), | 480 | panes: usecase.NewManagePanesUseCase(idGenerator), | ||
| 474 | history: usecase.NewSearchHistoryUseCase(repos.history), | 481 | history: usecase.NewSearchHistoryUseCase(repos.history), | ||
| 475 | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | 482 | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | ||
| 476 | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | 483 | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | ||
| 484 | + | permission: permissionUC, | |||
| 477 | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | 485 | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | ||
| 478 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | 486 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | ||
| 479 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | 487 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | ||
| @@ -517,11 +525,13 @@ func buildUIDependencies( | |||||
| 517 | HistoryRepo: repos.history, | 525 | HistoryRepo: repos.history, | ||
| 518 | FavoriteRepo: repos.favorite, | 526 | FavoriteRepo: repos.favorite, | ||
| 519 | ZoomRepo: repos.zoom, | 527 | ZoomRepo: repos.zoom, | ||
| 528 | + | PermissionRepo: repos.permission, | |||
| 520 | TabsUC: uc.tabs, | 529 | TabsUC: uc.tabs, | ||
| 521 | PanesUC: uc.panes, | 530 | PanesUC: uc.panes, | ||
| 522 | HistoryUC: uc.history, | 531 | HistoryUC: uc.history, | ||
| 523 | FavoritesUC: uc.favorites, | 532 | FavoritesUC: uc.favorites, | ||
| 524 | ZoomUC: uc.zoom, | 533 | ZoomUC: uc.zoom, | ||
| 534 | + | PermissionUC: uc.permission, | |||
| 525 | NavigateUC: uc.navigate, | 535 | NavigateUC: uc.navigate, | ||
| 526 | CopyURLUC: uc.copyURL, | 536 | CopyURLUC: uc.copyURL, | ||
| 527 | Clipboard: uc.clipboard, | 537 | Clipboard: uc.clipboard, | ||
M docs/reference/index.md +1 -0
| @@ -3,6 +3,7 @@ | |||||
| 3 | ## Quick Links | 3 | ## Quick Links | ||
| 4 | 4 | ||||
| 5 | - [Configuration](./configuration.md) - All config keys | 5 | - [Configuration](./configuration.md) - All config keys | ||
| 6 | + | - [Permissions](./permissions.md) - Website permission handling (mic, camera, screen sharing) | |||
| 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | 7 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | ||
| 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | 8 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | ||
| 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | 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 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.10 | 6 | + | github.com/bnema/puregotk-webkit v0.0.12 |
| 7 | github.com/charmbracelet/bubbles v0.21.1 | 7 | github.com/charmbracelet/bubbles v0.21.1 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| @@ -22,7 +22,7 @@ require ( | |||||
| 22 | gopkg.in/yaml.v3 v3.0.1 | 22 | gopkg.in/yaml.v3 v3.0.1 | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 | 25 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 |
| 26 | 26 | ||||
| 27 | require ( | 27 | require ( | ||
| 28 | github.com/atotto/clipboard v0.1.4 // indirect | 28 | github.com/atotto/clipboard v0.1.4 // indirect | ||
M go.sum +4 -4 generated file: collapsed
A internal/application/port/logger.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/rs/zerolog" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // LoggerFromContext resolves the current request/session logger from context. | |||
| 10 | + | // Use cases depend on this boundary instead of importing infrastructure logging. | |||
| 11 | + | type LoggerFromContext func(ctx context.Context) *zerolog.Logger | |||
M internal/application/port/mocks/mocks.go +85 -0
| @@ -2140,6 +2140,91 @@ func (_c *MockKeybindingsSaver_SetKeybinding_Call) RunAndReturn(run func(ctx con | |||||
| 2140 | return _c | 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 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 2228 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2144 | // The first argument is typically a *testing.T value. | 2229 | // The first argument is typically a *testing.T value. | ||
| 2145 | func NewMockSessionLogger(t interface { | 2230 | func NewMockSessionLogger(t interface { | ||
A internal/application/port/permission.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PermissionDialogResult represents the user's response from a permission dialog. | |||
| 10 | + | type PermissionDialogResult struct { | |||
| 11 | + | // Allowed is true if the user clicked "Allow" or "Always Allow". | |||
| 12 | + | Allowed bool | |||
| 13 | + | ||||
| 14 | + | // Persistent is true if the user clicked "Always Allow" (save this decision). | |||
| 15 | + | // This should only be true for mic/camera permissions, not display capture. | |||
| 16 | + | Persistent bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // PermissionDialogPresenter defines the interface for showing permission dialogs. | |||
| 20 | + | // This is implemented by the UI layer (Adwaita dialogs). | |||
| 21 | + | type PermissionDialogPresenter interface { | |||
| 22 | + | // ShowPermissionDialog displays a permission request dialog to the user. | |||
| 23 | + | // The callback is invoked with the user's decision. | |||
| 24 | + | // | |||
| 25 | + | // Parameters: | |||
| 26 | + | // - ctx: context for cancellation and logging | |||
| 27 | + | // - origin: the website origin requesting permission (e.g., "https://meet.example.com") | |||
| 28 | + | // - permTypes: the types of permissions being requested | |||
| 29 | + | // - callback: invoked when the user makes a decision or dialog is dismissed | |||
| 30 | + | ShowPermissionDialog( | |||
| 31 | + | ctx context.Context, | |||
| 32 | + | origin string, | |||
| 33 | + | permTypes []entity.PermissionType, | |||
| 34 | + | callback func(result PermissionDialogResult), | |||
| 35 | + | ) | |||
| 36 | + | } | |||
A internal/application/port/permission_repository.go +24 -0
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PermissionRepository defines operations for permission persistence. | |||
| 10 | + | // Only microphone and camera permissions are persisted per W3C spec. | |||
| 11 | + | type PermissionRepository interface { | |||
| 12 | + | // Get retrieves the permission record for a specific origin and permission type. | |||
| 13 | + | // Returns nil if no record exists (treat as "prompt" state). | |||
| 14 | + | Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) | |||
| 15 | + | ||||
| 16 | + | // Set saves or updates a permission record. | |||
| 17 | + | Set(ctx context.Context, record *entity.PermissionRecord) error | |||
| 18 | + | ||||
| 19 | + | // Delete removes a permission record for a specific origin and type. | |||
| 20 | + | Delete(ctx context.Context, origin string, permType entity.PermissionType) error | |||
| 21 | + | ||||
| 22 | + | // GetAll retrieves all permission records for an origin. | |||
| 23 | + | GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) | |||
| 24 | + | } | |||
M internal/application/port/webview.go +5 -0
| @@ -104,6 +104,11 @@ type WebViewCallbacks struct { | |||||
| 104 | OnLinkHover func(uri string) | 104 | OnLinkHover func(uri string) | ||
| 105 | // OnWebProcessTerminated is called when the web process exits unexpectedly. | 105 | // OnWebProcessTerminated is called when the web process exits unexpectedly. | ||
| 106 | OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string) | 106 | OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string) | ||
| 107 | + | ||||
| 108 | + | // OnPermissionRequest is called when a site requests permission (mic, camera, screen sharing). | |||
| 109 | + | // Return true to indicate the request was handled. Call allow()/deny() to respond. | |||
| 110 | + | // The permission types are passed as strings ("microphone", "camera", "display", etc.). | |||
| 111 | + | OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool | |||
| 107 | } | 112 | } | ||
| 108 | 113 | ||||
| 109 | // FindOptions configures search behavior. | 114 | // FindOptions configures search behavior. | ||
A internal/application/usecase/handle_permission.go +389 -0
| @@ -0,0 +1,389 @@ | |||||
| 1 | + | // Package usecase contains application use cases that orchestrate domain logic. | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/rs/zerolog" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // PermissionCallback provides allow/deny functions for the permission request. | |||
| 16 | + | type PermissionCallback struct { | |||
| 17 | + | Allow func() | |||
| 18 | + | Deny func() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // HandlePermissionUseCase handles media permission requests from WebKit. | |||
| 22 | + | // It implements the permission strategy defined in the architecture: | |||
| 23 | + | // - Display capture: auto-allow (XDG portal handles UI) | |||
| 24 | + | // - Device enumeration: auto-allow (low risk) | |||
| 25 | + | // - Mic/Camera: check stored → dialog → persist if "Always" | |||
| 26 | + | type HandlePermissionUseCase struct { | |||
| 27 | + | permRepo port.PermissionRepository | |||
| 28 | + | dialog port.PermissionDialogPresenter | |||
| 29 | + | loggerFromContext port.LoggerFromContext | |||
| 30 | + | dialogMu sync.RWMutex | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewHandlePermissionUseCase creates a new permission handling use case. | |||
| 34 | + | func NewHandlePermissionUseCase( | |||
| 35 | + | permRepo port.PermissionRepository, | |||
| 36 | + | dialog port.PermissionDialogPresenter, | |||
| 37 | + | loggerFromContext port.LoggerFromContext, | |||
| 38 | + | ) *HandlePermissionUseCase { | |||
| 39 | + | return &HandlePermissionUseCase{ | |||
| 40 | + | permRepo: permRepo, | |||
| 41 | + | dialog: dialog, | |||
| 42 | + | loggerFromContext: loggerFromContext, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger { | |||
| 47 | + | if ctx == nil { | |||
| 48 | + | ctx = context.Background() | |||
| 49 | + | } | |||
| 50 | + | if uc == nil || uc.loggerFromContext == nil { | |||
| 51 | + | return zerolog.Ctx(ctx) | |||
| 52 | + | } | |||
| 53 | + | log := uc.loggerFromContext(ctx) | |||
| 54 | + | if log == nil { | |||
| 55 | + | return zerolog.Ctx(ctx) | |||
| 56 | + | } | |||
| 57 | + | return log | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // SetDialogPresenter sets the dialog presenter. This can be called after | |||
| 61 | + | // initialization when the UI window is available. | |||
| 62 | + | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | |||
| 63 | + | uc.dialogMu.Lock() | |||
| 64 | + | defer uc.dialogMu.Unlock() | |||
| 65 | + | uc.dialog = dialog | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // getDialog safely returns the current dialog presenter. | |||
| 69 | + | func (uc *HandlePermissionUseCase) getDialog() port.PermissionDialogPresenter { | |||
| 70 | + | uc.dialogMu.RLock() | |||
| 71 | + | defer uc.dialogMu.RUnlock() | |||
| 72 | + | return uc.dialog | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // HandlePermissionRequest processes a permission request from WebKit. | |||
| 76 | + | // This is the main entry point for the permission use case. | |||
| 77 | + | // | |||
| 78 | + | // Parameters: | |||
| 79 | + | // - ctx: context for cancellation and logging | |||
| 80 | + | // - origin: the website origin (URI) requesting permission | |||
| 81 | + | // - permTypes: the types of permissions being requested | |||
| 82 | + | // - callback: callbacks to allow or deny the request (must call one) | |||
| 83 | + | func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 84 | + | ctx context.Context, | |||
| 85 | + | origin string, | |||
| 86 | + | permTypes []entity.PermissionType, | |||
| 87 | + | callback PermissionCallback, | |||
| 88 | + | ) { | |||
| 89 | + | log := uc.logger(ctx).With(). | |||
| 90 | + | Str("component", "permission"). | |||
| 91 | + | Str("origin", origin). | |||
| 92 | + | Strs("types", entity.PermissionTypesToStrings(permTypes)). | |||
| 93 | + | Logger() | |||
| 94 | + | ||||
| 95 | + | // Validate origin | |||
| 96 | + | if origin == "" { | |||
| 97 | + | log.Warn().Msg("permission request with empty origin, denying") | |||
| 98 | + | callback.Deny() | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Check if all types are auto-allow | |||
| 103 | + | // Also handle empty permTypes as a deny (shouldn't happen but defensive) | |||
| 104 | + | if len(permTypes) == 0 { | |||
| 105 | + | log.Warn().Msg("permission request with empty types, denying") | |||
| 106 | + | callback.Deny() | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) { | |||
| 111 | + | log.Debug().Msg("manual override denied auto-allow permission request") | |||
| 112 | + | callback.Deny() | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | if isAutoAllowSet(permTypes) { | |||
| 117 | + | log.Debug().Msg("auto-allowing permission request") | |||
| 118 | + | callback.Allow() | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // For mic/camera: check stored permissions first | |||
| 123 | + | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | |||
| 124 | + | switch decision { | |||
| 125 | + | case entity.PermissionGranted: | |||
| 126 | + | log.Debug().Msg("using stored permission: granted") | |||
| 127 | + | callback.Allow() | |||
| 128 | + | return | |||
| 129 | + | case entity.PermissionDenied: | |||
| 130 | + | log.Debug().Msg("using stored permission: denied") | |||
| 131 | + | callback.Deny() | |||
| 132 | + | return | |||
| 133 | + | case entity.PermissionPrompt: | |||
| 134 | + | // Fall through to show dialog | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Show dialog for undecided permissions | |||
| 138 | + | uc.showPermissionDialog(ctx, origin, permTypes, callback) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // QueryPermissionState returns the current permission state for the W3C Permissions API. | |||
| 142 | + | // This is used by websites to check if they already have permission before calling getUserMedia(). | |||
| 143 | + | func (uc *HandlePermissionUseCase) QueryPermissionState( | |||
| 144 | + | ctx context.Context, | |||
| 145 | + | origin string, | |||
| 146 | + | permType entity.PermissionType, | |||
| 147 | + | ) entity.PermissionDecision { | |||
| 148 | + | log := uc.logger(ctx).With(). | |||
| 149 | + | Str("component", "permission"). | |||
| 150 | + | Str("origin", origin). | |||
| 151 | + | Str("type", string(permType)). | |||
| 152 | + | Logger() | |||
| 153 | + | ||||
| 154 | + | if origin == "" { | |||
| 155 | + | log.Warn().Msg("query: empty origin, returning prompt") | |||
| 156 | + | return entity.PermissionPrompt | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Check stored permission first (including manual overrides on auto-allow types). | |||
| 160 | + | record, err := uc.permRepo.Get(ctx, origin, permType) | |||
| 161 | + | if err != nil { | |||
| 162 | + | log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt") | |||
| 163 | + | return entity.PermissionPrompt | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | if record != nil { | |||
| 167 | + | log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission") | |||
| 168 | + | return record.Decision | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | if entity.IsAutoAllow(permType) { | |||
| 172 | + | log.Debug().Msg("query: no stored permission for auto-allow type, returning granted") | |||
| 173 | + | return entity.PermissionGranted | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | log.Debug().Msg("query: no stored permission, returning prompt") | |||
| 177 | + | return entity.PermissionPrompt | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // isAutoAllowSet returns true if all permission types in the set are auto-allow. | |||
| 181 | + | func isAutoAllowSet(permTypes []entity.PermissionType) bool { | |||
| 182 | + | for _, pt := range permTypes { | |||
| 183 | + | if !entity.IsAutoAllow(pt) { | |||
| 184 | + | return false | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | return true | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // checkStoredPermissions checks if all permissions in the set have stored decisions. | |||
| 191 | + | // Returns granted if all are granted, denied if any are denied, prompt otherwise. | |||
| 192 | + | func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 193 | + | ctx context.Context, | |||
| 194 | + | origin string, | |||
| 195 | + | permTypes []entity.PermissionType, | |||
| 196 | + | ) entity.PermissionDecision { | |||
| 197 | + | log := uc.logger(ctx) | |||
| 198 | + | ||||
| 199 | + | hasPrompt := false | |||
| 200 | + | ||||
| 201 | + | for _, permType := range permTypes { | |||
| 202 | + | // Skip auto-allow types - they're handled before this function | |||
| 203 | + | if entity.IsAutoAllow(permType) { | |||
| 204 | + | continue | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Non-persistable types that aren't auto-allow should prompt | |||
| 208 | + | // This shouldn't happen in practice, but we handle it defensively | |||
| 209 | + | if !entity.CanPersist(permType) { | |||
| 210 | + | hasPrompt = true | |||
| 211 | + | continue | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | record, err := uc.permRepo.Get(ctx, origin, permType) | |||
| 215 | + | if err != nil { | |||
| 216 | + | log.Warn(). | |||
| 217 | + | Err(err). | |||
| 218 | + | Str("perm_type", string(permType)). | |||
| 219 | + | Msg("failed to get stored permission") | |||
| 220 | + | // On error, treat as prompt (fall back to dialog) | |||
| 221 | + | hasPrompt = true | |||
| 222 | + | continue | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | if record == nil { | |||
| 226 | + | hasPrompt = true | |||
| 227 | + | continue | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | switch record.Decision { | |||
| 231 | + | case entity.PermissionDenied: | |||
| 232 | + | // If any permission is denied, deny the whole request (conservative) | |||
| 233 | + | return entity.PermissionDenied | |||
| 234 | + | case entity.PermissionPrompt: | |||
| 235 | + | hasPrompt = true | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | if hasPrompt { | |||
| 240 | + | return entity.PermissionPrompt | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // All permissions are granted | |||
| 244 | + | return entity.PermissionGranted | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // showPermissionDialog shows the permission dialog and handles the result. | |||
| 248 | + | func (uc *HandlePermissionUseCase) showPermissionDialog( | |||
| 249 | + | ctx context.Context, | |||
| 250 | + | origin string, | |||
| 251 | + | permTypes []entity.PermissionType, | |||
| 252 | + | callback PermissionCallback, | |||
| 253 | + | ) { | |||
| 254 | + | log := uc.logger(ctx) | |||
| 255 | + | ||||
| 256 | + | dialog := uc.getDialog() | |||
| 257 | + | ||||
| 258 | + | // If no dialog presenter is set, deny the permission | |||
| 259 | + | if dialog == nil { | |||
| 260 | + | log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission") | |||
| 261 | + | callback.Deny() | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | dialog.ShowPermissionDialog(ctx, origin, permTypes, func(result port.PermissionDialogResult) { | |||
| 266 | + | if result.Allowed { | |||
| 267 | + | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | |||
| 268 | + | callback.Allow() | |||
| 269 | + | ||||
| 270 | + | // Persist if "Always Allow" was chosen | |||
| 271 | + | if result.Persistent { | |||
| 272 | + | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | |||
| 273 | + | } | |||
| 274 | + | } else { | |||
| 275 | + | log.Debug().Msg("user denied permission") | |||
| 276 | + | callback.Deny() | |||
| 277 | + | ||||
| 278 | + | // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false) | |||
| 279 | + | if result.Persistent { | |||
| 280 | + | uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | }) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // persistPermission saves the permission decision to the repository. | |||
| 287 | + | func (uc *HandlePermissionUseCase) persistPermission( | |||
| 288 | + | ctx context.Context, | |||
| 289 | + | origin string, | |||
| 290 | + | permTypes []entity.PermissionType, | |||
| 291 | + | decision entity.PermissionDecision, | |||
| 292 | + | ) { | |||
| 293 | + | log := uc.logger(ctx) | |||
| 294 | + | ||||
| 295 | + | for _, permType := range permTypes { | |||
| 296 | + | // Only persist allowed types | |||
| 297 | + | if !entity.CanPersist(permType) { | |||
| 298 | + | continue | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | record := &entity.PermissionRecord{ | |||
| 302 | + | Origin: origin, | |||
| 303 | + | Type: permType, | |||
| 304 | + | Decision: decision, | |||
| 305 | + | UpdatedAt: time.Now().Unix(), | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | if err := uc.permRepo.Set(ctx, record); err != nil { | |||
| 309 | + | log.Warn(). | |||
| 310 | + | Err(err). | |||
| 311 | + | Str("perm_type", string(permType)). | |||
| 312 | + | Str("decision", string(decision)). | |||
| 313 | + | Msg("failed to persist permission") | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // GetManualPermissionDecision returns the stored decision (if any) for origin/type. | |||
| 319 | + | func (uc *HandlePermissionUseCase) GetManualPermissionDecision( | |||
| 320 | + | ctx context.Context, | |||
| 321 | + | origin string, | |||
| 322 | + | permType entity.PermissionType, | |||
| 323 | + | ) (*entity.PermissionRecord, error) { | |||
| 324 | + | if origin == "" { | |||
| 325 | + | return nil, errors.New("origin is required") | |||
| 326 | + | } | |||
| 327 | + | return uc.permRepo.Get(ctx, origin, permType) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // SetManualPermissionDecision stores an explicit decision for origin/type. | |||
| 331 | + | // This is used by the UI permission indicator as the source of truth for lock state. | |||
| 332 | + | func (uc *HandlePermissionUseCase) SetManualPermissionDecision( | |||
| 333 | + | ctx context.Context, | |||
| 334 | + | origin string, | |||
| 335 | + | permType entity.PermissionType, | |||
| 336 | + | decision entity.PermissionDecision, | |||
| 337 | + | ) error { | |||
| 338 | + | if origin == "" { | |||
| 339 | + | return errors.New("origin is required") | |||
| 340 | + | } | |||
| 341 | + | if !entity.CanPersist(permType) { | |||
| 342 | + | return errors.New("permission type not persistable") | |||
| 343 | + | } | |||
| 344 | + | if decision == entity.PermissionPrompt { | |||
| 345 | + | return uc.permRepo.Delete(ctx, origin, permType) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | return uc.permRepo.Set(ctx, &entity.PermissionRecord{ | |||
| 349 | + | Origin: origin, | |||
| 350 | + | Type: permType, | |||
| 351 | + | Decision: decision, | |||
| 352 | + | UpdatedAt: time.Now().Unix(), | |||
| 353 | + | }) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // ResetManualPermissionDecision clears any stored decision for origin/type. | |||
| 357 | + | func (uc *HandlePermissionUseCase) ResetManualPermissionDecision( | |||
| 358 | + | ctx context.Context, | |||
| 359 | + | origin string, | |||
| 360 | + | permType entity.PermissionType, | |||
| 361 | + | ) error { | |||
| 362 | + | if origin == "" { | |||
| 363 | + | return errors.New("origin is required") | |||
| 364 | + | } | |||
| 365 | + | return uc.permRepo.Delete(ctx, origin, permType) | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied( | |||
| 369 | + | ctx context.Context, | |||
| 370 | + | origin string, | |||
| 371 | + | permTypes []entity.PermissionType, | |||
| 372 | + | ) bool { | |||
| 373 | + | for _, permType := range permTypes { | |||
| 374 | + | if !entity.IsAutoAllow(permType) { | |||
| 375 | + | continue | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | record, err := uc.permRepo.Get(ctx, origin, permType) | |||
| 379 | + | if err != nil || record == nil { | |||
| 380 | + | continue | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | if record.Decision == entity.PermissionDenied { | |||
| 384 | + | return true | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | return false | |||
| 389 | + | } | |||
A internal/application/usecase/handle_permission_test.go +536 -0
| @@ -0,0 +1,536 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 13 | + | "github.com/rs/zerolog" | |||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func permissionLoggerFromContext(ctx context.Context) *zerolog.Logger { | |||
| 20 | + | return zerolog.Ctx(ctx) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||
| 24 | + | ctx := testContext() | |||
| 25 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 26 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 27 | + | ||||
| 28 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 29 | + | ||||
| 30 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 31 | + | Return(nil, nil) | |||
| 32 | + | ||||
| 33 | + | allowed := false | |||
| 34 | + | denied := false | |||
| 35 | + | callback := usecase.PermissionCallback{ | |||
| 36 | + | Allow: func() { allowed = true }, | |||
| 37 | + | Deny: func() { denied = true }, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Display capture should be auto-allowed (portal handles UI) | |||
| 41 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 42 | + | entity.PermissionTypeDisplay, | |||
| 43 | + | }, callback) | |||
| 44 | + | ||||
| 45 | + | assert.True(t, allowed, "display capture should be auto-allowed") | |||
| 46 | + | assert.False(t, denied) | |||
| 47 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) { | |||
| 51 | + | ctx := testContext() | |||
| 52 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 53 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 54 | + | ||||
| 55 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 56 | + | ||||
| 57 | + | permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo). | |||
| 58 | + | Return(nil, nil) | |||
| 59 | + | ||||
| 60 | + | allowed := false | |||
| 61 | + | callback := usecase.PermissionCallback{ | |||
| 62 | + | Allow: func() { allowed = true }, | |||
| 63 | + | Deny: func() {}, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Device info should be auto-allowed | |||
| 67 | + | uc.HandlePermissionRequest(ctx, "https://example.com", []entity.PermissionType{ | |||
| 68 | + | entity.PermissionTypeDeviceInfo, | |||
| 69 | + | }, callback) | |||
| 70 | + | ||||
| 71 | + | assert.True(t, allowed, "device info should be auto-allowed") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) { | |||
| 75 | + | ctx := testContext() | |||
| 76 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 77 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 78 | + | ||||
| 79 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 80 | + | ||||
| 81 | + | // Return stored granted permission | |||
| 82 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 83 | + | Return(&entity.PermissionRecord{ | |||
| 84 | + | Origin: "https://meet.example.com", | |||
| 85 | + | Type: entity.PermissionTypeMicrophone, | |||
| 86 | + | Decision: entity.PermissionGranted, | |||
| 87 | + | UpdatedAt: time.Now().Unix(), | |||
| 88 | + | }, nil) | |||
| 89 | + | ||||
| 90 | + | allowed := false | |||
| 91 | + | callback := usecase.PermissionCallback{ | |||
| 92 | + | Allow: func() { allowed = true }, | |||
| 93 | + | Deny: func() {}, | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 97 | + | entity.PermissionTypeMicrophone, | |||
| 98 | + | }, callback) | |||
| 99 | + | ||||
| 100 | + | assert.True(t, allowed, "should use stored granted permission") | |||
| 101 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) { | |||
| 105 | + | ctx := testContext() | |||
| 106 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 107 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 108 | + | ||||
| 109 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 110 | + | ||||
| 111 | + | // Return stored denied permission | |||
| 112 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera). | |||
| 113 | + | Return(&entity.PermissionRecord{ | |||
| 114 | + | Origin: "https://meet.example.com", | |||
| 115 | + | Type: entity.PermissionTypeCamera, | |||
| 116 | + | Decision: entity.PermissionDenied, | |||
| 117 | + | UpdatedAt: time.Now().Unix(), | |||
| 118 | + | }, nil) | |||
| 119 | + | ||||
| 120 | + | denied := false | |||
| 121 | + | callback := usecase.PermissionCallback{ | |||
| 122 | + | Allow: func() {}, | |||
| 123 | + | Deny: func() { denied = true }, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 127 | + | entity.PermissionTypeCamera, | |||
| 128 | + | }, callback) | |||
| 129 | + | ||||
| 130 | + | assert.True(t, denied, "should use stored denied permission") | |||
| 131 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) { | |||
| 135 | + | ctx := testContext() | |||
| 136 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 137 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 138 | + | ||||
| 139 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 140 | + | ||||
| 141 | + | // No stored permission (returns nil) | |||
| 142 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 143 | + | Return(nil, nil) | |||
| 144 | + | ||||
| 145 | + | allowed := false | |||
| 146 | + | callback := usecase.PermissionCallback{ | |||
| 147 | + | Allow: func() { allowed = true }, | |||
| 148 | + | Deny: func() {}, | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // Dialog shows and user clicks Allow (not persistent) | |||
| 152 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{ | |||
| 153 | + | entity.PermissionTypeMicrophone, | |||
| 154 | + | }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, callback func(port.PermissionDialogResult)) { | |||
| 155 | + | callback(port.PermissionDialogResult{Allowed: true, Persistent: false}) | |||
| 156 | + | }) | |||
| 157 | + | ||||
| 158 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 159 | + | entity.PermissionTypeMicrophone, | |||
| 160 | + | }, callback) | |||
| 161 | + | ||||
| 162 | + | assert.True(t, allowed, "user allowed permission") | |||
| 163 | + | // Should not persist since Persistent=false | |||
| 164 | + | permRepo.AssertNotCalled(t, "Set") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) { | |||
| 168 | + | ctx := testContext() | |||
| 169 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 170 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 171 | + | ||||
| 172 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 173 | + | ||||
| 174 | + | // No stored permission | |||
| 175 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 176 | + | Return(nil, nil) | |||
| 177 | + | ||||
| 178 | + | allowed := false | |||
| 179 | + | callback := usecase.PermissionCallback{ | |||
| 180 | + | Allow: func() { allowed = true }, | |||
| 181 | + | Deny: func() {}, | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Dialog shows and user clicks "Always Allow" | |||
| 185 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{ | |||
| 186 | + | entity.PermissionTypeMicrophone, | |||
| 187 | + | }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) { | |||
| 188 | + | cb(port.PermissionDialogResult{Allowed: true, Persistent: true}) | |||
| 189 | + | }) | |||
| 190 | + | ||||
| 191 | + | // Should persist the granted permission | |||
| 192 | + | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |||
| 193 | + | Run(func(_ context.Context, r *entity.PermissionRecord) { | |||
| 194 | + | assert.Equal(t, "https://meet.example.com", r.Origin) | |||
| 195 | + | assert.Equal(t, entity.PermissionTypeMicrophone, r.Type) | |||
| 196 | + | assert.Equal(t, entity.PermissionGranted, r.Decision) | |||
| 197 | + | }).Return(nil) | |||
| 198 | + | ||||
| 199 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 200 | + | entity.PermissionTypeMicrophone, | |||
| 201 | + | }, callback) | |||
| 202 | + | ||||
| 203 | + | assert.True(t, allowed) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestHandlePermissionUseCase_PersistDenied(t *testing.T) { | |||
| 207 | + | ctx := testContext() | |||
| 208 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 209 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 210 | + | ||||
| 211 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 212 | + | ||||
| 213 | + | // No stored permission | |||
| 214 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera). | |||
| 215 | + | Return(nil, nil) | |||
| 216 | + | ||||
| 217 | + | denied := false | |||
| 218 | + | callback := usecase.PermissionCallback{ | |||
| 219 | + | Allow: func() {}, | |||
| 220 | + | Deny: func() { denied = true }, | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Dialog shows and user clicks "Always Deny" | |||
| 224 | + | dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{ | |||
| 225 | + | entity.PermissionTypeCamera, | |||
| 226 | + | }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) { | |||
| 227 | + | // User denied but wants to persist this decision | |||
| 228 | + | cb(port.PermissionDialogResult{Allowed: false, Persistent: true}) | |||
| 229 | + | }) | |||
| 230 | + | ||||
| 231 | + | // Should persist the denied permission | |||
| 232 | + | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |||
| 233 | + | Run(func(_ context.Context, r *entity.PermissionRecord) { | |||
| 234 | + | assert.Equal(t, entity.PermissionDenied, r.Decision) | |||
| 235 | + | }).Return(nil) | |||
| 236 | + | ||||
| 237 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 238 | + | entity.PermissionTypeCamera, | |||
| 239 | + | }, callback) | |||
| 240 | + | ||||
| 241 | + | assert.True(t, denied) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) { | |||
| 245 | + | ctx := testContext() | |||
| 246 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 247 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 248 | + | ||||
| 249 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 250 | + | ||||
| 251 | + | denied := false | |||
| 252 | + | callback := usecase.PermissionCallback{ | |||
| 253 | + | Allow: func() {}, | |||
| 254 | + | Deny: func() { denied = true }, | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // Empty origin should be denied | |||
| 258 | + | uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{ | |||
| 259 | + | entity.PermissionTypeMicrophone, | |||
| 260 | + | }, callback) | |||
| 261 | + | ||||
| 262 | + | assert.True(t, denied, "empty origin should be denied") | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) { | |||
| 266 | + | ctx := testContext() | |||
| 267 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 268 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 269 | + | ||||
| 270 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 271 | + | ||||
| 272 | + | // Both mic and camera - one denied means whole request denied | |||
| 273 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 274 | + | Return(&entity.PermissionRecord{ | |||
| 275 | + | Origin: "https://meet.example.com", | |||
| 276 | + | Type: entity.PermissionTypeMicrophone, | |||
| 277 | + | Decision: entity.PermissionGranted, | |||
| 278 | + | }, nil) | |||
| 279 | + | ||||
| 280 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera). | |||
| 281 | + | Return(&entity.PermissionRecord{ | |||
| 282 | + | Origin: "https://meet.example.com", | |||
| 283 | + | Type: entity.PermissionTypeCamera, | |||
| 284 | + | Decision: entity.PermissionDenied, | |||
| 285 | + | }, nil) | |||
| 286 | + | ||||
| 287 | + | denied := false | |||
| 288 | + | callback := usecase.PermissionCallback{ | |||
| 289 | + | Allow: func() {}, | |||
| 290 | + | Deny: func() { denied = true }, | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 294 | + | entity.PermissionTypeMicrophone, | |||
| 295 | + | entity.PermissionTypeCamera, | |||
| 296 | + | }, callback) | |||
| 297 | + | ||||
| 298 | + | assert.True(t, denied, "if any permission is denied, whole request is denied") | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) { | |||
| 302 | + | ctx := testContext() | |||
| 303 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 304 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 305 | + | ||||
| 306 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 307 | + | permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay). | |||
| 308 | + | Return(nil, nil) | |||
| 309 | + | ||||
| 310 | + | state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay) | |||
| 311 | + | ||||
| 312 | + | assert.Equal(t, entity.PermissionGranted, state, "display capture should auto-return granted") | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t *testing.T) { | |||
| 316 | + | ctx := testContext() | |||
| 317 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 318 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 319 | + | ||||
| 320 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 321 | + | ||||
| 322 | + | permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay). | |||
| 323 | + | Return(&entity.PermissionRecord{ | |||
| 324 | + | Origin: "https://example.com", | |||
| 325 | + | Type: entity.PermissionTypeDisplay, | |||
| 326 | + | Decision: entity.PermissionDenied, | |||
| 327 | + | }, nil) | |||
| 328 | + | ||||
| 329 | + | state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay) | |||
| 330 | + | ||||
| 331 | + | assert.Equal(t, entity.PermissionDenied, state, "stored manual deny should override auto-allow") | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) { | |||
| 335 | + | ctx := testContext() | |||
| 336 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 337 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 338 | + | ||||
| 339 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 340 | + | ||||
| 341 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 342 | + | Return(&entity.PermissionRecord{ | |||
| 343 | + | Origin: "https://meet.example.com", | |||
| 344 | + | Type: entity.PermissionTypeMicrophone, | |||
| 345 | + | Decision: entity.PermissionDenied, | |||
| 346 | + | }, nil) | |||
| 347 | + | ||||
| 348 | + | state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeMicrophone) | |||
| 349 | + | ||||
| 350 | + | assert.Equal(t, entity.PermissionDenied, state) | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) { | |||
| 354 | + | ctx := testContext() | |||
| 355 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 356 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 357 | + | ||||
| 358 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 359 | + | ||||
| 360 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera). | |||
| 361 | + | Return(nil, nil) | |||
| 362 | + | ||||
| 363 | + | state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeCamera) | |||
| 364 | + | ||||
| 365 | + | assert.Equal(t, entity.PermissionPrompt, state, "no record should return prompt") | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t *testing.T) { | |||
| 369 | + | ctx := testContext() | |||
| 370 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 371 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 372 | + | ||||
| 373 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 374 | + | ||||
| 375 | + | state := uc.QueryPermissionState(ctx, "", entity.PermissionTypeDisplay) | |||
| 376 | + | ||||
| 377 | + | assert.Equal(t, entity.PermissionPrompt, state, "empty origin should return prompt") | |||
| 378 | + | permRepo.AssertNotCalled(t, "Get") | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) { | |||
| 382 | + | ctx := testContext() | |||
| 383 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 384 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 385 | + | ||||
| 386 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 387 | + | ||||
| 388 | + | // Display capture is not persistable per W3C spec | |||
| 389 | + | // A stored override may exist, so a Get call is expected before auto-allow. | |||
| 390 | + | // No Set call should happen even if we try to persist | |||
| 391 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 392 | + | Return(nil, nil) | |||
| 393 | + | ||||
| 394 | + | allowed := false | |||
| 395 | + | callback := usecase.PermissionCallback{ | |||
| 396 | + | Allow: func() { allowed = true }, | |||
| 397 | + | Deny: func() {}, | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 401 | + | entity.PermissionTypeDisplay, | |||
| 402 | + | }, callback) | |||
| 403 | + | ||||
| 404 | + | assert.True(t, allowed) | |||
| 405 | + | permRepo.AssertNotCalled(t, "Set") | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) { | |||
| 409 | + | ctx := testContext() | |||
| 410 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 411 | + | ||||
| 412 | + | // Create use case with nil dialog presenter | |||
| 413 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |||
| 414 | + | ||||
| 415 | + | denied := false | |||
| 416 | + | callback := usecase.PermissionCallback{ | |||
| 417 | + | Allow: func() {}, | |||
| 418 | + | Deny: func() { denied = true }, | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // Mic request with no stored permission and no dialog should be denied | |||
| 422 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone). | |||
| 423 | + | Return(nil, nil) | |||
| 424 | + | ||||
| 425 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 426 | + | entity.PermissionTypeMicrophone, | |||
| 427 | + | }, callback) | |||
| 428 | + | ||||
| 429 | + | assert.True(t, denied, "should deny when no dialog presenter is available") | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) { | |||
| 433 | + | ctx := testContext() | |||
| 434 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 435 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 436 | + | ||||
| 437 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |||
| 438 | + | ||||
| 439 | + | permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay). | |||
| 440 | + | Return(&entity.PermissionRecord{ | |||
| 441 | + | Origin: "https://meet.example.com", | |||
| 442 | + | Type: entity.PermissionTypeDisplay, | |||
| 443 | + | Decision: entity.PermissionDenied, | |||
| 444 | + | }, nil) | |||
| 445 | + | ||||
| 446 | + | denied := false | |||
| 447 | + | callback := usecase.PermissionCallback{ | |||
| 448 | + | Allow: func() {}, | |||
| 449 | + | Deny: func() { denied = true }, | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{ | |||
| 453 | + | entity.PermissionTypeDisplay, | |||
| 454 | + | }, callback) | |||
| 455 | + | ||||
| 456 | + | assert.True(t, denied, "display should be denied when manual override is denied") | |||
| 457 | + | dialog.AssertNotCalled(t, "ShowPermissionDialog") | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) { | |||
| 461 | + | ctx := testContext() | |||
| 462 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 463 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |||
| 464 | + | ||||
| 465 | + | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |||
| 466 | + | Run(func(_ context.Context, record *entity.PermissionRecord) { | |||
| 467 | + | assert.Equal(t, "https://app.zoom.us", record.Origin) | |||
| 468 | + | assert.Equal(t, entity.PermissionTypeMicrophone, record.Type) | |||
| 469 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |||
| 470 | + | }). | |||
| 471 | + | Return(nil) | |||
| 472 | + | ||||
| 473 | + | err := uc.SetManualPermissionDecision( | |||
| 474 | + | ctx, | |||
| 475 | + | "https://app.zoom.us", | |||
| 476 | + | entity.PermissionTypeMicrophone, | |||
| 477 | + | entity.PermissionGranted, | |||
| 478 | + | ) | |||
| 479 | + | require.NoError(t, err) | |||
| 480 | + | ||||
| 481 | + | permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |||
| 482 | + | Return(&entity.PermissionRecord{ | |||
| 483 | + | Origin: "https://app.zoom.us", | |||
| 484 | + | Type: entity.PermissionTypeMicrophone, | |||
| 485 | + | Decision: entity.PermissionGranted, | |||
| 486 | + | }, nil) | |||
| 487 | + | ||||
| 488 | + | record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |||
| 489 | + | require.NoError(t, err) | |||
| 490 | + | if assert.NotNil(t, record) { | |||
| 491 | + | assert.Equal(t, entity.PermissionGranted, record.Decision) | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone). | |||
| 495 | + | Return(nil) | |||
| 496 | + | err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone) | |||
| 497 | + | require.NoError(t, err) | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *testing.T) { | |||
| 501 | + | ctx := testContext() | |||
| 502 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 503 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |||
| 504 | + | ||||
| 505 | + | err := uc.SetManualPermissionDecision( | |||
| 506 | + | ctx, | |||
| 507 | + | "https://app.zoom.us", | |||
| 508 | + | entity.PermissionTypeDisplay, | |||
| 509 | + | entity.PermissionDenied, | |||
| 510 | + | ) | |||
| 511 | + | ||||
| 512 | + | require.Error(t, err) | |||
| 513 | + | require.ErrorContains(t, err, "permission type not persistable") | |||
| 514 | + | permRepo.AssertNotCalled(t, "Set") | |||
| 515 | + | permRepo.AssertNotCalled(t, "Delete") | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) { | |||
| 519 | + | ctx := testContext() | |||
| 520 | + | permRepo := repomocks.NewMockPermissionRepository(t) | |||
| 521 | + | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |||
| 522 | + | ||||
| 523 | + | called := false | |||
| 524 | + | loggerFactory := func(ctx context.Context) *zerolog.Logger { | |||
| 525 | + | called = true | |||
| 526 | + | return zerolog.Ctx(ctx) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, loggerFactory) | |||
| 530 | + | uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{entity.PermissionTypeMicrophone}, usecase.PermissionCallback{ | |||
| 531 | + | Allow: func() {}, | |||
| 532 | + | Deny: func() {}, | |||
| 533 | + | }) | |||
| 534 | + | ||||
| 535 | + | assert.True(t, called) | |||
| 536 | + | } | |||
M internal/bootstrap/webkit_stack.go +11 -2
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 14 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 15 | "github.com/bnema/dumber/internal/ui/theme" | 15 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 16 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 16 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -211,14 +212,22 @@ func initWebViewPool( | |||||
| 211 | pool.SetFilterApplier(filterManager) | 212 | pool.SetFilterApplier(filterManager) | ||
| 212 | } | 213 | } | ||
| 213 | 214 | ||||
| 214 | - | if err := pool.PrewarmFirst(ctx); err != nil { | 215 | + | if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) { |
| 215 | - | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | 216 | + | if err := pool.PrewarmFirst(ctx); err != nil { |
| 217 | + | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |||
| 218 | + | } | |||
| 219 | + | } else { | |||
| 220 | + | logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet") | |||
| 216 | } | 221 | } | ||
| 217 | logging.Trace().Mark("pool_prewarm_first") | 222 | logging.Trace().Mark("pool_prewarm_first") | ||
| 218 | 223 | ||||
| 219 | return pool | 224 | return pool | ||
| 220 | } | 225 | } | ||
| 221 | 226 | ||||
| 227 | + | func shouldPrewarmFirstWebView(display *gdk.Display) bool { | |||
| 228 | + | return display != nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 222 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | 231 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | ||
| 223 | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | 232 | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | ||
| 224 | return p.WebProcessMemoryLimitMB > 0 || | 233 | return p.WebProcessMemoryLimitMB > 0 || | ||
M internal/bootstrap/webkit_stack_test.go +6 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| @@ -47,3 +48,8 @@ func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) { | |||||
| 47 | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | 48 | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | ||
| 48 | assert.True(t, opts.ITPEnabled) | 49 | assert.True(t, opts.ITPEnabled) | ||
| 49 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestShouldPrewarmFirstWebView(t *testing.T) { | |||
| 53 | + | assert.False(t, shouldPrewarmFirstWebView(nil)) | |||
| 54 | + | assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{})) | |||
| 55 | + | } | |||
A internal/domain/entity/permission.go +114 -0
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | // DomainTimestamp represents a Unix timestamp in seconds. | |||
| 4 | + | // This is a domain-owned type to avoid importing time in the domain layer. | |||
| 5 | + | ||||
| 6 | + | // PermissionType represents the type of permission being requested. | |||
| 7 | + | type PermissionType string | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | // PermissionTypeMicrophone represents microphone access permission. | |||
| 11 | + | PermissionTypeMicrophone PermissionType = "microphone" | |||
| 12 | + | ||||
| 13 | + | // PermissionTypeCamera represents camera access permission. | |||
| 14 | + | PermissionTypeCamera PermissionType = "camera" | |||
| 15 | + | ||||
| 16 | + | // PermissionTypeDisplay represents screen sharing/display capture permission. | |||
| 17 | + | PermissionTypeDisplay PermissionType = "display" | |||
| 18 | + | ||||
| 19 | + | // PermissionTypeDeviceInfo represents device enumeration permission. | |||
| 20 | + | PermissionTypeDeviceInfo PermissionType = "device_info" | |||
| 21 | + | ||||
| 22 | + | // PermissionTypeClipboard represents clipboard access permission. | |||
| 23 | + | PermissionTypeClipboard PermissionType = "clipboard" | |||
| 24 | + | ||||
| 25 | + | // PermissionTypeNotification represents notification permission. | |||
| 26 | + | PermissionTypeNotification PermissionType = "notification" | |||
| 27 | + | ||||
| 28 | + | // PermissionTypeGeolocation represents geolocation permission. | |||
| 29 | + | PermissionTypeGeolocation PermissionType = "geolocation" | |||
| 30 | + | ||||
| 31 | + | // PermissionTypePointerLock represents pointer lock permission. | |||
| 32 | + | PermissionTypePointerLock PermissionType = "pointer_lock" | |||
| 33 | + | ||||
| 34 | + | // PermissionTypeMediaKeySystem represents DRM/media key system permission. | |||
| 35 | + | PermissionTypeMediaKeySystem PermissionType = "media_key_system" | |||
| 36 | + | ||||
| 37 | + | // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission. | |||
| 38 | + | PermissionTypeWebsiteDataAccess PermissionType = "website_data_access" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // PermissionDecision represents the user's decision for a permission. | |||
| 42 | + | type PermissionDecision string | |||
| 43 | + | ||||
| 44 | + | const ( | |||
| 45 | + | // PermissionGranted means the permission was allowed. | |||
| 46 | + | PermissionGranted PermissionDecision = "granted" | |||
| 47 | + | ||||
| 48 | + | // PermissionDenied means the permission was denied. | |||
| 49 | + | PermissionDenied PermissionDecision = "denied" | |||
| 50 | + | ||||
| 51 | + | // PermissionPrompt means no decision has been made yet (default state). | |||
| 52 | + | PermissionPrompt PermissionDecision = "prompt" | |||
| 53 | + | ) | |||
| 54 | + | ||||
| 55 | + | // PermissionRecord stores a permission decision for a specific origin and type. | |||
| 56 | + | type PermissionRecord struct { | |||
| 57 | + | Origin string // The origin (domain) this permission applies to | |||
| 58 | + | Type PermissionType // The type of permission | |||
| 59 | + | Decision PermissionDecision // The decision: granted, denied, or prompt | |||
| 60 | + | UpdatedAt int64 // Unix timestamp in seconds when this record was last updated | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // IsGranted returns true if the permission is granted. | |||
| 64 | + | func (p *PermissionRecord) IsGranted() bool { | |||
| 65 | + | return p.Decision == PermissionGranted | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // IsDenied returns true if the permission is denied. | |||
| 69 | + | func (p *PermissionRecord) IsDenied() bool { | |||
| 70 | + | return p.Decision == PermissionDenied | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // PermissionSet represents multiple permission decisions for a single request. | |||
| 74 | + | // This is used when handling combined permission requests (e.g., mic + camera). | |||
| 75 | + | type PermissionSet struct { | |||
| 76 | + | Types []PermissionType | |||
| 77 | + | Decision PermissionDecision | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // CanPersist returns true if this permission type can be persisted. | |||
| 81 | + | // Per W3C spec, display capture permissions cannot be persisted. | |||
| 82 | + | func CanPersist(permType PermissionType) bool { | |||
| 83 | + | switch permType { | |||
| 84 | + | case PermissionTypeDisplay: | |||
| 85 | + | return false // W3C forbids persisting display capture permissions | |||
| 86 | + | case PermissionTypeDeviceInfo, PermissionTypePointerLock: | |||
| 87 | + | return false // Auto-allowed, no need to persist | |||
| 88 | + | default: | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // IsAutoAllow returns true if this permission type should be auto-allowed. | |||
| 94 | + | func IsAutoAllow(permType PermissionType) bool { | |||
| 95 | + | switch permType { | |||
| 96 | + | case PermissionTypeDisplay: | |||
| 97 | + | return true // Portal handles the UI | |||
| 98 | + | case PermissionTypeDeviceInfo: | |||
| 99 | + | return true // Low risk, just lists devices | |||
| 100 | + | case PermissionTypePointerLock: | |||
| 101 | + | return true // Low risk, default WebKit behavior | |||
| 102 | + | default: | |||
| 103 | + | return false | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // PermissionTypesToStrings converts permission types to strings for logging. | |||
| 108 | + | func PermissionTypesToStrings(types []PermissionType) []string { | |||
| 109 | + | result := make([]string, len(types)) | |||
| 110 | + | for i, t := range types { | |||
| 111 | + | result[i] = string(t) | |||
| 112 | + | } | |||
| 113 | + | return result | |||
| 114 | + | } | |||
A internal/domain/entity/permission_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package entity_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestPermissionType_Constants(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | permType entity.PermissionType | |||
| 14 | + | expected string | |||
| 15 | + | }{ | |||
| 16 | + | {entity.PermissionTypeMicrophone, "microphone"}, | |||
| 17 | + | {entity.PermissionTypeCamera, "camera"}, | |||
| 18 | + | {entity.PermissionTypeDisplay, "display"}, | |||
| 19 | + | {entity.PermissionTypeDeviceInfo, "device_info"}, | |||
| 20 | + | {entity.PermissionTypeClipboard, "clipboard"}, | |||
| 21 | + | {entity.PermissionTypeNotification, "notification"}, | |||
| 22 | + | {entity.PermissionTypeGeolocation, "geolocation"}, | |||
| 23 | + | {entity.PermissionTypePointerLock, "pointer_lock"}, | |||
| 24 | + | {entity.PermissionTypeMediaKeySystem, "media_key_system"}, | |||
| 25 | + | {entity.PermissionTypeWebsiteDataAccess, "website_data_access"}, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(string(tt.permType), func(t *testing.T) { | |||
| 30 | + | assert.Equal(t, tt.expected, string(tt.permType)) | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestPermissionDecision_Constants(t *testing.T) { | |||
| 36 | + | tests := []struct { | |||
| 37 | + | decision entity.PermissionDecision | |||
| 38 | + | expected string | |||
| 39 | + | }{ | |||
| 40 | + | {entity.PermissionGranted, "granted"}, | |||
| 41 | + | {entity.PermissionDenied, "denied"}, | |||
| 42 | + | {entity.PermissionPrompt, "prompt"}, | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | for _, tt := range tests { | |||
| 46 | + | t.Run(string(tt.decision), func(t *testing.T) { | |||
| 47 | + | assert.Equal(t, tt.expected, string(tt.decision)) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestPermissionRecord_IsGranted(t *testing.T) { | |||
| 53 | + | tests := []struct { | |||
| 54 | + | name string | |||
| 55 | + | decision entity.PermissionDecision | |||
| 56 | + | expected bool | |||
| 57 | + | }{ | |||
| 58 | + | {"granted", entity.PermissionGranted, true}, | |||
| 59 | + | {"denied", entity.PermissionDenied, false}, | |||
| 60 | + | {"prompt", entity.PermissionPrompt, false}, | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | for _, tt := range tests { | |||
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 65 | + | record := &entity.PermissionRecord{ | |||
| 66 | + | Origin: "https://example.com", | |||
| 67 | + | Type: entity.PermissionTypeMicrophone, | |||
| 68 | + | Decision: tt.decision, | |||
| 69 | + | UpdatedAt: time.Now().Unix(), | |||
| 70 | + | } | |||
| 71 | + | assert.Equal(t, tt.expected, record.IsGranted()) | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestPermissionRecord_IsDenied(t *testing.T) { | |||
| 77 | + | tests := []struct { | |||
| 78 | + | name string | |||
| 79 | + | decision entity.PermissionDecision | |||
| 80 | + | expected bool | |||
| 81 | + | }{ | |||
| 82 | + | {"granted", entity.PermissionGranted, false}, | |||
| 83 | + | {"denied", entity.PermissionDenied, true}, | |||
| 84 | + | {"prompt", entity.PermissionPrompt, false}, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | for _, tt := range tests { | |||
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 89 | + | record := &entity.PermissionRecord{ | |||
| 90 | + | Origin: "https://example.com", | |||
| 91 | + | Type: entity.PermissionTypeCamera, | |||
| 92 | + | Decision: tt.decision, | |||
| 93 | + | UpdatedAt: time.Now().Unix(), | |||
| 94 | + | } | |||
| 95 | + | assert.Equal(t, tt.expected, record.IsDenied()) | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestCanPersist(t *testing.T) { | |||
| 101 | + | tests := []struct { | |||
| 102 | + | name string | |||
| 103 | + | permType entity.PermissionType | |||
| 104 | + | expected bool | |||
| 105 | + | }{ | |||
| 106 | + | // Non-persistable per W3C spec | |||
| 107 | + | {"display capture", entity.PermissionTypeDisplay, false}, | |||
| 108 | + | {"device info", entity.PermissionTypeDeviceInfo, false}, | |||
| 109 | + | {"pointer lock", entity.PermissionTypePointerLock, false}, | |||
| 110 | + | ||||
| 111 | + | // Persistable | |||
| 112 | + | {"microphone", entity.PermissionTypeMicrophone, true}, | |||
| 113 | + | {"camera", entity.PermissionTypeCamera, true}, | |||
| 114 | + | {"clipboard", entity.PermissionTypeClipboard, true}, | |||
| 115 | + | {"notification", entity.PermissionTypeNotification, true}, | |||
| 116 | + | {"geolocation", entity.PermissionTypeGeolocation, true}, | |||
| 117 | + | {"media key system", entity.PermissionTypeMediaKeySystem, true}, | |||
| 118 | + | {"website data access", entity.PermissionTypeWebsiteDataAccess, true}, | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | for _, tt := range tests { | |||
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 123 | + | assert.Equal(t, tt.expected, entity.CanPersist(tt.permType)) | |||
| 124 | + | }) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestIsAutoAllow(t *testing.T) { | |||
| 129 | + | tests := []struct { | |||
| 130 | + | name string | |||
| 131 | + | permType entity.PermissionType | |||
| 132 | + | expected bool | |||
| 133 | + | }{ | |||
| 134 | + | // Auto-allowed (portal handles UI or low risk) | |||
| 135 | + | {"display capture", entity.PermissionTypeDisplay, true}, | |||
| 136 | + | {"device info", entity.PermissionTypeDeviceInfo, true}, | |||
| 137 | + | {"pointer lock", entity.PermissionTypePointerLock, true}, | |||
| 138 | + | ||||
| 139 | + | // Not auto-allowed (require user consent) | |||
| 140 | + | {"microphone", entity.PermissionTypeMicrophone, false}, | |||
| 141 | + | {"camera", entity.PermissionTypeCamera, false}, | |||
| 142 | + | {"clipboard", entity.PermissionTypeClipboard, false}, | |||
| 143 | + | {"notification", entity.PermissionTypeNotification, false}, | |||
| 144 | + | {"geolocation", entity.PermissionTypeGeolocation, false}, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType)) | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestPermissionSet(t *testing.T) { | |||
| 155 | + | set := entity.PermissionSet{ | |||
| 156 | + | Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera}, | |||
| 157 | + | Decision: entity.PermissionGranted, | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | assert.Len(t, set.Types, 2) | |||
| 161 | + | assert.Contains(t, set.Types, entity.PermissionTypeMicrophone) | |||
| 162 | + | assert.Contains(t, set.Types, entity.PermissionTypeCamera) | |||
| 163 | + | assert.Equal(t, entity.PermissionGranted, set.Decision) | |||
| 164 | + | } | |||
M internal/domain/repository/mocks/mocks.go +289 -0
| @@ -2979,6 +2979,295 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C | |||||
| 2979 | return _c | 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 | // NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 3271 | // NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2983 | // The first argument is typically a *testing.T value. | 3272 | // The first argument is typically a *testing.T value. | ||
| 2984 | func NewMockSessionRepository(t interface { | 3273 | func NewMockSessionRepository(t interface { | ||
M internal/domain/url/normalize.go +40 -0
| @@ -203,3 +203,43 @@ func IsExternalScheme(uri string) bool { | |||||
| 203 | return true | 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 | return &LazyHistoryRepository{provider: provider} | 40 | return &LazyHistoryRepository{provider: provider} | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | + | // LazyPermissionRepository wraps a permission repository with lazy database initialization. | |||
| 44 | + | type LazyPermissionRepository struct { | |||
| 45 | + | provider port.DatabaseProvider | |||
| 46 | + | repo port.PermissionRepository | |||
| 47 | + | once sync.Once | |||
| 48 | + | initErr error | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // NewLazyPermissionRepository creates a lazy-loading permission repository. | |||
| 52 | + | func NewLazyPermissionRepository(provider port.DatabaseProvider) port.PermissionRepository { | |||
| 53 | + | return &LazyPermissionRepository{provider: provider} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (r *LazyPermissionRepository) init(ctx context.Context) error { | |||
| 57 | + | r.once.Do(func() { | |||
| 58 | + | db, err := r.provider.DB(ctx) | |||
| 59 | + | if err != nil { | |||
| 60 | + | r.initErr = err | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | r.repo = NewPermissionRepository(db) | |||
| 64 | + | }) | |||
| 65 | + | return r.initErr | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (r *LazyPermissionRepository) Get( | |||
| 69 | + | ctx context.Context, | |||
| 70 | + | origin string, | |||
| 71 | + | permType entity.PermissionType, | |||
| 72 | + | ) (*entity.PermissionRecord, error) { | |||
| 73 | + | if err := r.init(ctx); err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | return r.repo.Get(ctx, origin, permType) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (r *LazyPermissionRepository) Set(ctx context.Context, record *entity.PermissionRecord) error { | |||
| 80 | + | if err := r.init(ctx); err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | return r.repo.Set(ctx, record) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (r *LazyPermissionRepository) Delete(ctx context.Context, origin string, permType entity.PermissionType) error { | |||
| 87 | + | if err := r.init(ctx); err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | return r.repo.Delete(ctx, origin, permType) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (r *LazyPermissionRepository) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) { | |||
| 94 | + | if err := r.init(ctx); err != nil { | |||
| 95 | + | return nil, err | |||
| 96 | + | } | |||
| 97 | + | return r.repo.GetAll(ctx, origin) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 43 | func (r *LazyHistoryRepository) init(ctx context.Context) error { | 100 | func (r *LazyHistoryRepository) init(ctx context.Context) error { | ||
| 44 | r.once.Do(func() { | 101 | r.once.Do(func() { | ||
| 45 | db, err := r.provider.DB(ctx) | 102 | db, err := r.provider.DB(ctx) | ||
| @@ -696,6 +753,7 @@ type LazyRepositories struct { | |||||
| 696 | Session repository.SessionRepository | 753 | Session repository.SessionRepository | ||
| 697 | SessionState repository.SessionStateRepository | 754 | SessionState repository.SessionStateRepository | ||
| 698 | Filter repository.ContentWhitelistRepository | 755 | Filter repository.ContentWhitelistRepository | ||
| 756 | + | Permission port.PermissionRepository | |||
| 699 | } | 757 | } | ||
| 700 | 758 | ||||
| 701 | // NewLazyRepositories creates all lazy repositories from a database provider. | 759 | // NewLazyRepositories creates all lazy repositories from a database provider. | ||
| @@ -709,6 +767,7 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories { | |||||
| 709 | Session: NewLazySessionRepository(provider), | 767 | Session: NewLazySessionRepository(provider), | ||
| 710 | SessionState: NewLazySessionStateRepository(provider), | 768 | SessionState: NewLazySessionStateRepository(provider), | ||
| 711 | Filter: NewLazyContentWhitelistRepository(provider), | 769 | Filter: NewLazyContentWhitelistRepository(provider), | ||
| 770 | + | Permission: NewLazyPermissionRepository(provider), | |||
| 712 | } | 771 | } | ||
| 713 | } | 772 | } | ||
| 714 | 773 | ||||
A internal/infrastructure/persistence/sqlite/migrations/007_permissions.sql +16 -0
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | -- +goose Up | |||
| 2 | + | -- Permission storage for media access (mic, camera, etc.) | |||
| 3 | + | -- Per W3C spec: display capture permissions cannot be persisted | |||
| 4 | + | ||||
| 5 | + | CREATE TABLE IF NOT EXISTS permissions ( | |||
| 6 | + | origin TEXT NOT NULL, | |||
| 7 | + | permission_type TEXT NOT NULL, | |||
| 8 | + | decision TEXT NOT NULL CHECK(decision IN ('granted', 'denied')), | |||
| 9 | + | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
| 10 | + | PRIMARY KEY (origin, permission_type) | |||
| 11 | + | ); | |||
| 12 | + | ||||
| 13 | + | CREATE INDEX IF NOT EXISTS idx_permissions_updated_at ON permissions(updated_at); | |||
| 14 | + | ||||
| 15 | + | -- +goose Down | |||
| 16 | + | DROP TABLE IF EXISTS permissions; | |||
A internal/infrastructure/persistence/sqlite/permission_repo.go +102 -0
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package sqlite | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "errors" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type permissionRepo struct { | |||
| 15 | + | queries *sqlc.Queries | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // NewPermissionRepository creates a new SQLite-backed permission repository. | |||
| 19 | + | func NewPermissionRepository(db *sql.DB) port.PermissionRepository { | |||
| 20 | + | return &permissionRepo{queries: sqlc.New(db)} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (r *permissionRepo) Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) { | |||
| 24 | + | log := logging.FromContext(ctx) | |||
| 25 | + | log.Debug().Str("origin", origin).Str("type", string(permType)).Msg("getting permission") | |||
| 26 | + | ||||
| 27 | + | row, err := r.queries.GetPermission(ctx, sqlc.GetPermissionParams{ | |||
| 28 | + | Origin: origin, | |||
| 29 | + | PermissionType: string(permType), | |||
| 30 | + | }) | |||
| 31 | + | if err != nil { | |||
| 32 | + | if errors.Is(err, sql.ErrNoRows) { | |||
| 33 | + | return nil, nil | |||
| 34 | + | } | |||
| 35 | + | return nil, err | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | return permissionFromRow(row), nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error { | |||
| 42 | + | log := logging.FromContext(ctx) | |||
| 43 | + | ||||
| 44 | + | if record == nil { | |||
| 45 | + | log.Error().Msg("cannot set nil permission record") | |||
| 46 | + | return errors.New("cannot set nil permission record") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | log.Debug(). | |||
| 50 | + | Str("origin", record.Origin). | |||
| 51 | + | Str("type", string(record.Type)). | |||
| 52 | + | Str("decision", string(record.Decision)). | |||
| 53 | + | Msg("setting permission") | |||
| 54 | + | ||||
| 55 | + | return r.queries.SetPermission(ctx, sqlc.SetPermissionParams{ | |||
| 56 | + | Origin: record.Origin, | |||
| 57 | + | PermissionType: string(record.Type), | |||
| 58 | + | Decision: string(record.Decision), | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (r *permissionRepo) Delete(ctx context.Context, origin string, permType entity.PermissionType) error { | |||
| 63 | + | log := logging.FromContext(ctx) | |||
| 64 | + | log.Debug().Str("origin", origin).Str("type", string(permType)).Msg("deleting permission") | |||
| 65 | + | ||||
| 66 | + | return r.queries.DeletePermission(ctx, sqlc.DeletePermissionParams{ | |||
| 67 | + | Origin: origin, | |||
| 68 | + | PermissionType: string(permType), | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (r *permissionRepo) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) { | |||
| 73 | + | log := logging.FromContext(ctx) | |||
| 74 | + | log.Debug().Str("origin", origin).Msg("getting all permissions for origin") | |||
| 75 | + | ||||
| 76 | + | // sqlc returns an empty slice (not sql.ErrNoRows) when nothing matches. | |||
| 77 | + | rows, err := r.queries.ListPermissionsByOrigin(ctx, origin) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return nil, err | |||
| 80 | + | } | |||
| 81 | + | if len(rows) == 0 { | |||
| 82 | + | return []*entity.PermissionRecord{}, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | records := make([]*entity.PermissionRecord, len(rows)) | |||
| 86 | + | for i, row := range rows { | |||
| 87 | + | records[i] = permissionFromRow(row) | |||
| 88 | + | } | |||
| 89 | + | return records, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func permissionFromRow(row sqlc.Permission) *entity.PermissionRecord { | |||
| 93 | + | record := &entity.PermissionRecord{ | |||
| 94 | + | Origin: row.Origin, | |||
| 95 | + | Type: entity.PermissionType(row.PermissionType), | |||
| 96 | + | Decision: entity.PermissionDecision(row.Decision), | |||
| 97 | + | } | |||
| 98 | + | if row.UpdatedAt.Valid { | |||
| 99 | + | record.UpdatedAt = row.UpdatedAt.Time.Unix() | |||
| 100 | + | } | |||
| 101 | + | return record | |||
| 102 | + | } | |||
A internal/infrastructure/persistence/sqlite/queries/permissions.sql +18 -0
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | -- name: GetPermission :one | |||
| 2 | + | SELECT * FROM permissions WHERE origin = ? AND permission_type = ? LIMIT 1; | |||
| 3 | + | ||||
| 4 | + | -- name: SetPermission :exec | |||
| 5 | + | INSERT INTO permissions (origin, permission_type, decision, updated_at) | |||
| 6 | + | VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |||
| 7 | + | ON CONFLICT(origin, permission_type) DO UPDATE SET | |||
| 8 | + | decision = excluded.decision, | |||
| 9 | + | updated_at = CURRENT_TIMESTAMP; | |||
| 10 | + | ||||
| 11 | + | -- name: DeletePermission :exec | |||
| 12 | + | DELETE FROM permissions WHERE origin = ? AND permission_type = ?; | |||
| 13 | + | ||||
| 14 | + | -- name: ListPermissionsByOrigin :many | |||
| 15 | + | SELECT * FROM permissions WHERE origin = ? ORDER BY updated_at DESC; | |||
| 16 | + | ||||
| 17 | + | -- name: ListAllPermissions :many | |||
| 18 | + | SELECT * FROM permissions ORDER BY origin, permission_type; | |||
M internal/infrastructure/persistence/sqlite/sqlc/models.go +7 -0
| @@ -63,6 +63,13 @@ type HistoryFt struct { | |||||
| 63 | Title string `json:"title"` | 63 | Title string `json:"title"` | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | type Permission struct { | |||
| 67 | + | Origin string `json:"origin"` | |||
| 68 | + | PermissionType string `json:"permission_type"` | |||
| 69 | + | Decision string `json:"decision"` | |||
| 70 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |||
| 71 | + | } | |||
| 72 | + | ||||
| 66 | type Session struct { | 73 | type Session struct { | ||
| 67 | ID string `json:"id"` | 74 | ID string `json:"id"` | ||
| 68 | Type string `json:"type"` | 75 | Type string `json:"type"` | ||
A internal/infrastructure/persistence/sqlite/sqlc/permissions.sql.go +128 -0
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | + | // versions: | |||
| 3 | + | // sqlc v1.30.0 | |||
| 4 | + | // source: permissions.sql | |||
| 5 | + | ||||
| 6 | + | package sqlc | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const DeletePermission = `-- name: DeletePermission :exec | |||
| 13 | + | DELETE FROM permissions WHERE origin = ? AND permission_type = ? | |||
| 14 | + | ` | |||
| 15 | + | ||||
| 16 | + | type DeletePermissionParams struct { | |||
| 17 | + | Origin string `json:"origin"` | |||
| 18 | + | PermissionType string `json:"permission_type"` | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (q *Queries) DeletePermission(ctx context.Context, arg DeletePermissionParams) error { | |||
| 22 | + | _, err := q.db.ExecContext(ctx, DeletePermission, arg.Origin, arg.PermissionType) | |||
| 23 | + | return err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | const GetPermission = `-- name: GetPermission :one | |||
| 27 | + | SELECT origin, permission_type, decision, updated_at FROM permissions WHERE origin = ? AND permission_type = ? LIMIT 1 | |||
| 28 | + | ` | |||
| 29 | + | ||||
| 30 | + | type GetPermissionParams struct { | |||
| 31 | + | Origin string `json:"origin"` | |||
| 32 | + | PermissionType string `json:"permission_type"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (q *Queries) GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) { | |||
| 36 | + | row := q.db.QueryRowContext(ctx, GetPermission, arg.Origin, arg.PermissionType) | |||
| 37 | + | var i Permission | |||
| 38 | + | err := row.Scan( | |||
| 39 | + | &i.Origin, | |||
| 40 | + | &i.PermissionType, | |||
| 41 | + | &i.Decision, | |||
| 42 | + | &i.UpdatedAt, | |||
| 43 | + | ) | |||
| 44 | + | return i, err | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | const ListAllPermissions = `-- name: ListAllPermissions :many | |||
| 48 | + | SELECT origin, permission_type, decision, updated_at FROM permissions ORDER BY origin, permission_type | |||
| 49 | + | ` | |||
| 50 | + | ||||
| 51 | + | func (q *Queries) ListAllPermissions(ctx context.Context) ([]Permission, error) { | |||
| 52 | + | rows, err := q.db.QueryContext(ctx, ListAllPermissions) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return nil, err | |||
| 55 | + | } | |||
| 56 | + | defer rows.Close() | |||
| 57 | + | items := []Permission{} | |||
| 58 | + | for rows.Next() { | |||
| 59 | + | var i Permission | |||
| 60 | + | if err := rows.Scan( | |||
| 61 | + | &i.Origin, | |||
| 62 | + | &i.PermissionType, | |||
| 63 | + | &i.Decision, | |||
| 64 | + | &i.UpdatedAt, | |||
| 65 | + | ); err != nil { | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | items = append(items, i) | |||
| 69 | + | } | |||
| 70 | + | if err := rows.Close(); err != nil { | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | if err := rows.Err(); err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | return items, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | const ListPermissionsByOrigin = `-- name: ListPermissionsByOrigin :many | |||
| 80 | + | SELECT origin, permission_type, decision, updated_at FROM permissions WHERE origin = ? ORDER BY updated_at DESC | |||
| 81 | + | ` | |||
| 82 | + | ||||
| 83 | + | func (q *Queries) ListPermissionsByOrigin(ctx context.Context, origin string) ([]Permission, error) { | |||
| 84 | + | rows, err := q.db.QueryContext(ctx, ListPermissionsByOrigin, origin) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | defer rows.Close() | |||
| 89 | + | items := []Permission{} | |||
| 90 | + | for rows.Next() { | |||
| 91 | + | var i Permission | |||
| 92 | + | if err := rows.Scan( | |||
| 93 | + | &i.Origin, | |||
| 94 | + | &i.PermissionType, | |||
| 95 | + | &i.Decision, | |||
| 96 | + | &i.UpdatedAt, | |||
| 97 | + | ); err != nil { | |||
| 98 | + | return nil, err | |||
| 99 | + | } | |||
| 100 | + | items = append(items, i) | |||
| 101 | + | } | |||
| 102 | + | if err := rows.Close(); err != nil { | |||
| 103 | + | return nil, err | |||
| 104 | + | } | |||
| 105 | + | if err := rows.Err(); err != nil { | |||
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | return items, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | const SetPermission = `-- name: SetPermission :exec | |||
| 112 | + | INSERT INTO permissions (origin, permission_type, decision, updated_at) | |||
| 113 | + | VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |||
| 114 | + | ON CONFLICT(origin, permission_type) DO UPDATE SET | |||
| 115 | + | decision = excluded.decision, | |||
| 116 | + | updated_at = CURRENT_TIMESTAMP | |||
| 117 | + | ` | |||
| 118 | + | ||||
| 119 | + | type SetPermissionParams struct { | |||
| 120 | + | Origin string `json:"origin"` | |||
| 121 | + | PermissionType string `json:"permission_type"` | |||
| 122 | + | Decision string `json:"decision"` | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (q *Queries) SetPermission(ctx context.Context, arg SetPermissionParams) error { | |||
| 126 | + | _, err := q.db.ExecContext(ctx, SetPermission, arg.Origin, arg.PermissionType, arg.Decision) | |||
| 127 | + | return err | |||
| 128 | + | } | |||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
| @@ -26,6 +26,7 @@ type Querier interface { | |||||
| 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 27 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | 27 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | ||
| 28 | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | 28 | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | ||
| 29 | + | DeletePermission(ctx context.Context, arg DeletePermissionParams) error | |||
| 29 | DeleteSession(ctx context.Context, id string) error | 30 | DeleteSession(ctx context.Context, id string) error | ||
| 30 | DeleteSessionState(ctx context.Context, sessionID string) error | 31 | DeleteSessionState(ctx context.Context, sessionID string) error | ||
| 31 | DeleteTag(ctx context.Context, id int64) error | 32 | DeleteTag(ctx context.Context, id int64) error | ||
| @@ -51,6 +52,7 @@ type Querier interface { | |||||
| 51 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | 52 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | ||
| 52 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | 53 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | ||
| 53 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | 54 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 55 | + | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | |||
| 54 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | 56 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | ||
| 55 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | 57 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 56 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 58 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| @@ -67,6 +69,8 @@ type Querier interface { | |||||
| 67 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | 69 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | ||
| 68 | InsertSession(ctx context.Context, arg InsertSessionParams) error | 70 | InsertSession(ctx context.Context, arg InsertSessionParams) error | ||
| 69 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | 71 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | ||
| 72 | + | ListAllPermissions(ctx context.Context) ([]Permission, error) | |||
| 73 | + | ListPermissionsByOrigin(ctx context.Context, origin string) ([]Permission, error) | |||
| 70 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | 74 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | ||
| 71 | MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error | 75 | MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error | ||
| 72 | RemoveFromWhitelist(ctx context.Context, domain string) error | 76 | RemoveFromWhitelist(ctx context.Context, domain string) error | ||
| @@ -77,6 +81,7 @@ type Querier interface { | |||||
| 77 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | 81 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | ||
| 78 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | 82 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | ||
| 79 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 83 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | ||
| 84 | + | SetPermission(ctx context.Context, arg SetPermissionParams) error | |||
| 80 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | 85 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | ||
| 81 | UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error | 86 | UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error | ||
| 82 | UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error | 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 | // ContentInjector encapsulates script injection into WebViews. | 163 | // ContentInjector encapsulates script injection into WebViews. | ||
| 145 | // It injects dark mode detection scripts for internal pages (dumb://) | 164 | // It injects dark mode detection scripts for internal pages (dumb://) | ||
| 146 | // and theme CSS variables for WebUI styling. | 165 | // and theme CSS variables for WebUI styling. | ||
| @@ -217,7 +236,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 217 | 236 | ||||
| 218 | prefersDark := ci.PrefersDark() | 237 | prefersDark := ci.PrefersDark() | ||
| 219 | 238 | ||||
| 220 | - | // 1. Inject WebView ID for debugging (internal pages only) | 239 | + | // 1. Inject WebRTC compatibility aliases for all pages. |
| 240 | + | addScript( | |||
| 241 | + | webkit.NewUserScript( | |||
| 242 | + | buildWebRTCCompatScript(), | |||
| 243 | + | webkit.UserContentInjectTopFrameValue, | |||
| 244 | + | webkit.UserScriptInjectAtDocumentStartValue, | |||
| 245 | + | nil, | |||
| 246 | + | nil, | |||
| 247 | + | ), | |||
| 248 | + | "webrtc-compat-shim", | |||
| 249 | + | ) | |||
| 250 | + | ||||
| 251 | + | // 2. Inject WebView ID for debugging (internal pages only) | |||
| 221 | if webviewID != 0 { | 252 | if webviewID != 0 { | ||
| 222 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 253 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 223 | addScript( | 254 | addScript( | ||
| @@ -235,7 +266,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 235 | log.Warn().Msg("webview ID is 0, skipping ID injection") | 266 | log.Warn().Msg("webview ID is 0, skipping ID injection") | ||
| 236 | } | 267 | } | ||
| 237 | 268 | ||||
| 238 | - | // 2. Inject dark mode handler for internal pages only | 269 | + | // 3. Inject dark mode handler for internal pages only |
| 239 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 270 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | ||
| 240 | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | 271 | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | ||
| 241 | addScript( | 272 | addScript( | ||
| @@ -249,7 +280,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 249 | "internal-dark-mode-handler", | 280 | "internal-dark-mode-handler", | ||
| 250 | ) | 281 | ) | ||
| 251 | 282 | ||||
| 252 | - | // 3. Inject theme CSS for internal pages (dumb://* only) | 283 | + | // 4. Inject theme CSS for internal pages (dumb://* only) |
| 253 | if ci.themeCSSVars != "" { | 284 | if ci.themeCSSVars != "" { | ||
| 254 | // Escape for JS string literal | 285 | // Escape for JS string literal | ||
| 255 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | 286 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | ||
| @@ -269,7 +300,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 269 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | 300 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | ||
| 270 | } | 301 | } | ||
| 271 | 302 | ||||
| 272 | - | // 4. Inject find highlight CSS for all pages | 303 | + | // 5. Inject find highlight CSS for all pages |
| 273 | if ci.findCSS != "" { | 304 | if ci.findCSS != "" { | ||
| 274 | stylesheet := webkit.NewUserStyleSheet( | 305 | stylesheet := webkit.NewUserStyleSheet( | ||
| 275 | ci.findCSS, | 306 | ci.findCSS, | ||
| @@ -286,7 +317,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 286 | } | 317 | } | ||
| 287 | } | 318 | } | ||
| 288 | 319 | ||||
| 289 | - | // 5. Inject auto-copy selection script for all pages (if enabled) | 320 | + | // 6. Inject auto-copy selection script for all pages (if enabled) |
| 290 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | 321 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | ||
| 291 | if autoCopyEnabled { | 322 | if autoCopyEnabled { | ||
| 292 | addScript( | 323 | addScript( | ||
A internal/infrastructure/webkit/injector_webrtc_compat_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildWebRTCCompatScript_AddsPrefixedAliases(t *testing.T) { | |||
| 10 | + | script := buildWebRTCCompatScript() | |||
| 11 | + | ||||
| 12 | + | assert.Contains(t, script, "window.webkitRTCPeerConnection") | |||
| 13 | + | assert.Regexp(t, `window\.RTCPeerConnection\s*=\s*window\.webkitRTCPeerConnection`, script) | |||
| 14 | + | assert.Regexp(t, `window\.RTCSessionDescription\s*=\s*window\.webkitRTCSessionDescription`, script) | |||
| 15 | + | assert.Regexp(t, `window\.RTCIceCandidate\s*=\s*window\.webkitRTCIceCandidate`, script) | |||
| 16 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +37 -32
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | const ( | 28 | const ( | ||
| 29 | HomePath = "home" | 29 | HomePath = "home" | ||
| 30 | ConfigPath = "config" | 30 | ConfigPath = "config" | ||
| 31 | + | WebRTCPath = "webrtc" | |||
| 31 | ErrorPath = "error" | 32 | ErrorPath = "error" | ||
| 32 | CrashPath = "crash" | 33 | CrashPath = "crash" | ||
| 33 | IndexHTML = "index.html" | 34 | IndexHTML = "index.html" | ||
| @@ -511,38 +512,8 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 511 | return nil | 512 | return nil | ||
| 512 | } | 513 | } | ||
| 513 | 514 | ||||
| 514 | - | // Determine the target file based on host and path | 515 | + | relPath, ok := resolveAssetPath(u) |
| 515 | - | host := u.Host | 516 | + | if !ok { |
| 516 | - | path := strings.TrimPrefix(u.Path, "/") | |||
| 517 | - | ||||
| 518 | - | var relPath string | |||
| 519 | - | switch { | |||
| 520 | - | // Home root maps to index.html. | |||
| 521 | - | case host == HomePath && (path == "" || path == "/"): | |||
| 522 | - | relPath = IndexHTML | |||
| 523 | - | // Home asset paths map directly to assets. | |||
| 524 | - | case host == HomePath && path != "": | |||
| 525 | - | relPath = path | |||
| 526 | - | // Config root maps to config.html. | |||
| 527 | - | case host == ConfigPath && (path == "" || path == "/"): | |||
| 528 | - | relPath = "config.html" | |||
| 529 | - | // Config asset paths map directly to assets. | |||
| 530 | - | case host == ConfigPath && path != "": | |||
| 531 | - | relPath = path | |||
| 532 | - | // Error root maps to error.html. | |||
| 533 | - | case host == ErrorPath && (path == "" || path == "/"): | |||
| 534 | - | relPath = "error.html" | |||
| 535 | - | // Error asset paths map directly to assets. | |||
| 536 | - | case host == ErrorPath && path != "": | |||
| 537 | - | relPath = path | |||
| 538 | - | // Opaque home form maps to index.html. | |||
| 539 | - | case u.Opaque == HomePath: | |||
| 540 | - | relPath = IndexHTML | |||
| 541 | - | // Opaque error form maps to error.html. | |||
| 542 | - | case u.Opaque == ErrorPath: | |||
| 543 | - | relPath = "error.html" | |||
| 544 | - | default: | |||
| 545 | - | // Not a recognized asset path | |||
| 546 | return nil | 517 | return nil | ||
| 547 | } | 518 | } | ||
| 548 | 519 | ||||
| @@ -568,6 +539,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 568 | } | 539 | } | ||
| 569 | } | 540 | } | ||
| 570 | 541 | ||||
| 542 | + | func resolveAssetPath(u *url.URL) (string, bool) { | |||
| 543 | + | if u == nil { | |||
| 544 | + | return "", false | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | rootByHost := map[string]string{ | |||
| 548 | + | HomePath: IndexHTML, | |||
| 549 | + | ConfigPath: "config.html", | |||
| 550 | + | WebRTCPath: "webrtc.html", | |||
| 551 | + | ErrorPath: "error.html", | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | if root, ok := rootByHost[u.Host]; ok { | |||
| 555 | + | path := strings.TrimPrefix(u.Path, "/") | |||
| 556 | + | if path == "" { | |||
| 557 | + | return root, true | |||
| 558 | + | } | |||
| 559 | + | return path, true | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | switch u.Opaque { | |||
| 563 | + | case HomePath: | |||
| 564 | + | return IndexHTML, true | |||
| 565 | + | case ConfigPath: | |||
| 566 | + | return "config.html", true | |||
| 567 | + | case ErrorPath: | |||
| 568 | + | return "error.html", true | |||
| 569 | + | case WebRTCPath: | |||
| 570 | + | return "webrtc.html", true | |||
| 571 | + | default: | |||
| 572 | + | return "", false | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | ||||
| 571 | // getMimeType determines the MIME type for a given file path. | 576 | // getMimeType determines the MIME type for a given file path. | ||
| 572 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | 577 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | ||
| 573 | if h == nil { | 578 | if h == nil { | ||
M internal/infrastructure/webkit/scheme_handler_test.go +41 -0
| @@ -2,8 +2,10 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "net/url" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/assets" | |||
| 7 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 11 | ) | ||
| @@ -87,3 +89,42 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||||
| 87 | require.NotNil(t, resp) | 89 | require.NotNil(t, resp) | ||
| 88 | assert.Contains(t, string(resp.Data), `data-target=""`) | 90 | assert.Contains(t, string(resp.Data), `data-target=""`) | ||
| 89 | } | 91 | } | ||
| 92 | + | ||||
| 93 | + | func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) { | |||
| 94 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 95 | + | h.SetAssets(assets.WebUIAssets) | |||
| 96 | + | ||||
| 97 | + | u, err := url.Parse("dumb://webrtc/") | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | resp := h.handleAsset(u) | |||
| 101 | + | require.NotNil(t, resp) | |||
| 102 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 103 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | |||
| 107 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 108 | + | h.SetAssets(assets.WebUIAssets) | |||
| 109 | + | ||||
| 110 | + | u, err := url.Parse("dumb:webrtc") | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | ||||
| 113 | + | resp := h.handleAsset(u) | |||
| 114 | + | require.NotNil(t, resp) | |||
| 115 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 116 | + | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | |||
| 120 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 121 | + | h.SetAssets(assets.WebUIAssets) | |||
| 122 | + | ||||
| 123 | + | u, err := url.Parse("dumb:config") | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | ||||
| 126 | + | resp := h.handleAsset(u) | |||
| 127 | + | require.NotNil(t, resp) | |||
| 128 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 129 | + | assert.Contains(t, string(resp.Data), "config") | |||
| 130 | + | } | |||
M internal/infrastructure/webkit/settings.go +10 -1
| @@ -60,10 +60,15 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||||
| 60 | applyCanvasSettings(settings) | 60 | applyCanvasSettings(settings) | ||
| 61 | applyWebRTCSettings(settings) | 61 | applyWebRTCSettings(settings) | ||
| 62 | 62 | ||||
| 63 | + | webrtcEnabled := settings.GetPropertyEnableWebrtc() | |||
| 64 | + | mediaStreamEnabled := settings.GetPropertyEnableMediaStream() | |||
| 65 | + | ||||
| 63 | log.Debug(). | 66 | log.Debug(). | ||
| 64 | Str("sans_font", cfg.Appearance.SansFont). | 67 | Str("sans_font", cfg.Appearance.SansFont). | ||
| 65 | Str("rendering_mode", string(cfg.Rendering.Mode)). | 68 | Str("rendering_mode", string(cfg.Rendering.Mode)). | ||
| 66 | Bool("developer_extras", cfg.Debug.EnableDevTools). | 69 | Bool("developer_extras", cfg.Debug.EnableDevTools). | ||
| 70 | + | Bool("webrtc_enabled", webrtcEnabled). | |||
| 71 | + | Bool("media_stream_enabled", mediaStreamEnabled). | |||
| 67 | Msg("settings applied") | 72 | Msg("settings applied") | ||
| 68 | } | 73 | } | ||
| 69 | 74 | ||||
| @@ -117,7 +122,6 @@ func applyMediaSettings(settings *webkit.Settings, cfg *config.Config, log *zero | |||||
| 117 | settings.SetEnableMedia(true) | 122 | settings.SetEnableMedia(true) | ||
| 118 | settings.SetEnableMediasource(true) | 123 | settings.SetEnableMediasource(true) | ||
| 119 | settings.SetEnableMediaCapabilities(true) | 124 | settings.SetEnableMediaCapabilities(true) | ||
| 120 | - | settings.SetEnableMediaStream(true) | |||
| 121 | settings.SetEnableEncryptedMedia(true) | 125 | settings.SetEnableEncryptedMedia(true) | ||
| 122 | settings.SetMediaPlaybackRequiresUserGesture(true) | 126 | settings.SetMediaPlaybackRequiresUserGesture(true) | ||
| 123 | settings.SetMediaPlaybackAllowsInline(true) | 127 | settings.SetMediaPlaybackAllowsInline(true) | ||
| @@ -152,6 +156,11 @@ func applyCanvasSettings(settings *webkit.Settings) { | |||||
| 152 | } | 156 | } | ||
| 153 | 157 | ||||
| 154 | func applyWebRTCSettings(settings *webkit.Settings) { | 158 | func applyWebRTCSettings(settings *webkit.Settings) { | ||
| 159 | + | // Set both direct API and gobject properties for robustness with generated bindings. | |||
| 160 | + | settings.SetPropertyEnableMediaStream(true) | |||
| 161 | + | settings.SetPropertyEnableWebrtc(true) | |||
| 162 | + | ||||
| 163 | + | settings.SetEnableMediaStream(true) | |||
| 155 | settings.SetEnableWebrtc(true) | 164 | settings.SetEnableWebrtc(true) | ||
| 156 | } | 165 | } | ||
| 157 | 166 | ||||
M internal/infrastructure/webkit/webview.go +262 -0
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/jwijenbergh/puregotk/v4/gio" | 19 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 20 | "github.com/jwijenbergh/puregotk/v4/glib" | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 21 | "github.com/jwijenbergh/puregotk/v4/gobject" | 21 | "github.com/jwijenbergh/puregotk/v4/gobject" | ||
| 22 | + | gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types" | |||
| 22 | "github.com/jwijenbergh/puregotk/v4/gtk" | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 23 | "github.com/rs/zerolog" | 24 | "github.com/rs/zerolog" | ||
| 24 | ) | 25 | ) | ||
| @@ -154,6 +155,11 @@ type WebView struct { | |||||
| 154 | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | 155 | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | ||
| 155 | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | 156 | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | ||
| 156 | 157 | ||||
| 158 | + | // PermissionRequest is called when a site requests permission (mic, camera, screen sharing). | |||
| 159 | + | // Return true to indicate the request was handled. Call allow()/deny() to respond. | |||
| 160 | + | // The permission types are determined from the request object. | |||
| 161 | + | OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool | |||
| 162 | + | ||||
| 157 | logger zerolog.Logger | 163 | logger zerolog.Logger | ||
| 158 | mu sync.RWMutex | 164 | mu sync.RWMutex | ||
| 159 | 165 | ||||
| @@ -409,9 +415,11 @@ func (wv *WebView) connectSignals() { | |||||
| 409 | wv.connectEnterFullscreenSignal() | 415 | wv.connectEnterFullscreenSignal() | ||
| 410 | wv.connectLeaveFullscreenSignal() | 416 | wv.connectLeaveFullscreenSignal() | ||
| 411 | wv.connectAudioStateSignal() | 417 | wv.connectAudioStateSignal() | ||
| 418 | + | wv.connectMediaCaptureStateSignals() | |||
| 412 | wv.connectMouseTargetChangedSignal() | 419 | wv.connectMouseTargetChangedSignal() | ||
| 413 | wv.connectBackForwardListChangedSignal() | 420 | wv.connectBackForwardListChangedSignal() | ||
| 414 | wv.connectWebProcessTerminatedSignal() | 421 | wv.connectWebProcessTerminatedSignal() | ||
| 422 | + | wv.connectPermissionRequestSignal() | |||
| 415 | } | 423 | } | ||
| 416 | 424 | ||||
| 417 | func (wv *WebView) connectLoadChangedSignal() { | 425 | func (wv *WebView) connectLoadChangedSignal() { | ||
| @@ -829,6 +837,39 @@ func (wv *WebView) connectAudioStateSignal() { | |||||
| 829 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 837 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 830 | } | 838 | } | ||
| 831 | 839 | ||||
| 840 | + | func (wv *WebView) connectMediaCaptureStateSignals() { | |||
| 841 | + | connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) { | |||
| 842 | + | cb := func() { | |||
| 843 | + | state := readState() | |||
| 844 | + | wv.logger.Debug(). | |||
| 845 | + | Uint64("id", uint64(wv.id)). | |||
| 846 | + | Str("kind", kind). | |||
| 847 | + | Str("state", mediaCaptureStateString(state)). | |||
| 848 | + | Int("state_code", int(state)). | |||
| 849 | + | Msg("media capture state changed") | |||
| 850 | + | } | |||
| 851 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb)) | |||
| 852 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera") | |||
| 856 | + | connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone") | |||
| 857 | + | connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display") | |||
| 858 | + | } | |||
| 859 | + | ||||
| 860 | + | func mediaCaptureStateString(state webkit.MediaCaptureState) string { | |||
| 861 | + | switch state { | |||
| 862 | + | case webkit.MediaCaptureStateNoneValue: | |||
| 863 | + | return "none" | |||
| 864 | + | case webkit.MediaCaptureStateActiveValue: | |||
| 865 | + | return "active" | |||
| 866 | + | case webkit.MediaCaptureStateMutedValue: | |||
| 867 | + | return "muted" | |||
| 868 | + | default: | |||
| 869 | + | return "unknown" | |||
| 870 | + | } | |||
| 871 | + | } | |||
| 872 | + | ||||
| 832 | func (wv *WebView) connectMouseTargetChangedSignal() { | 873 | func (wv *WebView) connectMouseTargetChangedSignal() { | ||
| 833 | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | 874 | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | ||
| 834 | if wv.OnLinkHover == nil { | 875 | if wv.OnLinkHover == nil { | ||
| @@ -930,6 +971,224 @@ func (wv *WebView) connectWebProcessTerminatedSignal() { | |||||
| 930 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 971 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 931 | } | 972 | } | ||
| 932 | 973 | ||||
| 974 | + | // connectPermissionRequestSignal sets up the permission-request signal handler. | |||
| 975 | + | // This is emitted when a site calls getUserMedia() or getDisplayMedia(). | |||
| 976 | + | func (wv *WebView) connectPermissionRequestSignal() { | |||
| 977 | + | permissionCb := func(_ webkit.WebView, requestPtr uintptr) bool { | |||
| 978 | + | ctx := logging.WithContext(context.Background(), wv.logger) | |||
| 979 | + | ||||
| 980 | + | if wv.OnPermissionRequest == nil { | |||
| 981 | + | return false // Not handled, WebKit will deny by default | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | // Extract and normalize origin from current URI | |||
| 985 | + | uri := wv.URI() | |||
| 986 | + | if uri == "" { | |||
| 987 | + | wv.logger.Debug().Msg("permission request with empty origin, denying") | |||
| 988 | + | return false | |||
| 989 | + | } | |||
| 990 | + | origin, err := urlutil.ExtractOrigin(uri) | |||
| 991 | + | if err != nil { | |||
| 992 | + | wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying") | |||
| 993 | + | return false | |||
| 994 | + | } | |||
| 995 | + | ||||
| 996 | + | // Determine permission types from the request | |||
| 997 | + | permTypes := wv.determinePermissionTypes(ctx, requestPtr) | |||
| 998 | + | if len(permTypes) == 0 { | |||
| 999 | + | wv.logger.Warn().Msg("permission request with unknown type, denying") | |||
| 1000 | + | return false | |||
| 1001 | + | } | |||
| 1002 | + | ||||
| 1003 | + | // Ref the request object to prevent use-after-free | |||
| 1004 | + | // The request may outlive the signal handler if we show a dialog | |||
| 1005 | + | requestObj := gobject.ObjectNewFromInternalPtr(requestPtr) | |||
| 1006 | + | if requestObj == nil { | |||
| 1007 | + | wv.logger.Warn().Msg("permission request: failed to wrap request object") | |||
| 1008 | + | return false | |||
| 1009 | + | } | |||
| 1010 | + | requestObj.Ref() | |||
| 1011 | + | ||||
| 1012 | + | // Create allow/deny callbacks that wrap the WebKit permission request | |||
| 1013 | + | allowCalled := false | |||
| 1014 | + | denyCalled := false | |||
| 1015 | + | ||||
| 1016 | + | allow := func() { | |||
| 1017 | + | if allowCalled || denyCalled { | |||
| 1018 | + | return | |||
| 1019 | + | } | |||
| 1020 | + | allowCalled = true | |||
| 1021 | + | wv.allowPermissionRequest(requestPtr) | |||
| 1022 | + | requestObj.Unref() | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | deny := func() { | |||
| 1026 | + | if allowCalled || denyCalled { | |||
| 1027 | + | return | |||
| 1028 | + | } | |||
| 1029 | + | denyCalled = true | |||
| 1030 | + | wv.denyPermissionRequest(requestPtr) | |||
| 1031 | + | requestObj.Unref() | |||
| 1032 | + | } | |||
| 1033 | + | ||||
| 1034 | + | // Call the handler | |||
| 1035 | + | return wv.OnPermissionRequest(origin, permTypes, allow, deny) | |||
| 1036 | + | } | |||
| 1037 | + | ||||
| 1038 | + | sigID := wv.inner.ConnectPermissionRequest(&permissionCb) | |||
| 1039 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 1040 | + | } | |||
| 1041 | + | ||||
| 1042 | + | // determinePermissionTypes extracts permission types from a WebKit permission request. | |||
| 1043 | + | // This uses type checking to identify UserMediaPermissionRequest and its specific types. | |||
| 1044 | + | // We use GObject property accessors for audio/video request flags. Display detection | |||
| 1045 | + | // uses the dedicated WebKit API because this WebKit build does not expose an | |||
| 1046 | + | // "is-for-display-device" GObject property. | |||
| 1047 | + | func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) []string { | |||
| 1048 | + | requestKind := detectPermissionRequestKind(ctx, requestPtr) | |||
| 1049 | + | switch requestKind { | |||
| 1050 | + | case permissionRequestKindUserMedia: | |||
| 1051 | + | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1052 | + | if userMediaReq == nil { | |||
| 1053 | + | return nil | |||
| 1054 | + | } | |||
| 1055 | + | ||||
| 1056 | + | // Use GObject property accessors — more reliable than the C function wrappers | |||
| 1057 | + | // which can hit the purego bool return value bug. | |||
| 1058 | + | isAudio := userMediaReq.GetPropertyIsForAudioDevice() | |||
| 1059 | + | isVideo := userMediaReq.GetPropertyIsForVideoDevice() | |||
| 1060 | + | isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) | |||
| 1061 | + | ||||
| 1062 | + | wv.logger.Debug(). | |||
| 1063 | + | Bool("is_audio", isAudio). | |||
| 1064 | + | Bool("is_video", isVideo). | |||
| 1065 | + | Bool("is_display", isDisplay). | |||
| 1066 | + | Msg("permission request type detection") | |||
| 1067 | + | ||||
| 1068 | + | return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay) | |||
| 1069 | + | case permissionRequestKindDeviceInfo: | |||
| 1070 | + | return classifyPermissionRequestTypes(ctx, requestKind, false, false, false) | |||
| 1071 | + | default: | |||
| 1072 | + | if requestPtr != 0 { | |||
| 1073 | + | typeName := permissionRequestTypeName(ctx, requestPtr) | |||
| 1074 | + | if typeName != "" { | |||
| 1075 | + | wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type") | |||
| 1076 | + | } else { | |||
| 1077 | + | wv.logger.Warn().Msg("unknown permission request type") | |||
| 1078 | + | } | |||
| 1079 | + | } | |||
| 1080 | + | // Unknown permission type - could be clipboard, notifications, geolocation, etc. | |||
| 1081 | + | // For now, return empty to trigger denial. Future phases will add these types. | |||
| 1082 | + | return nil | |||
| 1083 | + | } | |||
| 1084 | + | } | |||
| 1085 | + | ||||
| 1086 | + | type permissionRequestKind int | |||
| 1087 | + | ||||
| 1088 | + | const ( | |||
| 1089 | + | permissionRequestKindUnknown permissionRequestKind = iota | |||
| 1090 | + | permissionRequestKindUserMedia | |||
| 1091 | + | permissionRequestKindDeviceInfo | |||
| 1092 | + | ) | |||
| 1093 | + | ||||
| 1094 | + | func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind { | |||
| 1095 | + | if isPermissionRequestType(ctx, requestPtr, webkit.UserMediaPermissionRequestGLibType()) { | |||
| 1096 | + | return permissionRequestKindUserMedia | |||
| 1097 | + | } | |||
| 1098 | + | if isPermissionRequestType(ctx, requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) { | |||
| 1099 | + | return permissionRequestKindDeviceInfo | |||
| 1100 | + | } | |||
| 1101 | + | return permissionRequestKindUnknown | |||
| 1102 | + | } | |||
| 1103 | + | ||||
| 1104 | + | func isPermissionRequestType(ctx context.Context, requestPtr uintptr, requestType gtypes.GType) bool { | |||
| 1105 | + | return gobjectTypeCheckInstanceIsAByPtr(ctx, requestPtr, requestType) | |||
| 1106 | + | } | |||
| 1107 | + | ||||
| 1108 | + | func classifyPermissionRequestTypes( | |||
| 1109 | + | ctx context.Context, | |||
| 1110 | + | kind permissionRequestKind, | |||
| 1111 | + | isAudio, isVideo, isDisplay bool, | |||
| 1112 | + | ) []string { | |||
| 1113 | + | switch kind { | |||
| 1114 | + | case permissionRequestKindUserMedia: | |||
| 1115 | + | return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay) | |||
| 1116 | + | case permissionRequestKindDeviceInfo: | |||
| 1117 | + | return []string{"device_info"} | |||
| 1118 | + | default: | |||
| 1119 | + | return nil | |||
| 1120 | + | } | |||
| 1121 | + | } | |||
| 1122 | + | ||||
| 1123 | + | func classifyUserMediaPermissionTypes(ctx context.Context, isAudio, isVideo, isDisplay bool) []string { | |||
| 1124 | + | types := make([]string, 0, 2) | |||
| 1125 | + | ||||
| 1126 | + | if isAudio { | |||
| 1127 | + | types = append(types, "microphone") | |||
| 1128 | + | } | |||
| 1129 | + | ||||
| 1130 | + | if isDisplay { | |||
| 1131 | + | types = append(types, "display") | |||
| 1132 | + | } else if isVideo { | |||
| 1133 | + | types = append(types, "camera") | |||
| 1134 | + | } | |||
| 1135 | + | ||||
| 1136 | + | if len(types) == 0 { | |||
| 1137 | + | // On some Wayland/WebKit paths, getDisplayMedia() can arrive with all | |||
| 1138 | + | // flags false even though the request is a screen-capture request. | |||
| 1139 | + | logging.FromContext(ctx).Debug(). | |||
| 1140 | + | Bool("is_audio", isAudio). | |||
| 1141 | + | Bool("is_video", isVideo). | |||
| 1142 | + | Bool("is_display", isDisplay). | |||
| 1143 | + | Msg("user media request had no flags; using display fallback") | |||
| 1144 | + | return []string{"display"} | |||
| 1145 | + | } | |||
| 1146 | + | ||||
| 1147 | + | return types | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1150 | + | // allowPermissionRequest calls Allow() on the WebKit permission request. | |||
| 1151 | + | func (wv *WebView) allowPermissionRequest(requestPtr uintptr) { | |||
| 1152 | + | // Try UserMediaPermissionRequest first | |||
| 1153 | + | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1154 | + | if userMediaReq != nil { | |||
| 1155 | + | userMediaReq.Allow() | |||
| 1156 | + | wv.logger.Debug().Msg("permission request allowed") | |||
| 1157 | + | return | |||
| 1158 | + | } | |||
| 1159 | + | ||||
| 1160 | + | // Try DeviceInfoPermissionRequest | |||
| 1161 | + | deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1162 | + | if deviceInfoReq != nil { | |||
| 1163 | + | deviceInfoReq.Allow() | |||
| 1164 | + | wv.logger.Debug().Msg("permission request allowed") | |||
| 1165 | + | return | |||
| 1166 | + | } | |||
| 1167 | + | ||||
| 1168 | + | wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow") | |||
| 1169 | + | } | |||
| 1170 | + | ||||
| 1171 | + | // denyPermissionRequest calls Deny() on the WebKit permission request. | |||
| 1172 | + | func (wv *WebView) denyPermissionRequest(requestPtr uintptr) { | |||
| 1173 | + | // Try UserMediaPermissionRequest first | |||
| 1174 | + | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1175 | + | if userMediaReq != nil { | |||
| 1176 | + | userMediaReq.Deny() | |||
| 1177 | + | wv.logger.Debug().Msg("permission request denied") | |||
| 1178 | + | return | |||
| 1179 | + | } | |||
| 1180 | + | ||||
| 1181 | + | // Try DeviceInfoPermissionRequest | |||
| 1182 | + | deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr) | |||
| 1183 | + | if deviceInfoReq != nil { | |||
| 1184 | + | deviceInfoReq.Deny() | |||
| 1185 | + | wv.logger.Debug().Msg("permission request denied") | |||
| 1186 | + | return | |||
| 1187 | + | } | |||
| 1188 | + | ||||
| 1189 | + | wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny") | |||
| 1190 | + | } | |||
| 1191 | + | ||||
| 933 | // ID returns the unique identifier for this WebView. | 1192 | // ID returns the unique identifier for this WebView. | ||
| 934 | func (wv *WebView) ID() WebViewID { | 1193 | func (wv *WebView) ID() WebViewID { | ||
| 935 | return wv.id | 1194 | return wv.id | ||
| @@ -1214,6 +1473,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1214 | wv.OnCreate = nil | 1473 | wv.OnCreate = nil | ||
| 1215 | wv.OnLinkHover = nil | 1474 | wv.OnLinkHover = nil | ||
| 1216 | wv.OnWebProcessTerminated = nil | 1475 | wv.OnWebProcessTerminated = nil | ||
| 1476 | + | wv.OnPermissionRequest = nil | |||
| 1217 | return | 1477 | return | ||
| 1218 | } | 1478 | } | ||
| 1219 | 1479 | ||||
| @@ -1260,6 +1520,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1260 | } else { | 1520 | } else { | ||
| 1261 | wv.OnWebProcessTerminated = nil | 1521 | wv.OnWebProcessTerminated = nil | ||
| 1262 | } | 1522 | } | ||
| 1523 | + | wv.OnPermissionRequest = callbacks.OnPermissionRequest | |||
| 1263 | } | 1524 | } | ||
| 1264 | 1525 | ||||
| 1265 | // ShowDevTools opens the WebKit inspector/developer tools. | 1526 | // ShowDevTools opens the WebKit inspector/developer tools. | ||
| @@ -1374,6 +1635,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) { | |||||
| 1374 | wv.OnAudioStateChanged = nil | 1635 | wv.OnAudioStateChanged = nil | ||
| 1375 | wv.OnLinkHover = nil | 1636 | wv.OnLinkHover = nil | ||
| 1376 | wv.OnWebProcessTerminated = nil | 1637 | wv.OnWebProcessTerminated = nil | ||
| 1638 | + | wv.OnPermissionRequest = nil | |||
| 1377 | 1639 | ||||
| 1378 | // 3. Clear async callback references | 1640 | // 3. Clear async callback references | ||
| 1379 | wv.mu.Lock() | 1641 | wv.mu.Lock() | ||
A internal/infrastructure/webkit/webview_permission_types_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestClassifyUserMediaPermissionTypes(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | isAudio bool | |||
| 14 | + | isVideo bool | |||
| 15 | + | isDisplay bool | |||
| 16 | + | expected []string | |||
| 17 | + | }{ | |||
| 18 | + | { | |||
| 19 | + | name: "audio and camera", | |||
| 20 | + | isAudio: true, | |||
| 21 | + | isVideo: true, | |||
| 22 | + | isDisplay: false, | |||
| 23 | + | expected: []string{"microphone", "camera"}, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "screen only", | |||
| 27 | + | isAudio: false, | |||
| 28 | + | isVideo: false, | |||
| 29 | + | isDisplay: true, | |||
| 30 | + | expected: []string{"display"}, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "screen with audio", | |||
| 34 | + | isAudio: true, | |||
| 35 | + | isVideo: false, | |||
| 36 | + | isDisplay: true, | |||
| 37 | + | expected: []string{"microphone", "display"}, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | name: "webkit display fallback when all flags false", | |||
| 41 | + | isAudio: false, | |||
| 42 | + | isVideo: false, | |||
| 43 | + | isDisplay: false, | |||
| 44 | + | expected: []string{"display"}, | |||
| 45 | + | }, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, tt := range tests { | |||
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 50 | + | assert.Equal(t, tt.expected, classifyUserMediaPermissionTypes(context.Background(), tt.isAudio, tt.isVideo, tt.isDisplay)) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestClassifyPermissionRequestTypes(t *testing.T) { | |||
| 56 | + | tests := []struct { | |||
| 57 | + | name string | |||
| 58 | + | kind permissionRequestKind | |||
| 59 | + | isAudio bool | |||
| 60 | + | isVideo bool | |||
| 61 | + | isDisplay bool | |||
| 62 | + | expected []string | |||
| 63 | + | }{ | |||
| 64 | + | { | |||
| 65 | + | name: "device info request stays device_info", | |||
| 66 | + | kind: permissionRequestKindDeviceInfo, | |||
| 67 | + | expected: []string{"device_info"}, | |||
| 68 | + | }, | |||
| 69 | + | { | |||
| 70 | + | name: "user media camera", | |||
| 71 | + | kind: permissionRequestKindUserMedia, | |||
| 72 | + | isAudio: false, | |||
| 73 | + | isVideo: true, | |||
| 74 | + | isDisplay: false, | |||
| 75 | + | expected: []string{"camera"}, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "user media fallback display", | |||
| 79 | + | kind: permissionRequestKindUserMedia, | |||
| 80 | + | isAudio: false, | |||
| 81 | + | isVideo: false, | |||
| 82 | + | isDisplay: false, | |||
| 83 | + | expected: []string{"display"}, | |||
| 84 | + | }, | |||
| 85 | + | { | |||
| 86 | + | name: "unknown request denied", | |||
| 87 | + | kind: permissionRequestKindUnknown, | |||
| 88 | + | expected: nil, | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | for _, tt := range tests { | |||
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | + | assert.Equal(t, tt.expected, classifyPermissionRequestTypes(context.Background(), tt.kind, tt.isAudio, tt.isVideo, tt.isDisplay)) | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | } | |||
M internal/ui/app.go +128 -2
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 17 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| @@ -24,6 +25,7 @@ import ( | |||||
| 24 | "github.com/bnema/dumber/internal/ui/adapter" | 25 | "github.com/bnema/dumber/internal/ui/adapter" | ||
| 25 | "github.com/bnema/dumber/internal/ui/component" | 26 | "github.com/bnema/dumber/internal/ui/component" | ||
| 26 | "github.com/bnema/dumber/internal/ui/coordinator" | 27 | "github.com/bnema/dumber/internal/ui/coordinator" | ||
| 28 | + | "github.com/bnema/dumber/internal/ui/dialog" | |||
| 27 | "github.com/bnema/dumber/internal/ui/dispatcher" | 29 | "github.com/bnema/dumber/internal/ui/dispatcher" | ||
| 28 | "github.com/bnema/dumber/internal/ui/focus" | 30 | "github.com/bnema/dumber/internal/ui/focus" | ||
| 29 | "github.com/bnema/dumber/internal/ui/input" | 31 | "github.com/bnema/dumber/internal/ui/input" | ||
| @@ -95,6 +97,8 @@ type App struct { | |||||
| 95 | appToaster *component.Toaster | 97 | appToaster *component.Toaster | ||
| 96 | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | 98 | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | ||
| 97 | modeToaster *component.Toaster | 99 | modeToaster *component.Toaster | ||
| 100 | + | // Top-right WebRTC permission activity indicator | |||
| 101 | + | webrtcIndicator *component.WebRTCPermissionIndicator | |||
| 98 | 102 | ||||
| 99 | // Session management | 103 | // Session management | ||
| 100 | sessionManager *component.SessionManager | 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) | 210 | + | appID := AppID |
| 207 | - | a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue) | 211 | + | a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue) |
| 208 | if a.gtkApp == nil { | 212 | if a.gtkApp == nil { | ||
| 209 | log.Error().Msg("failed to create GTK application") | 213 | log.Error().Msg("failed to create GTK application") | ||
| 210 | return 1 | 214 | return 1 | ||
| @@ -254,6 +258,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 254 | a.initDownloadHandler(ctx) | 258 | a.initDownloadHandler(ctx) | ||
| 255 | 259 | ||||
| 256 | a.initCoordinators(ctx) | 260 | a.initCoordinators(ctx) | ||
| 261 | + | a.wireWebRTCPermissionIndicator() | |||
| 257 | logging.Trace().Mark("coordinators_init") | 262 | logging.Trace().Mark("coordinators_init") | ||
| 258 | a.initKeyboardHandler(ctx) | 263 | a.initKeyboardHandler(ctx) | ||
| 259 | a.initOmniboxConfig(ctx) | 264 | a.initOmniboxConfig(ctx) | ||
| @@ -351,6 +356,32 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 351 | } | 356 | } | ||
| 352 | a.mainWindow = mainWindow | 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 | // Apply GTK CSS styling from theme manager. | 385 | // Apply GTK CSS styling from theme manager. | ||
| 355 | if a.deps == nil || a.deps.Theme == nil { | 386 | if a.deps == nil || a.deps.Theme == nil { | ||
| 356 | return nil | 387 | return nil | ||
| @@ -1316,6 +1347,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1316 | a.faviconAdapter, | 1347 | a.faviconAdapter, | ||
| 1317 | getActiveWS, | 1348 | getActiveWS, | ||
| 1318 | a.deps.ZoomUC, | 1349 | a.deps.ZoomUC, | ||
| 1350 | + | a.deps.PermissionUC, | |||
| 1319 | ) | 1351 | ) | ||
| 1320 | 1352 | ||||
| 1321 | // Set idle inhibitor for fullscreen video playback | 1353 | // Set idle inhibitor for fullscreen video playback | ||
| @@ -1522,6 +1554,100 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1522 | log.Debug().Msg("coordinators initialized") | 1554 | log.Debug().Msg("coordinators initialized") | ||
| 1523 | } | 1555 | } | ||
| 1524 | 1556 | ||||
| 1557 | + | func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1558 | + | if a.webrtcIndicator == nil || a.contentCoord == nil { | |||
| 1559 | + | return | |||
| 1560 | + | } | |||
| 1561 | + | ctx := context.Background() | |||
| 1562 | + | if a.deps != nil && a.deps.Ctx != nil { | |||
| 1563 | + | ctx = a.deps.Ctx | |||
| 1564 | + | } | |||
| 1565 | + | log := logging.FromContext(ctx) | |||
| 1566 | + | ||||
| 1567 | + | a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) { | |||
| 1568 | + | a.webrtcIndicator.SetOrigin(origin) | |||
| 1569 | + | ||||
| 1570 | + | switch state { | |||
| 1571 | + | case coordinator.PermissionActivityRequesting: | |||
| 1572 | + | a.webrtcIndicator.MarkRequesting(permTypes) | |||
| 1573 | + | case coordinator.PermissionActivityAllowed: | |||
| 1574 | + | a.webrtcIndicator.MarkAllowed(permTypes) | |||
| 1575 | + | case coordinator.PermissionActivityBlocked: | |||
| 1576 | + | a.webrtcIndicator.MarkBlocked(permTypes) | |||
| 1577 | + | } | |||
| 1578 | + | ||||
| 1579 | + | for _, permType := range permTypes { | |||
| 1580 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |||
| 1581 | + | } | |||
| 1582 | + | }) | |||
| 1583 | + | ||||
| 1584 | + | a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) { | |||
| 1585 | + | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |||
| 1586 | + | return | |||
| 1587 | + | } | |||
| 1588 | + | ||||
| 1589 | + | if hasStored { | |||
| 1590 | + | // Already locked: reset to prompt (will re-ask on next request). | |||
| 1591 | + | if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil { | |||
| 1592 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision") | |||
| 1593 | + | return | |||
| 1594 | + | } | |||
| 1595 | + | } else { | |||
| 1596 | + | // Not locked: flip the state. | |||
| 1597 | + | // Allowed/requesting → deny future requests. | |||
| 1598 | + | // Blocked → allow future requests. | |||
| 1599 | + | decision := entity.PermissionDenied | |||
| 1600 | + | if state == string(coordinator.PermissionActivityBlocked) { | |||
| 1601 | + | decision = entity.PermissionGranted | |||
| 1602 | + | } | |||
| 1603 | + | if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil { | |||
| 1604 | + | log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision") | |||
| 1605 | + | return | |||
| 1606 | + | } | |||
| 1607 | + | } | |||
| 1608 | + | ||||
| 1609 | + | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |||
| 1610 | + | }) | |||
| 1611 | + | ||||
| 1612 | + | // Reset indicator when the active pane navigates away from the current origin. | |||
| 1613 | + | a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) { | |||
| 1614 | + | newOrigin, err := urlutil.ExtractOrigin(uri) | |||
| 1615 | + | if err != nil { | |||
| 1616 | + | // Internal pages (dumb://, about:) — clear the indicator. | |||
| 1617 | + | a.webrtcIndicator.Reset() | |||
| 1618 | + | return | |||
| 1619 | + | } | |||
| 1620 | + | ||||
| 1621 | + | currentOrigin := a.webrtcIndicator.Origin() | |||
| 1622 | + | if currentOrigin != "" && currentOrigin != newOrigin { | |||
| 1623 | + | a.webrtcIndicator.Reset() | |||
| 1624 | + | } | |||
| 1625 | + | }) | |||
| 1626 | + | } | |||
| 1627 | + | ||||
| 1628 | + | func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) { | |||
| 1629 | + | if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |||
| 1630 | + | return | |||
| 1631 | + | } | |||
| 1632 | + | ||||
| 1633 | + | record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType) | |||
| 1634 | + | if err != nil { | |||
| 1635 | + | logging.FromContext(ctx).Warn(). | |||
| 1636 | + | Err(err). | |||
| 1637 | + | Str("origin", origin). | |||
| 1638 | + | Str("type", string(permType)). | |||
| 1639 | + | Msg("failed to read manual permission decision") | |||
| 1640 | + | return | |||
| 1641 | + | } | |||
| 1642 | + | ||||
| 1643 | + | if record == nil { | |||
| 1644 | + | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |||
| 1645 | + | return | |||
| 1646 | + | } | |||
| 1647 | + | ||||
| 1648 | + | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | |||
| 1649 | + | } | |||
| 1650 | + | ||||
| 1525 | // generateID generates a unique ID for tabs and panes. | 1651 | // generateID generates a unique ID for tabs and panes. | ||
| 1526 | // Uses monotonically increasing counter to avoid ID collisions. | 1652 | // Uses monotonically increasing counter to avoid ID collisions. | ||
| 1527 | func (a *App) generateID() string { | 1653 | func (a *App) generateID() string { | ||
M internal/ui/component/modal_sizing.go +9 -0
| @@ -82,6 +82,15 @@ var TabPickerListDefaults = ListDisplayDefaults{ | |||||
| 82 | MaxResults: 20, | 82 | MaxResults: 20, | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | // PermissionPopupSizeDefaults provides default sizing for permission popup modal. | |||
| 86 | + | var PermissionPopupSizeDefaults = ModalSizeConfig{ | |||
| 87 | + | WidthPct: 0.4, | |||
| 88 | + | MaxWidth: 400, | |||
| 89 | + | TopMarginPct: 0.3, | |||
| 90 | + | FallbackWidth: 400, | |||
| 91 | + | FallbackHeight: 300, | |||
| 92 | + | } | |||
| 93 | + | ||||
| 85 | // EffectiveMaxRows returns the maximum number of visible rows that fit | 94 | // EffectiveMaxRows returns the maximum number of visible rows that fit | ||
| 86 | // in the available space. It computes available height by subtracting the | 95 | // in the available space. It computes available height by subtracting the | ||
| 87 | // top margin (TopMarginPct of parentHeight) and chrome height (header tabs + | 96 | // top margin (TopMarginPct of parentHeight) and chrome height (header tabs + | ||
M internal/ui/component/modal_sizing_test.go +9 -0
| @@ -137,6 +137,15 @@ func TestSessionManagerSizeDefaults_Values(t *testing.T) { | |||||
| 137 | assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0) | 137 | assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0) | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | + | func TestPermissionPopupSizeDefaults_Values(t *testing.T) { | |||
| 141 | + | assert.Greater(t, PermissionPopupSizeDefaults.WidthPct, 0.0) | |||
| 142 | + | assert.LessOrEqual(t, PermissionPopupSizeDefaults.WidthPct, 1.0) | |||
| 143 | + | assert.Positive(t, PermissionPopupSizeDefaults.MaxWidth) | |||
| 144 | + | assert.Greater(t, PermissionPopupSizeDefaults.TopMarginPct, 0.0) | |||
| 145 | + | assert.LessOrEqual(t, PermissionPopupSizeDefaults.TopMarginPct, 1.0) | |||
| 146 | + | assert.Equal(t, 300, PermissionPopupSizeDefaults.FallbackHeight) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 140 | func TestListDisplayDefaults_Values(t *testing.T) { | 149 | func TestListDisplayDefaults_Values(t *testing.T) { | ||
| 141 | assert.Positive(t, OmniboxListDefaults.MaxVisibleRows) | 150 | assert.Positive(t, OmniboxListDefaults.MaxVisibleRows) | ||
| 142 | assert.Positive(t, OmniboxListDefaults.MaxResults) | 151 | assert.Positive(t, OmniboxListDefaults.MaxResults) | ||
A internal/ui/component/permission_popup.go +292 -0
| @@ -0,0 +1,292 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 10 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // buttonSpacing is the spacing between buttons in the permission popup. | |||
| 14 | + | const buttonSpacing = 6 | |||
| 15 | + | ||||
| 16 | + | // PermissionPopup is a custom overlay component for permission prompts. | |||
| 17 | + | // It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug | |||
| 18 | + | // and match the app's custom UI style. | |||
| 19 | + | type PermissionPopup struct { | |||
| 20 | + | outerBox *gtk.Box | |||
| 21 | + | mainBox *gtk.Box | |||
| 22 | + | ||||
| 23 | + | headingLabel *gtk.Label | |||
| 24 | + | bodyLabel *gtk.Label | |||
| 25 | + | ||||
| 26 | + | btnAllow *gtk.Button | |||
| 27 | + | btnAlwaysAllow *gtk.Button | |||
| 28 | + | btnDeny *gtk.Button | |||
| 29 | + | btnAlwaysDeny *gtk.Button | |||
| 30 | + | ||||
| 31 | + | parentOverlay layout.OverlayWidget | |||
| 32 | + | uiScale float64 | |||
| 33 | + | ||||
| 34 | + | mu sync.Mutex | |||
| 35 | + | visible bool | |||
| 36 | + | callback func(allowed, persistent bool) | |||
| 37 | + | ||||
| 38 | + | retainedCallbacks []interface{} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // NewPermissionPopup creates a new permission popup component. | |||
| 42 | + | func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup { | |||
| 43 | + | if uiScale <= 0 { | |||
| 44 | + | uiScale = 1.0 | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | pp := &PermissionPopup{ | |||
| 48 | + | parentOverlay: parentOverlay, | |||
| 49 | + | uiScale: uiScale, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | if err := pp.createWidgets(); err != nil { | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | pp.attachKeyController() | |||
| 56 | + | return pp | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Widget returns the outer GTK widget for overlay registration. | |||
| 60 | + | func (pp *PermissionPopup) Widget() *gtk.Widget { | |||
| 61 | + | if pp.outerBox == nil { | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | return &pp.outerBox.Widget | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Show displays the permission popup with the given heading and body text. | |||
| 68 | + | // The callback receives (allowed, persistent) when the user makes a choice. | |||
| 69 | + | func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) { | |||
| 70 | + | log := logging.FromContext(ctx) | |||
| 71 | + | ||||
| 72 | + | pp.mu.Lock() | |||
| 73 | + | if pp.visible { | |||
| 74 | + | pp.mu.Unlock() | |||
| 75 | + | log.Warn().Msg("permission popup already visible, ignoring Show") | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | pp.visible = true | |||
| 79 | + | pp.callback = callback | |||
| 80 | + | pp.mu.Unlock() | |||
| 81 | + | ||||
| 82 | + | if pp.headingLabel != nil { | |||
| 83 | + | pp.headingLabel.SetText(heading) | |||
| 84 | + | } | |||
| 85 | + | if pp.bodyLabel != nil { | |||
| 86 | + | pp.bodyLabel.SetText(body) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | pp.resizeAndCenter() | |||
| 90 | + | if pp.outerBox != nil { | |||
| 91 | + | pp.outerBox.SetVisible(true) | |||
| 92 | + | } | |||
| 93 | + | // Focus the Deny button as the conservative default | |||
| 94 | + | if pp.btnDeny != nil { | |||
| 95 | + | pp.btnDeny.GrabFocus() | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Hide hides the popup without invoking the callback. | |||
| 100 | + | func (pp *PermissionPopup) Hide() { | |||
| 101 | + | pp.mu.Lock() | |||
| 102 | + | if !pp.visible { | |||
| 103 | + | pp.mu.Unlock() | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | pp.visible = false | |||
| 107 | + | pp.callback = nil | |||
| 108 | + | pp.mu.Unlock() | |||
| 109 | + | ||||
| 110 | + | if pp.outerBox != nil { | |||
| 111 | + | pp.outerBox.SetVisible(false) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // IsVisible returns whether the popup is currently displayed. | |||
| 116 | + | func (pp *PermissionPopup) IsVisible() bool { | |||
| 117 | + | pp.mu.Lock() | |||
| 118 | + | defer pp.mu.Unlock() | |||
| 119 | + | return pp.visible | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (pp *PermissionPopup) dismiss(allowed, persistent bool) { | |||
| 123 | + | pp.mu.Lock() | |||
| 124 | + | if !pp.visible { | |||
| 125 | + | pp.mu.Unlock() | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | pp.visible = false | |||
| 129 | + | cb := pp.callback | |||
| 130 | + | pp.callback = nil | |||
| 131 | + | pp.mu.Unlock() | |||
| 132 | + | ||||
| 133 | + | if pp.outerBox != nil { | |||
| 134 | + | pp.outerBox.SetVisible(false) | |||
| 135 | + | } | |||
| 136 | + | if cb != nil { | |||
| 137 | + | cb(allowed, persistent) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // setupContainers creates and configures the outer and main boxes. | |||
| 142 | + | func (pp *PermissionPopup) setupContainers() error { | |||
| 143 | + | pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 144 | + | if pp.outerBox == nil { | |||
| 145 | + | return errNilWidget("permissionPopupOuterBox") | |||
| 146 | + | } | |||
| 147 | + | pp.outerBox.AddCssClass("permission-popup-outer") | |||
| 148 | + | pp.outerBox.SetHalign(gtk.AlignCenterValue) | |||
| 149 | + | pp.outerBox.SetValign(gtk.AlignStartValue) | |||
| 150 | + | pp.outerBox.SetVisible(false) | |||
| 151 | + | ||||
| 152 | + | pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 153 | + | if pp.mainBox == nil { | |||
| 154 | + | return errNilWidget("permissionPopupMainBox") | |||
| 155 | + | } | |||
| 156 | + | pp.mainBox.AddCssClass("permission-popup-container") | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // setupLabels creates and configures the heading and body labels. | |||
| 161 | + | func (pp *PermissionPopup) setupLabels() error { | |||
| 162 | + | emptyText := "" | |||
| 163 | + | pp.headingLabel = gtk.NewLabel(&emptyText) | |||
| 164 | + | if pp.headingLabel == nil { | |||
| 165 | + | return errNilWidget("permissionPopupHeadingLabel") | |||
| 166 | + | } | |||
| 167 | + | pp.headingLabel.AddCssClass("permission-popup-heading") | |||
| 168 | + | pp.headingLabel.SetHalign(gtk.AlignStartValue) | |||
| 169 | + | ||||
| 170 | + | pp.bodyLabel = gtk.NewLabel(&emptyText) | |||
| 171 | + | if pp.bodyLabel == nil { | |||
| 172 | + | return errNilWidget("permissionPopupBodyLabel") | |||
| 173 | + | } | |||
| 174 | + | pp.bodyLabel.AddCssClass("permission-popup-body") | |||
| 175 | + | pp.bodyLabel.SetHalign(gtk.AlignStartValue) | |||
| 176 | + | pp.bodyLabel.SetWrap(true) | |||
| 177 | + | return nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (pp *PermissionPopup) createWidgets() error { | |||
| 181 | + | if err := pp.setupContainers(); err != nil { | |||
| 182 | + | return err | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | if err := pp.setupLabels(); err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Button row | |||
| 190 | + | btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing) | |||
| 191 | + | if btnRow == nil { | |||
| 192 | + | return errNilWidget("permissionPopupBtnRow") | |||
| 193 | + | } | |||
| 194 | + | btnRow.AddCssClass("permission-popup-btn-row") | |||
| 195 | + | btnRow.SetHalign(gtk.AlignEndValue) | |||
| 196 | + | ||||
| 197 | + | // Create buttons: Always Deny | Deny | Allow | Always Allow | |||
| 198 | + | var err error | |||
| 199 | + | pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"}) | |||
| 200 | + | if err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"}) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"}) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return err | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"}) | |||
| 215 | + | if err != nil { | |||
| 216 | + | return err | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Wire button clicks | |||
| 220 | + | pp.wireButton(pp.btnAlwaysDeny, false, true) | |||
| 221 | + | pp.wireButton(pp.btnDeny, false, false) | |||
| 222 | + | pp.wireButton(pp.btnAllow, true, false) | |||
| 223 | + | pp.wireButton(pp.btnAlwaysAllow, true, true) | |||
| 224 | + | ||||
| 225 | + | // Assemble button row | |||
| 226 | + | btnRow.Append(&pp.btnAlwaysDeny.Widget) | |||
| 227 | + | btnRow.Append(&pp.btnDeny.Widget) | |||
| 228 | + | btnRow.Append(&pp.btnAllow.Widget) | |||
| 229 | + | btnRow.Append(&pp.btnAlwaysAllow.Widget) | |||
| 230 | + | ||||
| 231 | + | // Assemble main box | |||
| 232 | + | pp.mainBox.Append(&pp.headingLabel.Widget) | |||
| 233 | + | pp.mainBox.Append(&pp.bodyLabel.Widget) | |||
| 234 | + | pp.mainBox.Append(&btnRow.Widget) | |||
| 235 | + | ||||
| 236 | + | // Assemble outer box | |||
| 237 | + | pp.outerBox.Append(&pp.mainBox.Widget) | |||
| 238 | + | ||||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // createPermissionButton creates a permission button with the given label and CSS classes. | |||
| 243 | + | func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) { | |||
| 244 | + | btn := gtk.NewButtonWithLabel(label) | |||
| 245 | + | if btn == nil { | |||
| 246 | + | return nil, errNilWidget("permissionPopupBtn" + label) | |||
| 247 | + | } | |||
| 248 | + | for _, class := range cssClasses { | |||
| 249 | + | btn.AddCssClass(class) | |||
| 250 | + | } | |||
| 251 | + | return btn, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // wireButton connects a button click to the dismiss callback. | |||
| 255 | + | func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) { | |||
| 256 | + | cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) } | |||
| 257 | + | pp.retainedCallbacks = append(pp.retainedCallbacks, cb) | |||
| 258 | + | btn.ConnectClicked(&cb) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func (pp *PermissionPopup) attachKeyController() { | |||
| 262 | + | if pp.outerBox == nil { | |||
| 263 | + | return | |||
| 264 | + | } | |||
| 265 | + | controller := gtk.NewEventControllerKey() | |||
| 266 | + | if controller == nil { | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 270 | + | ||||
| 271 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |||
| 272 | + | if keyval == uint(gdk.KEY_Escape) { | |||
| 273 | + | // Escape = deny (conservative default) | |||
| 274 | + | pp.dismiss(false, false) | |||
| 275 | + | return true | |||
| 276 | + | } | |||
| 277 | + | return false | |||
| 278 | + | } | |||
| 279 | + | pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb) | |||
| 280 | + | controller.ConnectKeyPressed(&keyPressedCb) | |||
| 281 | + | pp.outerBox.AddController(&controller.EventController) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (pp *PermissionPopup) resizeAndCenter() { | |||
| 285 | + | if pp.outerBox == nil || pp.mainBox == nil { | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults) | |||
| 290 | + | pp.mainBox.SetSizeRequest(width, -1) | |||
| 291 | + | pp.outerBox.SetMarginTop(marginTop) | |||
| 292 | + | } | |||
A internal/ui/component/webrtc_permission_indicator.go +529 -0
| @@ -0,0 +1,529 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // revealerTransitionDuration is the animation duration in milliseconds | |||
| 12 | + | // for the details panel slide-down. | |||
| 13 | + | const revealerTransitionDuration = 180 | |||
| 14 | + | ||||
| 15 | + | var trackedWebRTCPermissionTypes = []entity.PermissionType{ | |||
| 16 | + | entity.PermissionTypeMicrophone, | |||
| 17 | + | entity.PermissionTypeCamera, | |||
| 18 | + | entity.PermissionTypeDisplay, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // WebRTCPermissionIndicator is a discrete top-right overlay that shows | |||
| 22 | + | // permission activity for microphone, camera, and display capture. | |||
| 23 | + | // | |||
| 24 | + | // Collapsed: a small colored dot. | |||
| 25 | + | // Hovered: expands left and downward to show per-device states. | |||
| 26 | + | // Uses GtkRevealer for smooth slide-down animation of the details panel. | |||
| 27 | + | type WebRTCPermissionIndicator struct { | |||
| 28 | + | outerBox *gtk.Box | |||
| 29 | + | cardBox *gtk.Box | |||
| 30 | + | dotBox *gtk.Box | |||
| 31 | + | originLabel *gtk.Label | |||
| 32 | + | detailsBox *gtk.Box | |||
| 33 | + | revealer *gtk.Revealer | |||
| 34 | + | ||||
| 35 | + | rowButtons map[entity.PermissionType]*gtk.Button | |||
| 36 | + | states map[entity.PermissionType]webrtcPermissionState | |||
| 37 | + | stored map[entity.PermissionType]entity.PermissionDecision | |||
| 38 | + | ||||
| 39 | + | currentOrigin string | |||
| 40 | + | onToggleLock func(origin string, permType entity.PermissionType, state string, hasStored bool) | |||
| 41 | + | ||||
| 42 | + | mu sync.Mutex | |||
| 43 | + | expanded bool | |||
| 44 | + | retainedCallbacks []interface{} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // NewWebRTCPermissionIndicator creates a top-right WebRTC activity indicator. | |||
| 48 | + | func NewWebRTCPermissionIndicator() *WebRTCPermissionIndicator { | |||
| 49 | + | indicator := buildWebRTCPermissionIndicatorWidgets() | |||
| 50 | + | if indicator == nil { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | indicator.wireRowButtons() | |||
| 55 | + | indicator.attachHoverController() | |||
| 56 | + | indicator.refreshLocked() | |||
| 57 | + | ||||
| 58 | + | return indicator | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // buildWebRTCPermissionIndicatorWidgets creates and assembles all GTK widgets | |||
| 62 | + | // for the indicator: outer container, card, dot, revealer, details, and rows. | |||
| 63 | + | func buildWebRTCPermissionIndicatorWidgets() *WebRTCPermissionIndicator { | |||
| 64 | + | outer, card := createIndicatorContainers() | |||
| 65 | + | if outer == nil || card == nil { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | dotRow, dot, originLabel := createIndicatorDot() | |||
| 70 | + | if dotRow == nil || dot == nil || originLabel == nil { | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | details := createIndicatorDetails() | |||
| 75 | + | if details == nil { | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | rowButtons, ok := createWebRTCPermissionRows(details) | |||
| 80 | + | if !ok { | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | revealer := createIndicatorRevealer(details) | |||
| 85 | + | if revealer == nil { | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | card.Append(&dotRow.Widget) | |||
| 90 | + | card.Append(&revealer.Widget) | |||
| 91 | + | outer.Append(&card.Widget) | |||
| 92 | + | ||||
| 93 | + | return &WebRTCPermissionIndicator{ | |||
| 94 | + | outerBox: outer, | |||
| 95 | + | cardBox: card, | |||
| 96 | + | dotBox: dot, | |||
| 97 | + | originLabel: originLabel, | |||
| 98 | + | detailsBox: details, | |||
| 99 | + | revealer: revealer, | |||
| 100 | + | rowButtons: rowButtons, | |||
| 101 | + | states: make(map[entity.PermissionType]webrtcPermissionState, len(trackedWebRTCPermissionTypes)), | |||
| 102 | + | stored: make(map[entity.PermissionType]entity.PermissionDecision, len(trackedWebRTCPermissionTypes)), | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func createIndicatorContainers() (*gtk.Box, *gtk.Box) { | |||
| 107 | + | outer := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 108 | + | if outer == nil { | |||
| 109 | + | return nil, nil | |||
| 110 | + | } | |||
| 111 | + | outer.AddCssClass("webrtc-indicator-outer") | |||
| 112 | + | outer.SetHalign(gtk.AlignEndValue) | |||
| 113 | + | outer.SetValign(gtk.AlignStartValue) | |||
| 114 | + | outer.SetHexpand(true) | |||
| 115 | + | outer.SetVexpand(true) | |||
| 116 | + | outer.SetVisible(false) | |||
| 117 | + | ||||
| 118 | + | card := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 119 | + | if card == nil { | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | card.AddCssClass("webrtc-indicator-card") | |||
| 123 | + | ||||
| 124 | + | return outer, card | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func createIndicatorDot() (*gtk.Box, *gtk.Box, *gtk.Label) { | |||
| 128 | + | dotRow := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |||
| 129 | + | if dotRow == nil { | |||
| 130 | + | return nil, nil, nil | |||
| 131 | + | } | |||
| 132 | + | dotRow.AddCssClass("webrtc-indicator-header") | |||
| 133 | + | dotRow.SetHalign(gtk.AlignEndValue) | |||
| 134 | + | ||||
| 135 | + | // Origin label on the left, dot on the right. | |||
| 136 | + | originText := "" | |||
| 137 | + | originLabel := gtk.NewLabel(&originText) | |||
| 138 | + | if originLabel == nil { | |||
| 139 | + | return nil, nil, nil | |||
| 140 | + | } | |||
| 141 | + | originLabel.AddCssClass("webrtc-indicator-origin") | |||
| 142 | + | originLabel.SetHalign(gtk.AlignStartValue) | |||
| 143 | + | originLabel.SetVisible(false) // Hidden when collapsed; shown on expand. | |||
| 144 | + | ||||
| 145 | + | dot := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 146 | + | if dot == nil { | |||
| 147 | + | return nil, nil, nil | |||
| 148 | + | } | |||
| 149 | + | dot.AddCssClass("webrtc-indicator-dot") | |||
| 150 | + | dot.SetHalign(gtk.AlignEndValue) | |||
| 151 | + | dot.SetValign(gtk.AlignCenterValue) | |||
| 152 | + | ||||
| 153 | + | dotRow.Append(&originLabel.Widget) | |||
| 154 | + | dotRow.Append(&dot.Widget) | |||
| 155 | + | ||||
| 156 | + | return dotRow, dot, originLabel | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func createIndicatorDetails() *gtk.Box { | |||
| 160 | + | details := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 161 | + | if details == nil { | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | details.AddCssClass("webrtc-indicator-details") | |||
| 165 | + | return details | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func createIndicatorRevealer(child *gtk.Box) *gtk.Revealer { | |||
| 169 | + | revealer := gtk.NewRevealer() | |||
| 170 | + | if revealer == nil { | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideDownValue) | |||
| 174 | + | revealer.SetTransitionDuration(revealerTransitionDuration) | |||
| 175 | + | revealer.SetRevealChild(false) | |||
| 176 | + | revealer.AddCssClass("webrtc-indicator-revealer") | |||
| 177 | + | revealer.SetChild(&child.Widget) | |||
| 178 | + | ||||
| 179 | + | return revealer | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func createWebRTCPermissionRows(details *gtk.Box) (map[entity.PermissionType]*gtk.Button, bool) { | |||
| 183 | + | rows := make(map[entity.PermissionType]*gtk.Button, len(trackedWebRTCPermissionTypes)) | |||
| 184 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 185 | + | text := formatRowLabel(permType, webrtcPermissionStateIdle, "", false) | |||
| 186 | + | row := gtk.NewButtonWithLabel(text) | |||
| 187 | + | if row == nil { | |||
| 188 | + | return nil, false | |||
| 189 | + | } | |||
| 190 | + | row.AddCssClass("webrtc-indicator-row") | |||
| 191 | + | row.SetHalign(gtk.AlignFillValue) | |||
| 192 | + | details.Append(&row.Widget) | |||
| 193 | + | rows[permType] = row | |||
| 194 | + | } | |||
| 195 | + | return rows, true | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (w *WebRTCPermissionIndicator) wireRowButtons() { | |||
| 199 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 200 | + | capturedPermType := permType | |||
| 201 | + | btn := w.rowButtons[capturedPermType] | |||
| 202 | + | if btn == nil { | |||
| 203 | + | continue | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | clickCb := func(_ gtk.Button) { | |||
| 207 | + | w.mu.Lock() | |||
| 208 | + | origin := w.currentOrigin | |||
| 209 | + | state := w.states[capturedPermType] | |||
| 210 | + | _, hasStored := w.stored[capturedPermType] | |||
| 211 | + | toggle := w.onToggleLock | |||
| 212 | + | w.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | if toggle == nil { | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | toggle(origin, capturedPermType, string(state), hasStored) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | w.retainedCallbacks = append(w.retainedCallbacks, clickCb) | |||
| 221 | + | btn.ConnectClicked(&clickCb) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Widget returns the GTK widget for adding to the main overlay. | |||
| 226 | + | func (w *WebRTCPermissionIndicator) Widget() *gtk.Widget { | |||
| 227 | + | if w == nil || w.outerBox == nil { | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | return &w.outerBox.Widget | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // MarkRequesting marks tracked permissions as currently requesting access. | |||
| 234 | + | func (w *WebRTCPermissionIndicator) MarkRequesting(permTypes []entity.PermissionType) { | |||
| 235 | + | w.setState(permTypes, webrtcPermissionStateRequesting) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // MarkAllowed marks tracked permissions as allowed. | |||
| 239 | + | func (w *WebRTCPermissionIndicator) MarkAllowed(permTypes []entity.PermissionType) { | |||
| 240 | + | w.setState(permTypes, webrtcPermissionStateAllowed) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // MarkBlocked marks tracked permissions as blocked. | |||
| 244 | + | func (w *WebRTCPermissionIndicator) MarkBlocked(permTypes []entity.PermissionType) { | |||
| 245 | + | w.setState(permTypes, webrtcPermissionStateBlocked) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // Origin returns the current origin tracked by the indicator. | |||
| 249 | + | func (w *WebRTCPermissionIndicator) Origin() string { | |||
| 250 | + | if w == nil { | |||
| 251 | + | return "" | |||
| 252 | + | } | |||
| 253 | + | w.mu.Lock() | |||
| 254 | + | defer w.mu.Unlock() | |||
| 255 | + | return w.currentOrigin | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // SetOrigin sets the active origin for lock/unlock actions shown in details. | |||
| 259 | + | func (w *WebRTCPermissionIndicator) SetOrigin(origin string) { | |||
| 260 | + | if w == nil { | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | w.mu.Lock() | |||
| 264 | + | defer w.mu.Unlock() | |||
| 265 | + | w.currentOrigin = origin | |||
| 266 | + | w.refreshLocked() | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // SetStoredDecision updates whether a permission type has an explicit stored decision. | |||
| 270 | + | func (w *WebRTCPermissionIndicator) SetStoredDecision( | |||
| 271 | + | permType entity.PermissionType, | |||
| 272 | + | decision entity.PermissionDecision, | |||
| 273 | + | hasStored bool, | |||
| 274 | + | ) { | |||
| 275 | + | if w == nil || !isTrackedWebRTCPermissionType(permType) { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | w.mu.Lock() | |||
| 280 | + | defer w.mu.Unlock() | |||
| 281 | + | ||||
| 282 | + | if hasStored { | |||
| 283 | + | w.stored[permType] = decision | |||
| 284 | + | } else { | |||
| 285 | + | delete(w.stored, permType) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | w.refreshLocked() | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Reset clears all permission states and stored decisions, hiding the indicator. | |||
| 292 | + | // Call this when the active page navigates to a different origin. | |||
| 293 | + | func (w *WebRTCPermissionIndicator) Reset() { | |||
| 294 | + | if w == nil { | |||
| 295 | + | return | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | w.mu.Lock() | |||
| 299 | + | defer w.mu.Unlock() | |||
| 300 | + | ||||
| 301 | + | clear(w.states) | |||
| 302 | + | clear(w.stored) | |||
| 303 | + | w.currentOrigin = "" | |||
| 304 | + | ||||
| 305 | + | w.refreshLocked() | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // SetOnToggleLock sets callback invoked when a row lock/unlock action is clicked. | |||
| 309 | + | func (w *WebRTCPermissionIndicator) SetOnToggleLock( | |||
| 310 | + | callback func(origin string, permType entity.PermissionType, state string, hasStored bool), | |||
| 311 | + | ) { | |||
| 312 | + | if w == nil { | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | w.mu.Lock() | |||
| 316 | + | defer w.mu.Unlock() | |||
| 317 | + | w.onToggleLock = callback | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func (w *WebRTCPermissionIndicator) setState(permTypes []entity.PermissionType, state webrtcPermissionState) { | |||
| 321 | + | if w == nil { | |||
| 322 | + | return | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | w.mu.Lock() | |||
| 326 | + | defer w.mu.Unlock() | |||
| 327 | + | ||||
| 328 | + | for _, permType := range permTypes { | |||
| 329 | + | if !isTrackedWebRTCPermissionType(permType) { | |||
| 330 | + | continue | |||
| 331 | + | } | |||
| 332 | + | w.states[permType] = state | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | w.refreshLocked() | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func (w *WebRTCPermissionIndicator) refreshLocked() { | |||
| 339 | + | if w.outerBox == nil { | |||
| 340 | + | return | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | visible := shouldShowWebRTCPermissionIndicator(w.states) | |||
| 344 | + | w.outerBox.SetVisible(visible) | |||
| 345 | + | ||||
| 346 | + | summary := summarizeWebRTCPermissionState(w.states) | |||
| 347 | + | if w.dotBox != nil { | |||
| 348 | + | updateStateCSSClass(&w.dotBox.Widget, summary) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | if w.originLabel != nil { | |||
| 352 | + | origin := w.currentOrigin | |||
| 353 | + | if origin == "" { | |||
| 354 | + | origin = "Current site" | |||
| 355 | + | } | |||
| 356 | + | w.originLabel.SetText(origin) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | for _, permType := range trackedWebRTCPermissionTypes { | |||
| 360 | + | row := w.rowButtons[permType] | |||
| 361 | + | if row == nil { | |||
| 362 | + | continue | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | state := w.states[permType] | |||
| 366 | + | if state == "" { | |||
| 367 | + | state = webrtcPermissionStateIdle | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | storedDecision, hasStored := w.stored[permType] | |||
| 371 | + | label := formatRowLabel(permType, state, storedDecision, hasStored) | |||
| 372 | + | row.SetLabel(label) | |||
| 373 | + | updateStateCSSClass(&row.Widget, state) | |||
| 374 | + | updateLockedCSSClass(&row.Widget, hasStored) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | if !visible { | |||
| 378 | + | w.setExpandedLocked(false) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func (w *WebRTCPermissionIndicator) attachHoverController() { | |||
| 383 | + | if w == nil || w.outerBox == nil { | |||
| 384 | + | return | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | controller := gtk.NewEventControllerMotion() | |||
| 388 | + | if controller == nil { | |||
| 389 | + | return | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |||
| 393 | + | w.mu.Lock() | |||
| 394 | + | defer w.mu.Unlock() | |||
| 395 | + | w.setExpandedLocked(true) | |||
| 396 | + | } | |||
| 397 | + | leaveCb := func(_ gtk.EventControllerMotion) { | |||
| 398 | + | w.mu.Lock() | |||
| 399 | + | defer w.mu.Unlock() | |||
| 400 | + | w.setExpandedLocked(false) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | w.retainedCallbacks = append(w.retainedCallbacks, enterCb, leaveCb) | |||
| 404 | + | controller.ConnectEnter(&enterCb) | |||
| 405 | + | controller.ConnectLeave(&leaveCb) | |||
| 406 | + | w.outerBox.AddController(&controller.EventController) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (w *WebRTCPermissionIndicator) setExpandedLocked(expanded bool) { | |||
| 410 | + | if w.revealer == nil || w.cardBox == nil { | |||
| 411 | + | return | |||
| 412 | + | } | |||
| 413 | + | if w.expanded == expanded { | |||
| 414 | + | return | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | w.expanded = expanded | |||
| 418 | + | w.revealer.SetRevealChild(expanded) | |||
| 419 | + | ||||
| 420 | + | // Show/hide origin label so it doesn't take space when collapsed to just a dot. | |||
| 421 | + | // Toggle hexpand alongside visibility to prevent expand propagation when collapsed. | |||
| 422 | + | if w.originLabel != nil { | |||
| 423 | + | w.originLabel.SetVisible(expanded) | |||
| 424 | + | w.originLabel.SetHexpand(expanded) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | if expanded { | |||
| 428 | + | w.cardBox.AddCssClass("expanded") | |||
| 429 | + | } else { | |||
| 430 | + | w.cardBox.RemoveCssClass("expanded") | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func isTrackedWebRTCPermissionType(permType entity.PermissionType) bool { | |||
| 435 | + | switch permType { | |||
| 436 | + | case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay: | |||
| 437 | + | return true | |||
| 438 | + | default: | |||
| 439 | + | return false | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func permissionDisplayName(permType entity.PermissionType) string { | |||
| 444 | + | switch permType { | |||
| 445 | + | case entity.PermissionTypeMicrophone: | |||
| 446 | + | return "\U0001F3A4 Mic" // 🎤 | |||
| 447 | + | case entity.PermissionTypeCamera: | |||
| 448 | + | return "\U0001F4F7 Cam" // 📷 | |||
| 449 | + | case entity.PermissionTypeDisplay: | |||
| 450 | + | return "\U0001F4BB Screen" // 💻 | |||
| 451 | + | default: | |||
| 452 | + | return "Unknown" | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func stateDisplayName(state webrtcPermissionState) string { | |||
| 457 | + | switch state { | |||
| 458 | + | case webrtcPermissionStateRequesting: | |||
| 459 | + | return "\u26A0 requesting" // ⚠ | |||
| 460 | + | case webrtcPermissionStateAllowed: | |||
| 461 | + | return "\u2713 active" // ✓ | |||
| 462 | + | case webrtcPermissionStateBlocked: | |||
| 463 | + | return "\u2717 blocked" // ✗ | |||
| 464 | + | default: | |||
| 465 | + | return "idle" | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func formatRowLabel( | |||
| 470 | + | permType entity.PermissionType, | |||
| 471 | + | state webrtcPermissionState, | |||
| 472 | + | storedDecision entity.PermissionDecision, | |||
| 473 | + | hasStored bool, | |||
| 474 | + | ) string { | |||
| 475 | + | name := permissionDisplayName(permType) | |||
| 476 | + | status := stateDisplayName(state) | |||
| 477 | + | ||||
| 478 | + | if hasStored { | |||
| 479 | + | // Locked: show current policy and offer to reset. | |||
| 480 | + | policy := "allow" | |||
| 481 | + | if storedDecision == entity.PermissionDenied { | |||
| 482 | + | policy = "deny" | |||
| 483 | + | } | |||
| 484 | + | return fmt.Sprintf("%s %s \U0001F512 %s \u00B7 reset", name, status, policy) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // Not locked: show the flip action. | |||
| 488 | + | switch state { | |||
| 489 | + | case webrtcPermissionStateAllowed, webrtcPermissionStateRequesting: | |||
| 490 | + | return fmt.Sprintf("%s %s \u00B7 block", name, status) | |||
| 491 | + | case webrtcPermissionStateBlocked: | |||
| 492 | + | return fmt.Sprintf("%s %s \u00B7 allow", name, status) | |||
| 493 | + | default: | |||
| 494 | + | return fmt.Sprintf("%s %s", name, status) | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | func updateLockedCSSClass(widget *gtk.Widget, locked bool) { | |||
| 499 | + | if widget == nil { | |||
| 500 | + | return | |||
| 501 | + | } | |||
| 502 | + | if locked { | |||
| 503 | + | widget.AddCssClass("row-locked") | |||
| 504 | + | } else { | |||
| 505 | + | widget.RemoveCssClass("row-locked") | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func updateStateCSSClass(widget *gtk.Widget, state webrtcPermissionState) { | |||
| 510 | + | if widget == nil { | |||
| 511 | + | return | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | widget.RemoveCssClass("state-idle") | |||
| 515 | + | widget.RemoveCssClass("state-requesting") | |||
| 516 | + | widget.RemoveCssClass("state-allowed") | |||
| 517 | + | widget.RemoveCssClass("state-blocked") | |||
| 518 | + | ||||
| 519 | + | switch state { | |||
| 520 | + | case webrtcPermissionStateRequesting: | |||
| 521 | + | widget.AddCssClass("state-requesting") | |||
| 522 | + | case webrtcPermissionStateAllowed: | |||
| 523 | + | widget.AddCssClass("state-allowed") | |||
| 524 | + | case webrtcPermissionStateBlocked: | |||
| 525 | + | widget.AddCssClass("state-blocked") | |||
| 526 | + | default: | |||
| 527 | + | widget.AddCssClass("state-idle") | |||
| 528 | + | } | |||
| 529 | + | } | |||
A internal/ui/component/webrtc_permission_indicator_state.go +55 -0
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | type webrtcPermissionState string | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | webrtcPermissionStateIdle webrtcPermissionState = "idle" | |||
| 9 | + | webrtcPermissionStateRequesting webrtcPermissionState = "requesting" | |||
| 10 | + | webrtcPermissionStateAllowed webrtcPermissionState = "allowed" | |||
| 11 | + | webrtcPermissionStateBlocked webrtcPermissionState = "blocked" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func summarizeWebRTCPermissionState(states map[entity.PermissionType]webrtcPermissionState) webrtcPermissionState { | |||
| 15 | + | if len(states) == 0 { | |||
| 16 | + | return webrtcPermissionStateIdle | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | hasAllowed := false | |||
| 20 | + | hasBlocked := false | |||
| 21 | + | ||||
| 22 | + | for _, state := range states { | |||
| 23 | + | switch state { | |||
| 24 | + | case webrtcPermissionStateRequesting: | |||
| 25 | + | return webrtcPermissionStateRequesting | |||
| 26 | + | case webrtcPermissionStateBlocked: | |||
| 27 | + | hasBlocked = true | |||
| 28 | + | case webrtcPermissionStateAllowed: | |||
| 29 | + | hasAllowed = true | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if hasBlocked { | |||
| 34 | + | return webrtcPermissionStateBlocked | |||
| 35 | + | } | |||
| 36 | + | if hasAllowed { | |||
| 37 | + | return webrtcPermissionStateAllowed | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | return webrtcPermissionStateIdle | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func shouldShowWebRTCPermissionIndicator(states map[entity.PermissionType]webrtcPermissionState) bool { | |||
| 44 | + | if len(states) == 0 { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, state := range states { | |||
| 49 | + | if state != webrtcPermissionStateIdle { | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | return false | |||
| 55 | + | } | |||
A internal/ui/component/webrtc_permission_indicator_state_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWebRTCSummaryState_RequestingWins(t *testing.T) { | |||
| 11 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 12 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 13 | + | entity.PermissionTypeCamera: webrtcPermissionStateRequesting, | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 17 | + | assert.Equal(t, webrtcPermissionStateRequesting, summary) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestWebRTCSummaryState_BlockedBeatsAllowed(t *testing.T) { | |||
| 21 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 22 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 23 | + | entity.PermissionTypeCamera: webrtcPermissionStateBlocked, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 27 | + | assert.Equal(t, webrtcPermissionStateBlocked, summary) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestWebRTCSummaryState_AllowedWhenOnlyAllowed(t *testing.T) { | |||
| 31 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 32 | + | entity.PermissionTypeDisplay: webrtcPermissionStateAllowed, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | summary := summarizeWebRTCPermissionState(states) | |||
| 36 | + | assert.Equal(t, webrtcPermissionStateAllowed, summary) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestWebRTCSummaryState_IdleWhenEmpty(t *testing.T) { | |||
| 40 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(nil)) | |||
| 41 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(map[entity.PermissionType]webrtcPermissionState{})) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestWebRTCSummaryState_IdleWhenAllIdle(t *testing.T) { | |||
| 45 | + | states := map[entity.PermissionType]webrtcPermissionState{ | |||
| 46 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateIdle, | |||
| 47 | + | entity.PermissionTypeCamera: webrtcPermissionStateIdle, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | assert.Equal(t, webrtcPermissionStateIdle, summarizeWebRTCPermissionState(states)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestWebRTCShouldShowIndicator(t *testing.T) { | |||
| 54 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(nil)) | |||
| 55 | + | assert.False(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |||
| 56 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateIdle, | |||
| 57 | + | })) | |||
| 58 | + | assert.True(t, shouldShowWebRTCPermissionIndicator(map[entity.PermissionType]webrtcPermissionState{ | |||
| 59 | + | entity.PermissionTypeMicrophone: webrtcPermissionStateAllowed, | |||
| 60 | + | })) | |||
| 61 | + | } | |||
M internal/ui/coordinator/content.go +139 -0
| @@ -90,6 +90,7 @@ type ContentCoordinator struct { | |||||
| 90 | widgetFactory layout.WidgetFactory | 90 | widgetFactory layout.WidgetFactory | ||
| 91 | faviconAdapter *adapter.FaviconAdapter | 91 | faviconAdapter *adapter.FaviconAdapter | ||
| 92 | zoomUC *usecase.ManageZoomUseCase | 92 | zoomUC *usecase.ManageZoomUseCase | ||
| 93 | + | permissionUC *usecase.HandlePermissionUseCase | |||
| 93 | injector *webkit.ContentInjector | 94 | injector *webkit.ContentInjector | ||
| 94 | 95 | ||||
| 95 | webViews map[entity.PaneID]*webkit.WebView | 96 | webViews map[entity.PaneID]*webkit.WebView | ||
| @@ -117,6 +118,12 @@ type ContentCoordinator struct { | |||||
| 117 | // Callback when active pane title changes (for window title updates) | 118 | // Callback when active pane title changes (for window title updates) | ||
| 118 | onWindowTitleChanged func(title string) | 119 | onWindowTitleChanged func(title string) | ||
| 119 | 120 | ||||
| 121 | + | // Callback when media permission activity changes (requesting/allowed/blocked). | |||
| 122 | + | onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState) | |||
| 123 | + | ||||
| 124 | + | // Callback when the active pane commits a navigation (new page loading). | |||
| 125 | + | onActiveNavigationCommitted func(uri string) | |||
| 126 | + | ||||
| 120 | // Callback when the WebView becomes visible (first real commit) | 127 | // Callback when the WebView becomes visible (first real commit) | ||
| 121 | onWebViewShown func(paneID entity.PaneID) | 128 | onWebViewShown func(paneID entity.PaneID) | ||
| 122 | 129 | ||||
| @@ -178,6 +185,15 @@ type popupOAuthState struct { | |||||
| 178 | Seen bool | 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 | // NewContentCoordinator creates a new ContentCoordinator. | 197 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| 182 | func NewContentCoordinator( | 198 | func NewContentCoordinator( | ||
| 183 | ctx context.Context, | 199 | ctx context.Context, | ||
| @@ -186,6 +202,7 @@ func NewContentCoordinator( | |||||
| 186 | faviconAdapter *adapter.FaviconAdapter, | 202 | faviconAdapter *adapter.FaviconAdapter, | ||
| 187 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView), | 203 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView), | ||
| 188 | zoomUC *usecase.ManageZoomUseCase, | 204 | zoomUC *usecase.ManageZoomUseCase, | ||
| 205 | + | permissionUC *usecase.HandlePermissionUseCase, | |||
| 189 | ) *ContentCoordinator { | 206 | ) *ContentCoordinator { | ||
| 190 | log := logging.FromContext(ctx) | 207 | log := logging.FromContext(ctx) | ||
| 191 | log.Debug().Msg("creating content coordinator") | 208 | log.Debug().Msg("creating content coordinator") | ||
| @@ -195,6 +212,7 @@ func NewContentCoordinator( | |||||
| 195 | widgetFactory: widgetFactory, | 212 | widgetFactory: widgetFactory, | ||
| 196 | faviconAdapter: faviconAdapter, | 213 | faviconAdapter: faviconAdapter, | ||
| 197 | zoomUC: zoomUC, | 214 | zoomUC: zoomUC, | ||
| 215 | + | permissionUC: permissionUC, | |||
| 198 | webViews: make(map[entity.PaneID]*webkit.WebView), | 216 | webViews: make(map[entity.PaneID]*webkit.WebView), | ||
| 199 | paneTitles: make(map[entity.PaneID]string), | 217 | paneTitles: make(map[entity.PaneID]string), | ||
| 200 | navOrigins: make(map[entity.PaneID]string), | 218 | navOrigins: make(map[entity.PaneID]string), | ||
| @@ -216,6 +234,18 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan | |||||
| 216 | c.onHistoryRecord = fn | 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 | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | 249 | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | ||
| 220 | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | 250 | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | ||
| 221 | c.onPaneURIUpdated = fn | 251 | c.onPaneURIUpdated = fn | ||
| @@ -1080,6 +1110,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 1080 | c.onHistoryRecord(ctx, paneID, uri) | 1110 | c.onHistoryRecord(ctx, paneID, uri) | ||
| 1081 | } | 1111 | } | ||
| 1082 | 1112 | ||||
| 1113 | + | // Notify active pane navigation for permission indicator reset. | |||
| 1114 | + | c.notifyActiveNavigation(paneID, uri) | |||
| 1115 | + | ||||
| 1083 | // Apply zoom | 1116 | // Apply zoom | ||
| 1084 | if c.zoomUC == nil { | 1117 | if c.zoomUC == nil { | ||
| 1085 | return | 1118 | return | ||
| @@ -1093,6 +1126,16 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 1093 | _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) | 1126 | _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) | ||
| 1094 | } | 1127 | } | ||
| 1095 | 1128 | ||||
| 1129 | + | func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) { | |||
| 1130 | + | if c.onActiveNavigationCommitted == nil { | |||
| 1131 | + | return | |||
| 1132 | + | } | |||
| 1133 | + | ws, _ := c.getActiveWS() | |||
| 1134 | + | if ws != nil && ws.ActivePaneID == paneID { | |||
| 1135 | + | c.onActiveNavigationCommitted(uri) | |||
| 1136 | + | } | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1096 | func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool { | 1139 | func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool { | ||
| 1097 | if wv == nil || wv.IsDestroyed() { | 1140 | if wv == nil || wv.IsDestroyed() { | ||
| 1098 | return false | 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 | // Fullscreen handlers for idle inhibition | 1696 | // Fullscreen handlers for idle inhibition | ||
| 1650 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | 1697 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | ||
| 1651 | 1698 | ||||
| @@ -1943,6 +1990,98 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||||
| 1943 | return true | 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 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | 2085 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | ||
| 1947 | // Idle is inhibited when: | 2086 | // Idle is inhibited when: | ||
| 1948 | // - The webview enters fullscreen mode (e.g., fullscreen video) | 2087 | // - The webview enters fullscreen mode (e.g., fullscreen video) | ||
M internal/ui/deps.go +13 -11
| @@ -46,19 +46,21 @@ type Dependencies struct { | |||||
| 46 | MessageRouter *webkit.MessageRouter | 46 | MessageRouter *webkit.MessageRouter | ||
| 47 | 47 | ||||
| 48 | // Repositories | 48 | // Repositories | ||
| 49 | - | HistoryRepo repository.HistoryRepository | 49 | + | HistoryRepo repository.HistoryRepository |
| 50 | - | FavoriteRepo repository.FavoriteRepository | 50 | + | FavoriteRepo repository.FavoriteRepository |
| 51 | - | ZoomRepo repository.ZoomRepository | 51 | + | ZoomRepo repository.ZoomRepository |
| 52 | - | FilterRepo repository.ContentWhitelistRepository | 52 | + | PermissionRepo port.PermissionRepository |
| 53 | + | FilterRepo repository.ContentWhitelistRepository | |||
| 53 | 54 | ||||
| 54 | // Use Cases | 55 | // Use Cases | ||
| 55 | - | TabsUC *usecase.ManageTabsUseCase | 56 | + | TabsUC *usecase.ManageTabsUseCase |
| 56 | - | PanesUC *usecase.ManagePanesUseCase | 57 | + | PanesUC *usecase.ManagePanesUseCase |
| 57 | - | NavigateUC *usecase.NavigateUseCase | 58 | + | NavigateUC *usecase.NavigateUseCase |
| 58 | - | ZoomUC *usecase.ManageZoomUseCase | 59 | + | ZoomUC *usecase.ManageZoomUseCase |
| 59 | - | FavoritesUC *usecase.ManageFavoritesUseCase | 60 | + | PermissionUC *usecase.HandlePermissionUseCase |
| 60 | - | HistoryUC *usecase.SearchHistoryUseCase | 61 | + | FavoritesUC *usecase.ManageFavoritesUseCase |
| 61 | - | CopyURLUC *usecase.CopyURLUseCase | 62 | + | HistoryUC *usecase.SearchHistoryUseCase |
| 63 | + | CopyURLUC *usecase.CopyURLUseCase | |||
| 62 | 64 | ||||
| 63 | // Infrastructure Adapters | 65 | // Infrastructure Adapters | ||
| 64 | Clipboard port.Clipboard | 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 | sb.WriteString(generateTabPickerCSS(p)) | 128 | sb.WriteString(generateTabPickerCSS(p)) | ||
| 129 | sb.WriteString("\n") | 129 | sb.WriteString("\n") | ||
| 130 | 130 | ||||
| 131 | + | // Permission popup styling | |||
| 132 | + | sb.WriteString(generatePermissionPopupCSS(p)) | |||
| 133 | + | sb.WriteString("\n") | |||
| 134 | + | ||||
| 135 | + | // WebRTC permission activity indicator styling | |||
| 136 | + | sb.WriteString(generateWebRTCPermissionIndicatorCSS(p)) | |||
| 137 | + | sb.WriteString("\n") | |||
| 138 | + | ||||
| 131 | // Accent picker styling | 139 | // Accent picker styling | ||
| 132 | sb.WriteString(generateAccentPickerCSS(p)) | 140 | sb.WriteString(generateAccentPickerCSS(p)) | ||
| 133 | 141 | ||||
A internal/ui/theme/permission_popup_css.go +85 -0
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generatePermissionPopupCSS creates permission popup overlay styles. | |||
| 4 | + | // Uses em units for scalable UI, matches omnibox/session manager styling patterns. | |||
| 5 | + | func generatePermissionPopupCSS(p Palette) string { | |||
| 6 | + | _ = p | |||
| 7 | + | return `/* ===== Permission Popup Styling ===== */ | |||
| 8 | + | ||||
| 9 | + | .permission-popup-outer { | |||
| 10 | + | /* Positioning handled via SetHalign/SetValign in Go */ | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | .permission-popup-container { | |||
| 14 | + | background-color: var(--surface-variant); | |||
| 15 | + | border: 0.0625em solid var(--border); | |||
| 16 | + | border-radius: 0.1875em; | |||
| 17 | + | padding: 0; | |||
| 18 | + | min-width: 20em; | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | .permission-popup-heading { | |||
| 22 | + | font-size: 0.9375em; | |||
| 23 | + | font-weight: 600; | |||
| 24 | + | color: var(--text); | |||
| 25 | + | padding: 0.75em 1em 0.25em 1em; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | .permission-popup-body { | |||
| 29 | + | font-size: 0.8125em; | |||
| 30 | + | font-weight: 400; | |||
| 31 | + | color: var(--muted); | |||
| 32 | + | padding: 0.25em 1em 0.75em 1em; | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | .permission-popup-btn-row { | |||
| 36 | + | border-top: 0.0625em solid var(--border); | |||
| 37 | + | padding: 0.5em 0.75em; | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | /* Shared button base */ | |||
| 41 | + | .permission-popup-btn { | |||
| 42 | + | background-image: none; | |||
| 43 | + | border: 0.0625em solid var(--border); | |||
| 44 | + | border-radius: 0.1875em; | |||
| 45 | + | padding: 0.375em 0.75em; | |||
| 46 | + | font-size: 0.8125em; | |||
| 47 | + | font-weight: 500; | |||
| 48 | + | transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | /* Allow buttons - accent styled */ | |||
| 52 | + | .permission-popup-btn-allow { | |||
| 53 | + | background-color: alpha(var(--accent), 0.15); | |||
| 54 | + | color: var(--accent); | |||
| 55 | + | border-color: alpha(var(--accent), 0.3); | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | .permission-popup-btn-allow:hover { | |||
| 59 | + | background-color: alpha(var(--accent), 0.25); | |||
| 60 | + | border-color: var(--accent); | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | /* Deny button - subtle default */ | |||
| 64 | + | .permission-popup-btn-deny { | |||
| 65 | + | background-color: var(--surface); | |||
| 66 | + | color: var(--text); | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | .permission-popup-btn-deny:hover { | |||
| 70 | + | background-color: shade(var(--surface), 1.15); | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | /* Always Deny - destructive */ | |||
| 74 | + | .permission-popup-btn-destructive { | |||
| 75 | + | background-color: alpha(var(--destructive), 0.15); | |||
| 76 | + | color: var(--destructive); | |||
| 77 | + | border-color: alpha(var(--destructive), 0.3); | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | .permission-popup-btn-destructive:hover { | |||
| 81 | + | background-color: alpha(var(--destructive), 0.25); | |||
| 82 | + | border-color: var(--destructive); | |||
| 83 | + | } | |||
| 84 | + | ` | |||
| 85 | + | } | |||
A internal/ui/theme/webrtc_indicator_css.go +160 -0
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generateWebRTCPermissionIndicatorCSS creates styles for the top-right | |||
| 4 | + | // WebRTC permission activity indicator. | |||
| 5 | + | // The dot anchors top-right; on hover the card expands left and down | |||
| 6 | + | // using a GtkRevealer (slide-down) for the details panel. | |||
| 7 | + | func generateWebRTCPermissionIndicatorCSS(p Palette) string { | |||
| 8 | + | _ = p | |||
| 9 | + | return `/* ===== WebRTC Permission Indicator ===== */ | |||
| 10 | + | ||||
| 11 | + | .webrtc-indicator-outer { | |||
| 12 | + | margin-top: 0.125em; | |||
| 13 | + | margin-right: 0.125em; | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | /* Card container: transparent when collapsed, styled when expanded. | |||
| 17 | + | Right-aligned so the dot stays top-right and content grows leftward. */ | |||
| 18 | + | .webrtc-indicator-card { | |||
| 19 | + | background-color: transparent; | |||
| 20 | + | border: 0.0625em solid transparent; | |||
| 21 | + | border-radius: 999px; | |||
| 22 | + | padding: 0; | |||
| 23 | + | transition: background-color 180ms ease-in-out, | |||
| 24 | + | border-color 180ms ease-in-out, | |||
| 25 | + | box-shadow 180ms ease-in-out; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | .webrtc-indicator-card.expanded { | |||
| 29 | + | background-color: alpha(var(--surface-variant), 0.96); | |||
| 30 | + | border-color: alpha(var(--border), 0.8); | |||
| 31 | + | border-radius: 0.375em; | |||
| 32 | + | box-shadow: 0 0.25em 1em alpha(black, 0.28), | |||
| 33 | + | 0 0.0625em 0.25em alpha(black, 0.12); | |||
| 34 | + | padding: 0.4em 0.5em; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | /* Summary dot - small colored circle, right-aligned */ | |||
| 38 | + | .webrtc-indicator-dot { | |||
| 39 | + | min-width: 0.5em; | |||
| 40 | + | min-height: 0.5em; | |||
| 41 | + | border-radius: 999px; | |||
| 42 | + | background-color: var(--muted); | |||
| 43 | + | border: 0.0625em solid alpha(var(--border), 0.6); | |||
| 44 | + | transition: background-color 200ms ease-in-out, | |||
| 45 | + | border-color 200ms ease-in-out, | |||
| 46 | + | box-shadow 200ms ease-in-out; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | /* Origin label shown next to the dot. */ | |||
| 50 | + | .webrtc-indicator-origin { | |||
| 51 | + | font-size: 0.6875em; | |||
| 52 | + | font-weight: 600; | |||
| 53 | + | letter-spacing: 0.02em; | |||
| 54 | + | padding: 0 0.4em 0 0; | |||
| 55 | + | color: var(--muted); | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | /* Details section inside the revealer (permission rows only) */ | |||
| 59 | + | .webrtc-indicator-details { | |||
| 60 | + | margin-top: 0.3em; | |||
| 61 | + | padding-top: 0.3em; | |||
| 62 | + | border-top: 0.0625em solid alpha(var(--border), 0.5); | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | /* Permission row buttons */ | |||
| 66 | + | .webrtc-indicator-row { | |||
| 67 | + | font-size: 0.6875em; | |||
| 68 | + | font-weight: 500; | |||
| 69 | + | padding: 0.25em 0.4em; | |||
| 70 | + | margin-top: 0.15em; | |||
| 71 | + | border-radius: 0.25em; | |||
| 72 | + | border-left: 0.1875em solid transparent; | |||
| 73 | + | border-top: none; | |||
| 74 | + | border-right: none; | |||
| 75 | + | border-bottom: none; | |||
| 76 | + | background-color: alpha(var(--bg), 0.35); | |||
| 77 | + | background-image: none; | |||
| 78 | + | color: var(--muted); | |||
| 79 | + | text-align: left; | |||
| 80 | + | transition: background-color 150ms ease-in-out, | |||
| 81 | + | border-color 150ms ease-in-out, | |||
| 82 | + | color 150ms ease-in-out; | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | .webrtc-indicator-row:hover { | |||
| 86 | + | background-color: alpha(var(--bg), 0.55); | |||
| 87 | + | background-image: none; | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | /* ---- State: idle ---- */ | |||
| 91 | + | .webrtc-indicator-dot.state-idle { | |||
| 92 | + | background-color: var(--muted); | |||
| 93 | + | border-color: alpha(var(--border), 0.6); | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | .webrtc-indicator-row.state-idle { | |||
| 97 | + | color: var(--muted); | |||
| 98 | + | border-left-color: alpha(var(--muted), 0.4); | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | /* ---- State: requesting (amber) ---- */ | |||
| 102 | + | .webrtc-indicator-dot.state-requesting { | |||
| 103 | + | background-color: var(--warning); | |||
| 104 | + | border-color: alpha(var(--warning), 0.6); | |||
| 105 | + | box-shadow: 0 0 0.4em alpha(var(--warning), 0.5); | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | .webrtc-indicator-row.state-requesting { | |||
| 109 | + | color: var(--warning); | |||
| 110 | + | border-left-color: var(--warning); | |||
| 111 | + | background-color: alpha(var(--warning), 0.08); | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | .webrtc-indicator-row.state-requesting:hover { | |||
| 115 | + | background-color: alpha(var(--warning), 0.14); | |||
| 116 | + | background-image: none; | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | /* ---- State: allowed (green) ---- */ | |||
| 120 | + | .webrtc-indicator-dot.state-allowed { | |||
| 121 | + | background-color: var(--success); | |||
| 122 | + | border-color: alpha(var(--success), 0.6); | |||
| 123 | + | box-shadow: 0 0 0.35em alpha(var(--success), 0.35); | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | .webrtc-indicator-row.state-allowed { | |||
| 127 | + | color: var(--success); | |||
| 128 | + | border-left-color: var(--success); | |||
| 129 | + | background-color: alpha(var(--success), 0.06); | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | .webrtc-indicator-row.state-allowed:hover { | |||
| 133 | + | background-color: alpha(var(--success), 0.12); | |||
| 134 | + | background-image: none; | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | /* ---- State: blocked (red) ---- */ | |||
| 138 | + | .webrtc-indicator-dot.state-blocked { | |||
| 139 | + | background-color: var(--destructive); | |||
| 140 | + | border-color: alpha(var(--destructive), 0.6); | |||
| 141 | + | box-shadow: 0 0 0.35em alpha(var(--destructive), 0.35); | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | .webrtc-indicator-row.state-blocked { | |||
| 145 | + | color: var(--destructive); | |||
| 146 | + | border-left-color: var(--destructive); | |||
| 147 | + | background-color: alpha(var(--destructive), 0.06); | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | .webrtc-indicator-row.state-blocked:hover { | |||
| 151 | + | background-color: alpha(var(--destructive), 0.12); | |||
| 152 | + | background-image: none; | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | /* ---- Locked row modifier ---- */ | |||
| 156 | + | .webrtc-indicator-row.row-locked { | |||
| 157 | + | font-weight: 600; | |||
| 158 | + | } | |||
| 159 | + | ` | |||
| 160 | + | } | |||
M internal/ui/window/main_window.go +6 -0
| @@ -185,6 +185,12 @@ func (mw *MainWindow) SetTitle(title string) { | |||||
| 185 | mw.window.SetTitle(&title) | 185 | mw.window.SetTitle(&title) | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | + | // ContentOverlay returns the content overlay widget. | |||
| 189 | + | // Used by components that need the overlay dimensions for sizing calculations. | |||
| 190 | + | func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |||
| 191 | + | return mw.contentOverlay | |||
| 192 | + | } | |||
| 193 | + | ||||
| 188 | // AddOverlay adds a widget to the content overlay. | 194 | // AddOverlay adds a widget to the content overlay. | ||
| 189 | // The widget will be displayed on top of the workspace content. | 195 | // The widget will be displayed on top of the workspace content. | ||
| 190 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | 196 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | ||
M webui/package.json +3 -1
| @@ -4,14 +4,16 @@ | |||||
| 4 | "private": true, | 4 | "private": true, | ||
| 5 | "type": "module", | 5 | "type": "module", | ||
| 6 | "scripts": { | 6 | "scripts": { | ||
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build", | 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build", |
| 8 | "build:homepage": "VITE_PAGE=homepage vite build", | 8 | "build:homepage": "VITE_PAGE=homepage vite build", | ||
| 9 | "build:error": "VITE_PAGE=error vite build", | 9 | "build:error": "VITE_PAGE=error vite build", | ||
| 10 | "build:config": "VITE_PAGE=config vite build", | 10 | "build:config": "VITE_PAGE=config vite build", | ||
| 11 | + | "build:webrtc": "VITE_PAGE=webrtc vite build", | |||
| 11 | "dev": "VITE_PAGE=homepage vite build --watch", | 12 | "dev": "VITE_PAGE=homepage vite build --watch", | ||
| 12 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | 13 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | ||
| 13 | "dev:error": "VITE_PAGE=error vite build --watch", | 14 | "dev:error": "VITE_PAGE=error vite build --watch", | ||
| 14 | "dev:config": "VITE_PAGE=config vite build --watch", | 15 | "dev:config": "VITE_PAGE=config vite build --watch", | ||
| 16 | + | "dev:webrtc": "VITE_PAGE=webrtc vite build --watch", | |||
| 15 | "check": "svelte-check", | 17 | "check": "svelte-check", | ||
| 16 | "lint": "eslint src --fix", | 18 | "lint": "eslint src --fix", | ||
| 17 | "fmt": "prettier --write ." | 19 | "fmt": "prettier --write ." | ||
A webui/src/pages/webrtc.ts +165 -0
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | import "../styles/app.css"; | |||
| 2 | + | ||||
| 3 | + | const activeStreams: MediaStream[] = []; | |||
| 4 | + | ||||
| 5 | + | function now(): string { | |||
| 6 | + | return new Date().toLocaleTimeString(); | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | function appendLog(output: HTMLPreElement, message: string): void { | |||
| 10 | + | output.textContent = `${output.textContent}[${now()}] ${message}\n`; | |||
| 11 | + | output.scrollTop = output.scrollHeight; | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | function setStatus(status: HTMLElement, value: string): void { | |||
| 15 | + | status.textContent = value; | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | function stopAllStreams(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): void { | |||
| 19 | + | for (const stream of activeStreams.splice(0)) { | |||
| 20 | + | for (const track of stream.getTracks()) { | |||
| 21 | + | track.stop(); | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | video.pause(); | |||
| 26 | + | video.srcObject = null; | |||
| 27 | + | setStatus(status, "idle"); | |||
| 28 | + | appendLog(output, "Stopped all active tracks"); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | async function probeDevices(output: HTMLPreElement): Promise<void> { | |||
| 32 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.enumerateDevices !== "function") { | |||
| 33 | + | appendLog(output, "enumerateDevices is unavailable"); | |||
| 34 | + | return; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | try { | |||
| 38 | + | const devices = await navigator.mediaDevices.enumerateDevices(); | |||
| 39 | + | const summary = devices | |||
| 40 | + | .map((device) => `${device.kind} ${device.label || "(label hidden until permission)"}`) | |||
| 41 | + | .join(" | "); | |||
| 42 | + | ||||
| 43 | + | appendLog(output, `Devices: ${summary || "none"}`); | |||
| 44 | + | } catch (err) { | |||
| 45 | + | appendLog(output, `enumerateDevices failed: ${String(err)}`); | |||
| 46 | + | appendLog(output, "Devices: none"); | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | async function requestUserMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |||
| 51 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getUserMedia !== "function") { | |||
| 52 | + | appendLog(output, "getUserMedia is unavailable"); | |||
| 53 | + | return; | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | try { | |||
| 57 | + | setStatus(status, "requesting camera + microphone"); | |||
| 58 | + | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); | |||
| 59 | + | activeStreams.push(stream); | |||
| 60 | + | video.srcObject = stream; | |||
| 61 | + | await video.play(); | |||
| 62 | + | setStatus(status, "camera + microphone granted"); | |||
| 63 | + | appendLog(output, "camera + microphone permission granted"); | |||
| 64 | + | await probeDevices(output); | |||
| 65 | + | } catch (err) { | |||
| 66 | + | setStatus(status, "camera + microphone blocked"); | |||
| 67 | + | appendLog(output, `camera + microphone request failed: ${String(err)}`); | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | async function requestDisplayMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> { | |||
| 72 | + | if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getDisplayMedia !== "function") { | |||
| 73 | + | appendLog(output, "getDisplayMedia is unavailable"); | |||
| 74 | + | return; | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | try { | |||
| 78 | + | setStatus(status, "requesting screen capture"); | |||
| 79 | + | const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); | |||
| 80 | + | activeStreams.push(stream); | |||
| 81 | + | video.srcObject = stream; | |||
| 82 | + | await video.play(); | |||
| 83 | + | setStatus(status, "screen capture granted"); | |||
| 84 | + | appendLog(output, "screen capture permission granted"); | |||
| 85 | + | } catch (err) { | |||
| 86 | + | setStatus(status, "screen capture blocked"); | |||
| 87 | + | appendLog(output, `screen capture request failed: ${String(err)}`); | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | function mount(): void { | |||
| 92 | + | document.title = "WebRTC Diagnostics"; | |||
| 93 | + | document.body.innerHTML = ` | |||
| 94 | + | <main class="page-shell" style="padding: 24px; max-width: 920px; margin: 0 auto; display: grid; gap: 16px;"> | |||
| 95 | + | <header style="display: grid; gap: 8px;"> | |||
| 96 | + | <h1 style="margin: 0; font-size: 1.8rem;">WebRTC Diagnostics</h1> | |||
| 97 | + | <p style="margin: 0; opacity: 0.85;">Use this page to validate camera, microphone, and screen permission behavior.</p> | |||
| 98 | + | </header> | |||
| 99 | + | ||||
| 100 | + | <section style="display: flex; flex-wrap: wrap; gap: 10px;"> | |||
| 101 | + | <button id="btn-av" type="button">Request camera + microphone</button> | |||
| 102 | + | <button id="btn-screen" type="button">Request screen capture</button> | |||
| 103 | + | <button id="btn-stop" type="button">Stop all streams</button> | |||
| 104 | + | </section> | |||
| 105 | + | ||||
| 106 | + | <section style="display: grid; gap: 8px;"> | |||
| 107 | + | <strong>Status: <span id="status">idle</span></strong> | |||
| 108 | + | <video id="preview" autoplay playsinline muted style="width: 100%; max-height: 320px; border-radius: 10px; background: #0c1522;"></video> | |||
| 109 | + | </section> | |||
| 110 | + | ||||
| 111 | + | <section style="display: grid; gap: 8px;"> | |||
| 112 | + | <strong>Event Log</strong> | |||
| 113 | + | <pre id="output" style="margin: 0; padding: 12px; border-radius: 10px; background: #0d1727; color: #dbe8f4; min-height: 180px; max-height: 280px; overflow: auto;">[${now()}] Ready\n</pre> | |||
| 114 | + | </section> | |||
| 115 | + | </main> | |||
| 116 | + | `; | |||
| 117 | + | ||||
| 118 | + | const status = document.getElementById("status"); | |||
| 119 | + | const preview = document.getElementById("preview"); | |||
| 120 | + | const output = document.getElementById("output"); | |||
| 121 | + | const btnAV = document.getElementById("btn-av"); | |||
| 122 | + | const btnScreen = document.getElementById("btn-screen"); | |||
| 123 | + | const btnStop = document.getElementById("btn-stop"); | |||
| 124 | + | ||||
| 125 | + | if (!(status instanceof HTMLElement)) { | |||
| 126 | + | return; | |||
| 127 | + | } | |||
| 128 | + | if (!(preview instanceof HTMLVideoElement)) { | |||
| 129 | + | return; | |||
| 130 | + | } | |||
| 131 | + | if (!(output instanceof HTMLPreElement)) { | |||
| 132 | + | return; | |||
| 133 | + | } | |||
| 134 | + | if (!(btnAV instanceof HTMLButtonElement)) { | |||
| 135 | + | return; | |||
| 136 | + | } | |||
| 137 | + | if (!(btnScreen instanceof HTMLButtonElement)) { | |||
| 138 | + | return; | |||
| 139 | + | } | |||
| 140 | + | if (!(btnStop instanceof HTMLButtonElement)) { | |||
| 141 | + | return; | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | btnAV.addEventListener("click", () => { | |||
| 145 | + | requestUserMedia(preview, output, status).catch((err: unknown) => { | |||
| 146 | + | appendLog(output, `unexpected user media error: ${String(err)}`); | |||
| 147 | + | }); | |||
| 148 | + | }); | |||
| 149 | + | ||||
| 150 | + | btnScreen.addEventListener("click", () => { | |||
| 151 | + | requestDisplayMedia(preview, output, status).catch((err: unknown) => { | |||
| 152 | + | appendLog(output, `unexpected display media error: ${String(err)}`); | |||
| 153 | + | }); | |||
| 154 | + | }); | |||
| 155 | + | ||||
| 156 | + | btnStop.addEventListener("click", () => { | |||
| 157 | + | stopAllStreams(preview, output, status); | |||
| 158 | + | }); | |||
| 159 | + | ||||
| 160 | + | window.addEventListener("beforeunload", () => { | |||
| 161 | + | stopAllStreams(preview, output, status); | |||
| 162 | + | }); | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | mount(); | |||
M webui/vite.config.ts +12 -0
| @@ -42,6 +42,18 @@ const pages = { | |||||
| 42 | filename: "config.html", | 42 | filename: "config.html", | ||
| 43 | }, | 43 | }, | ||
| 44 | }, | 44 | }, | ||
| 45 | + | webrtc: { | |||
| 46 | + | entry: "src/pages/webrtc.ts", | |||
| 47 | + | output: "webrtc.min.js", | |||
| 48 | + | global: "DumberWebRTC", | |||
| 49 | + | html: { | |||
| 50 | + | name: "webrtc", | |||
| 51 | + | title: "WebRTC Diagnostics", | |||
| 52 | + | script: "webrtc.min.js", | |||
| 53 | + | css: "style.css", | |||
| 54 | + | filename: "webrtc.html", | |||
| 55 | + | }, | |||
| 56 | + | }, | |||
| 45 | } as const; | 57 | } as const; | ||
| 46 | 58 | ||||
| 47 | // Get page from VITE_PAGE env var, default to building all | 59 | // Get page from VITE_PAGE env var, default to building all | ||