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