detached b7f1b12

feat(permission): add UI dialog implementation

Add Adwaita AlertDialog for permission requests with Allow, Always Allow,
Deny, and Always Deny options.
Implement PermissionDialogPresenter interface.
Wire handlePermissionRequest method in ContentCoordinator.
commit
b7f1b12c2df5f75f0491da9f996a0e9b5e617bf2
author
brice · · 2026-01-30 05:01 UTC
committer
brice · · 2026-02-07 11:58 UTC
parent
d4ce0c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +225 -0
M internal/ui/coordinator/content.go +69 -0
@@ -90,6 +90,7 @@ type ContentCoordinator struct {
9090 widgetFactory layout.WidgetFactory
9191 faviconAdapter *adapter.FaviconAdapter
9292 zoomUC *usecase.ManageZoomUseCase
93+ permissionUC *usecase.HandlePermissionUseCase
9394 injector *webkit.ContentInjector
9495
9596 webViews map[entity.PaneID]*webkit.WebView
@@ -186,6 +187,7 @@ func NewContentCoordinator(
186187 faviconAdapter *adapter.FaviconAdapter,
187188 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
188189 zoomUC *usecase.ManageZoomUseCase,
190+ permissionUC *usecase.HandlePermissionUseCase,
189191 ) *ContentCoordinator {
190192 log := logging.FromContext(ctx)
191193 log.Debug().Msg("creating content coordinator")
@@ -195,6 +197,7 @@ func NewContentCoordinator(
195197 widgetFactory: widgetFactory,
196198 faviconAdapter: faviconAdapter,
197199 zoomUC: zoomUC,
200+ permissionUC: permissionUC,
198201 webViews: make(map[entity.PaneID]*webkit.WebView),
199202 paneTitles: make(map[entity.PaneID]string),
200203 navOrigins: make(map[entity.PaneID]string),
@@ -1646,6 +1649,10 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
16461649 }
16471650 }
16481651
1652+ // Permission request handler
1653+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
1654+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
1655+ }
16491656 // Fullscreen handlers for idle inhibition
16501657 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
16511658
@@ -1943,6 +1950,68 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
19431950 return true
19441951 }
19451952
1953+// handlePermissionRequest processes media permission requests from WebKit.
1954+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
1955+func (c *ContentCoordinator) handlePermissionRequest(
1956+ ctx context.Context,
1957+ origin string,
1958+ permTypes []string,
1959+ allow, deny func(),
1960+) bool {
1961+ log := logging.FromContext(ctx)
1962+
1963+ // Convert string permission types to entity types
1964+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
1965+ for _, pt := range permTypes {
1966+ switch pt {
1967+ case "microphone":
1968+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
1969+ case "camera":
1970+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
1971+ case "display":
1972+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
1973+ case "device_info":
1974+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
1975+ default:
1976+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
1977+ }
1978+ }
1979+
1980+ if len(entityTypes) == 0 {
1981+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
1982+ deny()
1983+ return true
1984+ }
1985+
1986+ // Check if permission use case is available
1987+ if c.permissionUC == nil {
1988+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
1989+ // Auto-allow display and device_info, deny others
1990+ allAutoAllow := true
1991+ for _, pt := range entityTypes {
1992+ if !entity.IsAutoAllow(pt) {
1993+ allAutoAllow = false
1994+ break
1995+ }
1996+ }
1997+ if allAutoAllow {
1998+ allow()
1999+ } else {
2000+ deny()
2001+ }
2002+ return true
2003+ }
2004+
2005+ // Delegate to use case
2006+ callback := usecase.PermissionCallback{
2007+ Allow: allow,
2008+ Deny: deny,
2009+ }
2010+
2011+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2012+ return true
2013+}
2014+
19462015 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
19472016 // Idle is inhibited when:
19482017 // - The webview enters fullscreen mode (e.g., fullscreen video)
@@ -90,6 +90,7 @@ type ContentCoordinator struct {
90 widgetFactory layout.WidgetFactory90 widgetFactory layout.WidgetFactory
91 faviconAdapter *adapter.FaviconAdapter91 faviconAdapter *adapter.FaviconAdapter
92 zoomUC *usecase.ManageZoomUseCase92 zoomUC *usecase.ManageZoomUseCase
93+ permissionUC *usecase.HandlePermissionUseCase
93 injector *webkit.ContentInjector94 injector *webkit.ContentInjector
94 95
95 webViews map[entity.PaneID]*webkit.WebView96 webViews map[entity.PaneID]*webkit.WebView
@@ -186,6 +187,7 @@ func NewContentCoordinator(
186 faviconAdapter *adapter.FaviconAdapter,187 faviconAdapter *adapter.FaviconAdapter,
187 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),188 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
188 zoomUC *usecase.ManageZoomUseCase,189 zoomUC *usecase.ManageZoomUseCase,
190+ permissionUC *usecase.HandlePermissionUseCase,
189 ) *ContentCoordinator {191 ) *ContentCoordinator {
190 log := logging.FromContext(ctx)192 log := logging.FromContext(ctx)
191 log.Debug().Msg("creating content coordinator")193 log.Debug().Msg("creating content coordinator")
@@ -195,6 +197,7 @@ func NewContentCoordinator(
195 widgetFactory: widgetFactory,197 widgetFactory: widgetFactory,
196 faviconAdapter: faviconAdapter,198 faviconAdapter: faviconAdapter,
197 zoomUC: zoomUC,199 zoomUC: zoomUC,
200+ permissionUC: permissionUC,
198 webViews: make(map[entity.PaneID]*webkit.WebView),201 webViews: make(map[entity.PaneID]*webkit.WebView),
199 paneTitles: make(map[entity.PaneID]string),202 paneTitles: make(map[entity.PaneID]string),
200 navOrigins: make(map[entity.PaneID]string),203 navOrigins: make(map[entity.PaneID]string),
@@ -1646,6 +1649,10 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1646 }1649 }
1647 }1650 }
1648 1651
1652+ // Permission request handler
1653+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
1654+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
1655+ }
1649 // Fullscreen handlers for idle inhibition1656 // Fullscreen handlers for idle inhibition
1650 c.setupIdleInhibitionHandlers(ctx, paneID, wv)1657 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1651 1658
@@ -1943,6 +1950,68 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1943 return true1950 return true
1944 }1951 }
1945 1952
1953+// handlePermissionRequest processes media permission requests from WebKit.
1954+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
1955+func (c *ContentCoordinator) handlePermissionRequest(
1956+ ctx context.Context,
1957+ origin string,
1958+ permTypes []string,
1959+ allow, deny func(),
1960+) bool {
1961+ log := logging.FromContext(ctx)
1962+
1963+ // Convert string permission types to entity types
1964+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
1965+ for _, pt := range permTypes {
1966+ switch pt {
1967+ case "microphone":
1968+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
1969+ case "camera":
1970+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
1971+ case "display":
1972+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
1973+ case "device_info":
1974+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
1975+ default:
1976+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
1977+ }
1978+ }
1979+
1980+ if len(entityTypes) == 0 {
1981+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
1982+ deny()
1983+ return true
1984+ }
1985+
1986+ // Check if permission use case is available
1987+ if c.permissionUC == nil {
1988+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
1989+ // Auto-allow display and device_info, deny others
1990+ allAutoAllow := true
1991+ for _, pt := range entityTypes {
1992+ if !entity.IsAutoAllow(pt) {
1993+ allAutoAllow = false
1994+ break
1995+ }
1996+ }
1997+ if allAutoAllow {
1998+ allow()
1999+ } else {
2000+ deny()
2001+ }
2002+ return true
2003+ }
2004+
2005+ // Delegate to use case
2006+ callback := usecase.PermissionCallback{
2007+ Allow: allow,
2008+ Deny: deny,
2009+ }
2010+
2011+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2012+ return true
2013+}
2014+
1946 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.2015 // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1947 // Idle is inhibited when:2016 // Idle is inhibited when:
1948 // - The webview enters fullscreen mode (e.g., fullscreen video)2017 // - The webview enters fullscreen mode (e.g., fullscreen video)
A internal/ui/dialog/permission.go +156 -0
@@ -0,0 +1,156 @@
1+// Package dialog provides UI dialog implementations for the application layer.
2+package dialog
3+
4+import (
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/jwijenbergh/puregotk/v4/adw"
12+ "github.com/jwijenbergh/puregotk/v4/gtk"
13+)
14+
15+// PermissionDialog implements the port.PermissionDialogPresenter interface.
16+// It shows Adwaita AlertDialogs for media permission requests.
17+type PermissionDialog struct {
18+ // parentWindow is the parent window for modal dialogs (can be nil)
19+ parentWindow *gtk.ApplicationWindow
20+}
21+
22+// NewPermissionDialog creates a new permission dialog presenter.
23+func NewPermissionDialog(parentWindow *gtk.ApplicationWindow) *PermissionDialog {
24+ return &PermissionDialog{
25+ parentWindow: parentWindow,
26+ }
27+}
28+
29+// ShowPermissionDialog displays a permission request dialog to the user.
30+func (d *PermissionDialog) ShowPermissionDialog(
31+ ctx context.Context,
32+ origin string,
33+ permTypes []entity.PermissionType,
34+ callback func(result port.PermissionDialogResult),
35+) {
36+ log := logging.FromContext(ctx)
37+
38+ // Build dialog text
39+ heading := d.buildHeading(permTypes)
40+ body := d.buildBody(origin, permTypes)
41+
42+ // Create alert dialog
43+ dialog := adw.NewAlertDialog(&heading, &body)
44+ if dialog == nil {
45+ log.Error().Msg("failed to create permission dialog")
46+ // Deny by default if dialog creation fails
47+ callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
48+ return
49+ }
50+
51+ // Add responses: Allow, Always Allow, Deny, Always Deny
52+ // Using underscore prefix for mnemonic keyboard shortcuts
53+ dialog.AddResponse("allow", "_Allow")
54+ dialog.AddResponse("allow_always", "Always _Allow")
55+ dialog.AddResponse("deny", "_Deny")
56+ dialog.AddResponse("deny_always", "Always _Deny")
57+
58+ // Set appearances
59+ dialog.SetResponseAppearance("allow", adw.ResponseSuggestedValue)
60+ dialog.SetResponseAppearance("allow_always", adw.ResponseSuggestedValue)
61+ dialog.SetResponseAppearance("deny", adw.ResponseDefaultValue)
62+ dialog.SetResponseAppearance("deny_always", adw.ResponseDestructiveValue)
63+
64+ // Set default response to Deny (conservative default)
65+ defaultResponse := "deny"
66+ dialog.SetDefaultResponse(&defaultResponse)
67+ dialog.SetCloseResponse("deny")
68+
69+ // Connect response signal - store callback to prevent GC
70+ responseCb := func(_ adw.AlertDialog, response string) {
71+ switch response {
72+ case "allow":
73+ callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
74+ case "allow_always":
75+ callback(port.PermissionDialogResult{Allowed: true, Persistent: true})
76+ case "deny_always":
77+ callback(port.PermissionDialogResult{Allowed: false, Persistent: true})
78+ default:
79+ callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
80+ }
81+ }
82+ dialog.ConnectResponse(&responseCb)
83+
84+ // Present the dialog
85+ if d.parentWindow != nil {
86+ // Get the widget representation of the window
87+ parentWidget := gtk.WidgetNewFromInternalPtr(d.parentWindow.GoPointer())
88+ dialog.Present(parentWidget)
89+ } else {
90+ // Present without parent - GTK will use default window
91+ dialog.Present(nil)
92+ }
93+
94+ log.Debug().
95+ Str("origin", origin).
96+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
97+ Msg("showing permission dialog")
98+}
99+
100+// buildHeading creates the dialog heading based on permission types.
101+func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
102+ hasMic := false
103+ hasCam := false
104+
105+ for _, pt := range permTypes {
106+ switch pt {
107+ case entity.PermissionTypeMicrophone, entity.PermissionTypeMicrophoneAndCamera:
108+ hasMic = true
109+ case entity.PermissionTypeCamera:
110+ hasCam = true
111+ }
112+ }
113+
114+ switch {
115+ case hasMic && hasCam:
116+ return "Allow Microphone and Camera?"
117+ case hasMic:
118+ return "Allow Microphone Access?"
119+ case hasCam:
120+ return "Allow Camera Access?"
121+ default:
122+ return "Allow Permission?"
123+ }
124+}
125+
126+// buildBody creates the dialog body text.
127+func (d *PermissionDialog) buildBody(origin string, permTypes []entity.PermissionType) string {
128+ hasMic := false
129+ hasCam := false
130+
131+ for _, pt := range permTypes {
132+ switch pt {
133+ case entity.PermissionTypeMicrophone, entity.PermissionTypeMicrophoneAndCamera:
134+ hasMic = true
135+ case entity.PermissionTypeCamera:
136+ hasCam = true
137+ }
138+ }
139+
140+ var action string
141+ switch {
142+ case hasMic && hasCam:
143+ action = "access your microphone and camera"
144+ case hasMic:
145+ action = "access your microphone"
146+ case hasCam:
147+ action = "access your camera"
148+ default:
149+ action = "access your device"
150+ }
151+
152+ return fmt.Sprintf("%s wants to %s.", origin, action)
153+}
154+
155+// Ensure PermissionDialog implements the interface.
156+var _ port.PermissionDialogPresenter = (*PermissionDialog)(nil)
@@ -0,0 +1,156 @@
1+// Package dialog provides UI dialog implementations for the application layer.
2+package dialog
3+
4+import (
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/jwijenbergh/puregotk/v4/adw"
12+ "github.com/jwijenbergh/puregotk/v4/gtk"
13+)
14+
15+// PermissionDialog implements the port.PermissionDialogPresenter interface.
16+// It shows Adwaita AlertDialogs for media permission requests.
17+type PermissionDialog struct {
18+ // parentWindow is the parent window for modal dialogs (can be nil)
19+ parentWindow *gtk.ApplicationWindow
20+}
21+
22+// NewPermissionDialog creates a new permission dialog presenter.
23+func NewPermissionDialog(parentWindow *gtk.ApplicationWindow) *PermissionDialog {
24+ return &PermissionDialog{
25+ parentWindow: parentWindow,
26+ }
27+}
28+
29+// ShowPermissionDialog displays a permission request dialog to the user.
30+func (d *PermissionDialog) ShowPermissionDialog(
31+ ctx context.Context,
32+ origin string,
33+ permTypes []entity.PermissionType,
34+ callback func(result port.PermissionDialogResult),
35+) {
36+ log := logging.FromContext(ctx)
37+
38+ // Build dialog text
39+ heading := d.buildHeading(permTypes)
40+ body := d.buildBody(origin, permTypes)
41+
42+ // Create alert dialog
43+ dialog := adw.NewAlertDialog(&heading, &body)
44+ if dialog == nil {
45+ log.Error().Msg("failed to create permission dialog")
46+ // Deny by default if dialog creation fails
47+ callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
48+ return
49+ }
50+
51+ // Add responses: Allow, Always Allow, Deny, Always Deny
52+ // Using underscore prefix for mnemonic keyboard shortcuts
53+ dialog.AddResponse("allow", "_Allow")
54+ dialog.AddResponse("allow_always", "Always _Allow")
55+ dialog.AddResponse("deny", "_Deny")
56+ dialog.AddResponse("deny_always", "Always _Deny")
57+
58+ // Set appearances
59+ dialog.SetResponseAppearance("allow", adw.ResponseSuggestedValue)
60+ dialog.SetResponseAppearance("allow_always", adw.ResponseSuggestedValue)
61+ dialog.SetResponseAppearance("deny", adw.ResponseDefaultValue)
62+ dialog.SetResponseAppearance("deny_always", adw.ResponseDestructiveValue)
63+
64+ // Set default response to Deny (conservative default)
65+ defaultResponse := "deny"
66+ dialog.SetDefaultResponse(&defaultResponse)
67+ dialog.SetCloseResponse("deny")
68+
69+ // Connect response signal - store callback to prevent GC
70+ responseCb := func(_ adw.AlertDialog, response string) {
71+ switch response {
72+ case "allow":
73+ callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
74+ case "allow_always":
75+ callback(port.PermissionDialogResult{Allowed: true, Persistent: true})
76+ case "deny_always":
77+ callback(port.PermissionDialogResult{Allowed: false, Persistent: true})
78+ default:
79+ callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
80+ }
81+ }
82+ dialog.ConnectResponse(&responseCb)
83+
84+ // Present the dialog
85+ if d.parentWindow != nil {
86+ // Get the widget representation of the window
87+ parentWidget := gtk.WidgetNewFromInternalPtr(d.parentWindow.GoPointer())
88+ dialog.Present(parentWidget)
89+ } else {
90+ // Present without parent - GTK will use default window
91+ dialog.Present(nil)
92+ }
93+
94+ log.Debug().
95+ Str("origin", origin).
96+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
97+ Msg("showing permission dialog")
98+}
99+
100+// buildHeading creates the dialog heading based on permission types.
101+func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
102+ hasMic := false
103+ hasCam := false
104+
105+ for _, pt := range permTypes {
106+ switch pt {
107+ case entity.PermissionTypeMicrophone, entity.PermissionTypeMicrophoneAndCamera:
108+ hasMic = true
109+ case entity.PermissionTypeCamera:
110+ hasCam = true
111+ }
112+ }
113+
114+ switch {
115+ case hasMic && hasCam:
116+ return "Allow Microphone and Camera?"
117+ case hasMic:
118+ return "Allow Microphone Access?"
119+ case hasCam:
120+ return "Allow Camera Access?"
121+ default:
122+ return "Allow Permission?"
123+ }
124+}
125+
126+// buildBody creates the dialog body text.
127+func (d *PermissionDialog) buildBody(origin string, permTypes []entity.PermissionType) string {
128+ hasMic := false
129+ hasCam := false
130+
131+ for _, pt := range permTypes {
132+ switch pt {
133+ case entity.PermissionTypeMicrophone, entity.PermissionTypeMicrophoneAndCamera:
134+ hasMic = true
135+ case entity.PermissionTypeCamera:
136+ hasCam = true
137+ }
138+ }
139+
140+ var action string
141+ switch {
142+ case hasMic && hasCam:
143+ action = "access your microphone and camera"
144+ case hasMic:
145+ action = "access your microphone"
146+ case hasCam:
147+ action = "access your camera"
148+ default:
149+ action = "access your device"
150+ }
151+
152+ return fmt.Sprintf("%s wants to %s.", origin, action)
153+}
154+
155+// Ensure PermissionDialog implements the interface.
156+var _ port.PermissionDialogPresenter = (*PermissionDialog)(nil)

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