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)