v0.27.1 c78ce83

handle_permission.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close