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)