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, metadata) {
137 log.Debug().Msg("manual override denied auto-allow permission request")
138 callback.Deny()
139 return
140 }
141
142 if isAutoAllowSet(permTypes, metadata) {
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, metadata)
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// Note: it has no CEF caller and does not consider PermissionMetadataKeyUnmediatedCapture
177// (display is reported as granted); revisit if a CEF Permissions API bridge is added.
178// This is used by websites to check if they already have permission before calling getUserMedia().
179func (uc *HandlePermissionUseCase) QueryPermissionState(
180 ctx context.Context,
181 origin string,
182 permType entity.PermissionType,
183) entity.PermissionDecision {
184 log := uc.logger(ctx).With().
185 Str("component", "permission").
186 Str("origin", origin).
187 Str("type", string(permType)).
188 Logger()
189
190 if origin == "" {
191 log.Warn().Msg("query: empty origin, returning prompt")
192 return entity.PermissionPrompt
193 }
194
195 // Check stored permission first (including manual overrides on auto-allow types).
196 record, err := uc.permRepo.Get(ctx, origin, permType)
197 if err != nil {
198 log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
199 return entity.PermissionPrompt
200 }
201
202 if record != nil {
203 log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
204 return record.Decision
205 }
206
207 if entity.IsAutoAllow(permType) {
208 log.Debug().Msg("query: no stored permission for auto-allow type, returning granted")
209 return entity.PermissionGranted
210 }
211
212 log.Debug().Msg("query: no stored permission, returning prompt")
213 return entity.PermissionPrompt
214}
215
216// isAutoAllowSet returns true if all permission types in the set are auto-allow.
217func isAutoAllowSet(permTypes []entity.PermissionType, metadata entity.PermissionMetadata) bool {
218 for _, pt := range permTypes {
219 if !entity.IsAutoAllowFor(pt, metadata) {
220 return false
221 }
222 }
223 return true
224}
225
226// checkStoredPermissions checks if all permissions in the set have stored decisions.
227// Returns granted if all are granted, denied if any are denied, prompt otherwise.
228func (uc *HandlePermissionUseCase) checkStoredPermissions(
229 ctx context.Context,
230 origin string,
231 permTypes []entity.PermissionType,
232 metadata entity.PermissionMetadata,
233) entity.PermissionDecision {
234 log := uc.logger(ctx)
235
236 hasPrompt := false
237
238 for _, permType := range permTypes {
239 // Skip auto-allow types - they're handled before this function
240 if entity.IsAutoAllowFor(permType, metadata) {
241 continue
242 }
243
244 // Non-persistable types that aren't auto-allow should prompt
245 // This shouldn't happen in practice, but we handle it defensively
246 if !entity.CanPersist(permType) {
247 hasPrompt = true
248 continue
249 }
250
251 record, err := uc.permRepo.Get(ctx, origin, permType)
252 if err != nil {
253 log.Warn().
254 Err(err).
255 Str("perm_type", string(permType)).
256 Msg("failed to get stored permission")
257 // On error, treat as prompt (fall back to dialog)
258 hasPrompt = true
259 continue
260 }
261
262 if record == nil {
263 hasPrompt = true
264 continue
265 }
266
267 switch record.Decision {
268 case entity.PermissionDenied:
269 // If any permission is denied, deny the whole request (conservative)
270 return entity.PermissionDenied
271 case entity.PermissionPrompt:
272 hasPrompt = true
273 }
274 }
275
276 if hasPrompt {
277 return entity.PermissionPrompt
278 }
279
280 // All permissions are granted
281 return entity.PermissionGranted
282}
283
284// showPermissionDialog shows the permission dialog and handles the result.
285func (uc *HandlePermissionUseCase) showPermissionDialog(
286 ctx context.Context,
287 origin string,
288 permTypes []entity.PermissionType,
289 metadata entity.PermissionMetadata,
290 callback PermissionCallback,
291) {
292 log := uc.logger(ctx)
293
294 dialog := uc.getDialog()
295
296 // If no dialog presenter is set, deny the permission
297 if dialog == nil {
298 log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
299 callback.Deny()
300 return
301 }
302
303 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
304 if result.Allowed {
305 if isWebsiteDataAccessPermission(permTypes) {
306 permissionLoggerWithMetadata(log, metadata).Info().
307 Bool("persistent", result.Persistent).
308 Msg("website data access permission allowed")
309 }
310 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
311 callback.Allow()
312
313 // Persist if "Always Allow" was chosen
314 if result.Persistent {
315 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
316 }
317 } else {
318 if isWebsiteDataAccessPermission(permTypes) {
319 permissionLoggerWithMetadata(log, metadata).Info().
320 Bool("persistent", result.Persistent).
321 Msg("website data access permission denied")
322 }
323 log.Debug().Msg("user denied permission")
324 callback.Deny()
325
326 // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
327 if result.Persistent {
328 uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
329 }
330 }
331 })
332}
333
334// persistPermission saves the permission decision to the repository.
335func (uc *HandlePermissionUseCase) persistPermission(
336 ctx context.Context,
337 origin string,
338 permTypes []entity.PermissionType,
339 decision entity.PermissionDecision,
340) {
341 log := uc.logger(ctx)
342
343 for _, permType := range permTypes {
344 // Only persist allowed types
345 if !entity.CanPersist(permType) {
346 continue
347 }
348
349 record := &entity.PermissionRecord{
350 Origin: origin,
351 Type: permType,
352 Decision: decision,
353 UpdatedAt: time.Now().Unix(),
354 }
355
356 if err := uc.permRepo.Set(ctx, record); err != nil {
357 log.Warn().
358 Err(err).
359 Str("perm_type", string(permType)).
360 Str("decision", string(decision)).
361 Msg("failed to persist permission")
362 }
363 }
364}
365
366// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
367func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
368 ctx context.Context,
369 origin string,
370 permType entity.PermissionType,
371) (*entity.PermissionRecord, error) {
372 if origin == "" {
373 return nil, errors.New("origin is required")
374 }
375 return uc.permRepo.Get(ctx, origin, permType)
376}
377
378// SetManualPermissionDecision stores an explicit decision for origin/type.
379// This is used by the UI permission indicator as the source of truth for lock state.
380func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
381 ctx context.Context,
382 origin string,
383 permType entity.PermissionType,
384 decision entity.PermissionDecision,
385) error {
386 if origin == "" {
387 return errors.New("origin is required")
388 }
389 if !entity.CanPersist(permType) {
390 return errors.New("permission type not persistable")
391 }
392 if decision == entity.PermissionPrompt {
393 return uc.permRepo.Delete(ctx, origin, permType)
394 }
395
396 return uc.permRepo.Set(ctx, &entity.PermissionRecord{
397 Origin: origin,
398 Type: permType,
399 Decision: decision,
400 UpdatedAt: time.Now().Unix(),
401 })
402}
403
404// ResetManualPermissionDecision clears any stored decision for origin/type.
405func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
406 ctx context.Context,
407 origin string,
408 permType entity.PermissionType,
409) error {
410 if origin == "" {
411 return errors.New("origin is required")
412 }
413 return uc.permRepo.Delete(ctx, origin, permType)
414}
415
416func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
417 ctx context.Context,
418 origin string,
419 permTypes []entity.PermissionType,
420 metadata entity.PermissionMetadata,
421) bool {
422 for _, permType := range permTypes {
423 if !entity.IsAutoAllowFor(permType, metadata) {
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}