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