v0.28.0 3cb3770

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