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)