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