permission.go RawHistoryFolder

1// Package dialog provides UI dialog implementations for the application layer.
2package dialog
3
4import (
5	"context"
6	"fmt"
7	"strings"
8	"sync"
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/bnema/dumber/internal/ui/component"
14)
15
16type permissionPopup interface {
17	Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool))
18}
19
20type permissionDialogRequest struct {
21	ctx       context.Context
22	origin    string
23	permTypes []entity.PermissionType
24	metadata  entity.PermissionMetadata
25	callback  func(result port.PermissionDialogResult)
26}
27
28// PermissionDialog implements the port.PermissionDialogPresenter interface.
29// It uses a custom PermissionPopup overlay to sidestep the purego ConnectResponse bug
30// and match the app's custom UI style.
31type PermissionDialog struct {
32	popup permissionPopup
33
34	mu     sync.Mutex
35	active bool
36	queue  []permissionDialogRequest
37}
38
39// NewPermissionDialog creates a new permission dialog presenter.
40// The popup is created once and reused for each permission request.
41func NewPermissionDialog(popup *component.PermissionPopup) *PermissionDialog {
42	return &PermissionDialog{
43		popup: popup,
44	}
45}
46
47// ShowPermissionDialog displays a permission request dialog to the user.
48func (d *PermissionDialog) ShowPermissionDialog(
49	ctx context.Context,
50	origin string,
51	permTypes []entity.PermissionType,
52	metadata entity.PermissionMetadata,
53	callback func(result port.PermissionDialogResult),
54) {
55	req := permissionDialogRequest{
56		ctx:       ctx,
57		origin:    origin,
58		permTypes: permTypes,
59		metadata:  metadata,
60		callback:  callback,
61	}
62
63	if !d.enqueueOrStart(req) {
64		return
65	}
66
67	d.showRequest(req)
68}
69
70func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
71	d.mu.Lock()
72	defer d.mu.Unlock()
73
74	if d.active {
75		d.queue = append(d.queue, req)
76		return false
77	}
78
79	d.active = true
80	return true
81}
82
83func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
84	ctx := req.ctx
85	origin := req.origin
86	permTypes := req.permTypes
87	metadata := req.metadata
88	callback := req.callback
89
90	log := logging.FromContext(ctx)
91
92	if d.popup == nil {
93		log.Error().Msg("permission popup not available")
94		callback(port.PermissionDialogResult{Allowed: false, Persistent: false})
95		d.showNextQueuedRequest()
96		return
97	}
98
99	// Build dialog text
100	heading := d.buildHeading(permTypes)
101	body := d.buildBody(origin, permTypes, metadata)
102
103	isDataAccess := parsePermFlags(permTypes).dataAccess
104	if isDataAccess {
105		log.Info().
106			Str("origin", origin).
107			Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], logging.PermissionLogURLMaxLen)).
108			Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], logging.PermissionLogURLMaxLen)).
109			Msg("showing website data access permission dialog")
110	}
111
112	d.popup.Show(ctx, heading, body, anyPersistable(permTypes), func(allowed, persistent bool) {
113		if isDataAccess {
114			log.Info().
115				Str("origin", origin).
116				Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], logging.PermissionLogURLMaxLen)).
117				Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], logging.PermissionLogURLMaxLen)).
118				Bool("allowed", allowed).
119				Bool("persistent", persistent).
120				Msg("website data access permission dialog response")
121		}
122		log.Debug().
123			Bool("allowed", allowed).
124			Bool("persistent", persistent).
125			Msg("permission popup response")
126		callback(port.PermissionDialogResult{Allowed: allowed, Persistent: persistent})
127		d.showNextQueuedRequest()
128	})
129
130	log.Debug().
131		Str("origin", origin).
132		Strs("types", entity.PermissionTypesToStrings(permTypes)).
133		Msg("showing permission popup")
134}
135
136func (d *PermissionDialog) showNextQueuedRequest() {
137	d.mu.Lock()
138	if len(d.queue) == 0 {
139		d.active = false
140		d.mu.Unlock()
141		return
142	}
143
144	next := d.queue[0]
145	d.queue = d.queue[1:]
146	d.mu.Unlock()
147
148	d.showRequest(next)
149}
150
151// permFlags holds parsed permission type flags.
152type permFlags struct {
153	mic, cam, display, dataAccess, notification, geolocation bool
154}
155
156// parsePermFlags extracts boolean flags from permission types.
157func parsePermFlags(permTypes []entity.PermissionType) permFlags {
158	var f permFlags
159	for _, pt := range permTypes {
160		switch pt {
161		case entity.PermissionTypeMicrophone:
162			f.mic = true
163		case entity.PermissionTypeCamera:
164			f.cam = true
165		case entity.PermissionTypeDisplay:
166			f.display = true
167		case entity.PermissionTypeWebsiteDataAccess:
168			f.dataAccess = true
169		case entity.PermissionTypeNotification:
170			f.notification = true
171		case entity.PermissionTypeGeolocation:
172			f.geolocation = true
173		}
174	}
175	return f
176}
177
178// anyPersistable reports whether at least one requested type can be remembered
179// ("Always Allow/Deny"). When none can, the popup hides those buttons.
180func anyPersistable(permTypes []entity.PermissionType) bool {
181	for _, pt := range permTypes {
182		if entity.CanPersist(pt) {
183			return true
184		}
185	}
186	return false
187}
188
189// joinPermissionLabels joins labels with commas and "and".
190func joinPermissionLabels(labels []string) string {
191	switch len(labels) {
192	case 0:
193		return ""
194	case 1:
195		return labels[0]
196	case 2:
197		return labels[0] + " and " + labels[1]
198	default:
199		return strings.Join(labels[:len(labels)-1], ", ") +
200			", and " + labels[len(labels)-1]
201	}
202}
203
204// buildHeading creates the dialog heading based on permission types.
205func (d *PermissionDialog) buildHeading(
206	permTypes []entity.PermissionType,
207) string {
208	f := parsePermFlags(permTypes)
209	var labels []string
210	if f.mic {
211		labels = append(labels, "Microphone")
212	}
213	if f.cam {
214		labels = append(labels, "Camera")
215	}
216	if f.display {
217		labels = append(labels, "Screen Sharing")
218	}
219	if f.dataAccess {
220		labels = append(labels, "Data Access")
221	}
222	if f.notification {
223		labels = append(labels, "Notifications")
224	}
225	if f.geolocation {
226		labels = append(labels, "Location")
227	}
228	switch {
229	case len(labels) == 0:
230		return "Allow Permission?"
231	case len(labels) == 1 && f.dataAccess:
232		return "Allow Third-Party Data Access?"
233	case len(labels) == 1 && f.display:
234		return "Allow Screen Sharing?"
235	case len(labels) == 1 && f.notification:
236		return "Allow Notifications?"
237	case len(labels) == 1:
238		return "Allow " + labels[0] + " Access?"
239	default:
240		return "Allow " + joinPermissionLabels(labels) + "?"
241	}
242}
243
244// buildBody creates the dialog body text.
245func (d *PermissionDialog) buildBody(
246	origin string, permTypes []entity.PermissionType, metadata entity.PermissionMetadata,
247) string {
248	f := parsePermFlags(permTypes)
249	var parts []string
250	if f.mic && f.cam {
251		parts = append(parts, "access your microphone and camera")
252	} else if f.mic {
253		parts = append(parts, "access your microphone")
254	} else if f.cam {
255		parts = append(parts, "access your camera")
256	}
257	if f.display {
258		switch {
259		case metadata.IsUnmediatedCapture() && metadata.IsUnmediatedAudio():
260			parts = append(parts, "share your entire screen and system audio")
261		case metadata.IsUnmediatedCapture():
262			parts = append(parts, "share your entire screen")
263		default:
264			parts = append(parts, "share your screen")
265		}
266	}
267	if f.notification {
268		parts = append(parts, "show notifications")
269	}
270	if f.geolocation {
271		parts = append(parts, "access your location")
272	}
273	if f.dataAccess {
274		reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
275		curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain]
276		if reqDomain != "" && curDomain != "" {
277			parts = append(parts, fmt.Sprintf(
278				"allow %s to access its data (including cookies) while you browse %s",
279				reqDomain, curDomain))
280		} else if len(parts) == 0 {
281			parts = append(parts, "access its stored data while you browse this site")
282		} else {
283			parts = append(parts, "access its stored data")
284		}
285	}
286
287	var action string
288	switch {
289	case len(parts) == 0:
290		action = "access your device"
291	case len(parts) == 1:
292		action = parts[0]
293	case len(parts) == 2 && (!f.mic || !f.cam):
294		// Simple two-part join when neither part contains "and".
295		action = parts[0] + " and " + parts[1]
296	default:
297		// Oxford-comma join for 3+ parts, or when a part already
298		// contains "and" (e.g. "access your microphone and camera").
299		action = strings.Join(parts[:len(parts)-1], ", ") +
300			", and " + parts[len(parts)-1]
301	}
302
303	return fmt.Sprintf("%s wants to %s.", origin, action)
304}
305
306// Ensure PermissionDialog implements the interface.
307var _ port.PermissionDialogPresenter = (*PermissionDialog)(nil)

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