1// Package usecase contains application use cases that orchestrate domain logic.
2package usecase
3
4import (
5 "context"
6 "errors"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/rs/zerolog"
14)
15
16// PermissionCallback provides allow/deny functions for the permission request.
17type PermissionCallback struct {
18 Allow func()
19 Deny func()
20}
21
22// HandlePermissionUseCase handles media permission requests from WebKit.
23// It implements the permission strategy defined in the architecture:
24// - Display capture: auto-allow (XDG portal handles UI)
25// - Device enumeration: auto-allow (low risk)
26// - Mic/Camera: check stored → dialog → persist if "Always"
27type HandlePermissionUseCase struct {
28 permRepo port.PermissionRepository
29 dialog port.PermissionDialogPresenter
30 loggerFromContext port.LoggerFromContext
31 dialogMu sync.RWMutex
32}
33
34// NewHandlePermissionUseCase creates a new permission handling use case.
35func NewHandlePermissionUseCase(
36 permRepo port.PermissionRepository,
37 dialog port.PermissionDialogPresenter,
38 loggerFromContext port.LoggerFromContext,
39) *HandlePermissionUseCase {
40 return &HandlePermissionUseCase{
41 permRepo: permRepo,
42 dialog: dialog,
43 loggerFromContext: loggerFromContext,
44 }
45}
46
47func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
48 if ctx == nil {
49 ctx = context.Background()
50 }
51 if uc == nil || uc.loggerFromContext == nil {
52 return zerolog.Ctx(ctx)
53 }
54 log := uc.loggerFromContext(ctx)
55 if log == nil {
56 return zerolog.Ctx(ctx)
57 }
58 return log
59}
60
61func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool {
62 for _, permType := range permTypes {
63 if permType == entity.PermissionTypeWebsiteDataAccess {
64 return true
65 }
66 }
67 return false
68}
69
70func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
71 if log == nil {
72 nop := zerolog.Nop()
73 return &nop
74 }
75 ctx := log.With()
76 if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
77 ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, logging.PermissionLogURLMaxLen))
78 }
79 if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
80 ctx = ctx.Str("current_domain", logging.TruncateURL(current, logging.PermissionLogURLMaxLen))
81 }
82 enriched := ctx.Logger()
83 return &enriched
84}
85
86// SetDialogPresenter sets the dialog presenter. This can be called after
87// initialization when the UI window is available.
88func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
89 uc.dialogMu.Lock()
90 defer uc.dialogMu.Unlock()
91 uc.dialog = dialog
92}
93
94// getDialog safely returns the current dialog presenter.
95func (uc *HandlePermissionUseCase) getDialog() port.PermissionDialogPresenter {
96 uc.dialogMu.RLock()
97 defer uc.dialogMu.RUnlock()
98 return uc.dialog
99}
100
101// HandlePermissionRequest processes a permission request from WebKit.
102// This is the main entry point for the permission use case.
103//
104// Parameters:
105// - ctx: context for cancellation and logging
106// - origin: the website origin (URI) requesting permission
107// - permTypes: the types of permissions being requested
108// - metadata: permission-type-specific context; for website_data_access both keys must be present:
109// entity.PermissionMetadataKeyRequestingDomain — the origin requesting cross-site data access
110// entity.PermissionMetadataKeyCurrentDomain — the domain currently loaded in the WebView
111// - callback: callbacks to allow or deny the request (must call one)
112func (uc *HandlePermissionUseCase) HandlePermissionRequest(
113 ctx context.Context,
114 origin string,
115 permTypes []entity.PermissionType,
116 metadata entity.PermissionMetadata,
117 callback PermissionCallback,
118) {
119 log := uc.logger(ctx).With().
120 Str("component", "permission").
121 Str("origin", origin).
122 Strs("types", entity.PermissionTypesToStrings(permTypes)).
123 Logger()
124
125 // Validate origin
126 if origin == "" {
127 log.Warn().Msg("permission request with empty origin, denying")
128 callback.Deny()
129 return
130 }
131
132 // Check if all types are auto-allow
133 // Also handle empty permTypes as a deny (shouldn't happen but defensive)
134 if len(permTypes) == 0 {
135 log.Warn().Msg("permission request with empty types, denying")
136 callback.Deny()
137 return
138 }
139
140 if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
141 log.Debug().Msg("manual override denied auto-allow permission request")
142 callback.Deny()
143 return
144 }
145
146 if isAutoAllowSet(permTypes) {
147 log.Debug().Msg("auto-allowing permission request")
148 callback.Allow()
149 return
150 }
151
152 // For mic/camera: check stored permissions first
153 decision := uc.checkStoredPermissions(ctx, origin, permTypes)
154 switch decision {
155 case entity.PermissionGranted:
156 if isWebsiteDataAccessPermission(permTypes) {
157 permissionLoggerWithMetadata(&log, metadata).Info().
158 Msg("using stored website data access permission: granted")
159 }
160 log.Debug().Msg("using stored permission: granted")
161 callback.Allow()
162 return
163 case entity.PermissionDenied:
164 if isWebsiteDataAccessPermission(permTypes) {
165 permissionLoggerWithMetadata(&log, metadata).Info().
166 Msg("using stored website data access permission: denied")
167 }
168 log.Debug().Msg("using stored permission: denied")
169 callback.Deny()
170 return
171 case entity.PermissionPrompt:
172 // Fall through to show dialog
173 }
174
175 // Show dialog for undecided permissions
176 uc.showPermissionDialog(ctx, origin, permTypes, metadata, callback)
177}
178
179// QueryPermissionState returns the current permission state for the W3C Permissions API.
180// This is used by websites to check if they already have permission before calling getUserMedia().
181func (uc *HandlePermissionUseCase) QueryPermissionState(
182 ctx context.Context,
183 origin string,
184 permType entity.PermissionType,
185) entity.PermissionDecision {
186 log := uc.logger(ctx).With().
187 Str("component", "permission").
188 Str("origin", origin).
189 Str("type", string(permType)).
190 Logger()
191
192 if origin == "" {
193 log.Warn().Msg("query: empty origin, returning prompt")
194 return entity.PermissionPrompt
195 }
196
197 // Check stored permission first (including manual overrides on auto-allow types).
198 record, err := uc.permRepo.Get(ctx, origin, permType)
199 if err != nil {
200 log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
201 return entity.PermissionPrompt
202 }
203
204 if record != nil {
205 log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
206 return record.Decision
207 }
208
209 if entity.IsAutoAllow(permType) {
210 log.Debug().Msg("query: no stored permission for auto-allow type, returning granted")
211 return entity.PermissionGranted
212 }
213
214 log.Debug().Msg("query: no stored permission, returning prompt")
215 return entity.PermissionPrompt
216}
217
218// isAutoAllowSet returns true if all permission types in the set are auto-allow.
219func isAutoAllowSet(permTypes []entity.PermissionType) bool {
220 for _, pt := range permTypes {
221 if !entity.IsAutoAllow(pt) {
222 return false
223 }
224 }
225 return true
226}
227
228// checkStoredPermissions checks if all permissions in the set have stored decisions.
229// Returns granted if all are granted, denied if any are denied, prompt otherwise.
230func (uc *HandlePermissionUseCase) checkStoredPermissions(
231 ctx context.Context,
232 origin string,
233 permTypes []entity.PermissionType,
234) entity.PermissionDecision {
235 log := uc.logger(ctx)
236
237 hasPrompt := false
238
239 for _, permType := range permTypes {
240 // Skip auto-allow types - they're handled before this function
241 if entity.IsAutoAllow(permType) {
242 continue
243 }
244
245 // Non-persistable types that aren't auto-allow should prompt
246 // This shouldn't happen in practice, but we handle it defensively
247 if !entity.CanPersist(permType) {
248 hasPrompt = true
249 continue
250 }
251
252 record, err := uc.permRepo.Get(ctx, origin, permType)
253 if err != nil {
254 log.Warn().
255 Err(err).
256 Str("perm_type", string(permType)).
257 Msg("failed to get stored permission")
258 // On error, treat as prompt (fall back to dialog)
259 hasPrompt = true
260 continue
261 }
262
263 if record == nil {
264 hasPrompt = true
265 continue
266 }
267
268 switch record.Decision {
269 case entity.PermissionDenied:
270 // If any permission is denied, deny the whole request (conservative)
271 return entity.PermissionDenied
272 case entity.PermissionPrompt:
273 hasPrompt = true
274 }
275 }
276
277 if hasPrompt {
278 return entity.PermissionPrompt
279 }
280
281 // All permissions are granted
282 return entity.PermissionGranted
283}
284
285// showPermissionDialog shows the permission dialog and handles the result.
286func (uc *HandlePermissionUseCase) showPermissionDialog(
287 ctx context.Context,
288 origin string,
289 permTypes []entity.PermissionType,
290 metadata entity.PermissionMetadata,
291 callback PermissionCallback,
292) {
293 log := uc.logger(ctx)
294
295 dialog := uc.getDialog()
296
297 // If no dialog presenter is set, deny the permission
298 if dialog == nil {
299 log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
300 callback.Deny()
301 return
302 }
303
304 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
305 if result.Allowed {
306 if isWebsiteDataAccessPermission(permTypes) {
307 permissionLoggerWithMetadata(log, metadata).Info().
308 Bool("persistent", result.Persistent).
309 Msg("website data access permission allowed")
310 }
311 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
312 callback.Allow()
313
314 // Persist if "Always Allow" was chosen
315 if result.Persistent {
316 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
317 }
318 } else {
319 if isWebsiteDataAccessPermission(permTypes) {
320 permissionLoggerWithMetadata(log, metadata).Info().
321 Bool("persistent", result.Persistent).
322 Msg("website data access permission denied")
323 }
324 log.Debug().Msg("user denied permission")
325 callback.Deny()
326
327 // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
328 if result.Persistent {
329 uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
330 }
331 }
332 })
333}
334
335// persistPermission saves the permission decision to the repository.
336func (uc *HandlePermissionUseCase) persistPermission(
337 ctx context.Context,
338 origin string,
339 permTypes []entity.PermissionType,
340 decision entity.PermissionDecision,
341) {
342 log := uc.logger(ctx)
343
344 for _, permType := range permTypes {
345 // Only persist allowed types
346 if !entity.CanPersist(permType) {
347 continue
348 }
349
350 record := &entity.PermissionRecord{
351 Origin: origin,
352 Type: permType,
353 Decision: decision,
354 UpdatedAt: time.Now().Unix(),
355 }
356
357 if err := uc.permRepo.Set(ctx, record); err != nil {
358 log.Warn().
359 Err(err).
360 Str("perm_type", string(permType)).
361 Str("decision", string(decision)).
362 Msg("failed to persist permission")
363 }
364 }
365}
366
367// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
368func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
369 ctx context.Context,
370 origin string,
371 permType entity.PermissionType,
372) (*entity.PermissionRecord, error) {
373 if origin == "" {
374 return nil, errors.New("origin is required")
375 }
376 return uc.permRepo.Get(ctx, origin, permType)
377}
378
379// SetManualPermissionDecision stores an explicit decision for origin/type.
380// This is used by the UI permission indicator as the source of truth for lock state.
381func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
382 ctx context.Context,
383 origin string,
384 permType entity.PermissionType,
385 decision entity.PermissionDecision,
386) error {
387 if origin == "" {
388 return errors.New("origin is required")
389 }
390 if !entity.CanPersist(permType) {
391 return errors.New("permission type not persistable")
392 }
393 if decision == entity.PermissionPrompt {
394 return uc.permRepo.Delete(ctx, origin, permType)
395 }
396
397 return uc.permRepo.Set(ctx, &entity.PermissionRecord{
398 Origin: origin,
399 Type: permType,
400 Decision: decision,
401 UpdatedAt: time.Now().Unix(),
402 })
403}
404
405// ResetManualPermissionDecision clears any stored decision for origin/type.
406func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
407 ctx context.Context,
408 origin string,
409 permType entity.PermissionType,
410) error {
411 if origin == "" {
412 return errors.New("origin is required")
413 }
414 return uc.permRepo.Delete(ctx, origin, permType)
415}
416
417func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
418 ctx context.Context,
419 origin string,
420 permTypes []entity.PermissionType,
421) bool {
422 for _, permType := range permTypes {
423 if !entity.IsAutoAllow(permType) {
424 continue
425 }
426
427 record, err := uc.permRepo.Get(ctx, origin, permType)
428 if err != nil || record == nil {
429 continue
430 }
431
432 if record.Decision == entity.PermissionDenied {
433 return true
434 }
435 }
436
437 return false
438}