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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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( | |||
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 85 | // 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 | 92 | if isAutoAllowSet(permTypes) { | |
| 87 | 93 | log.Debug().Msg("auto-allowing permission request") | |
| 88 | 94 | callback.Allow() | |
| @@ -165,13 +171,15 @@ func (uc *HandlePermissionUseCase) checkStoredPermissions( | |||
| 165 | 171 | hasPrompt := false | |
| 166 | 172 | ||
| 167 | 173 | for _, permType := range permTypes { | |
| 168 | - | // Skip auto-allow types | |
| 174 | + | // Skip auto-allow types - they're handled before this function | |
| 169 | 175 | if entity.IsAutoAllow(permType) { | |
| 170 | 176 | 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 | 181 | if !entity.CanPersist(permType) { | |
| 182 | + | hasPrompt = true | |
| 175 | 183 | continue | |
| 176 | 184 | } | |
| 177 | 185 | ||
| @@ -266,7 +274,7 @@ func (uc *HandlePermissionUseCase) persistPermission( | |||
| 266 | 274 | Origin: origin, | |
| 267 | 275 | Type: permType, | |
| 268 | 276 | Decision: decision, | |
| 269 | - | UpdatedAt: time.Now(), | |
| 277 | + | UpdatedAt: time.Now().Unix(), | |
| 270 | 278 | } | |
| 271 | 279 | ||
| 272 | 280 | 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-allow | 85 | // 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 := false | 171 | hasPrompt := false | ||
| 166 | 172 | ||||
| 167 | for _, permType := range permTypes { | 173 | for _, permType := range permTypes { | ||
| 168 | - | // Skip auto-allow types | 174 | + | // Skip auto-allow types - they're handled before this function |
| 169 | if entity.IsAutoAllow(permType) { | 175 | if entity.IsAutoAllow(permType) { | ||
| 170 | continue | 176 | 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 | continue | 183 | 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) { | |||
| 73 | 73 | Origin: "https://meet.example.com", | |
| 74 | 74 | Type: entity.PermissionTypeMicrophone, | |
| 75 | 75 | Decision: entity.PermissionGranted, | |
| 76 | - | UpdatedAt: time.Now(), | |
| 76 | + | UpdatedAt: time.Now().Unix(), | |
| 77 | 77 | }, nil) | |
| 78 | 78 | ||
| 79 | 79 | allowed := false | |
| @@ -103,7 +103,7 @@ func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) { | |||
| 103 | 103 | Origin: "https://meet.example.com", | |
| 104 | 104 | Type: entity.PermissionTypeCamera, | |
| 105 | 105 | Decision: entity.PermissionDenied, | |
| 106 | - | UpdatedAt: time.Now(), | |
| 106 | + | UpdatedAt: time.Now().Unix(), | |
| 107 | 107 | }, nil) | |
| 108 | 108 | ||
| 109 | 109 | 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 := false | 79 | 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 := false | 109 | denied := false | ||
M internal/domain/entity/permission.go +3 -4
| @@ -1,8 +1,7 @@ | |||
| 1 | 1 | package entity | |
| 2 | 2 | ||
| 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. | |
| 6 | 5 | ||
| 7 | 6 | // PermissionType represents the type of permission being requested. | |
| 8 | 7 | type PermissionType string | |
| @@ -58,7 +57,7 @@ type PermissionRecord struct { | |||
| 58 | 57 | Origin string // The origin (domain) this permission applies to | |
| 59 | 58 | Type PermissionType // The type of permission | |
| 60 | 59 | 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 | |
| 62 | 61 | } | |
| 63 | 62 | ||
| 64 | 63 | // IsGranted returns true if the permission is granted. | |
| @@ -1,8 +1,7 @@ | |||||
| 1 | package entity | 1 | 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 string | 7 | type PermissionType string | ||
| @@ -58,7 +57,7 @@ type PermissionRecord struct { | |||||
| 58 | Origin string // The origin (domain) this permission applies to | 57 | Origin string // The origin (domain) this permission applies to | ||
| 59 | Type PermissionType // The type of permission | 58 | Type PermissionType // The type of permission | ||
| 60 | Decision PermissionDecision // The decision: granted, denied, or prompt | 59 | 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 |
| 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) { | |||
| 66 | 66 | Origin: "https://example.com", | |
| 67 | 67 | Type: entity.PermissionTypeMicrophone, | |
| 68 | 68 | Decision: tt.decision, | |
| 69 | - | UpdatedAt: time.Now(), | |
| 69 | + | UpdatedAt: time.Now().Unix(), | |
| 70 | 70 | } | |
| 71 | 71 | assert.Equal(t, tt.expected, record.IsGranted()) | |
| 72 | 72 | }) | |
| @@ -90,7 +90,7 @@ func TestPermissionRecord_IsDenied(t *testing.T) { | |||
| 90 | 90 | Origin: "https://example.com", | |
| 91 | 91 | Type: entity.PermissionTypeCamera, | |
| 92 | 92 | Decision: tt.decision, | |
| 93 | - | UpdatedAt: time.Now(), | |
| 93 | + | UpdatedAt: time.Now().Unix(), | |
| 94 | 94 | } | |
| 95 | 95 | assert.Equal(t, tt.expected, record.IsDenied()) | |
| 96 | 96 | }) | |
| @@ -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 { | |||
| 206 | 206 | ||
| 207 | 207 | // ExtractOrigin extracts the origin (scheme://host) from a URI. | |
| 208 | 208 | // This normalizes URIs to origins for permission storage and comparison. | |
| 209 | + | // Canonicalizes by lowercasing scheme and host, and omitting default ports. | |
| 209 | 210 | // Example: "https://example.com/path?query" -> "https://example.com" | |
| 211 | + | // Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com" | |
| 210 | 212 | func ExtractOrigin(uri string) (string, error) { | |
| 211 | 213 | parsed, err := url.Parse(uri) | |
| 212 | 214 | if err != nil { | |
| @@ -217,5 +219,24 @@ func ExtractOrigin(uri string) (string, error) { | |||
| 217 | 219 | return "", fmt.Errorf("URI missing scheme or host: %s", uri) | |
| 218 | 220 | } | |
| 219 | 221 | ||
| 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 | |
| 221 | 242 | } | |
| @@ -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, 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 | |||
| 221 | } | 242 | } | ||
M internal/domain/url/normalize_test.go +30 -0
| @@ -531,6 +531,36 @@ func TestExtractOrigin_ValidURI(t *testing.T) { | |||
| 531 | 531 | uri: "http://localhost:8080/app", | |
| 532 | 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 | 566 | 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 | |||
| 40 | 40 | ||
| 41 | 41 | func (r *permissionRepo) Set(ctx context.Context, record *entity.PermissionRecord) error { | |
| 42 | 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 | 49 | log.Debug(). | |
| 44 | 50 | Str("origin", record.Origin). | |
| 45 | 51 | Str("type", string(record.Type)). | |
| @@ -86,7 +92,7 @@ func permissionFromRow(row sqlc.Permission) *entity.PermissionRecord { | |||
| 86 | 92 | Decision: entity.PermissionDecision(row.Decision), | |
| 87 | 93 | } | |
| 88 | 94 | if row.UpdatedAt.Valid { | |
| 89 | - | record.UpdatedAt = row.UpdatedAt.Time | |
| 95 | + | record.UpdatedAt = row.UpdatedAt.Time.Unix() | |
| 90 | 96 | } | |
| 91 | 97 | return record | |
| 92 | 98 | } | |
| @@ -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.Time | 95 | + | record.UpdatedAt = row.UpdatedAt.Time.Unix() |
| 90 | } | 96 | } | ||
| 91 | return record | 97 | return record | ||
| 92 | } | 98 | } | ||
M internal/ui/app.go +14 -3
| @@ -349,10 +349,21 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 349 | 349 | } | |
| 350 | 350 | a.mainWindow = mainWindow | |
| 351 | 351 | ||
| 352 | - | // Create and set the permission dialog presenter | |
| 352 | + | // Create permission popup and dialog presenter | |
| 353 | 353 | 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 | + | } | |
| 356 | 367 | } | |
| 357 | 368 | ||
| 358 | 369 | // Apply GTK CSS styling from theme manager. | |
| @@ -349,10 +349,21 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 349 | } | 349 | } | ||
| 350 | a.mainWindow = mainWindow | 350 | a.mainWindow = mainWindow | ||
| 351 | 351 | ||||
| 352 | - | // Create and set the permission dialog presenter | 352 | + | // 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{ | |||
| 82 | 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 | 94 | // EffectiveMaxRows returns the maximum number of visible rows that fit | |
| 86 | 95 | // in the available space. It computes available height by subtracting the | |
| 87 | 96 | // 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 fit | 94 | // EffectiveMaxRows returns the maximum number of visible rows that fit | ||
| 86 | // in the available space. It computes available height by subtracting the | 95 | // 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 ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "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" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 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 | 17 | type PermissionDialog struct { | |
| 18 | - | // parentWindow is the parent window for modal dialogs (can be nil) | |
| 19 | - | parentWindow *gtk.ApplicationWindow | |
| 18 | + | popup *component.PermissionPopup | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | 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 | 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 | 36 | log := logging.FromContext(ctx) | |
| 37 | 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 | |
| 38 | + | if d.popup == nil { | |
| 39 | + | log.Error().Msg("permission popup not available") | |
| 47 | 40 | callback(port.PermissionDialogResult{Allowed: false, Persistent: false}) | |
| 48 | 41 | return | |
| 49 | 42 | } | |
| 50 | 43 | ||
| 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 | + | }) | |
| 93 | 55 | ||
| 94 | 56 | log.Debug(). | |
| 95 | 57 | Str("origin", origin). | |
| 96 | 58 | Strs("types", entity.PermissionTypesToStrings(permTypes)). | |
| 97 | - | Msg("showing permission dialog") | |
| 59 | + | Msg("showing permission popup") | |
| 98 | 60 | } | |
| 99 | 61 | ||
| 100 | 62 | // 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 text | 38 | + | 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 | return | 41 | return | ||
| 49 | } | 42 | } | ||
| 50 | 43 | ||||
| 51 | - | // Add responses: Allow, Always Allow, Deny, Always Deny | 44 | + | // Build dialog text |
| 52 | - | // Using underscore prefix for mnemonic keyboard shortcuts | 45 | + | 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 appearances | 51 | + | 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 | |||
| 128 | 128 | sb.WriteString(generateTabPickerCSS(p)) | |
| 129 | 129 | sb.WriteString("\n") | |
| 130 | 130 | ||
| 131 | + | // Permission popup styling | |
| 132 | + | sb.WriteString(generatePermissionPopupCSS(p)) | |
| 133 | + | sb.WriteString("\n") | |
| 134 | + | ||
| 131 | 135 | // Accent picker styling | |
| 132 | 136 | sb.WriteString(generateAccentPickerCSS(p)) | |
| 133 | 137 | ||
| @@ -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 styling | 135 | // 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) { | |||
| 185 | 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 | 194 | // AddOverlay adds a widget to the content overlay. | |
| 189 | 195 | // The widget will be displayed on top of the workspace content. | |
| 190 | 196 | 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) { | ||