v0.32.0 0617278

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, 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, 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 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		}
170	}
171	return f
172}
173
174// joinPermissionLabels joins labels with commas and "and".
175func joinPermissionLabels(labels []string) string {
176	switch len(labels) {
177	case 0:
178		return ""
179	case 1:
180		return labels[0]
181	case 2:
182		return labels[0] + " and " + labels[1]
183	default:
184		return strings.Join(labels[:len(labels)-1], ", ") +
185			", and " + labels[len(labels)-1]
186	}
187}
188
189// buildHeading creates the dialog heading based on permission types.
190func (d *PermissionDialog) buildHeading(
191	permTypes []entity.PermissionType,
192) string {
193	f := parsePermFlags(permTypes)
194	var labels []string
195	if f.mic {
196		labels = append(labels, "Microphone")
197	}
198	if f.cam {
199		labels = append(labels, "Camera")
200	}
201	if f.display {
202		labels = append(labels, "Screen Sharing")
203	}
204	if f.dataAccess {
205		labels = append(labels, "Data Access")
206	}
207	switch {
208	case len(labels) == 0:
209		return "Allow Permission?"
210	case len(labels) == 1 && f.dataAccess:
211		return "Allow Third-Party Data Access?"
212	case len(labels) == 1 && f.display:
213		return "Allow Screen Sharing?"
214	case len(labels) == 1:
215		return "Allow " + labels[0] + " Access?"
216	default:
217		return "Allow " + joinPermissionLabels(labels) + "?"
218	}
219}
220
221// buildBody creates the dialog body text.
222func (d *PermissionDialog) buildBody(
223	origin string, permTypes []entity.PermissionType, metadata entity.PermissionMetadata,
224) string {
225	f := parsePermFlags(permTypes)
226	var parts []string
227	if f.mic && f.cam {
228		parts = append(parts, "access your microphone and camera")
229	} else if f.mic {
230		parts = append(parts, "access your microphone")
231	} else if f.cam {
232		parts = append(parts, "access your camera")
233	}
234	if f.display {
235		parts = append(parts, "share your screen")
236	}
237	if f.dataAccess {
238		reqDomain := metadata[entity.PermissionMetadataKeyRequestingDomain]
239		curDomain := metadata[entity.PermissionMetadataKeyCurrentDomain]
240		if reqDomain != "" && curDomain != "" {
241			parts = append(parts, fmt.Sprintf(
242				"allow %s to access its data (including cookies) while you browse %s",
243				reqDomain, curDomain))
244		} else if len(parts) == 0 {
245			parts = append(parts, "access its stored data while you browse this site")
246		} else {
247			parts = append(parts, "access its stored data")
248		}
249	}
250
251	var action string
252	switch {
253	case len(parts) == 0:
254		action = "access your device"
255	case len(parts) == 1:
256		action = parts[0]
257	case len(parts) == 2 && (!f.mic || !f.cam):
258		// Simple two-part join when neither part contains "and".
259		action = parts[0] + " and " + parts[1]
260	default:
261		// Oxford-comma join for 3+ parts, or when a part already
262		// contains "and" (e.g. "access your microphone and camera").
263		action = strings.Join(parts[:len(parts)-1], ", ") +
264			", and " + parts[len(parts)-1]
265	}
266
267	return fmt.Sprintf("%s wants to %s.", origin, action)
268}
269
270// Ensure PermissionDialog implements the interface.
271var _ 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