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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 90 | 90 | widgetFactory layout.WidgetFactory | |
| 91 | 91 | faviconAdapter *adapter.FaviconAdapter | |
| 92 | 92 | zoomUC *usecase.ManageZoomUseCase | |
| 93 | + | permissionUC *usecase.HandlePermissionUseCase | |
| 93 | 94 | injector *webkit.ContentInjector | |
| 94 | 95 | ||
| 95 | 96 | webViews map[entity.PaneID]*webkit.WebView | |
| @@ -186,6 +187,7 @@ func NewContentCoordinator( | |||
| 186 | 187 | faviconAdapter *adapter.FaviconAdapter, | |
| 187 | 188 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView), | |
| 188 | 189 | zoomUC *usecase.ManageZoomUseCase, | |
| 190 | + | permissionUC *usecase.HandlePermissionUseCase, | |
| 189 | 191 | ) *ContentCoordinator { | |
| 190 | 192 | log := logging.FromContext(ctx) | |
| 191 | 193 | log.Debug().Msg("creating content coordinator") | |
| @@ -195,6 +197,7 @@ func NewContentCoordinator( | |||
| 195 | 197 | widgetFactory: widgetFactory, | |
| 196 | 198 | faviconAdapter: faviconAdapter, | |
| 197 | 199 | zoomUC: zoomUC, | |
| 200 | + | permissionUC: permissionUC, | |
| 198 | 201 | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 199 | 202 | paneTitles: make(map[entity.PaneID]string), | |
| 200 | 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 | 1656 | // Fullscreen handlers for idle inhibition | |
| 1650 | 1657 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | |
| 1651 | 1658 | ||
| @@ -1943,6 +1950,68 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||
| 1943 | 1950 | 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 | 2015 | // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. | |
| 1947 | 2016 | // Idle is inhibited when: | |
| 1948 | 2017 | // - The webview enters fullscreen mode (e.g., fullscreen video) | |
| @@ -90,6 +90,7 @@ type ContentCoordinator struct { | |||||
| 90 | widgetFactory layout.WidgetFactory | 90 | widgetFactory layout.WidgetFactory | ||
| 91 | faviconAdapter *adapter.FaviconAdapter | 91 | faviconAdapter *adapter.FaviconAdapter | ||
| 92 | zoomUC *usecase.ManageZoomUseCase | 92 | zoomUC *usecase.ManageZoomUseCase | ||
| 93 | + | permissionUC *usecase.HandlePermissionUseCase | |||
| 93 | injector *webkit.ContentInjector | 94 | injector *webkit.ContentInjector | ||
| 94 | 95 | ||||
| 95 | webViews map[entity.PaneID]*webkit.WebView | 96 | webViews map[entity.PaneID]*webkit.WebView | ||
| @@ -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 inhibition | 1656 | // 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 true | 1950 | 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) | |||