feat(cef): wire CEF permission requests into existing permission flow
Implement purecef.PermissionHandler on handlerSet so camera, microphone, screen capture, geolocation and notification requests reach WebViewCallbacks.OnPermissionRequest. Requests are resolved exactly once (allow/deny/dismiss/browser close), unsupported types and missing handlers deny, and CEF callbacks are continued on the CEF UI thread. Also accept notification/geolocation in the content coordinator and label them in the permission dialog.
- commit
- ad51442c0c757616b9f7ee767ada199e3366461b
- author
- brice · · 2026-10-03 06:05 UTC
- committer
- brice · · 2026-10-03 07:50 UTC
- parent
- e8e7279
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +660 -2
A internal/infrastructure/cef/permission_handler.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | "github.com/rs/zerolog" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Mask of media access bits Dumber knows how to map to a permission type. | |
| 17 | + | const supportedMediaAccessMask = uint32( | |
| 18 | + | purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture | | |
| 19 | + | purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture | | |
| 20 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture | | |
| 21 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |
| 22 | + | ||
| 23 | + | // mediaPermissionTypes maps a CEF media access bitmask to Dumber permission | |
| 24 | + | // types. The returned allowedMask contains only the requested bits that Dumber | |
| 25 | + | // understands; it is what must be granted when the user allows the request. | |
| 26 | + | // An empty request or one containing unknown bits yields no types (deny). | |
| 27 | + | func mediaPermissionTypes(requested uint32) (types []string, allowedMask uint32) { | |
| 28 | + | if requested == 0 || requested&^supportedMediaAccessMask != 0 { | |
| 29 | + | return nil, 0 | |
| 30 | + | } | |
| 31 | + | allowedMask = requested | |
| 32 | + | if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) != 0 { | |
| 33 | + | types = append(types, string(entity.PermissionTypeMicrophone)) | |
| 34 | + | } | |
| 35 | + | if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) != 0 { | |
| 36 | + | types = append(types, string(entity.PermissionTypeCamera)) | |
| 37 | + | } | |
| 38 | + | const desktop = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture | | |
| 39 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |
| 40 | + | if allowedMask&desktop != 0 { | |
| 41 | + | types = append(types, string(entity.PermissionTypeDisplay)) | |
| 42 | + | } | |
| 43 | + | return types, allowedMask | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber | |
| 47 | + | // permission types. ok is false when the mask is empty or contains any bit that | |
| 48 | + | // Dumber does not support, in which case the whole request must be denied. | |
| 49 | + | func promptPermissionTypes(requested uint32) (types []string, ok bool) { | |
| 50 | + | if requested == 0 { | |
| 51 | + | return nil, false | |
| 52 | + | } | |
| 53 | + | const ( | |
| 54 | + | camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream | | |
| 55 | + | purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom) | |
| 56 | + | mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream) | |
| 57 | + | geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |
| 58 | + | notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |
| 59 | + | supported = camera | mic | geolocation | notifications | |
| 60 | + | ) | |
| 61 | + | if requested&^supported != 0 { | |
| 62 | + | return nil, false | |
| 63 | + | } | |
| 64 | + | if requested&mic != 0 { | |
| 65 | + | types = append(types, string(entity.PermissionTypeMicrophone)) | |
| 66 | + | } | |
| 67 | + | if requested&camera != 0 { | |
| 68 | + | types = append(types, string(entity.PermissionTypeCamera)) | |
| 69 | + | } | |
| 70 | + | if requested&geolocation != 0 { | |
| 71 | + | types = append(types, string(entity.PermissionTypeGeolocation)) | |
| 72 | + | } | |
| 73 | + | if requested¬ifications != 0 { | |
| 74 | + | types = append(types, string(entity.PermissionTypeNotification)) | |
| 75 | + | } | |
| 76 | + | return types, true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // cefPermissionRequest guarantees that a CEF permission callback is resolved | |
| 80 | + | // exactly once, whichever of allow, deny, dismissal or browser destruction | |
| 81 | + | // happens first. | |
| 82 | + | type cefPermissionRequest struct { | |
| 83 | + | done atomic.Bool | |
| 84 | + | finish func(allow bool) | |
| 85 | + | untrack func() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // resolve completes the request with the given decision. Only the first call | |
| 89 | + | // (of resolve and abandon combined) has any effect. | |
| 90 | + | func (r *cefPermissionRequest) resolve(allow bool) { | |
| 91 | + | if !r.done.CompareAndSwap(false, true) { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | if r.untrack != nil { | |
| 95 | + | r.untrack() | |
| 96 | + | } | |
| 97 | + | r.finish(allow) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // abandon marks the request as finished without touching the CEF callback. | |
| 101 | + | // It is used when CEF has already invalidated the callback (prompt dismissed). | |
| 102 | + | func (r *cefPermissionRequest) abandon() bool { | |
| 103 | + | if !r.done.CompareAndSwap(false, true) { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | if r.untrack != nil { | |
| 107 | + | r.untrack() | |
| 108 | + | } | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (r *cefPermissionRequest) isDone() bool { return r.done.Load() } | |
| 113 | + | ||
| 114 | + | type permissionKey struct { | |
| 115 | + | prompt bool | |
| 116 | + | id uint64 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // permissionTracker holds the unresolved requests of one WebView. The zero | |
| 120 | + | // value is ready to use. | |
| 121 | + | type permissionTracker struct { | |
| 122 | + | mu sync.Mutex | |
| 123 | + | nextID uint64 | |
| 124 | + | pending map[permissionKey]*cefPermissionRequest | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (t *permissionTracker) nextMediaKey() permissionKey { | |
| 128 | + | t.mu.Lock() | |
| 129 | + | defer t.mu.Unlock() | |
| 130 | + | t.nextID++ | |
| 131 | + | return permissionKey{id: t.nextID} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // add registers req under key and returns any request previously registered | |
| 135 | + | // under the same key. | |
| 136 | + | func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest { | |
| 137 | + | t.mu.Lock() | |
| 138 | + | defer t.mu.Unlock() | |
| 139 | + | if t.pending == nil { | |
| 140 | + | t.pending = make(map[permissionKey]*cefPermissionRequest) | |
| 141 | + | } | |
| 142 | + | prev := t.pending[key] | |
| 143 | + | t.pending[key] = req | |
| 144 | + | req.untrack = func() { | |
| 145 | + | t.mu.Lock() | |
| 146 | + | defer t.mu.Unlock() | |
| 147 | + | if t.pending[key] == req { | |
| 148 | + | delete(t.pending, key) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | return prev | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest { | |
| 155 | + | t.mu.Lock() | |
| 156 | + | defer t.mu.Unlock() | |
| 157 | + | return t.pending[key] | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (t *permissionTracker) drain() []*cefPermissionRequest { | |
| 161 | + | t.mu.Lock() | |
| 162 | + | defer t.mu.Unlock() | |
| 163 | + | reqs := make([]*cefPermissionRequest, 0, len(t.pending)) | |
| 164 | + | for _, req := range t.pending { | |
| 165 | + | reqs = append(reqs, req) | |
| 166 | + | } | |
| 167 | + | return reqs | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests. | |
| 171 | + | func (h *handlerSet) OnRequestMediaAccessPermission( | |
| 172 | + | _ purecef.Browser, | |
| 173 | + | _ purecef.Frame, | |
| 174 | + | requestingOrigin string, | |
| 175 | + | requestedPermissions uint32, | |
| 176 | + | callback purecef.MediaAccessCallback, | |
| 177 | + | ) int32 { | |
| 178 | + | if h == nil || h.wv == nil || callback == nil { | |
| 179 | + | return 0 | |
| 180 | + | } | |
| 181 | + | types, allowedMask := mediaPermissionTypes(requestedPermissions) | |
| 182 | + | h.beginPermissionRequest(h.permissions.nextMediaKey(), requestingOrigin, types, func(allow bool) { | |
| 183 | + | if allow && allowedMask != 0 { | |
| 184 | + | callback.Cont(allowedMask) | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | callback.Cancel() | |
| 188 | + | }) | |
| 189 | + | return 1 | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // OnShowPermissionPrompt handles non-media permission requests | |
| 193 | + | // (geolocation, notifications, ...). | |
| 194 | + | func (h *handlerSet) OnShowPermissionPrompt( | |
| 195 | + | _ purecef.Browser, | |
| 196 | + | promptID uint64, | |
| 197 | + | requestingOrigin string, | |
| 198 | + | requestedPermissions uint32, | |
| 199 | + | callback purecef.PermissionPromptCallback, | |
| 200 | + | ) int32 { | |
| 201 | + | if h == nil || h.wv == nil || callback == nil { | |
| 202 | + | return 0 | |
| 203 | + | } | |
| 204 | + | types, _ := promptPermissionTypes(requestedPermissions) | |
| 205 | + | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, requestingOrigin, types, func(allow bool) { | |
| 206 | + | if allow { | |
| 207 | + | callback.Cont(purecef.PermissionRequestResultPermissionResultAccept) | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | callback.Cont(purecef.PermissionRequestResultPermissionResultDeny) | |
| 211 | + | }) | |
| 212 | + | return 1 | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled. | |
| 216 | + | // The callback is no longer valid, so it must not be invoked anymore. | |
| 217 | + | func (h *handlerSet) OnDismissPermissionPrompt( | |
| 218 | + | _ purecef.Browser, | |
| 219 | + | promptID uint64, | |
| 220 | + | result purecef.PermissionRequestResult, | |
| 221 | + | ) { | |
| 222 | + | if h == nil || h.wv == nil { | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | req := h.permissions.get(permissionKey{prompt: true, id: promptID}) | |
| 226 | + | if req == nil || !req.abandon() { | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | logCEFPermission(h.wv).Debug(). | |
| 230 | + | Uint64("prompt_id", promptID). | |
| 231 | + | Int32("result", result). | |
| 232 | + | Msg("cef: permission prompt dismissed") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // denyPendingPermissions resolves every unresolved request as denied. It is | |
| 236 | + | // called when the browser is closing so nothing is left hanging. | |
| 237 | + | func (h *handlerSet) denyPendingPermissions() { | |
| 238 | + | if h == nil { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | for _, req := range h.permissions.drain() { | |
| 242 | + | req.resolve(false) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // beginPermissionRequest registers a request, forwards it to the WebView's | |
| 247 | + | // OnPermissionRequest callback on the GTK thread, and guarantees finish is | |
| 248 | + | // invoked exactly once on the CEF UI thread. It denies when the request cannot | |
| 249 | + | // be mapped, the origin is invalid, no callback is available, or the WebView | |
| 250 | + | // is destroyed. | |
| 251 | + | func (h *handlerSet) beginPermissionRequest( | |
| 252 | + | key permissionKey, | |
| 253 | + | requestingOrigin string, | |
| 254 | + | types []string, | |
| 255 | + | finish func(allow bool), | |
| 256 | + | ) { | |
| 257 | + | wv := h.wv | |
| 258 | + | req := &cefPermissionRequest{ | |
| 259 | + | finish: func(allow bool) { | |
| 260 | + | dispatchPermissionResult(wv, func() { finish(allow) }) | |
| 261 | + | }, | |
| 262 | + | } | |
| 263 | + | if prev := h.permissions.add(key, req); prev != nil { | |
| 264 | + | prev.resolve(false) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | log := logCEFPermission(wv) | |
| 268 | + | if len(types) == 0 || wv.destroyed.Load() { | |
| 269 | + | log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request") | |
| 270 | + | req.resolve(false) | |
| 271 | + | return | |
| 272 | + | } | |
| 273 | + | origin, err := urlutil.ExtractOrigin(requestingOrigin) | |
| 274 | + | if err != nil { | |
| 275 | + | log.Debug().Err(err).Msg("cef: denying permission request with invalid origin") | |
| 276 | + | req.resolve(false) | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | wv.mu.RLock() | |
| 281 | + | cb := wv.callbacks | |
| 282 | + | wv.mu.RUnlock() | |
| 283 | + | if cb == nil || cb.OnPermissionRequest == nil { | |
| 284 | + | log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler") | |
| 285 | + | req.resolve(false) | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | allow := func() { req.resolve(true) } | |
| 290 | + | deny := func() { req.resolve(false) } | |
| 291 | + | wv.runOnGTK(func() { | |
| 292 | + | if req.isDone() { | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) { | |
| 296 | + | req.resolve(false) | |
| 297 | + | } | |
| 298 | + | }) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission | |
| 302 | + | // callbacks must be continued there. When the UI thread is unreachable fn runs | |
| 303 | + | // inline so the page never hangs. | |
| 304 | + | func dispatchPermissionResult(wv *WebView, fn func()) { | |
| 305 | + | if wv == nil || wv.engine == nil { | |
| 306 | + | fn() | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | task := cefNewTask(cefTaskFunc(fn)) | |
| 310 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 311 | + | logCEFPermission(wv).Warn(). | |
| 312 | + | Int32("post_result", result). | |
| 313 | + | Msg("cef: failed to post permission result to CEF UI thread; continuing inline") | |
| 314 | + | fn() | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func logCEFPermission(wv *WebView) *zerolog.Logger { | |
| 319 | + | if wv == nil || wv.ctx == nil { | |
| 320 | + | return logging.FromContext(context.Background()) | |
| 321 | + | } | |
| 322 | + | return logging.FromContext(wv.ctx) | |
| 323 | + | } | |
| @@ -0,0 +1,323 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | ||||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 9 | + | "github.com/rs/zerolog" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Mask of media access bits Dumber knows how to map to a permission type. | |||
| 17 | + | const supportedMediaAccessMask = uint32( | |||
| 18 | + | purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture | | |||
| 19 | + | purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture | | |||
| 20 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture | | |||
| 21 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |||
| 22 | + | ||||
| 23 | + | // mediaPermissionTypes maps a CEF media access bitmask to Dumber permission | |||
| 24 | + | // types. The returned allowedMask contains only the requested bits that Dumber | |||
| 25 | + | // understands; it is what must be granted when the user allows the request. | |||
| 26 | + | // An empty request or one containing unknown bits yields no types (deny). | |||
| 27 | + | func mediaPermissionTypes(requested uint32) (types []string, allowedMask uint32) { | |||
| 28 | + | if requested == 0 || requested&^supportedMediaAccessMask != 0 { | |||
| 29 | + | return nil, 0 | |||
| 30 | + | } | |||
| 31 | + | allowedMask = requested | |||
| 32 | + | if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) != 0 { | |||
| 33 | + | types = append(types, string(entity.PermissionTypeMicrophone)) | |||
| 34 | + | } | |||
| 35 | + | if allowedMask&uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) != 0 { | |||
| 36 | + | types = append(types, string(entity.PermissionTypeCamera)) | |||
| 37 | + | } | |||
| 38 | + | const desktop = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture | | |||
| 39 | + | purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |||
| 40 | + | if allowedMask&desktop != 0 { | |||
| 41 | + | types = append(types, string(entity.PermissionTypeDisplay)) | |||
| 42 | + | } | |||
| 43 | + | return types, allowedMask | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // promptPermissionTypes maps a CEF permission prompt bitmask to Dumber | |||
| 47 | + | // permission types. ok is false when the mask is empty or contains any bit that | |||
| 48 | + | // Dumber does not support, in which case the whole request must be denied. | |||
| 49 | + | func promptPermissionTypes(requested uint32) (types []string, ok bool) { | |||
| 50 | + | if requested == 0 { | |||
| 51 | + | return nil, false | |||
| 52 | + | } | |||
| 53 | + | const ( | |||
| 54 | + | camera = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream | | |||
| 55 | + | purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom) | |||
| 56 | + | mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream) | |||
| 57 | + | geolocation = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |||
| 58 | + | notifications = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |||
| 59 | + | supported = camera | mic | geolocation | notifications | |||
| 60 | + | ) | |||
| 61 | + | if requested&^supported != 0 { | |||
| 62 | + | return nil, false | |||
| 63 | + | } | |||
| 64 | + | if requested&mic != 0 { | |||
| 65 | + | types = append(types, string(entity.PermissionTypeMicrophone)) | |||
| 66 | + | } | |||
| 67 | + | if requested&camera != 0 { | |||
| 68 | + | types = append(types, string(entity.PermissionTypeCamera)) | |||
| 69 | + | } | |||
| 70 | + | if requested&geolocation != 0 { | |||
| 71 | + | types = append(types, string(entity.PermissionTypeGeolocation)) | |||
| 72 | + | } | |||
| 73 | + | if requested¬ifications != 0 { | |||
| 74 | + | types = append(types, string(entity.PermissionTypeNotification)) | |||
| 75 | + | } | |||
| 76 | + | return types, true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // cefPermissionRequest guarantees that a CEF permission callback is resolved | |||
| 80 | + | // exactly once, whichever of allow, deny, dismissal or browser destruction | |||
| 81 | + | // happens first. | |||
| 82 | + | type cefPermissionRequest struct { | |||
| 83 | + | done atomic.Bool | |||
| 84 | + | finish func(allow bool) | |||
| 85 | + | untrack func() | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // resolve completes the request with the given decision. Only the first call | |||
| 89 | + | // (of resolve and abandon combined) has any effect. | |||
| 90 | + | func (r *cefPermissionRequest) resolve(allow bool) { | |||
| 91 | + | if !r.done.CompareAndSwap(false, true) { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | if r.untrack != nil { | |||
| 95 | + | r.untrack() | |||
| 96 | + | } | |||
| 97 | + | r.finish(allow) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // abandon marks the request as finished without touching the CEF callback. | |||
| 101 | + | // It is used when CEF has already invalidated the callback (prompt dismissed). | |||
| 102 | + | func (r *cefPermissionRequest) abandon() bool { | |||
| 103 | + | if !r.done.CompareAndSwap(false, true) { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | if r.untrack != nil { | |||
| 107 | + | r.untrack() | |||
| 108 | + | } | |||
| 109 | + | return true | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (r *cefPermissionRequest) isDone() bool { return r.done.Load() } | |||
| 113 | + | ||||
| 114 | + | type permissionKey struct { | |||
| 115 | + | prompt bool | |||
| 116 | + | id uint64 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // permissionTracker holds the unresolved requests of one WebView. The zero | |||
| 120 | + | // value is ready to use. | |||
| 121 | + | type permissionTracker struct { | |||
| 122 | + | mu sync.Mutex | |||
| 123 | + | nextID uint64 | |||
| 124 | + | pending map[permissionKey]*cefPermissionRequest | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (t *permissionTracker) nextMediaKey() permissionKey { | |||
| 128 | + | t.mu.Lock() | |||
| 129 | + | defer t.mu.Unlock() | |||
| 130 | + | t.nextID++ | |||
| 131 | + | return permissionKey{id: t.nextID} | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // add registers req under key and returns any request previously registered | |||
| 135 | + | // under the same key. | |||
| 136 | + | func (t *permissionTracker) add(key permissionKey, req *cefPermissionRequest) *cefPermissionRequest { | |||
| 137 | + | t.mu.Lock() | |||
| 138 | + | defer t.mu.Unlock() | |||
| 139 | + | if t.pending == nil { | |||
| 140 | + | t.pending = make(map[permissionKey]*cefPermissionRequest) | |||
| 141 | + | } | |||
| 142 | + | prev := t.pending[key] | |||
| 143 | + | t.pending[key] = req | |||
| 144 | + | req.untrack = func() { | |||
| 145 | + | t.mu.Lock() | |||
| 146 | + | defer t.mu.Unlock() | |||
| 147 | + | if t.pending[key] == req { | |||
| 148 | + | delete(t.pending, key) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | return prev | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (t *permissionTracker) get(key permissionKey) *cefPermissionRequest { | |||
| 155 | + | t.mu.Lock() | |||
| 156 | + | defer t.mu.Unlock() | |||
| 157 | + | return t.pending[key] | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (t *permissionTracker) drain() []*cefPermissionRequest { | |||
| 161 | + | t.mu.Lock() | |||
| 162 | + | defer t.mu.Unlock() | |||
| 163 | + | reqs := make([]*cefPermissionRequest, 0, len(t.pending)) | |||
| 164 | + | for _, req := range t.pending { | |||
| 165 | + | reqs = append(reqs, req) | |||
| 166 | + | } | |||
| 167 | + | return reqs | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // OnRequestMediaAccessPermission handles getUserMedia/getDisplayMedia requests. | |||
| 171 | + | func (h *handlerSet) OnRequestMediaAccessPermission( | |||
| 172 | + | _ purecef.Browser, | |||
| 173 | + | _ purecef.Frame, | |||
| 174 | + | requestingOrigin string, | |||
| 175 | + | requestedPermissions uint32, | |||
| 176 | + | callback purecef.MediaAccessCallback, | |||
| 177 | + | ) int32 { | |||
| 178 | + | if h == nil || h.wv == nil || callback == nil { | |||
| 179 | + | return 0 | |||
| 180 | + | } | |||
| 181 | + | types, allowedMask := mediaPermissionTypes(requestedPermissions) | |||
| 182 | + | h.beginPermissionRequest(h.permissions.nextMediaKey(), requestingOrigin, types, func(allow bool) { | |||
| 183 | + | if allow && allowedMask != 0 { | |||
| 184 | + | callback.Cont(allowedMask) | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | callback.Cancel() | |||
| 188 | + | }) | |||
| 189 | + | return 1 | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // OnShowPermissionPrompt handles non-media permission requests | |||
| 193 | + | // (geolocation, notifications, ...). | |||
| 194 | + | func (h *handlerSet) OnShowPermissionPrompt( | |||
| 195 | + | _ purecef.Browser, | |||
| 196 | + | promptID uint64, | |||
| 197 | + | requestingOrigin string, | |||
| 198 | + | requestedPermissions uint32, | |||
| 199 | + | callback purecef.PermissionPromptCallback, | |||
| 200 | + | ) int32 { | |||
| 201 | + | if h == nil || h.wv == nil || callback == nil { | |||
| 202 | + | return 0 | |||
| 203 | + | } | |||
| 204 | + | types, _ := promptPermissionTypes(requestedPermissions) | |||
| 205 | + | h.beginPermissionRequest(permissionKey{prompt: true, id: promptID}, requestingOrigin, types, func(allow bool) { | |||
| 206 | + | if allow { | |||
| 207 | + | callback.Cont(purecef.PermissionRequestResultPermissionResultAccept) | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | callback.Cont(purecef.PermissionRequestResultPermissionResultDeny) | |||
| 211 | + | }) | |||
| 212 | + | return 1 | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // OnDismissPermissionPrompt is called when CEF dismisses a prompt we handled. | |||
| 216 | + | // The callback is no longer valid, so it must not be invoked anymore. | |||
| 217 | + | func (h *handlerSet) OnDismissPermissionPrompt( | |||
| 218 | + | _ purecef.Browser, | |||
| 219 | + | promptID uint64, | |||
| 220 | + | result purecef.PermissionRequestResult, | |||
| 221 | + | ) { | |||
| 222 | + | if h == nil || h.wv == nil { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | req := h.permissions.get(permissionKey{prompt: true, id: promptID}) | |||
| 226 | + | if req == nil || !req.abandon() { | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | logCEFPermission(h.wv).Debug(). | |||
| 230 | + | Uint64("prompt_id", promptID). | |||
| 231 | + | Int32("result", result). | |||
| 232 | + | Msg("cef: permission prompt dismissed") | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // denyPendingPermissions resolves every unresolved request as denied. It is | |||
| 236 | + | // called when the browser is closing so nothing is left hanging. | |||
| 237 | + | func (h *handlerSet) denyPendingPermissions() { | |||
| 238 | + | if h == nil { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | for _, req := range h.permissions.drain() { | |||
| 242 | + | req.resolve(false) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // beginPermissionRequest registers a request, forwards it to the WebView's | |||
| 247 | + | // OnPermissionRequest callback on the GTK thread, and guarantees finish is | |||
| 248 | + | // invoked exactly once on the CEF UI thread. It denies when the request cannot | |||
| 249 | + | // be mapped, the origin is invalid, no callback is available, or the WebView | |||
| 250 | + | // is destroyed. | |||
| 251 | + | func (h *handlerSet) beginPermissionRequest( | |||
| 252 | + | key permissionKey, | |||
| 253 | + | requestingOrigin string, | |||
| 254 | + | types []string, | |||
| 255 | + | finish func(allow bool), | |||
| 256 | + | ) { | |||
| 257 | + | wv := h.wv | |||
| 258 | + | req := &cefPermissionRequest{ | |||
| 259 | + | finish: func(allow bool) { | |||
| 260 | + | dispatchPermissionResult(wv, func() { finish(allow) }) | |||
| 261 | + | }, | |||
| 262 | + | } | |||
| 263 | + | if prev := h.permissions.add(key, req); prev != nil { | |||
| 264 | + | prev.resolve(false) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | log := logCEFPermission(wv) | |||
| 268 | + | if len(types) == 0 || wv.destroyed.Load() { | |||
| 269 | + | log.Debug().Strs("types", types).Msg("cef: denying unsupported or late permission request") | |||
| 270 | + | req.resolve(false) | |||
| 271 | + | return | |||
| 272 | + | } | |||
| 273 | + | origin, err := urlutil.ExtractOrigin(requestingOrigin) | |||
| 274 | + | if err != nil { | |||
| 275 | + | log.Debug().Err(err).Msg("cef: denying permission request with invalid origin") | |||
| 276 | + | req.resolve(false) | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | wv.mu.RLock() | |||
| 281 | + | cb := wv.callbacks | |||
| 282 | + | wv.mu.RUnlock() | |||
| 283 | + | if cb == nil || cb.OnPermissionRequest == nil { | |||
| 284 | + | log.Debug().Str("origin", origin).Msg("cef: denying permission request; no handler") | |||
| 285 | + | req.resolve(false) | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | allow := func() { req.resolve(true) } | |||
| 290 | + | deny := func() { req.resolve(false) } | |||
| 291 | + | wv.runOnGTK(func() { | |||
| 292 | + | if req.isDone() { | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | if !cb.OnPermissionRequest(origin, types, map[string]string{}, allow, deny) { | |||
| 296 | + | req.resolve(false) | |||
| 297 | + | } | |||
| 298 | + | }) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // dispatchPermissionResult runs fn on the CEF UI thread, as CEF permission | |||
| 302 | + | // callbacks must be continued there. When the UI thread is unreachable fn runs | |||
| 303 | + | // inline so the page never hangs. | |||
| 304 | + | func dispatchPermissionResult(wv *WebView, fn func()) { | |||
| 305 | + | if wv == nil || wv.engine == nil { | |||
| 306 | + | fn() | |||
| 307 | + | return | |||
| 308 | + | } | |||
| 309 | + | task := cefNewTask(cefTaskFunc(fn)) | |||
| 310 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |||
| 311 | + | logCEFPermission(wv).Warn(). | |||
| 312 | + | Int32("post_result", result). | |||
| 313 | + | Msg("cef: failed to post permission result to CEF UI thread; continuing inline") | |||
| 314 | + | fn() | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func logCEFPermission(wv *WebView) *zerolog.Logger { | |||
| 319 | + | if wv == nil || wv.ctx == nil { | |||
| 320 | + | return logging.FromContext(context.Background()) | |||
| 321 | + | } | |||
| 322 | + | return logging.FromContext(wv.ctx) | |||
| 323 | + | } | |||
A internal/infrastructure/cef/permission_handler_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type stubMediaAccessCallback struct { | |
| 15 | + | contCalls []uint32 | |
| 16 | + | cancelCalls int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) } | |
| 20 | + | func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ } | |
| 21 | + | ||
| 22 | + | type stubPermissionPromptCallback struct { | |
| 23 | + | results []purecef.PermissionRequestResult | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) { | |
| 27 | + | s.results = append(s.results, result) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type permissionCapture struct { | |
| 31 | + | origin string | |
| 32 | + | types []string | |
| 33 | + | allow func() | |
| 34 | + | deny func() | |
| 35 | + | calls int | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func newPermissionTestHandler(handled bool) (*handlerSet, *permissionCapture) { | |
| 39 | + | capture := &permissionCapture{} | |
| 40 | + | wv := &WebView{ | |
| 41 | + | ctx: context.Background(), | |
| 42 | + | callbacks: &port.WebViewCallbacks{ | |
| 43 | + | OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool { | |
| 44 | + | capture.calls++ | |
| 45 | + | capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny | |
| 46 | + | return handled | |
| 47 | + | }, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | return &handlerSet{wv: wv}, capture | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const ( | |
| 54 | + | mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) | |
| 55 | + | mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) | |
| 56 | + | mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) | |
| 57 | + | mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | func TestMediaPermissionTypes(t *testing.T) { | |
| 61 | + | tests := []struct { | |
| 62 | + | name string | |
| 63 | + | requested uint32 | |
| 64 | + | types []string | |
| 65 | + | allowed uint32 | |
| 66 | + | }{ | |
| 67 | + | {"none", 0, nil, 0}, | |
| 68 | + | {"microphone", mediaMic, []string{"microphone"}, mediaMic}, | |
| 69 | + | {"camera", mediaCamera, []string{"camera"}, mediaCamera}, | |
| 70 | + | {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}, mediaMic | mediaCamera}, | |
| 71 | + | {"desktop video", mediaDeskV, []string{"display"}, mediaDeskV}, | |
| 72 | + | {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}, mediaDeskA | mediaDeskV}, | |
| 73 | + | {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}, mediaCamera | mediaDeskV}, | |
| 74 | + | {"unknown bit denies everything", mediaMic | 1<<10, nil, 0}, | |
| 75 | + | } | |
| 76 | + | for _, tt := range tests { | |
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |
| 78 | + | types, allowed := mediaPermissionTypes(tt.requested) | |
| 79 | + | assert.Equal(t, tt.types, types) | |
| 80 | + | assert.Equal(t, tt.allowed, allowed) | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestPromptPermissionTypes(t *testing.T) { | |
| 86 | + | const ( | |
| 87 | + | geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |
| 88 | + | notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |
| 89 | + | mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream) | |
| 90 | + | cam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream) | |
| 91 | + | ptz = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom) | |
| 92 | + | clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard) | |
| 93 | + | ) | |
| 94 | + | tests := []struct { | |
| 95 | + | name string | |
| 96 | + | requested uint32 | |
| 97 | + | types []string | |
| 98 | + | ok bool | |
| 99 | + | }{ | |
| 100 | + | {"none", 0, nil, false}, | |
| 101 | + | {"geolocation", geo, []string{"geolocation"}, true}, | |
| 102 | + | {"notifications", notif, []string{"notification"}, true}, | |
| 103 | + | {"mic and camera", mic | cam, []string{"microphone", "camera"}, true}, | |
| 104 | + | {"camera pan tilt zoom", ptz, []string{"camera"}, true}, | |
| 105 | + | {"unsupported", clip, nil, false}, | |
| 106 | + | {"supported plus unsupported denies all", geo | clip, nil, false}, | |
| 107 | + | } | |
| 108 | + | for _, tt := range tests { | |
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |
| 110 | + | types, ok := promptPermissionTypes(tt.requested) | |
| 111 | + | assert.Equal(t, tt.ok, ok) | |
| 112 | + | assert.Equal(t, tt.types, types) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) { | |
| 118 | + | h, capture := newPermissionTestHandler(true) | |
| 119 | + | cb := &stubMediaAccessCallback{} | |
| 120 | + | ||
| 121 | + | handled := h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com/room", mediaMic|mediaCamera, cb) | |
| 122 | + | ||
| 123 | + | require.Equal(t, int32(1), handled) | |
| 124 | + | require.Equal(t, "https://meet.example.com", capture.origin) | |
| 125 | + | require.Equal(t, []string{"microphone", "camera"}, capture.types) | |
| 126 | + | require.Empty(t, cb.contCalls) | |
| 127 | + | ||
| 128 | + | capture.allow() | |
| 129 | + | capture.allow() | |
| 130 | + | capture.deny() | |
| 131 | + | ||
| 132 | + | assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls) | |
| 133 | + | assert.Zero(t, cb.cancelCalls) | |
| 134 | + | assert.Empty(t, h.permissions.pending) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestOnRequestMediaAccessPermissionDenyOnlyOnce(t *testing.T) { | |
| 138 | + | h, capture := newPermissionTestHandler(true) | |
| 139 | + | cb := &stubMediaAccessCallback{} | |
| 140 | + | ||
| 141 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |
| 142 | + | capture.deny() | |
| 143 | + | capture.allow() | |
| 144 | + | capture.deny() | |
| 145 | + | ||
| 146 | + | assert.Empty(t, cb.contCalls) | |
| 147 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestOnRequestMediaAccessPermissionDeniesWhenNotHandledOrUnsupported(t *testing.T) { | |
| 151 | + | t.Run("handler returns false", func(t *testing.T) { | |
| 152 | + | h, _ := newPermissionTestHandler(false) | |
| 153 | + | cb := &stubMediaAccessCallback{} | |
| 154 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |
| 155 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 156 | + | assert.Empty(t, cb.contCalls) | |
| 157 | + | }) | |
| 158 | + | t.Run("unknown bits", func(t *testing.T) { | |
| 159 | + | h, capture := newPermissionTestHandler(true) | |
| 160 | + | cb := &stubMediaAccessCallback{} | |
| 161 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic|1<<10, cb)) | |
| 162 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 163 | + | assert.Zero(t, capture.calls) | |
| 164 | + | }) | |
| 165 | + | t.Run("invalid origin", func(t *testing.T) { | |
| 166 | + | h, capture := newPermissionTestHandler(true) | |
| 167 | + | cb := &stubMediaAccessCallback{} | |
| 168 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "", mediaMic, cb)) | |
| 169 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 170 | + | assert.Zero(t, capture.calls) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) { | |
| 175 | + | for name, cbs := range map[string]*port.WebViewCallbacks{ | |
| 176 | + | "nil callbacks": nil, | |
| 177 | + | "nil OnPermissionRequest fn": {}, | |
| 178 | + | } { | |
| 179 | + | t.Run(name, func(t *testing.T) { | |
| 180 | + | h := &handlerSet{wv: &WebView{ctx: context.Background(), callbacks: cbs}} | |
| 181 | + | cb := &stubMediaAccessCallback{} | |
| 182 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |
| 183 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 184 | + | assert.Empty(t, cb.contCalls) | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) { | |
| 190 | + | h, capture := newPermissionTestHandler(true) | |
| 191 | + | h.wv.destroyed.Store(true) | |
| 192 | + | cb := &stubMediaAccessCallback{} | |
| 193 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |
| 194 | + | assert.Equal(t, 1, cb.cancelCalls) | |
| 195 | + | assert.Zero(t, capture.calls) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestOnShowPermissionPromptAllowAndDeny(t *testing.T) { | |
| 199 | + | const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |
| 200 | + | ||
| 201 | + | h, capture := newPermissionTestHandler(true) | |
| 202 | + | allowCb := &stubPermissionPromptCallback{} | |
| 203 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 7, "https://maps.example", geo, allowCb)) | |
| 204 | + | require.Equal(t, []string{"geolocation"}, capture.types) | |
| 205 | + | capture.allow() | |
| 206 | + | capture.deny() | |
| 207 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultAccept}, allowCb.results) | |
| 208 | + | ||
| 209 | + | denyCb := &stubPermissionPromptCallback{} | |
| 210 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 8, "https://maps.example", geo, denyCb)) | |
| 211 | + | capture.deny() | |
| 212 | + | capture.allow() | |
| 213 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, denyCb.results) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestOnShowPermissionPromptDeniesUnsupportedType(t *testing.T) { | |
| 217 | + | const clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard) | |
| 218 | + | h, capture := newPermissionTestHandler(true) | |
| 219 | + | cb := &stubPermissionPromptCallback{} | |
| 220 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 1, "https://a.example", clip, cb)) | |
| 221 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, cb.results) | |
| 222 | + | assert.Zero(t, capture.calls) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) { | |
| 226 | + | const notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |
| 227 | + | h, capture := newPermissionTestHandler(true) | |
| 228 | + | cb := &stubPermissionPromptCallback{} | |
| 229 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 3, "https://a.example", notif, cb)) | |
| 230 | + | ||
| 231 | + | h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss) | |
| 232 | + | capture.allow() | |
| 233 | + | capture.deny() | |
| 234 | + | ||
| 235 | + | assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued") | |
| 236 | + | assert.Empty(t, h.permissions.pending) | |
| 237 | + | ||
| 238 | + | // Dismissing an unknown or already-resolved prompt is a no-op. | |
| 239 | + | h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss) | |
| 240 | + | h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) { | |
| 244 | + | const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |
| 245 | + | h, capture := newPermissionTestHandler(true) | |
| 246 | + | media := &stubMediaAccessCallback{} | |
| 247 | + | prompt := &stubPermissionPromptCallback{} | |
| 248 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, media)) | |
| 249 | + | mediaAllow := capture.allow | |
| 250 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 5, "https://a.example", geo, prompt)) | |
| 251 | + | promptAllow := capture.allow | |
| 252 | + | ||
| 253 | + | h.denyPendingPermissions() | |
| 254 | + | h.denyPendingPermissions() | |
| 255 | + | mediaAllow() | |
| 256 | + | promptAllow() | |
| 257 | + | ||
| 258 | + | assert.Equal(t, 1, media.cancelCalls) | |
| 259 | + | assert.Empty(t, media.contCalls) | |
| 260 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, prompt.results) | |
| 261 | + | assert.Empty(t, h.permissions.pending) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) { | |
| 265 | + | prevNew, prevPost := cefNewTask, cefPostTask | |
| 266 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 267 | + | cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 } | |
| 268 | + | defer func() { cefNewTask, cefPostTask = prevNew, prevPost }() | |
| 269 | + | ||
| 270 | + | ran := 0 | |
| 271 | + | dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ }) | |
| 272 | + | assert.Equal(t, 1, ran) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestDispatchPermissionResultPostsToUIThread(t *testing.T) { | |
| 276 | + | prevNew, prevPost := cefNewTask, cefPostTask | |
| 277 | + | var posted purecef.Task | |
| 278 | + | var gotThread purecef.ThreadID | |
| 279 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 280 | + | cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 { | |
| 281 | + | gotThread, posted = id, task | |
| 282 | + | return 1 | |
| 283 | + | } | |
| 284 | + | defer func() { cefNewTask, cefPostTask = prevNew, prevPost }() | |
| 285 | + | ||
| 286 | + | ran := 0 | |
| 287 | + | dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ }) | |
| 288 | + | require.NotNil(t, posted) | |
| 289 | + | assert.Zero(t, ran) | |
| 290 | + | assert.Equal(t, purecef.ThreadIDTidUi, gotThread) | |
| 291 | + | posted.Execute() | |
| 292 | + | assert.Equal(t, 1, ran) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestGetPermissionHandlerEnabled(t *testing.T) { | |
| 296 | + | h := &handlerSet{wv: &WebView{ctx: context.Background()}} | |
| 297 | + | assert.NotNil(t, h.GetPermissionHandler()) | |
| 298 | + | } | |
| @@ -0,0 +1,298 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type stubMediaAccessCallback struct { | |||
| 15 | + | contCalls []uint32 | |||
| 16 | + | cancelCalls int | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (s *stubMediaAccessCallback) Cont(allowed uint32) { s.contCalls = append(s.contCalls, allowed) } | |||
| 20 | + | func (s *stubMediaAccessCallback) Cancel() { s.cancelCalls++ } | |||
| 21 | + | ||||
| 22 | + | type stubPermissionPromptCallback struct { | |||
| 23 | + | results []purecef.PermissionRequestResult | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (s *stubPermissionPromptCallback) Cont(result purecef.PermissionRequestResult) { | |||
| 27 | + | s.results = append(s.results, result) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type permissionCapture struct { | |||
| 31 | + | origin string | |||
| 32 | + | types []string | |||
| 33 | + | allow func() | |||
| 34 | + | deny func() | |||
| 35 | + | calls int | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func newPermissionTestHandler(handled bool) (*handlerSet, *permissionCapture) { | |||
| 39 | + | capture := &permissionCapture{} | |||
| 40 | + | wv := &WebView{ | |||
| 41 | + | ctx: context.Background(), | |||
| 42 | + | callbacks: &port.WebViewCallbacks{ | |||
| 43 | + | OnPermissionRequest: func(origin string, types []string, _ map[string]string, allow, deny func()) bool { | |||
| 44 | + | capture.calls++ | |||
| 45 | + | capture.origin, capture.types, capture.allow, capture.deny = origin, types, allow, deny | |||
| 46 | + | return handled | |||
| 47 | + | }, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | return &handlerSet{wv: wv}, capture | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | const ( | |||
| 54 | + | mediaMic = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceAudioCapture) | |||
| 55 | + | mediaCamera = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDeviceVideoCapture) | |||
| 56 | + | mediaDeskA = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopAudioCapture) | |||
| 57 | + | mediaDeskV = uint32(purecef.MediaAccessPermissionTypesMediaPermissionDesktopVideoCapture) | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | func TestMediaPermissionTypes(t *testing.T) { | |||
| 61 | + | tests := []struct { | |||
| 62 | + | name string | |||
| 63 | + | requested uint32 | |||
| 64 | + | types []string | |||
| 65 | + | allowed uint32 | |||
| 66 | + | }{ | |||
| 67 | + | {"none", 0, nil, 0}, | |||
| 68 | + | {"microphone", mediaMic, []string{"microphone"}, mediaMic}, | |||
| 69 | + | {"camera", mediaCamera, []string{"camera"}, mediaCamera}, | |||
| 70 | + | {"microphone and camera", mediaMic | mediaCamera, []string{"microphone", "camera"}, mediaMic | mediaCamera}, | |||
| 71 | + | {"desktop video", mediaDeskV, []string{"display"}, mediaDeskV}, | |||
| 72 | + | {"desktop audio and video", mediaDeskA | mediaDeskV, []string{"display"}, mediaDeskA | mediaDeskV}, | |||
| 73 | + | {"camera and desktop", mediaCamera | mediaDeskV, []string{"camera", "display"}, mediaCamera | mediaDeskV}, | |||
| 74 | + | {"unknown bit denies everything", mediaMic | 1<<10, nil, 0}, | |||
| 75 | + | } | |||
| 76 | + | for _, tt := range tests { | |||
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 78 | + | types, allowed := mediaPermissionTypes(tt.requested) | |||
| 79 | + | assert.Equal(t, tt.types, types) | |||
| 80 | + | assert.Equal(t, tt.allowed, allowed) | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestPromptPermissionTypes(t *testing.T) { | |||
| 86 | + | const ( | |||
| 87 | + | geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |||
| 88 | + | notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |||
| 89 | + | mic = uint32(purecef.PermissionRequestTypesPermissionTypeMicStream) | |||
| 90 | + | cam = uint32(purecef.PermissionRequestTypesPermissionTypeCameraStream) | |||
| 91 | + | ptz = uint32(purecef.PermissionRequestTypesPermissionTypeCameraPanTiltZoom) | |||
| 92 | + | clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard) | |||
| 93 | + | ) | |||
| 94 | + | tests := []struct { | |||
| 95 | + | name string | |||
| 96 | + | requested uint32 | |||
| 97 | + | types []string | |||
| 98 | + | ok bool | |||
| 99 | + | }{ | |||
| 100 | + | {"none", 0, nil, false}, | |||
| 101 | + | {"geolocation", geo, []string{"geolocation"}, true}, | |||
| 102 | + | {"notifications", notif, []string{"notification"}, true}, | |||
| 103 | + | {"mic and camera", mic | cam, []string{"microphone", "camera"}, true}, | |||
| 104 | + | {"camera pan tilt zoom", ptz, []string{"camera"}, true}, | |||
| 105 | + | {"unsupported", clip, nil, false}, | |||
| 106 | + | {"supported plus unsupported denies all", geo | clip, nil, false}, | |||
| 107 | + | } | |||
| 108 | + | for _, tt := range tests { | |||
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 110 | + | types, ok := promptPermissionTypes(tt.requested) | |||
| 111 | + | assert.Equal(t, tt.ok, ok) | |||
| 112 | + | assert.Equal(t, tt.types, types) | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestOnRequestMediaAccessPermissionAllowGrantsRequestedMask(t *testing.T) { | |||
| 118 | + | h, capture := newPermissionTestHandler(true) | |||
| 119 | + | cb := &stubMediaAccessCallback{} | |||
| 120 | + | ||||
| 121 | + | handled := h.OnRequestMediaAccessPermission(nil, nil, "https://meet.example.com/room", mediaMic|mediaCamera, cb) | |||
| 122 | + | ||||
| 123 | + | require.Equal(t, int32(1), handled) | |||
| 124 | + | require.Equal(t, "https://meet.example.com", capture.origin) | |||
| 125 | + | require.Equal(t, []string{"microphone", "camera"}, capture.types) | |||
| 126 | + | require.Empty(t, cb.contCalls) | |||
| 127 | + | ||||
| 128 | + | capture.allow() | |||
| 129 | + | capture.allow() | |||
| 130 | + | capture.deny() | |||
| 131 | + | ||||
| 132 | + | assert.Equal(t, []uint32{mediaMic | mediaCamera}, cb.contCalls) | |||
| 133 | + | assert.Zero(t, cb.cancelCalls) | |||
| 134 | + | assert.Empty(t, h.permissions.pending) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestOnRequestMediaAccessPermissionDenyOnlyOnce(t *testing.T) { | |||
| 138 | + | h, capture := newPermissionTestHandler(true) | |||
| 139 | + | cb := &stubMediaAccessCallback{} | |||
| 140 | + | ||||
| 141 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |||
| 142 | + | capture.deny() | |||
| 143 | + | capture.allow() | |||
| 144 | + | capture.deny() | |||
| 145 | + | ||||
| 146 | + | assert.Empty(t, cb.contCalls) | |||
| 147 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestOnRequestMediaAccessPermissionDeniesWhenNotHandledOrUnsupported(t *testing.T) { | |||
| 151 | + | t.Run("handler returns false", func(t *testing.T) { | |||
| 152 | + | h, _ := newPermissionTestHandler(false) | |||
| 153 | + | cb := &stubMediaAccessCallback{} | |||
| 154 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |||
| 155 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 156 | + | assert.Empty(t, cb.contCalls) | |||
| 157 | + | }) | |||
| 158 | + | t.Run("unknown bits", func(t *testing.T) { | |||
| 159 | + | h, capture := newPermissionTestHandler(true) | |||
| 160 | + | cb := &stubMediaAccessCallback{} | |||
| 161 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic|1<<10, cb)) | |||
| 162 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 163 | + | assert.Zero(t, capture.calls) | |||
| 164 | + | }) | |||
| 165 | + | t.Run("invalid origin", func(t *testing.T) { | |||
| 166 | + | h, capture := newPermissionTestHandler(true) | |||
| 167 | + | cb := &stubMediaAccessCallback{} | |||
| 168 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "", mediaMic, cb)) | |||
| 169 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 170 | + | assert.Zero(t, capture.calls) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestOnRequestMediaAccessPermissionDeniesWithoutCallback(t *testing.T) { | |||
| 175 | + | for name, cbs := range map[string]*port.WebViewCallbacks{ | |||
| 176 | + | "nil callbacks": nil, | |||
| 177 | + | "nil OnPermissionRequest fn": {}, | |||
| 178 | + | } { | |||
| 179 | + | t.Run(name, func(t *testing.T) { | |||
| 180 | + | h := &handlerSet{wv: &WebView{ctx: context.Background(), callbacks: cbs}} | |||
| 181 | + | cb := &stubMediaAccessCallback{} | |||
| 182 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |||
| 183 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 184 | + | assert.Empty(t, cb.contCalls) | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestOnRequestMediaAccessPermissionDeniesWhenDestroyed(t *testing.T) { | |||
| 190 | + | h, capture := newPermissionTestHandler(true) | |||
| 191 | + | h.wv.destroyed.Store(true) | |||
| 192 | + | cb := &stubMediaAccessCallback{} | |||
| 193 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, cb)) | |||
| 194 | + | assert.Equal(t, 1, cb.cancelCalls) | |||
| 195 | + | assert.Zero(t, capture.calls) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestOnShowPermissionPromptAllowAndDeny(t *testing.T) { | |||
| 199 | + | const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |||
| 200 | + | ||||
| 201 | + | h, capture := newPermissionTestHandler(true) | |||
| 202 | + | allowCb := &stubPermissionPromptCallback{} | |||
| 203 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 7, "https://maps.example", geo, allowCb)) | |||
| 204 | + | require.Equal(t, []string{"geolocation"}, capture.types) | |||
| 205 | + | capture.allow() | |||
| 206 | + | capture.deny() | |||
| 207 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultAccept}, allowCb.results) | |||
| 208 | + | ||||
| 209 | + | denyCb := &stubPermissionPromptCallback{} | |||
| 210 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 8, "https://maps.example", geo, denyCb)) | |||
| 211 | + | capture.deny() | |||
| 212 | + | capture.allow() | |||
| 213 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, denyCb.results) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestOnShowPermissionPromptDeniesUnsupportedType(t *testing.T) { | |||
| 217 | + | const clip = uint32(purecef.PermissionRequestTypesPermissionTypeClipboard) | |||
| 218 | + | h, capture := newPermissionTestHandler(true) | |||
| 219 | + | cb := &stubPermissionPromptCallback{} | |||
| 220 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 1, "https://a.example", clip, cb)) | |||
| 221 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, cb.results) | |||
| 222 | + | assert.Zero(t, capture.calls) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestOnDismissPermissionPromptDropsPendingWithoutCallingCEF(t *testing.T) { | |||
| 226 | + | const notif = uint32(purecef.PermissionRequestTypesPermissionTypeNotifications) | |||
| 227 | + | h, capture := newPermissionTestHandler(true) | |||
| 228 | + | cb := &stubPermissionPromptCallback{} | |||
| 229 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 3, "https://a.example", notif, cb)) | |||
| 230 | + | ||||
| 231 | + | h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss) | |||
| 232 | + | capture.allow() | |||
| 233 | + | capture.deny() | |||
| 234 | + | ||||
| 235 | + | assert.Empty(t, cb.results, "a dismissed prompt callback must not be continued") | |||
| 236 | + | assert.Empty(t, h.permissions.pending) | |||
| 237 | + | ||||
| 238 | + | // Dismissing an unknown or already-resolved prompt is a no-op. | |||
| 239 | + | h.OnDismissPermissionPrompt(nil, 3, purecef.PermissionRequestResultPermissionResultDismiss) | |||
| 240 | + | h.OnDismissPermissionPrompt(nil, 99, purecef.PermissionRequestResultPermissionResultDismiss) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestDenyPendingPermissionsResolvesAllOnce(t *testing.T) { | |||
| 244 | + | const geo = uint32(purecef.PermissionRequestTypesPermissionTypeGeolocation) | |||
| 245 | + | h, capture := newPermissionTestHandler(true) | |||
| 246 | + | media := &stubMediaAccessCallback{} | |||
| 247 | + | prompt := &stubPermissionPromptCallback{} | |||
| 248 | + | require.Equal(t, int32(1), h.OnRequestMediaAccessPermission(nil, nil, "https://a.example", mediaMic, media)) | |||
| 249 | + | mediaAllow := capture.allow | |||
| 250 | + | require.Equal(t, int32(1), h.OnShowPermissionPrompt(nil, 5, "https://a.example", geo, prompt)) | |||
| 251 | + | promptAllow := capture.allow | |||
| 252 | + | ||||
| 253 | + | h.denyPendingPermissions() | |||
| 254 | + | h.denyPendingPermissions() | |||
| 255 | + | mediaAllow() | |||
| 256 | + | promptAllow() | |||
| 257 | + | ||||
| 258 | + | assert.Equal(t, 1, media.cancelCalls) | |||
| 259 | + | assert.Empty(t, media.contCalls) | |||
| 260 | + | assert.Equal(t, []purecef.PermissionRequestResult{purecef.PermissionRequestResultPermissionResultDeny}, prompt.results) | |||
| 261 | + | assert.Empty(t, h.permissions.pending) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestDispatchPermissionResultRunsInlineWhenPostFails(t *testing.T) { | |||
| 265 | + | prevNew, prevPost := cefNewTask, cefPostTask | |||
| 266 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 267 | + | cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 } | |||
| 268 | + | defer func() { cefNewTask, cefPostTask = prevNew, prevPost }() | |||
| 269 | + | ||||
| 270 | + | ran := 0 | |||
| 271 | + | dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ }) | |||
| 272 | + | assert.Equal(t, 1, ran) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestDispatchPermissionResultPostsToUIThread(t *testing.T) { | |||
| 276 | + | prevNew, prevPost := cefNewTask, cefPostTask | |||
| 277 | + | var posted purecef.Task | |||
| 278 | + | var gotThread purecef.ThreadID | |||
| 279 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 280 | + | cefPostTask = func(id purecef.ThreadID, task purecef.Task) int32 { | |||
| 281 | + | gotThread, posted = id, task | |||
| 282 | + | return 1 | |||
| 283 | + | } | |||
| 284 | + | defer func() { cefNewTask, cefPostTask = prevNew, prevPost }() | |||
| 285 | + | ||||
| 286 | + | ran := 0 | |||
| 287 | + | dispatchPermissionResult(&WebView{ctx: context.Background(), engine: &Engine{}}, func() { ran++ }) | |||
| 288 | + | require.NotNil(t, posted) | |||
| 289 | + | assert.Zero(t, ran) | |||
| 290 | + | assert.Equal(t, purecef.ThreadIDTidUi, gotThread) | |||
| 291 | + | posted.Execute() | |||
| 292 | + | assert.Equal(t, 1, ran) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func TestGetPermissionHandlerEnabled(t *testing.T) { | |||
| 296 | + | h := &handlerSet{wv: &WebView{ctx: context.Background()}} | |||
| 297 | + | assert.NotNil(t, h.GetPermissionHandler()) | |||
| 298 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +4 -1
| @@ -30,6 +30,7 @@ type handlerSet struct { | |||
| 30 | 30 | fileDialogPresenter fileDialogPresenter | |
| 31 | 31 | renderHandlerOnce sync.Once | |
| 32 | 32 | renderHandler purecef.RenderHandler | |
| 33 | + | permissions permissionTracker | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | // Compile-time interface checks. | |
| @@ -44,6 +45,7 @@ var ( | |||
| 44 | 45 | _ purecef.DialogHandler = (*handlerSet)(nil) | |
| 45 | 46 | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 46 | 47 | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 48 | + | _ purecef.PermissionHandler = (*handlerSet)(nil) | |
| 47 | 49 | ) | |
| 48 | 50 | ||
| 49 | 51 | // =========================================================================== | |
| @@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n | |||
| 71 | 73 | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | |
| 72 | 74 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 73 | 75 | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | |
| 74 | - | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | |
| 76 | + | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h } | |
| 75 | 77 | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | |
| 76 | 78 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 77 | 79 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| @@ -1114,6 +1116,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||
| 1114 | 1116 | ||
| 1115 | 1117 | // OnBeforeClose fires the OnClose callback. | |
| 1116 | 1118 | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |
| 1119 | + | h.denyPendingPermissions() | |
| 1117 | 1120 | if h.wv.engine != nil { | |
| 1118 | 1121 | browserID := int32(0) | |
| 1119 | 1122 | if browser != nil { | |
| @@ -30,6 +30,7 @@ type handlerSet struct { | |||||
| 30 | fileDialogPresenter fileDialogPresenter | 30 | fileDialogPresenter fileDialogPresenter | ||
| 31 | renderHandlerOnce sync.Once | 31 | renderHandlerOnce sync.Once | ||
| 32 | renderHandler purecef.RenderHandler | 32 | renderHandler purecef.RenderHandler | ||
| 33 | + | permissions permissionTracker | |||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 35 | // Compile-time interface checks. | 36 | // Compile-time interface checks. | ||
| @@ -44,6 +45,7 @@ var ( | |||||
| 44 | _ purecef.DialogHandler = (*handlerSet)(nil) | 45 | _ purecef.DialogHandler = (*handlerSet)(nil) | ||
| 45 | _ purecef.DownloadHandler = (*handlerSet)(nil) | 46 | _ purecef.DownloadHandler = (*handlerSet)(nil) | ||
| 46 | _ purecef.FindHandler = (*handlerSet)(nil) | 47 | _ purecef.FindHandler = (*handlerSet)(nil) | ||
| 48 | + | _ purecef.PermissionHandler = (*handlerSet)(nil) | |||
| 47 | ) | 49 | ) | ||
| 48 | 50 | ||||
| 49 | // =========================================================================== | 51 | // =========================================================================== | ||
| @@ -71,7 +73,7 @@ func (h *handlerSet) GetDragHandler() purecef.DragHandler { return n | |||||
| 71 | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | 73 | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | ||
| 72 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | 74 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | ||
| 73 | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | 75 | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | ||
| 74 | - | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | 76 | + | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h } |
| 75 | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | 77 | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | ||
| 76 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | 78 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | ||
| 77 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | 79 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | ||
| @@ -1114,6 +1116,7 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||||
| 1114 | 1116 | ||||
| 1115 | // OnBeforeClose fires the OnClose callback. | 1117 | // OnBeforeClose fires the OnClose callback. | ||
| 1116 | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | 1118 | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | ||
| 1119 | + | h.denyPendingPermissions() | |||
| 1117 | if h.wv.engine != nil { | 1120 | if h.wv.engine != nil { | ||
| 1118 | browserID := int32(0) | 1121 | browserID := int32(0) | ||
| 1119 | if browser != nil { | 1122 | if browser != nil { | ||
M internal/ui/coordinator/content/callbacks.go +4 -0
| @@ -213,6 +213,10 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 213 | 213 | entityTypes = append(entityTypes, entity.PermissionTypeCamera) | |
| 214 | 214 | case "display": | |
| 215 | 215 | entityTypes = append(entityTypes, entity.PermissionTypeDisplay) | |
| 216 | + | case "notification": | |
| 217 | + | entityTypes = append(entityTypes, entity.PermissionTypeNotification) | |
| 218 | + | case "geolocation": | |
| 219 | + | entityTypes = append(entityTypes, entity.PermissionTypeGeolocation) | |
| 216 | 220 | case "device_info": | |
| 217 | 221 | entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo) | |
| 218 | 222 | case "website_data_access": | |
| @@ -213,6 +213,10 @@ func (c *Coordinator) handlePermissionRequest( | |||||
| 213 | entityTypes = append(entityTypes, entity.PermissionTypeCamera) | 213 | entityTypes = append(entityTypes, entity.PermissionTypeCamera) | ||
| 214 | case "display": | 214 | case "display": | ||
| 215 | entityTypes = append(entityTypes, entity.PermissionTypeDisplay) | 215 | entityTypes = append(entityTypes, entity.PermissionTypeDisplay) | ||
| 216 | + | case "notification": | |||
| 217 | + | entityTypes = append(entityTypes, entity.PermissionTypeNotification) | |||
| 218 | + | case "geolocation": | |||
| 219 | + | entityTypes = append(entityTypes, entity.PermissionTypeGeolocation) | |||
| 216 | case "device_info": | 220 | case "device_info": | ||
| 217 | entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo) | 221 | entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo) | ||
| 218 | case "website_data_access": | 222 | case "website_data_access": | ||
M internal/ui/dialog/permission.go +19 -1
| @@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() { | |||
| 150 | 150 | ||
| 151 | 151 | // permFlags holds parsed permission type flags. | |
| 152 | 152 | type permFlags struct { | |
| 153 | - | mic, cam, display, dataAccess bool | |
| 153 | + | mic, cam, display, dataAccess, notification, geolocation bool | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | 156 | // parsePermFlags extracts boolean flags from permission types. | |
| @@ -166,6 +166,10 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags { | |||
| 166 | 166 | f.display = true | |
| 167 | 167 | case entity.PermissionTypeWebsiteDataAccess: | |
| 168 | 168 | f.dataAccess = true | |
| 169 | + | case entity.PermissionTypeNotification: | |
| 170 | + | f.notification = true | |
| 171 | + | case entity.PermissionTypeGeolocation: | |
| 172 | + | f.geolocation = true | |
| 169 | 173 | } | |
| 170 | 174 | } | |
| 171 | 175 | return f | |
| @@ -204,6 +208,12 @@ func (d *PermissionDialog) buildHeading( | |||
| 204 | 208 | if f.dataAccess { | |
| 205 | 209 | labels = append(labels, "Data Access") | |
| 206 | 210 | } | |
| 211 | + | if f.notification { | |
| 212 | + | labels = append(labels, "Notifications") | |
| 213 | + | } | |
| 214 | + | if f.geolocation { | |
| 215 | + | labels = append(labels, "Location") | |
| 216 | + | } | |
| 207 | 217 | switch { | |
| 208 | 218 | case len(labels) == 0: | |
| 209 | 219 | return "Allow Permission?" | |
| @@ -211,6 +221,8 @@ func (d *PermissionDialog) buildHeading( | |||
| 211 | 221 | return "Allow Third-Party Data Access?" | |
| 212 | 222 | case len(labels) == 1 && f.display: | |
| 213 | 223 | return "Allow Screen Sharing?" | |
| 224 | + | case len(labels) == 1 && f.notification: | |
| 225 | + | return "Allow Notifications?" | |
| 214 | 226 | case len(labels) == 1: | |
| 215 | 227 | return "Allow " + labels[0] + " Access?" | |
| 216 | 228 | default: | |
| @@ -234,6 +246,12 @@ func (d *PermissionDialog) buildBody( | |||
| 234 | 246 | if f.display { | |
| 235 | 247 | parts = append(parts, "share your screen") | |
| 236 | 248 | } | |
| 249 | + | if f.notification { | |
| 250 | + | parts = append(parts, "show notifications") | |
| 251 | + | } | |
| 252 | + | if f.geolocation { | |
| 253 | + | parts = append(parts, "access your location") | |
| 254 | + | } | |
| 237 | 255 | if f.dataAccess { | |
| 238 | 256 | reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain] | |
| 239 | 257 | curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain] | |
| @@ -150,7 +150,7 @@ func (d *PermissionDialog) showNextQueuedRequest() { | |||||
| 150 | 150 | ||||
| 151 | // permFlags holds parsed permission type flags. | 151 | // permFlags holds parsed permission type flags. | ||
| 152 | type permFlags struct { | 152 | type permFlags struct { | ||
| 153 | - | mic, cam, display, dataAccess bool | 153 | + | mic, cam, display, dataAccess, notification, geolocation bool |
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | // parsePermFlags extracts boolean flags from permission types. | 156 | // parsePermFlags extracts boolean flags from permission types. | ||
| @@ -166,6 +166,10 @@ func parsePermFlags(permTypes []entity.PermissionType) permFlags { | |||||
| 166 | f.display = true | 166 | f.display = true | ||
| 167 | case entity.PermissionTypeWebsiteDataAccess: | 167 | case entity.PermissionTypeWebsiteDataAccess: | ||
| 168 | f.dataAccess = true | 168 | f.dataAccess = true | ||
| 169 | + | case entity.PermissionTypeNotification: | |||
| 170 | + | f.notification = true | |||
| 171 | + | case entity.PermissionTypeGeolocation: | |||
| 172 | + | f.geolocation = true | |||
| 169 | } | 173 | } | ||
| 170 | } | 174 | } | ||
| 171 | return f | 175 | return f | ||
| @@ -204,6 +208,12 @@ func (d *PermissionDialog) buildHeading( | |||||
| 204 | if f.dataAccess { | 208 | if f.dataAccess { | ||
| 205 | labels = append(labels, "Data Access") | 209 | labels = append(labels, "Data Access") | ||
| 206 | } | 210 | } | ||
| 211 | + | if f.notification { | |||
| 212 | + | labels = append(labels, "Notifications") | |||
| 213 | + | } | |||
| 214 | + | if f.geolocation { | |||
| 215 | + | labels = append(labels, "Location") | |||
| 216 | + | } | |||
| 207 | switch { | 217 | switch { | ||
| 208 | case len(labels) == 0: | 218 | case len(labels) == 0: | ||
| 209 | return "Allow Permission?" | 219 | return "Allow Permission?" | ||
| @@ -211,6 +221,8 @@ func (d *PermissionDialog) buildHeading( | |||||
| 211 | return "Allow Third-Party Data Access?" | 221 | return "Allow Third-Party Data Access?" | ||
| 212 | case len(labels) == 1 && f.display: | 222 | case len(labels) == 1 && f.display: | ||
| 213 | return "Allow Screen Sharing?" | 223 | return "Allow Screen Sharing?" | ||
| 224 | + | case len(labels) == 1 && f.notification: | |||
| 225 | + | return "Allow Notifications?" | |||
| 214 | case len(labels) == 1: | 226 | case len(labels) == 1: | ||
| 215 | return "Allow " + labels[0] + " Access?" | 227 | return "Allow " + labels[0] + " Access?" | ||
| 216 | default: | 228 | default: | ||
| @@ -234,6 +246,12 @@ func (d *PermissionDialog) buildBody( | |||||
| 234 | if f.display { | 246 | if f.display { | ||
| 235 | parts = append(parts, "share your screen") | 247 | parts = append(parts, "share your screen") | ||
| 236 | } | 248 | } | ||
| 249 | + | if f.notification { | |||
| 250 | + | parts = append(parts, "show notifications") | |||
| 251 | + | } | |||
| 252 | + | if f.geolocation { | |||
| 253 | + | parts = append(parts, "access your location") | |||
| 254 | + | } | |||
| 237 | if f.dataAccess { | 255 | if f.dataAccess { | ||
| 238 | reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain] | 256 | reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain] | ||
| 239 | curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain] | 257 | curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain] | ||
M internal/ui/dialog/permission_test.go +12 -0
| @@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||
| 142 | 142 | expectHeading: "Allow Microphone, Camera, and Screen Sharing?", | |
| 143 | 143 | expectedAction: "access your microphone and camera, and share your screen", | |
| 144 | 144 | }, | |
| 145 | + | { | |
| 146 | + | name: "notification only", | |
| 147 | + | permTypes: []entity.PermissionType{entity.PermissionTypeNotification}, | |
| 148 | + | expectHeading: "Allow Notifications?", | |
| 149 | + | expectedAction: "show notifications", | |
| 150 | + | }, | |
| 151 | + | { | |
| 152 | + | name: "geolocation only", | |
| 153 | + | permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation}, | |
| 154 | + | expectHeading: "Allow Location Access?", | |
| 155 | + | expectedAction: "access your location", | |
| 156 | + | }, | |
| 145 | 157 | { | |
| 146 | 158 | name: "website data access only", | |
| 147 | 159 | permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess}, | |
| @@ -142,6 +142,18 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T) | |||||
| 142 | expectHeading: "Allow Microphone, Camera, and Screen Sharing?", | 142 | expectHeading: "Allow Microphone, Camera, and Screen Sharing?", | ||
| 143 | expectedAction: "access your microphone and camera, and share your screen", | 143 | expectedAction: "access your microphone and camera, and share your screen", | ||
| 144 | }, | 144 | }, | ||
| 145 | + | { | |||
| 146 | + | name: "notification only", | |||
| 147 | + | permTypes: []entity.PermissionType{entity.PermissionTypeNotification}, | |||
| 148 | + | expectHeading: "Allow Notifications?", | |||
| 149 | + | expectedAction: "show notifications", | |||
| 150 | + | }, | |||
| 151 | + | { | |||
| 152 | + | name: "geolocation only", | |||
| 153 | + | permTypes: []entity.PermissionType{entity.PermissionTypeGeolocation}, | |||
| 154 | + | expectHeading: "Allow Location Access?", | |||
| 155 | + | expectedAction: "access your location", | |||
| 156 | + | }, | |||
| 145 | { | 157 | { | ||
| 146 | name: "website data access only", | 158 | name: "website data access only", | ||
| 147 | permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess}, | 159 | permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess}, | ||