detached 9bb5879

feat(permission): replace Adwaita dialog with custom popup overlay

Replace adw.AlertDialog with a custom PermissionPopup overlay component
to sidestep the purego ConnectResponse bug (empty string from missing
continue in syscall_sysv.go) and match the app's custom UI style.

- Add PermissionPopup component following omnibox/tab picker patterns
- Add themed CSS using existing variables (--accent, --destructive, etc.)
- Add PermissionPopupSizeDefaults for modal sizing
- Wire popup into main window overlay via app.go
- Expose ContentOverlay() accessor on MainWindow
- Canonicalize origins (lowercase, omit default ports) for storage
- Use Unix timestamps in PermissionRecord (avoid time import in domain)
- Add nil-record guard in SQLite permission repo
- Handle empty permTypes defensively in use case
commit
9bb5879575acd05079b042e196fa4a75ebde83e3
author
brice · · 2026-01-30 06:22 UTC
committer
brice · · 2026-02-07 11:58 UTC
parent
07bdb7c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +492 -75
M internal/application/usecase/handle_permission.go +11 -3
@@ -83,6 +83,12 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
8383 }
8484
8585 // Check if all types are auto-allow
86+ // Also handle empty permTypes as a deny (shouldn't happen but defensive)
87+ if len(permTypes) == 0 {
88+ log.Warn().Msg("permission request with empty types, denying")
89+ callback.Deny()
90+ return
91+ }
8692 if isAutoAllowSet(permTypes) {
8793 log.Debug().Msg("auto-allowing permission request")
8894 callback.Allow()
@@ -165,13 +171,15 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
165171 hasPrompt := false
166172
167173 for _, permType := range permTypes {
168- // Skip auto-allow types
174+ // Skip auto-allow types - they're handled before this function
169175 if entity.IsAutoAllow(permType) {
170176 continue
171177 }
172178
173- // Skip non-persistable types (shouldn't happen, but defensive)
179+ // Non-persistable types that aren't auto-allow should prompt
180+ // This shouldn't happen in practice, but we handle it defensively
174181 if !entity.CanPersist(permType) {
182+ hasPrompt = true
175183 continue
176184 }
177185
@@ -266,7 +274,7 @@ func (uc *HandlePermissionUseCase) persistPermission(
266274 Origin: origin,
267275 Type: permType,
268276 Decision: decision,
269- UpdatedAt: time.Now(),
277+ UpdatedAt: time.Now().Unix(),
270278 }
271279
272280 if err := uc.permRepo.Set(ctx, record); err != nil {
@@ -83,6 +83,12 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
83 }83 }
84 84
85 // Check if all types are auto-allow85 // Check if all types are auto-allow
86+ // Also handle empty permTypes as a deny (shouldn't happen but defensive)
87+ if len(permTypes) == 0 {
88+ log.Warn().Msg("permission request with empty types, denying")
89+ callback.Deny()
90+ return
91+ }
86 if isAutoAllowSet(permTypes) {92 if isAutoAllowSet(permTypes) {
87 log.Debug().Msg("auto-allowing permission request")93 log.Debug().Msg("auto-allowing permission request")
88 callback.Allow()94 callback.Allow()
@@ -165,13 +171,15 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions(
165 hasPrompt := false171 hasPrompt := false
166 172
167 for _, permType := range permTypes {173 for _, permType := range permTypes {
168- // Skip auto-allow types174+ // Skip auto-allow types - they're handled before this function
169 if entity.IsAutoAllow(permType) {175 if entity.IsAutoAllow(permType) {
170 continue176 continue
171 }177 }
172 178
173- // Skip non-persistable types (shouldn't happen, but defensive)179+ // Non-persistable types that aren't auto-allow should prompt
180+ // This shouldn't happen in practice, but we handle it defensively
174 if !entity.CanPersist(permType) {181 if !entity.CanPersist(permType) {
182+ hasPrompt = true
175 continue183 continue
176 }184 }
177 185
@@ -266,7 +274,7 @@ func (uc *HandlePermissionUseCase) persistPermission(
266 Origin: origin,274 Origin: origin,
267 Type: permType,275 Type: permType,
268 Decision: decision,276 Decision: decision,
269- UpdatedAt: time.Now(),277+ UpdatedAt: time.Now().Unix(),
270 }278 }
271 279
272 if err := uc.permRepo.Set(ctx, record); err != nil {280 if err := uc.permRepo.Set(ctx, record); err != nil {
M internal/application/usecase/handle_permission_test.go +2 -2
@@ -73,7 +73,7 @@ func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
7373 Origin: "https://meet.example.com",
7474 Type: entity.PermissionTypeMicrophone,
7575 Decision: entity.PermissionGranted,
76- UpdatedAt: time.Now(),
76+ UpdatedAt: time.Now().Unix(),
7777 }, nil)
7878
7979 allowed := false
@@ -103,7 +103,7 @@ func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
103103 Origin: "https://meet.example.com",
104104 Type: entity.PermissionTypeCamera,
105105 Decision: entity.PermissionDenied,
106- UpdatedAt: time.Now(),
106+ UpdatedAt: time.Now().Unix(),
107107 }, nil)
108108
109109 denied := false
@@ -73,7 +73,7 @@ func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
73 Origin: "https://meet.example.com",73 Origin: "https://meet.example.com",
74 Type: entity.PermissionTypeMicrophone,74 Type: entity.PermissionTypeMicrophone,
75 Decision: entity.PermissionGranted,75 Decision: entity.PermissionGranted,
76- UpdatedAt: time.Now(),76+ UpdatedAt: time.Now().Unix(),
77 }, nil)77 }, nil)
78 78
79 allowed := false79 allowed := false
@@ -103,7 +103,7 @@ func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
103 Origin: "https://meet.example.com",103 Origin: "https://meet.example.com",
104 Type: entity.PermissionTypeCamera,104 Type: entity.PermissionTypeCamera,
105 Decision: entity.PermissionDenied,105 Decision: entity.PermissionDenied,
106- UpdatedAt: time.Now(),106+ UpdatedAt: time.Now().Unix(),
107 }, nil)107 }, nil)
108 108
109 denied := false109 denied := false
M internal/domain/entity/permission.go +3 -4
@@ -1,8 +1,7 @@
11 package entity
22
3-import (
4- "time"
5-)
3+// DomainTimestamp represents a Unix timestamp in seconds.
4+// This is a domain-owned type to avoid importing time in the domain layer.
65
76 // PermissionType represents the type of permission being requested.
87 type PermissionType string
@@ -58,7 +57,7 @@ type PermissionRecord struct {
5857 Origin string // The origin (domain) this permission applies to
5958 Type PermissionType // The type of permission
6059 Decision PermissionDecision // The decision: granted, denied, or prompt
61- UpdatedAt time.Time // When this record was last updated
60+ UpdatedAt int64 // Unix timestamp in seconds when this record was last updated
6261 }
6362
6463 // IsGranted returns true if the permission is granted.
@@ -1,8 +1,7 @@
1 package entity1 package entity
2 2
3-import (3+// DomainTimestamp represents a Unix timestamp in seconds.
4- "time"4+// This is a domain-owned type to avoid importing time in the domain layer.
5-)
6 5
7 // PermissionType represents the type of permission being requested.6 // PermissionType represents the type of permission being requested.
8 type PermissionType string7 type PermissionType string
@@ -58,7 +57,7 @@ type PermissionRecord struct {
58 Origin string // The origin (domain) this permission applies to57 Origin string // The origin (domain) this permission applies to
59 Type PermissionType // The type of permission58 Type PermissionType // The type of permission
60 Decision PermissionDecision // The decision: granted, denied, or prompt59 Decision PermissionDecision // The decision: granted, denied, or prompt
61- UpdatedAt time.Time // When this record was last updated60+ UpdatedAt int64 // Unix timestamp in seconds when this record was last updated
62 }61 }
63 62
64 // IsGranted returns true if the permission is granted.63 // IsGranted returns true if the permission is granted.
M internal/domain/entity/permission_test.go +2 -2
@@ -66,7 +66,7 @@ func TestPermissionRecord_IsGranted(t *testing.T) {
6666 Origin: "https://example.com",
6767 Type: entity.PermissionTypeMicrophone,
6868 Decision: tt.decision,
69- UpdatedAt: time.Now(),
69+ UpdatedAt: time.Now().Unix(),
7070 }
7171 assert.Equal(t, tt.expected, record.IsGranted())
7272 })
@@ -90,7 +90,7 @@ func TestPermissionRecord_IsDenied(t *testing.T) {
9090 Origin: "https://example.com",
9191 Type: entity.PermissionTypeCamera,
9292 Decision: tt.decision,
93- UpdatedAt: time.Now(),
93+ UpdatedAt: time.Now().Unix(),
9494 }
9595 assert.Equal(t, tt.expected, record.IsDenied())
9696 })
@@ -66,7 +66,7 @@ func TestPermissionRecord_IsGranted(t *testing.T) {
66 Origin: "https://example.com",66 Origin: "https://example.com",
67 Type: entity.PermissionTypeMicrophone,67 Type: entity.PermissionTypeMicrophone,
68 Decision: tt.decision,68 Decision: tt.decision,
69- UpdatedAt: time.Now(),69+ UpdatedAt: time.Now().Unix(),
70 }70 }
71 assert.Equal(t, tt.expected, record.IsGranted())71 assert.Equal(t, tt.expected, record.IsGranted())
72 })72 })
@@ -90,7 +90,7 @@ func TestPermissionRecord_IsDenied(t *testing.T) {
90 Origin: "https://example.com",90 Origin: "https://example.com",
91 Type: entity.PermissionTypeCamera,91 Type: entity.PermissionTypeCamera,
92 Decision: tt.decision,92 Decision: tt.decision,
93- UpdatedAt: time.Now(),93+ UpdatedAt: time.Now().Unix(),
94 }94 }
95 assert.Equal(t, tt.expected, record.IsDenied())95 assert.Equal(t, tt.expected, record.IsDenied())
96 })96 })
M internal/domain/url/normalize.go +22 -1
@@ -206,7 +206,9 @@ func IsExternalScheme(uri string) bool {
206206
207207 // ExtractOrigin extracts the origin (scheme://host) from a URI.
208208 // This normalizes URIs to origins for permission storage and comparison.
209+// Canonicalizes by lowercasing scheme and host, and omitting default ports.
209210 // Example: "https://example.com/path?query" -> "https://example.com"
211+// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
210212 func ExtractOrigin(uri string) (string, error) {
211213 parsed, err := url.Parse(uri)
212214 if err != nil {
@@ -217,5 +219,24 @@ func ExtractOrigin(uri string) (string, error) {
217219 return "", fmt.Errorf("URI missing scheme or host: %s", uri)
218220 }
219221
220- return parsed.Scheme + "://" + parsed.Host, nil
222+ // Canonicalize: lowercase scheme and hostname
223+ scheme := strings.ToLower(parsed.Scheme)
224+ hostname := strings.ToLower(parsed.Hostname())
225+
226+ // Determine if we should include the port
227+ port := parsed.Port()
228+ if port != "" {
229+ // Omit default ports
230+ if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
231+ port = ""
232+ }
233+ }
234+
235+ // Build canonical origin
236+ origin := scheme + "://" + hostname
237+ if port != "" {
238+ origin = origin + ":" + port
239+ }
240+
241+ return origin, nil
221242 }
@@ -206,7 +206,9 @@ func IsExternalScheme(uri string) bool {
206 206
207 // ExtractOrigin extracts the origin (scheme://host) from a URI.207 // ExtractOrigin extracts the origin (scheme://host) from a URI.
208 // This normalizes URIs to origins for permission storage and comparison.208 // This normalizes URIs to origins for permission storage and comparison.
209+// Canonicalizes by lowercasing scheme and host, and omitting default ports.
209 // Example: "https://example.com/path?query" -> "https://example.com"210 // Example: "https://example.com/path?query" -> "https://example.com"
211+// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
210 func ExtractOrigin(uri string) (string, error) {212 func ExtractOrigin(uri string) (string, error) {
211 parsed, err := url.Parse(uri)213 parsed, err := url.Parse(uri)
212 if err != nil {214 if err != nil {
@@ -217,5 +219,24 @@ func ExtractOrigin(uri string) (string, error) {
217 return "", fmt.Errorf("URI missing scheme or host: %s", uri)219 return "", fmt.Errorf("URI missing scheme or host: %s", uri)
218 }220 }
219 221
220- return parsed.Scheme + "://" + parsed.Host, nil222+ // Canonicalize: lowercase scheme and hostname
223+ scheme := strings.ToLower(parsed.Scheme)
224+ hostname := strings.ToLower(parsed.Hostname())
225+
226+ // Determine if we should include the port
227+ port := parsed.Port()
228+ if port != "" {
229+ // Omit default ports
230+ if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
231+ port = ""
232+ }
233+ }
234+
235+ // Build canonical origin
236+ origin := scheme + "://" + hostname
237+ if port != "" {
238+ origin = origin + ":" + port
239+ }
240+
241+ return origin, nil
221 }242 }
M internal/domain/url/normalize_test.go +30 -0
@@ -531,6 +531,36 @@ func TestExtractOrigin_ValidURI(t *testing.T) {
531531 uri: "http://localhost:8080/app",
532532 expected: "http://localhost:8080",
533533 },
534+ {
535+ name: "uppercase host",
536+ uri: "https://EXAMPLE.COM/path",
537+ expected: "https://example.com",
538+ },
539+ {
540+ name: "uppercase scheme",
541+ uri: "HTTPS://example.com/path",
542+ expected: "https://example.com",
543+ },
544+ {
545+ name: "https default port omitted",
546+ uri: "https://example.com:443/path",
547+ expected: "https://example.com",
548+ },
549+ {
550+ name: "http default port omitted",
551+ uri: "http://example.com:80/path",
552+ expected: "http://example.com",
553+ },
554+ {
555+ name: "https explicit non-default port kept",
556+ uri: "https://example.com:8080/path",
557+ expected: "https://example.com:8080",
558+ },
559+ {
560+ name: "mixed case with default port",
561+ uri: "HTTPS://EXAMPLE.COM:443/path",
562+ expected: "https://example.com",
563+ },
534564 }
535565
536566 for _, tt := range tests {
@@ -531,6 +531,36 @@ func TestExtractOrigin_ValidURI(t *testing.T) {
531 uri: "http://localhost:8080/app",531 uri: "http://localhost:8080/app",
532 expected: "http://localhost:8080",532 expected: "http://localhost:8080",
533 },533 },
534+ {
535+ name: "uppercase host",
536+ uri: "https://EXAMPLE.COM/path",
537+ expected: "https://example.com",
538+ },
539+ {
540+ name: "uppercase scheme",
541+ uri: "HTTPS://example.com/path",
542+ expected: "https://example.com",
543+ },
544+ {
545+ name: "https default port omitted",
546+ uri: "https://example.com:443/path",
547+ expected: "https://example.com",
548+ },
549+ {
550+ name: "http default port omitted",
551+ uri: "http://example.com:80/path",
552+ expected: "http://example.com",
553+ },
554+ {
555+ name: "https explicit non-default port kept",
556+ uri: "https://example.com:8080/path",
557+ expected: "https://example.com:8080",
558+ },
559+ {
560+ name: "mixed case with default port",
561+ uri: "HTTPS://EXAMPLE.COM:443/path",
562+ expected: "https://example.com",
563+ },
534 }564 }
535 565
536 for _, tt := range tests {566 for _, tt := range tests {
M internal/infrastructure/persistence/sqlite/permission_repo.go +7 -1
@@ -40,6 +40,12 @@ func (r *permissionRepo) Get(ctx context.Context, origin string, permType entity
4040
4141 func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error {
4242 log := logging.FromContext(ctx)
43+
44+ if record == nil {
45+ log.Error().Msg("cannot set nil permission record")
46+ return errors.New("cannot set nil permission record")
47+ }
48+
4349 log.Debug().
4450 Str("origin", record.Origin).
4551 Str("type", string(record.Type)).
@@ -86,7 +92,7 @@ func permissionFromRow(row sqlc.Permission) *entity.PermissionRecord {
8692 Decision: entity.PermissionDecision(row.Decision),
8793 }
8894 if row.UpdatedAt.Valid {
89- record.UpdatedAt = row.UpdatedAt.Time
95+ record.UpdatedAt = row.UpdatedAt.Time.Unix()
9096 }
9197 return record
9298 }
@@ -40,6 +40,12 @@ func (r *permissionRepo) Get(ctx context.Context, origin string, permType entity
40 40
41 func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error {41 func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error {
42 log := logging.FromContext(ctx)42 log := logging.FromContext(ctx)
43+
44+ if record == nil {
45+ log.Error().Msg("cannot set nil permission record")
46+ return errors.New("cannot set nil permission record")
47+ }
48+
43 log.Debug().49 log.Debug().
44 Str("origin", record.Origin).50 Str("origin", record.Origin).
45 Str("type", string(record.Type)).51 Str("type", string(record.Type)).
@@ -86,7 +92,7 @@ func permissionFromRow(row sqlc.Permission) *entity.PermissionRecord {
86 Decision: entity.PermissionDecision(row.Decision),92 Decision: entity.PermissionDecision(row.Decision),
87 }93 }
88 if row.UpdatedAt.Valid {94 if row.UpdatedAt.Valid {
89- record.UpdatedAt = row.UpdatedAt.Time95+ record.UpdatedAt = row.UpdatedAt.Time.Unix()
90 }96 }
91 return record97 return record
92 }98 }
M internal/ui/app.go +14 -3
@@ -349,10 +349,21 @@ func (a *App) createMainWindow(ctx context.Context) error {
349349 }
350350 a.mainWindow = mainWindow
351351
352- // Create and set the permission dialog presenter
352+ // Create permission popup and dialog presenter
353353 if a.deps != nil && a.deps.PermissionUC != nil {
354- permDialog := dialog.NewPermissionDialog(a.mainWindow.Window())
355- a.deps.PermissionUC.SetDialogPresenter(permDialog)
354+ uiScale := 1.0
355+ if a.deps.Config != nil {
356+ uiScale = a.deps.Config.DefaultUIScale
357+ }
358+ permPopup := component.NewPermissionPopup(nil, uiScale)
359+ if permPopup != nil {
360+ // Add popup to the main window's content overlay
361+ if w := permPopup.Widget(); w != nil {
362+ a.mainWindow.AddOverlay(w)
363+ }
364+ permDialog := dialog.NewPermissionDialog(permPopup)
365+ a.deps.PermissionUC.SetDialogPresenter(permDialog)
366+ }
356367 }
357368
358369 // Apply GTK CSS styling from theme manager.
@@ -349,10 +349,21 @@ func (a *App) createMainWindow(ctx context.Context) error {
349 }349 }
350 a.mainWindow = mainWindow350 a.mainWindow = mainWindow
351 351
352- // Create and set the permission dialog presenter352+ // Create permission popup and dialog presenter
353 if a.deps != nil && a.deps.PermissionUC != nil {353 if a.deps != nil && a.deps.PermissionUC != nil {
354- permDialog := dialog.NewPermissionDialog(a.mainWindow.Window())354+ uiScale := 1.0
355- a.deps.PermissionUC.SetDialogPresenter(permDialog)355+ if a.deps.Config != nil {
356+ uiScale = a.deps.Config.DefaultUIScale
357+ }
358+ permPopup := component.NewPermissionPopup(nil, uiScale)
359+ if permPopup != nil {
360+ // Add popup to the main window's content overlay
361+ if w := permPopup.Widget(); w != nil {
362+ a.mainWindow.AddOverlay(w)
363+ }
364+ permDialog := dialog.NewPermissionDialog(permPopup)
365+ a.deps.PermissionUC.SetDialogPresenter(permDialog)
366+ }
356 }367 }
357 368
358 // Apply GTK CSS styling from theme manager.369 // Apply GTK CSS styling from theme manager.
M internal/ui/component/modal_sizing.go +9 -0
@@ -82,6 +82,15 @@ var TabPickerListDefaults = ListDisplayDefaults{
8282 MaxResults: 20,
8383 }
8484
85+// PermissionPopupSizeDefaults provides default sizing for permission popup modal.
86+var PermissionPopupSizeDefaults = ModalSizeConfig{
87+ WidthPct: 0.4,
88+ MaxWidth: 400,
89+ TopMarginPct: 0.3,
90+ FallbackWidth: 400,
91+ FallbackHeight: 600,
92+}
93+
8594 // EffectiveMaxRows returns the maximum number of visible rows that fit
8695 // in the available space. It computes available height by subtracting the
8796 // top margin (TopMarginPct of parentHeight) and chrome height (header tabs +
@@ -82,6 +82,15 @@ var TabPickerListDefaults = ListDisplayDefaults{
82 MaxResults: 20,82 MaxResults: 20,
83 }83 }
84 84
85+// PermissionPopupSizeDefaults provides default sizing for permission popup modal.
86+var PermissionPopupSizeDefaults = ModalSizeConfig{
87+ WidthPct: 0.4,
88+ MaxWidth: 400,
89+ TopMarginPct: 0.3,
90+ FallbackWidth: 400,
91+ FallbackHeight: 600,
92+}
93+
85 // EffectiveMaxRows returns the maximum number of visible rows that fit94 // EffectiveMaxRows returns the maximum number of visible rows that fit
86 // in the available space. It computes available height by subtracting the95 // in the available space. It computes available height by subtracting the
87 // top margin (TopMarginPct of parentHeight) and chrome height (header tabs +96 // top margin (TopMarginPct of parentHeight) and chrome height (header tabs +
A internal/ui/component/permission_popup.go +276 -0
@@ -0,0 +1,276 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+// PermissionPopup is a custom overlay component for permission prompts.
14+// It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug
15+// and match the app's custom UI style.
16+type PermissionPopup struct {
17+ outerBox *gtk.Box
18+ mainBox *gtk.Box
19+
20+ headingLabel *gtk.Label
21+ bodyLabel *gtk.Label
22+
23+ btnAllow *gtk.Button
24+ btnAlwaysAllow *gtk.Button
25+ btnDeny *gtk.Button
26+ btnAlwaysDeny *gtk.Button
27+
28+ parentOverlay layout.OverlayWidget
29+ uiScale float64
30+
31+ mu sync.Mutex
32+ visible bool
33+ callback func(allowed, persistent bool)
34+
35+ retainedCallbacks []interface{}
36+}
37+
38+// NewPermissionPopup creates a new permission popup component.
39+func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup {
40+ if uiScale <= 0 {
41+ uiScale = 1.0
42+ }
43+
44+ pp := &PermissionPopup{
45+ parentOverlay: parentOverlay,
46+ uiScale: uiScale,
47+ }
48+
49+ if err := pp.createWidgets(); err != nil {
50+ return nil
51+ }
52+ pp.attachKeyController()
53+ return pp
54+}
55+
56+// Widget returns the outer GTK widget for overlay registration.
57+func (pp *PermissionPopup) Widget() *gtk.Widget {
58+ if pp.outerBox == nil {
59+ return nil
60+ }
61+ return &pp.outerBox.Widget
62+}
63+
64+// Show displays the permission popup with the given heading and body text.
65+// The callback receives (allowed, persistent) when the user makes a choice.
66+func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
67+ log := logging.FromContext(ctx)
68+
69+ pp.mu.Lock()
70+ if pp.visible {
71+ pp.mu.Unlock()
72+ log.Warn().Msg("permission popup already visible, ignoring Show")
73+ return
74+ }
75+ pp.visible = true
76+ pp.callback = callback
77+ pp.mu.Unlock()
78+
79+ if pp.headingLabel != nil {
80+ pp.headingLabel.SetText(heading)
81+ }
82+ if pp.bodyLabel != nil {
83+ pp.bodyLabel.SetText(body)
84+ }
85+
86+ pp.resizeAndCenter()
87+ if pp.outerBox != nil {
88+ pp.outerBox.SetVisible(true)
89+ }
90+ // Focus the Deny button as the conservative default
91+ if pp.btnDeny != nil {
92+ pp.btnDeny.GrabFocus()
93+ }
94+}
95+
96+// Hide hides the popup without invoking the callback.
97+func (pp *PermissionPopup) Hide() {
98+ pp.mu.Lock()
99+ if !pp.visible {
100+ pp.mu.Unlock()
101+ return
102+ }
103+ pp.visible = false
104+ pp.callback = nil
105+ pp.mu.Unlock()
106+
107+ if pp.outerBox != nil {
108+ pp.outerBox.SetVisible(false)
109+ }
110+}
111+
112+// IsVisible returns whether the popup is currently displayed.
113+func (pp *PermissionPopup) IsVisible() bool {
114+ pp.mu.Lock()
115+ defer pp.mu.Unlock()
116+ return pp.visible
117+}
118+
119+func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
120+ pp.mu.Lock()
121+ if !pp.visible {
122+ pp.mu.Unlock()
123+ return
124+ }
125+ pp.visible = false
126+ cb := pp.callback
127+ pp.callback = nil
128+ pp.mu.Unlock()
129+
130+ if pp.outerBox != nil {
131+ pp.outerBox.SetVisible(false)
132+ }
133+ if cb != nil {
134+ cb(allowed, persistent)
135+ }
136+}
137+
138+func (pp *PermissionPopup) createWidgets() error {
139+ // Outer box - positioned in overlay, hidden by default
140+ pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
141+ if pp.outerBox == nil {
142+ return errNilWidget("permissionPopupOuterBox")
143+ }
144+ pp.outerBox.AddCssClass("permission-popup-outer")
145+ pp.outerBox.SetHalign(gtk.AlignCenterValue)
146+ pp.outerBox.SetValign(gtk.AlignStartValue)
147+ pp.outerBox.SetVisible(false)
148+
149+ // Main box - styled container
150+ pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
151+ if pp.mainBox == nil {
152+ return errNilWidget("permissionPopupMainBox")
153+ }
154+ pp.mainBox.AddCssClass("permission-popup-container")
155+
156+ // Heading label
157+ emptyText := ""
158+ pp.headingLabel = gtk.NewLabel(&emptyText)
159+ if pp.headingLabel == nil {
160+ return errNilWidget("permissionPopupHeadingLabel")
161+ }
162+ pp.headingLabel.AddCssClass("permission-popup-heading")
163+ pp.headingLabel.SetHalign(gtk.AlignStartValue)
164+
165+ // Body label
166+ pp.bodyLabel = gtk.NewLabel(&emptyText)
167+ if pp.bodyLabel == nil {
168+ return errNilWidget("permissionPopupBodyLabel")
169+ }
170+ pp.bodyLabel.AddCssClass("permission-popup-body")
171+ pp.bodyLabel.SetHalign(gtk.AlignStartValue)
172+ pp.bodyLabel.SetWrap(true)
173+
174+ // Button row
175+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, 6)
176+ if btnRow == nil {
177+ return errNilWidget("permissionPopupBtnRow")
178+ }
179+ btnRow.AddCssClass("permission-popup-btn-row")
180+ btnRow.SetHalign(gtk.AlignEndValue)
181+
182+ // Create buttons: Always Deny | Deny | Allow | Always Allow
183+ pp.btnAlwaysDeny = gtk.NewButtonWithLabel("Always Deny")
184+ if pp.btnAlwaysDeny == nil {
185+ return errNilWidget("permissionPopupBtnAlwaysDeny")
186+ }
187+ pp.btnAlwaysDeny.AddCssClass("permission-popup-btn")
188+ pp.btnAlwaysDeny.AddCssClass("permission-popup-btn-destructive")
189+
190+ pp.btnDeny = gtk.NewButtonWithLabel("Deny")
191+ if pp.btnDeny == nil {
192+ return errNilWidget("permissionPopupBtnDeny")
193+ }
194+ pp.btnDeny.AddCssClass("permission-popup-btn")
195+ pp.btnDeny.AddCssClass("permission-popup-btn-deny")
196+
197+ pp.btnAllow = gtk.NewButtonWithLabel("Allow")
198+ if pp.btnAllow == nil {
199+ return errNilWidget("permissionPopupBtnAllow")
200+ }
201+ pp.btnAllow.AddCssClass("permission-popup-btn")
202+ pp.btnAllow.AddCssClass("permission-popup-btn-allow")
203+
204+ pp.btnAlwaysAllow = gtk.NewButtonWithLabel("Always Allow")
205+ if pp.btnAlwaysAllow == nil {
206+ return errNilWidget("permissionPopupBtnAlwaysAllow")
207+ }
208+ pp.btnAlwaysAllow.AddCssClass("permission-popup-btn")
209+ pp.btnAlwaysAllow.AddCssClass("permission-popup-btn-allow")
210+
211+ // Wire button clicks
212+ alwaysDenyCb := func(_ gtk.Button) { pp.dismiss(false, true) }
213+ pp.retainedCallbacks = append(pp.retainedCallbacks, alwaysDenyCb)
214+ pp.btnAlwaysDeny.ConnectClicked(&alwaysDenyCb)
215+
216+ denyCb := func(_ gtk.Button) { pp.dismiss(false, false) }
217+ pp.retainedCallbacks = append(pp.retainedCallbacks, denyCb)
218+ pp.btnDeny.ConnectClicked(&denyCb)
219+
220+ allowCb := func(_ gtk.Button) { pp.dismiss(true, false) }
221+ pp.retainedCallbacks = append(pp.retainedCallbacks, allowCb)
222+ pp.btnAllow.ConnectClicked(&allowCb)
223+
224+ alwaysAllowCb := func(_ gtk.Button) { pp.dismiss(true, true) }
225+ pp.retainedCallbacks = append(pp.retainedCallbacks, alwaysAllowCb)
226+ pp.btnAlwaysAllow.ConnectClicked(&alwaysAllowCb)
227+
228+ // Assemble button row
229+ btnRow.Append(&pp.btnAlwaysDeny.Widget)
230+ btnRow.Append(&pp.btnDeny.Widget)
231+ btnRow.Append(&pp.btnAllow.Widget)
232+ btnRow.Append(&pp.btnAlwaysAllow.Widget)
233+
234+ // Assemble main box
235+ pp.mainBox.Append(&pp.headingLabel.Widget)
236+ pp.mainBox.Append(&pp.bodyLabel.Widget)
237+ pp.mainBox.Append(&btnRow.Widget)
238+
239+ // Assemble outer box
240+ pp.outerBox.Append(&pp.mainBox.Widget)
241+
242+ return nil
243+}
244+
245+func (pp *PermissionPopup) attachKeyController() {
246+ if pp.outerBox == nil {
247+ return
248+ }
249+ controller := gtk.NewEventControllerKey()
250+ if controller == nil {
251+ return
252+ }
253+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
254+
255+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
256+ if keyval == uint(gdk.KEY_Escape) {
257+ // Escape = deny (conservative default)
258+ pp.dismiss(false, false)
259+ return true
260+ }
261+ return false
262+ }
263+ pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
264+ controller.ConnectKeyPressed(&keyPressedCb)
265+ pp.outerBox.AddController(&controller.EventController)
266+}
267+
268+func (pp *PermissionPopup) resizeAndCenter() {
269+ if pp.outerBox == nil || pp.mainBox == nil {
270+ return
271+ }
272+
273+ width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
274+ pp.mainBox.SetSizeRequest(width, -1)
275+ pp.outerBox.SetMarginTop(marginTop)
276+}
@@ -0,0 +1,276 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+// PermissionPopup is a custom overlay component for permission prompts.
14+// It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug
15+// and match the app's custom UI style.
16+type PermissionPopup struct {
17+ outerBox *gtk.Box
18+ mainBox *gtk.Box
19+
20+ headingLabel *gtk.Label
21+ bodyLabel *gtk.Label
22+
23+ btnAllow *gtk.Button
24+ btnAlwaysAllow *gtk.Button
25+ btnDeny *gtk.Button
26+ btnAlwaysDeny *gtk.Button
27+
28+ parentOverlay layout.OverlayWidget
29+ uiScale float64
30+
31+ mu sync.Mutex
32+ visible bool
33+ callback func(allowed, persistent bool)
34+
35+ retainedCallbacks []interface{}
36+}
37+
38+// NewPermissionPopup creates a new permission popup component.
39+func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup {
40+ if uiScale <= 0 {
41+ uiScale = 1.0
42+ }
43+
44+ pp := &PermissionPopup{
45+ parentOverlay: parentOverlay,
46+ uiScale: uiScale,
47+ }
48+
49+ if err := pp.createWidgets(); err != nil {
50+ return nil
51+ }
52+ pp.attachKeyController()
53+ return pp
54+}
55+
56+// Widget returns the outer GTK widget for overlay registration.
57+func (pp *PermissionPopup) Widget() *gtk.Widget {
58+ if pp.outerBox == nil {
59+ return nil
60+ }
61+ return &pp.outerBox.Widget
62+}
63+
64+// Show displays the permission popup with the given heading and body text.
65+// The callback receives (allowed, persistent) when the user makes a choice.
66+func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
67+ log := logging.FromContext(ctx)
68+
69+ pp.mu.Lock()
70+ if pp.visible {
71+ pp.mu.Unlock()
72+ log.Warn().Msg("permission popup already visible, ignoring Show")
73+ return
74+ }
75+ pp.visible = true
76+ pp.callback = callback
77+ pp.mu.Unlock()
78+
79+ if pp.headingLabel != nil {
80+ pp.headingLabel.SetText(heading)
81+ }
82+ if pp.bodyLabel != nil {
83+ pp.bodyLabel.SetText(body)
84+ }
85+
86+ pp.resizeAndCenter()
87+ if pp.outerBox != nil {
88+ pp.outerBox.SetVisible(true)
89+ }
90+ // Focus the Deny button as the conservative default
91+ if pp.btnDeny != nil {
92+ pp.btnDeny.GrabFocus()
93+ }
94+}
95+
96+// Hide hides the popup without invoking the callback.
97+func (pp *PermissionPopup) Hide() {
98+ pp.mu.Lock()
99+ if !pp.visible {
100+ pp.mu.Unlock()
101+ return
102+ }
103+ pp.visible = false
104+ pp.callback = nil
105+ pp.mu.Unlock()
106+
107+ if pp.outerBox != nil {
108+ pp.outerBox.SetVisible(false)
109+ }
110+}
111+
112+// IsVisible returns whether the popup is currently displayed.
113+func (pp *PermissionPopup) IsVisible() bool {
114+ pp.mu.Lock()
115+ defer pp.mu.Unlock()
116+ return pp.visible
117+}
118+
119+func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
120+ pp.mu.Lock()
121+ if !pp.visible {
122+ pp.mu.Unlock()
123+ return
124+ }
125+ pp.visible = false
126+ cb := pp.callback
127+ pp.callback = nil
128+ pp.mu.Unlock()
129+
130+ if pp.outerBox != nil {
131+ pp.outerBox.SetVisible(false)
132+ }
133+ if cb != nil {
134+ cb(allowed, persistent)
135+ }
136+}
137+
138+func (pp *PermissionPopup) createWidgets() error {
139+ // Outer box - positioned in overlay, hidden by default
140+ pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
141+ if pp.outerBox == nil {
142+ return errNilWidget("permissionPopupOuterBox")
143+ }
144+ pp.outerBox.AddCssClass("permission-popup-outer")
145+ pp.outerBox.SetHalign(gtk.AlignCenterValue)
146+ pp.outerBox.SetValign(gtk.AlignStartValue)
147+ pp.outerBox.SetVisible(false)
148+
149+ // Main box - styled container
150+ pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
151+ if pp.mainBox == nil {
152+ return errNilWidget("permissionPopupMainBox")
153+ }
154+ pp.mainBox.AddCssClass("permission-popup-container")
155+
156+ // Heading label
157+ emptyText := ""
158+ pp.headingLabel = gtk.NewLabel(&emptyText)
159+ if pp.headingLabel == nil {
160+ return errNilWidget("permissionPopupHeadingLabel")
161+ }
162+ pp.headingLabel.AddCssClass("permission-popup-heading")
163+ pp.headingLabel.SetHalign(gtk.AlignStartValue)
164+
165+ // Body label
166+ pp.bodyLabel = gtk.NewLabel(&emptyText)
167+ if pp.bodyLabel == nil {
168+ return errNilWidget("permissionPopupBodyLabel")
169+ }
170+ pp.bodyLabel.AddCssClass("permission-popup-body")
171+ pp.bodyLabel.SetHalign(gtk.AlignStartValue)
172+ pp.bodyLabel.SetWrap(true)
173+
174+ // Button row
175+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, 6)
176+ if btnRow == nil {
177+ return errNilWidget("permissionPopupBtnRow")
178+ }
179+ btnRow.AddCssClass("permission-popup-btn-row")
180+ btnRow.SetHalign(gtk.AlignEndValue)
181+
182+ // Create buttons: Always Deny | Deny | Allow | Always Allow
183+ pp.btnAlwaysDeny = gtk.NewButtonWithLabel("Always Deny")
184+ if pp.btnAlwaysDeny == nil {
185+ return errNilWidget("permissionPopupBtnAlwaysDeny")
186+ }
187+ pp.btnAlwaysDeny.AddCssClass("permission-popup-btn")
188+ pp.btnAlwaysDeny.AddCssClass("permission-popup-btn-destructive")
189+
190+ pp.btnDeny = gtk.NewButtonWithLabel("Deny")
191+ if pp.btnDeny == nil {
192+ return errNilWidget("permissionPopupBtnDeny")
193+ }
194+ pp.btnDeny.AddCssClass("permission-popup-btn")
195+ pp.btnDeny.AddCssClass("permission-popup-btn-deny")
196+
197+ pp.btnAllow = gtk.NewButtonWithLabel("Allow")
198+ if pp.btnAllow == nil {
199+ return errNilWidget("permissionPopupBtnAllow")
200+ }
201+ pp.btnAllow.AddCssClass("permission-popup-btn")
202+ pp.btnAllow.AddCssClass("permission-popup-btn-allow")
203+
204+ pp.btnAlwaysAllow = gtk.NewButtonWithLabel("Always Allow")
205+ if pp.btnAlwaysAllow == nil {
206+ return errNilWidget("permissionPopupBtnAlwaysAllow")
207+ }
208+ pp.btnAlwaysAllow.AddCssClass("permission-popup-btn")
209+ pp.btnAlwaysAllow.AddCssClass("permission-popup-btn-allow")
210+
211+ // Wire button clicks
212+ alwaysDenyCb := func(_ gtk.Button) { pp.dismiss(false, true) }
213+ pp.retainedCallbacks = append(pp.retainedCallbacks, alwaysDenyCb)
214+ pp.btnAlwaysDeny.ConnectClicked(&alwaysDenyCb)
215+
216+ denyCb := func(_ gtk.Button) { pp.dismiss(false, false) }
217+ pp.retainedCallbacks = append(pp.retainedCallbacks, denyCb)
218+ pp.btnDeny.ConnectClicked(&denyCb)
219+
220+ allowCb := func(_ gtk.Button) { pp.dismiss(true, false) }
221+ pp.retainedCallbacks = append(pp.retainedCallbacks, allowCb)
222+ pp.btnAllow.ConnectClicked(&allowCb)
223+
224+ alwaysAllowCb := func(_ gtk.Button) { pp.dismiss(true, true) }
225+ pp.retainedCallbacks = append(pp.retainedCallbacks, alwaysAllowCb)
226+ pp.btnAlwaysAllow.ConnectClicked(&alwaysAllowCb)
227+
228+ // Assemble button row
229+ btnRow.Append(&pp.btnAlwaysDeny.Widget)
230+ btnRow.Append(&pp.btnDeny.Widget)
231+ btnRow.Append(&pp.btnAllow.Widget)
232+ btnRow.Append(&pp.btnAlwaysAllow.Widget)
233+
234+ // Assemble main box
235+ pp.mainBox.Append(&pp.headingLabel.Widget)
236+ pp.mainBox.Append(&pp.bodyLabel.Widget)
237+ pp.mainBox.Append(&btnRow.Widget)
238+
239+ // Assemble outer box
240+ pp.outerBox.Append(&pp.mainBox.Widget)
241+
242+ return nil
243+}
244+
245+func (pp *PermissionPopup) attachKeyController() {
246+ if pp.outerBox == nil {
247+ return
248+ }
249+ controller := gtk.NewEventControllerKey()
250+ if controller == nil {
251+ return
252+ }
253+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
254+
255+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
256+ if keyval == uint(gdk.KEY_Escape) {
257+ // Escape = deny (conservative default)
258+ pp.dismiss(false, false)
259+ return true
260+ }
261+ return false
262+ }
263+ pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
264+ controller.ConnectKeyPressed(&keyPressedCb)
265+ pp.outerBox.AddController(&controller.EventController)
266+}
267+
268+func (pp *PermissionPopup) resizeAndCenter() {
269+ if pp.outerBox == nil || pp.mainBox == nil {
270+ return
271+ }
272+
273+ width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
274+ pp.mainBox.SetSizeRequest(width, -1)
275+ pp.outerBox.SetMarginTop(marginTop)
276+}
M internal/ui/dialog/permission.go +21 -59
@@ -8,21 +8,21 @@ import (
88 "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
11- "github.com/jwijenbergh/puregotk/v4/adw"
12- "github.com/jwijenbergh/puregotk/v4/gtk"
11+ "github.com/bnema/dumber/internal/ui/component"
1312 )
1413
1514 // PermissionDialog implements the port.PermissionDialogPresenter interface.
16-// It shows Adwaita AlertDialogs for media permission requests.
15+// It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
16+// and match the app's custom UI style.
1717 type PermissionDialog struct {
18- // parentWindow is the parent window for modal dialogs (can be nil)
19- parentWindow *gtk.ApplicationWindow
18+ popup *component.PermissionPopup
2019 }
2120
2221 // NewPermissionDialog creates a new permission dialog presenter.
23-func NewPermissionDialog(parentWindow *gtk.ApplicationWindow) *PermissionDialog {
22+// The popup is created once and reused for each permission request.
23+func NewPermissionDialog(popup *component.PermissionPopup) *PermissionDialog {
2424 return &PermissionDialog{
25- parentWindow: parentWindow,
25+ popup: popup,
2626 }
2727 }
2828
@@ -35,66 +35,28 @@ func (d *PermissionDialog) ShowPermissionDialog(
3535 ) {
3636 log := logging.FromContext(ctx)
3737
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
38+ if d.popup == nil {
39+ log.Error().Msg("permission popup not available")
4740 callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
4841 return
4942 }
5043
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- }
44+ // Build dialog text
45+ heading := d.buildHeading(permTypes)
46+ body := d.buildBody(origin, permTypes)
47+
48+ d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
49+ log.Debug().
50+ Bool("allowed", allowed).
51+ Bool("persistent", persistent).
52+ Msg("permission popup response")
53+ callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
54+ })
9355
9456 log.Debug().
9557 Str("origin", origin).
9658 Strs("types", entity.PermissionTypesToStrings(permTypes)).
97- Msg("showing permission dialog")
59+ Msg("showing permission popup")
9860 }
9961
10062 // buildHeading creates the dialog heading based on permission types.
@@ -8,21 +8,21 @@ import (
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11- "github.com/jwijenbergh/puregotk/v4/adw"11+ "github.com/bnema/dumber/internal/ui/component"
12- "github.com/jwijenbergh/puregotk/v4/gtk"
13 )12 )
14 13
15 // PermissionDialog implements the port.PermissionDialogPresenter interface.14 // PermissionDialog implements the port.PermissionDialogPresenter interface.
16-// It shows Adwaita AlertDialogs for media permission requests.15+// It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
16+// and match the app's custom UI style.
17 type PermissionDialog struct {17 type PermissionDialog struct {
18- // parentWindow is the parent window for modal dialogs (can be nil)18+ popup *component.PermissionPopup
19- parentWindow *gtk.ApplicationWindow
20 }19 }
21 20
22 // NewPermissionDialog creates a new permission dialog presenter.21 // NewPermissionDialog creates a new permission dialog presenter.
23-func NewPermissionDialog(parentWindow *gtk.ApplicationWindow) *PermissionDialog {22+// The popup is created once and reused for each permission request.
23+func NewPermissionDialog(popup *component.PermissionPopup) *PermissionDialog {
24 return &PermissionDialog{24 return &PermissionDialog{
25- parentWindow: parentWindow,25+ popup: popup,
26 }26 }
27 }27 }
28 28
@@ -35,66 +35,28 @@ func (d *PermissionDialog) ShowPermissionDialog(
35 ) {35 ) {
36 log := logging.FromContext(ctx)36 log := logging.FromContext(ctx)
37 37
38- // Build dialog text38+ if d.popup == nil {
39- heading := d.buildHeading(permTypes)39+ log.Error().Msg("permission popup not available")
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})40 callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
48 return41 return
49 }42 }
50 43
51- // Add responses: Allow, Always Allow, Deny, Always Deny44+ // Build dialog text
52- // Using underscore prefix for mnemonic keyboard shortcuts45+ heading := d.buildHeading(permTypes)
53- dialog.AddResponse("allow", "_Allow")46+ body := d.buildBody(origin, permTypes)
54- dialog.AddResponse("allow_always", "Always _Allow")47+
55- dialog.AddResponse("deny", "_Deny")48+ d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
56- dialog.AddResponse("deny_always", "Always _Deny")49+ log.Debug().
57-50+ Bool("allowed", allowed).
58- // Set appearances51+ Bool("persistent", persistent).
59- dialog.SetResponseAppearance("allow", adw.ResponseSuggestedValue)52+ Msg("permission popup response")
60- dialog.SetResponseAppearance("allow_always", adw.ResponseSuggestedValue)53+ callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
61- dialog.SetResponseAppearance("deny", adw.ResponseDefaultValue)54+ })
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 55
94 log.Debug().56 log.Debug().
95 Str("origin", origin).57 Str("origin", origin).
96 Strs("types", entity.PermissionTypesToStrings(permTypes)).58 Strs("types", entity.PermissionTypesToStrings(permTypes)).
97- Msg("showing permission dialog")59+ Msg("showing permission popup")
98 }60 }
99 61
100 // buildHeading creates the dialog heading based on permission types.62 // buildHeading creates the dialog heading based on permission types.
M internal/ui/theme/css.go +4 -0
@@ -128,6 +128,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
128128 sb.WriteString(generateTabPickerCSS(p))
129129 sb.WriteString("\n")
130130
131+ // Permission popup styling
132+ sb.WriteString(generatePermissionPopupCSS(p))
133+ sb.WriteString("\n")
134+
131135 // Accent picker styling
132136 sb.WriteString(generateAccentPickerCSS(p))
133137
@@ -128,6 +128,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
128 sb.WriteString(generateTabPickerCSS(p))128 sb.WriteString(generateTabPickerCSS(p))
129 sb.WriteString("\n")129 sb.WriteString("\n")
130 130
131+ // Permission popup styling
132+ sb.WriteString(generatePermissionPopupCSS(p))
133+ sb.WriteString("\n")
134+
131 // Accent picker styling135 // Accent picker styling
132 sb.WriteString(generateAccentPickerCSS(p))136 sb.WriteString(generateAccentPickerCSS(p))
133 137
A internal/ui/theme/permission_popup_css.go +85 -0
@@ -0,0 +1,85 @@
1+package theme
2+
3+// generatePermissionPopupCSS creates permission popup overlay styles.
4+// Uses em units for scalable UI, matches omnibox/session manager styling patterns.
5+func generatePermissionPopupCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Permission Popup Styling ===== */
8+
9+.permission-popup-outer {
10+ /* Positioning handled via SetHalign/SetValign in Go */
11+}
12+
13+.permission-popup-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 20em;
19+}
20+
21+.permission-popup-heading {
22+ font-size: 0.9375em;
23+ font-weight: 600;
24+ color: var(--text);
25+ padding: 0.75em 1em 0.25em 1em;
26+}
27+
28+.permission-popup-body {
29+ font-size: 0.8125em;
30+ font-weight: 400;
31+ color: var(--muted);
32+ padding: 0.25em 1em 0.75em 1em;
33+}
34+
35+.permission-popup-btn-row {
36+ border-top: 0.0625em solid var(--border);
37+ padding: 0.5em 0.75em;
38+}
39+
40+/* Shared button base */
41+.permission-popup-btn {
42+ background-image: none;
43+ border: 0.0625em solid var(--border);
44+ border-radius: 0.1875em;
45+ padding: 0.375em 0.75em;
46+ font-size: 0.8125em;
47+ font-weight: 500;
48+ transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
49+}
50+
51+/* Allow buttons - accent styled */
52+.permission-popup-btn-allow {
53+ background-color: alpha(var(--accent), 0.15);
54+ color: var(--accent);
55+ border-color: alpha(var(--accent), 0.3);
56+}
57+
58+.permission-popup-btn-allow:hover {
59+ background-color: alpha(var(--accent), 0.25);
60+ border-color: var(--accent);
61+}
62+
63+/* Deny button - subtle default */
64+.permission-popup-btn-deny {
65+ background-color: var(--surface);
66+ color: var(--text);
67+}
68+
69+.permission-popup-btn-deny:hover {
70+ background-color: shade(var(--surface), 1.15);
71+}
72+
73+/* Always Deny - destructive */
74+.permission-popup-btn-destructive {
75+ background-color: alpha(var(--destructive), 0.15);
76+ color: var(--destructive);
77+ border-color: alpha(var(--destructive), 0.3);
78+}
79+
80+.permission-popup-btn-destructive:hover {
81+ background-color: alpha(var(--destructive), 0.25);
82+ border-color: var(--destructive);
83+}
84+`
85+}
@@ -0,0 +1,85 @@
1+package theme
2+
3+// generatePermissionPopupCSS creates permission popup overlay styles.
4+// Uses em units for scalable UI, matches omnibox/session manager styling patterns.
5+func generatePermissionPopupCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Permission Popup Styling ===== */
8+
9+.permission-popup-outer {
10+ /* Positioning handled via SetHalign/SetValign in Go */
11+}
12+
13+.permission-popup-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 20em;
19+}
20+
21+.permission-popup-heading {
22+ font-size: 0.9375em;
23+ font-weight: 600;
24+ color: var(--text);
25+ padding: 0.75em 1em 0.25em 1em;
26+}
27+
28+.permission-popup-body {
29+ font-size: 0.8125em;
30+ font-weight: 400;
31+ color: var(--muted);
32+ padding: 0.25em 1em 0.75em 1em;
33+}
34+
35+.permission-popup-btn-row {
36+ border-top: 0.0625em solid var(--border);
37+ padding: 0.5em 0.75em;
38+}
39+
40+/* Shared button base */
41+.permission-popup-btn {
42+ background-image: none;
43+ border: 0.0625em solid var(--border);
44+ border-radius: 0.1875em;
45+ padding: 0.375em 0.75em;
46+ font-size: 0.8125em;
47+ font-weight: 500;
48+ transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
49+}
50+
51+/* Allow buttons - accent styled */
52+.permission-popup-btn-allow {
53+ background-color: alpha(var(--accent), 0.15);
54+ color: var(--accent);
55+ border-color: alpha(var(--accent), 0.3);
56+}
57+
58+.permission-popup-btn-allow:hover {
59+ background-color: alpha(var(--accent), 0.25);
60+ border-color: var(--accent);
61+}
62+
63+/* Deny button - subtle default */
64+.permission-popup-btn-deny {
65+ background-color: var(--surface);
66+ color: var(--text);
67+}
68+
69+.permission-popup-btn-deny:hover {
70+ background-color: shade(var(--surface), 1.15);
71+}
72+
73+/* Always Deny - destructive */
74+.permission-popup-btn-destructive {
75+ background-color: alpha(var(--destructive), 0.15);
76+ color: var(--destructive);
77+ border-color: alpha(var(--destructive), 0.3);
78+}
79+
80+.permission-popup-btn-destructive:hover {
81+ background-color: alpha(var(--destructive), 0.25);
82+ border-color: var(--destructive);
83+}
84+`
85+}
M internal/ui/window/main_window.go +6 -0
@@ -185,6 +185,12 @@ func (mw *MainWindow) SetTitle(title string) {
185185 mw.window.SetTitle(&title)
186186 }
187187
188+// ContentOverlay returns the content overlay widget.
189+// Used by components that need the overlay dimensions for sizing calculations.
190+func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191+ return mw.contentOverlay
192+}
193+
188194 // AddOverlay adds a widget to the content overlay.
189195 // The widget will be displayed on top of the workspace content.
190196 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
@@ -185,6 +185,12 @@ func (mw *MainWindow) SetTitle(title string) {
185 mw.window.SetTitle(&title)185 mw.window.SetTitle(&title)
186 }186 }
187 187
188+// ContentOverlay returns the content overlay widget.
189+// Used by components that need the overlay dimensions for sizing calculations.
190+func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191+ return mw.contentOverlay
192+}
193+
188 // AddOverlay adds a widget to the content overlay.194 // AddOverlay adds a widget to the content overlay.
189 // The widget will be displayed on top of the workspace content.195 // The widget will be displayed on top of the workspace content.
190 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {196 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {

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