detached d4ce0c4

feat(permission): add WebKit permission request handling

Add permission-request signal handler with g_object_ref/unref safety.
Implement determinePermissionTypes to extract mic, camera, display, device_info.
Add OnPermissionRequest callback to WebView port interface.
Normalize origin from URI before passing to use case.
Fix Destroy() to clear OnPermissionRequest callback.
commit
d4ce0c489a0a90349b984c7198aa185e7f69d6c0
author
brice · · 2026-01-30 05:01 UTC
committer
brice · · 2026-02-07 11:57 UTC
parent
664884d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +161 -0
M internal/infrastructure/webkit/webview.go +161 -0
@@ -154,6 +154,11 @@ type WebView struct {
154154 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
155155 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
156156
157+ // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
158+ // Return true to indicate the request was handled. Call allow()/deny() to respond.
159+ // The permission types are determined from the request object.
160+ OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
161+
157162 logger zerolog.Logger
158163 mu sync.RWMutex
159164
@@ -412,6 +417,7 @@ func (wv *WebView) connectSignals() {
412417 wv.connectMouseTargetChangedSignal()
413418 wv.connectBackForwardListChangedSignal()
414419 wv.connectWebProcessTerminatedSignal()
420+ wv.connectPermissionRequestSignal()
415421 }
416422
417423 func (wv *WebView) connectLoadChangedSignal() {
@@ -930,6 +936,158 @@ func (wv *WebView) connectWebProcessTerminatedSignal() {
930936 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
931937 }
932938
939+// connectPermissionRequestSignal sets up the permission-request signal handler.
940+// This is emitted when a site calls getUserMedia() or getDisplayMedia().
941+func (wv *WebView) connectPermissionRequestSignal() {
942+ permissionCb := func(_ webkit.WebView, requestPtr uintptr) bool {
943+ if wv.OnPermissionRequest == nil {
944+ return false // Not handled, WebKit will deny by default
945+ }
946+
947+ // Extract and normalize origin from current URI
948+ uri := wv.URI()
949+ if uri == "" {
950+ wv.logger.Debug().Msg("permission request with empty origin, denying")
951+ return false
952+ }
953+ origin, err := extractOrigin(uri)
954+ if err != nil {
955+ wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying")
956+ return false
957+ }
958+
959+ // Determine permission types from the request
960+ permTypes := wv.determinePermissionTypes(requestPtr)
961+ if len(permTypes) == 0 {
962+ wv.logger.Warn().Msg("permission request with unknown type, denying")
963+ return false
964+ }
965+
966+ // Ref the request object to prevent use-after-free
967+ // The request may outlive the signal handler if we show a dialog
968+ requestObj := gobject.ObjectNewFromInternalPtr(requestPtr)
969+ if requestObj == nil {
970+ wv.logger.Warn().Msg("permission request: failed to wrap request object")
971+ return false
972+ }
973+ requestObj.Ref()
974+
975+ // Create allow/deny callbacks that wrap the WebKit permission request
976+ allowCalled := false
977+ denyCalled := false
978+
979+ allow := func() {
980+ if allowCalled || denyCalled {
981+ requestObj.Unref()
982+ return
983+ }
984+ allowCalled = true
985+ wv.allowPermissionRequest(requestPtr)
986+ requestObj.Unref()
987+ }
988+
989+ deny := func() {
990+ if allowCalled || denyCalled {
991+ requestObj.Unref()
992+ return
993+ }
994+ denyCalled = true
995+ wv.denyPermissionRequest(requestPtr)
996+ requestObj.Unref()
997+ }
998+
999+ // Call the handler
1000+ return wv.OnPermissionRequest(origin, permTypes, allow, deny)
1001+ }
1002+
1003+ sigID := wv.inner.ConnectPermissionRequest(&permissionCb)
1004+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1005+}
1006+
1007+// extractOrigin extracts the origin (scheme://host) from a URI.
1008+func extractOrigin(uri string) (string, error) {
1009+ u, err := url.Parse(uri)
1010+ if err != nil {
1011+ return "", err
1012+ }
1013+ if u.Scheme == "" || u.Host == "" {
1014+ return "", fmt.Errorf("URI missing scheme or host")
1015+ }
1016+ return u.Scheme + "://" + u.Host, nil
1017+}
1018+
1019+// determinePermissionTypes extracts permission types from a WebKit permission request.
1020+// This uses type checking to identify UserMediaPermissionRequest and its specific types.
1021+func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string {
1022+ _ = wv // suppress unused receiver warning, method may need receiver in future
1023+ var types []string
1024+
1025+ // Try to cast to UserMediaPermissionRequest
1026+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1027+ if userMediaReq != nil {
1028+ if webkit.UserMediaPermissionIsForAudioDevice(userMediaReq) {
1029+ types = append(types, "microphone")
1030+ }
1031+ if webkit.UserMediaPermissionIsForVideoDevice(userMediaReq) {
1032+ // Check if this is display capture or camera
1033+ if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) {
1034+ types = append(types, "display")
1035+ } else {
1036+ types = append(types, "camera")
1037+ }
1038+ }
1039+ return types
1040+ }
1041+
1042+ // Check for device enumeration request
1043+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1044+ if deviceInfoReq != nil {
1045+ return []string{"device_info"}
1046+ }
1047+
1048+ // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1049+ // For now, return empty to trigger denial. Future phases will add these types.
1050+ return nil
1051+}
1052+
1053+// allowPermissionRequest calls Allow() on the WebKit permission request.
1054+func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
1055+ // Try UserMediaPermissionRequest first
1056+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1057+ if userMediaReq != nil {
1058+ userMediaReq.Allow()
1059+ wv.logger.Debug().Msg("permission request allowed")
1060+ return
1061+ }
1062+
1063+ // Try DeviceInfoPermissionRequest
1064+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1065+ if deviceInfoReq != nil {
1066+ deviceInfoReq.Allow()
1067+ wv.logger.Debug().Msg("permission request allowed")
1068+ return
1069+ }
1070+}
1071+
1072+// denyPermissionRequest calls Deny() on the WebKit permission request.
1073+func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
1074+ // Try UserMediaPermissionRequest first
1075+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1076+ if userMediaReq != nil {
1077+ userMediaReq.Deny()
1078+ wv.logger.Debug().Msg("permission request denied")
1079+ return
1080+ }
1081+
1082+ // Try DeviceInfoPermissionRequest
1083+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1084+ if deviceInfoReq != nil {
1085+ deviceInfoReq.Deny()
1086+ wv.logger.Debug().Msg("permission request denied")
1087+ return
1088+ }
1089+}
1090+
9331091 // ID returns the unique identifier for this WebView.
9341092 func (wv *WebView) ID() WebViewID {
9351093 return wv.id
@@ -1214,6 +1372,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
12141372 wv.OnCreate = nil
12151373 wv.OnLinkHover = nil
12161374 wv.OnWebProcessTerminated = nil
1375+ wv.OnPermissionRequest = nil
12171376 return
12181377 }
12191378
@@ -1260,6 +1419,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
12601419 } else {
12611420 wv.OnWebProcessTerminated = nil
12621421 }
1422+ wv.OnPermissionRequest = callbacks.OnPermissionRequest
12631423 }
12641424
12651425 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1374,6 +1534,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
13741534 wv.OnAudioStateChanged = nil
13751535 wv.OnLinkHover = nil
13761536 wv.OnWebProcessTerminated = nil
1537+ wv.OnPermissionRequest = nil
13771538
13781539 // 3. Clear async callback references
13791540 wv.mu.Lock()
@@ -154,6 +154,11 @@ type WebView struct {
154 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)154 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)155 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
156 156
157+ // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
158+ // Return true to indicate the request was handled. Call allow()/deny() to respond.
159+ // The permission types are determined from the request object.
160+ OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
161+
157 logger zerolog.Logger162 logger zerolog.Logger
158 mu sync.RWMutex163 mu sync.RWMutex
159 164
@@ -412,6 +417,7 @@ func (wv *WebView) connectSignals() {
412 wv.connectMouseTargetChangedSignal()417 wv.connectMouseTargetChangedSignal()
413 wv.connectBackForwardListChangedSignal()418 wv.connectBackForwardListChangedSignal()
414 wv.connectWebProcessTerminatedSignal()419 wv.connectWebProcessTerminatedSignal()
420+ wv.connectPermissionRequestSignal()
415 }421 }
416 422
417 func (wv *WebView) connectLoadChangedSignal() {423 func (wv *WebView) connectLoadChangedSignal() {
@@ -930,6 +936,158 @@ func (wv *WebView) connectWebProcessTerminatedSignal() {
930 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))936 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
931 }937 }
932 938
939+// connectPermissionRequestSignal sets up the permission-request signal handler.
940+// This is emitted when a site calls getUserMedia() or getDisplayMedia().
941+func (wv *WebView) connectPermissionRequestSignal() {
942+ permissionCb := func(_ webkit.WebView, requestPtr uintptr) bool {
943+ if wv.OnPermissionRequest == nil {
944+ return false // Not handled, WebKit will deny by default
945+ }
946+
947+ // Extract and normalize origin from current URI
948+ uri := wv.URI()
949+ if uri == "" {
950+ wv.logger.Debug().Msg("permission request with empty origin, denying")
951+ return false
952+ }
953+ origin, err := extractOrigin(uri)
954+ if err != nil {
955+ wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying")
956+ return false
957+ }
958+
959+ // Determine permission types from the request
960+ permTypes := wv.determinePermissionTypes(requestPtr)
961+ if len(permTypes) == 0 {
962+ wv.logger.Warn().Msg("permission request with unknown type, denying")
963+ return false
964+ }
965+
966+ // Ref the request object to prevent use-after-free
967+ // The request may outlive the signal handler if we show a dialog
968+ requestObj := gobject.ObjectNewFromInternalPtr(requestPtr)
969+ if requestObj == nil {
970+ wv.logger.Warn().Msg("permission request: failed to wrap request object")
971+ return false
972+ }
973+ requestObj.Ref()
974+
975+ // Create allow/deny callbacks that wrap the WebKit permission request
976+ allowCalled := false
977+ denyCalled := false
978+
979+ allow := func() {
980+ if allowCalled || denyCalled {
981+ requestObj.Unref()
982+ return
983+ }
984+ allowCalled = true
985+ wv.allowPermissionRequest(requestPtr)
986+ requestObj.Unref()
987+ }
988+
989+ deny := func() {
990+ if allowCalled || denyCalled {
991+ requestObj.Unref()
992+ return
993+ }
994+ denyCalled = true
995+ wv.denyPermissionRequest(requestPtr)
996+ requestObj.Unref()
997+ }
998+
999+ // Call the handler
1000+ return wv.OnPermissionRequest(origin, permTypes, allow, deny)
1001+ }
1002+
1003+ sigID := wv.inner.ConnectPermissionRequest(&permissionCb)
1004+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1005+}
1006+
1007+// extractOrigin extracts the origin (scheme://host) from a URI.
1008+func extractOrigin(uri string) (string, error) {
1009+ u, err := url.Parse(uri)
1010+ if err != nil {
1011+ return "", err
1012+ }
1013+ if u.Scheme == "" || u.Host == "" {
1014+ return "", fmt.Errorf("URI missing scheme or host")
1015+ }
1016+ return u.Scheme + "://" + u.Host, nil
1017+}
1018+
1019+// determinePermissionTypes extracts permission types from a WebKit permission request.
1020+// This uses type checking to identify UserMediaPermissionRequest and its specific types.
1021+func (wv *WebView) determinePermissionTypes(requestPtr uintptr) []string {
1022+ _ = wv // suppress unused receiver warning, method may need receiver in future
1023+ var types []string
1024+
1025+ // Try to cast to UserMediaPermissionRequest
1026+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1027+ if userMediaReq != nil {
1028+ if webkit.UserMediaPermissionIsForAudioDevice(userMediaReq) {
1029+ types = append(types, "microphone")
1030+ }
1031+ if webkit.UserMediaPermissionIsForVideoDevice(userMediaReq) {
1032+ // Check if this is display capture or camera
1033+ if webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq) {
1034+ types = append(types, "display")
1035+ } else {
1036+ types = append(types, "camera")
1037+ }
1038+ }
1039+ return types
1040+ }
1041+
1042+ // Check for device enumeration request
1043+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1044+ if deviceInfoReq != nil {
1045+ return []string{"device_info"}
1046+ }
1047+
1048+ // Unknown permission type - could be clipboard, notifications, geolocation, etc.
1049+ // For now, return empty to trigger denial. Future phases will add these types.
1050+ return nil
1051+}
1052+
1053+// allowPermissionRequest calls Allow() on the WebKit permission request.
1054+func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
1055+ // Try UserMediaPermissionRequest first
1056+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1057+ if userMediaReq != nil {
1058+ userMediaReq.Allow()
1059+ wv.logger.Debug().Msg("permission request allowed")
1060+ return
1061+ }
1062+
1063+ // Try DeviceInfoPermissionRequest
1064+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1065+ if deviceInfoReq != nil {
1066+ deviceInfoReq.Allow()
1067+ wv.logger.Debug().Msg("permission request allowed")
1068+ return
1069+ }
1070+}
1071+
1072+// denyPermissionRequest calls Deny() on the WebKit permission request.
1073+func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
1074+ // Try UserMediaPermissionRequest first
1075+ userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1076+ if userMediaReq != nil {
1077+ userMediaReq.Deny()
1078+ wv.logger.Debug().Msg("permission request denied")
1079+ return
1080+ }
1081+
1082+ // Try DeviceInfoPermissionRequest
1083+ deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1084+ if deviceInfoReq != nil {
1085+ deviceInfoReq.Deny()
1086+ wv.logger.Debug().Msg("permission request denied")
1087+ return
1088+ }
1089+}
1090+
933 // ID returns the unique identifier for this WebView.1091 // ID returns the unique identifier for this WebView.
934 func (wv *WebView) ID() WebViewID {1092 func (wv *WebView) ID() WebViewID {
935 return wv.id1093 return wv.id
@@ -1214,6 +1372,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1214 wv.OnCreate = nil1372 wv.OnCreate = nil
1215 wv.OnLinkHover = nil1373 wv.OnLinkHover = nil
1216 wv.OnWebProcessTerminated = nil1374 wv.OnWebProcessTerminated = nil
1375+ wv.OnPermissionRequest = nil
1217 return1376 return
1218 }1377 }
1219 1378
@@ -1260,6 +1419,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1260 } else {1419 } else {
1261 wv.OnWebProcessTerminated = nil1420 wv.OnWebProcessTerminated = nil
1262 }1421 }
1422+ wv.OnPermissionRequest = callbacks.OnPermissionRequest
1263 }1423 }
1264 1424
1265 // ShowDevTools opens the WebKit inspector/developer tools.1425 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1374,6 +1534,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1374 wv.OnAudioStateChanged = nil1534 wv.OnAudioStateChanged = nil
1375 wv.OnLinkHover = nil1535 wv.OnLinkHover = nil
1376 wv.OnWebProcessTerminated = nil1536 wv.OnWebProcessTerminated = nil
1537+ wv.OnPermissionRequest = nil
1377 1538
1378 // 3. Clear async callback references1539 // 3. Clear async callback references
1379 wv.mu.Lock()1540 wv.mu.Lock()

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close