main 5041353

permission.go RawHistoryFolder

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