1package webkit
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "errors"
8 "fmt"
9 "io"
10 "mime"
11 "net"
12 "net/http"
13 "net/url"
14 "strings"
15 "time"
16 "unicode/utf8"
17
18 "github.com/bnema/dumber/internal/application/port"
19 "github.com/bnema/dumber/internal/domain/entity"
20 "github.com/bnema/dumber/internal/logging"
21 "github.com/bnema/puregotk/v4/gtk"
22 "github.com/bnema/puregotk/v4/webkit"
23)
24
25// contextMenuResolver implements port.ImageDataResolver by fetching image bytes
26// over HTTP. This is the WebKit-specific seam for the shared context menu
27// pipeline's copy-image and save-image actions.
28type contextMenuResolver struct {
29 client *http.Client
30 allowPrivateHosts bool
31 lookupIPAddr func(context.Context, string) ([]net.IPAddr, error)
32 dialContext func(context.Context, string, string) (net.Conn, error)
33}
34
35var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
36
37const maxImageFetchBytes = 50 * 1024 * 1024
38
39// ResolveImageData fetches raw image bytes from the given URI.
40func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) {
41 if imageURI == "" {
42 return entity.ImageData{}, fmt.Errorf("empty image URI")
43 }
44
45 parsed, parseErr := url.Parse(imageURI)
46 if parseErr != nil {
47 return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
48 }
49 switch strings.ToLower(parsed.Scheme) {
50 case "data":
51 return resolveDataURIImageData(imageURI)
52 case "http", "https":
53 default:
54 return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
55 }
56 if err := r.validateImageURL(ctx, parsed); err != nil {
57 return entity.ImageData{}, err
58 }
59
60 client := r.imageFetchClient(ctx)
61
62 req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
63 if err != nil {
64 return entity.ImageData{}, fmt.Errorf("build request: %w", err)
65 }
66
67 resp, err := client.Do(req)
68 if err != nil {
69 return entity.ImageData{}, fmt.Errorf("fetch image: %w", err)
70 }
71 defer func() { _ = resp.Body.Close() }()
72
73 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
74 return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
75 }
76
77 var buf bytes.Buffer
78 n, err := io.CopyN(&buf, resp.Body, maxImageFetchBytes+1)
79 if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
80 return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
81 }
82 if n == 0 {
83 return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
84 }
85 if n > maxImageFetchBytes {
86 return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
87 }
88 data := buf.Bytes()
89 responseURL := parsed.String()
90 if resp.Request != nil && resp.Request.URL != nil {
91 responseURL = resp.Request.URL.String()
92 }
93 return resolvedImageData(data, resp.Header.Get("Content-Type"), responseURL)
94}
95
96func resolveDataURIImageData(imageURI string) (entity.ImageData, error) {
97 payload, ok := strings.CutPrefix(imageURI, "data:")
98 if !ok {
99 return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", "")
100 }
101 meta, dataPart, found := strings.Cut(payload, ",")
102 if !found {
103 return entity.ImageData{}, fmt.Errorf("parse image URI: invalid data URI")
104 }
105
106 isBase64 := false
107 mimeType := ""
108 for _, part := range strings.Split(meta, ";") {
109 part = strings.TrimSpace(part)
110 if part == "" {
111 continue
112 }
113 if strings.EqualFold(part, "base64") {
114 isBase64 = true
115 continue
116 }
117 if mimeType == "" {
118 mimeType = part
119 }
120 }
121
122 var data []byte
123 var err error
124 if isBase64 {
125 data, err = base64.StdEncoding.DecodeString(dataPart)
126 } else {
127 var decoded string
128 decoded, err = url.PathUnescape(dataPart)
129 data = []byte(decoded)
130 }
131 if err != nil {
132 return entity.ImageData{}, fmt.Errorf("read image body: decode data URI: %w", err)
133 }
134 return resolvedImageData(data, mimeType, imageURI)
135}
136
137func resolvedImageData(data []byte, headerType, source string) (entity.ImageData, error) {
138 if len(data) == 0 {
139 return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
140 }
141 if len(data) > maxImageFetchBytes {
142 return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
143 }
144 rawDetectedType := strings.ToLower(http.DetectContentType(data))
145 detectedType := validatedImageMimeType(rawDetectedType)
146 mimeType := detectedType
147 if mimeType == "" && rawDetectedType == "application/octet-stream" {
148 mimeType = validatedImageMimeType(headerType)
149 }
150 if mimeType == "" {
151 return entity.ImageData{}, fmt.Errorf(
152 "read image body: content is not an image (detected=%q, header=%q, url=%q)",
153 rawDetectedType,
154 headerType,
155 source,
156 )
157 }
158 return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
159}
160
161func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
162 base := r.client
163 if base == nil {
164 base = defaultImageFetchClient
165 }
166 client := *base
167 transport := client.Transport
168 if transport == nil {
169 transport = http.DefaultTransport
170 }
171 if stdTransport, ok := transport.(*http.Transport); ok {
172 cloned := stdTransport.Clone()
173 cloned.DialContext = r.dialImageHostContext
174 client.Transport = cloned
175 } else {
176 client.Transport = &validatedImageRoundTripper{
177 base: transport,
178 validator: r.validateImageURL,
179 }
180 }
181 client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
182 return r.validateImageURL(req.Context(), req.URL)
183 }
184 return &client
185}
186
187func (r *contextMenuResolver) validateImageURL(ctx context.Context, imageURL *url.URL) error {
188 if imageURL == nil {
189 return fmt.Errorf("fetch image: missing URL")
190 }
191 switch strings.ToLower(imageURL.Scheme) {
192 case "http", "https":
193 default:
194 return fmt.Errorf("fetch image: unsupported URI scheme %q", imageURL.Scheme)
195 }
196 if imageURL.Host == "" {
197 return fmt.Errorf("fetch image: missing host")
198 }
199 hostname := imageURL.Hostname()
200 if err := r.validateImageHost(ctx, hostname); err != nil {
201 return err
202 }
203 return nil
204}
205
206func (r *contextMenuResolver) validateImageHost(ctx context.Context, host string) error {
207 if host == "" {
208 return fmt.Errorf("fetch image: missing host")
209 }
210 if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") {
211 if r.allowPrivateHosts {
212 return nil
213 }
214 return fmt.Errorf("fetch image: private host %q not allowed", host)
215 }
216 ip := net.ParseIP(host)
217 if ip == nil {
218 addrs, err := r.allowedImageHostAddrs(ctx, host)
219 if err != nil {
220 return err
221 }
222 if len(addrs) == 0 {
223 return fmt.Errorf("fetch image: private host %q not allowed", host)
224 }
225 return nil
226 }
227 if isPrivateImageIP(ip) && !r.allowPrivateHosts {
228 return fmt.Errorf("fetch image: private host %q not allowed", host)
229 }
230 return nil
231}
232
233func (r *contextMenuResolver) allowedImageHostAddrs(ctx context.Context, host string) ([]net.IPAddr, error) {
234 lookup := r.lookupIPAddr
235 if lookup == nil {
236 lookup = net.DefaultResolver.LookupIPAddr
237 }
238 addrs, err := lookup(ctx, host)
239 if err != nil {
240 return nil, fmt.Errorf("fetch image: resolve host %q: %w", host, err)
241 }
242 if r.allowPrivateHosts {
243 return addrs, nil
244 }
245 allowed := make([]net.IPAddr, 0, len(addrs))
246 for _, addr := range addrs {
247 if !isPrivateImageIP(addr.IP) {
248 allowed = append(allowed, addr)
249 }
250 }
251 return allowed, nil
252}
253
254func (r *contextMenuResolver) dialImageHostContext(ctx context.Context, network, addr string) (net.Conn, error) {
255 host, imagePort, err := net.SplitHostPort(addr)
256 if err != nil {
257 return nil, fmt.Errorf("fetch image: parse dial address %q: %w", addr, err)
258 }
259 if host == "" {
260 return nil, fmt.Errorf("fetch image: missing host")
261 }
262
263 dial := r.dialContext
264 if dial == nil {
265 dial = (&net.Dialer{}).DialContext
266 }
267
268 if ip := net.ParseIP(host); ip != nil {
269 if isPrivateImageIP(ip) && !r.allowPrivateHosts {
270 return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
271 }
272 return dial(ctx, network, net.JoinHostPort(ip.String(), imagePort))
273 }
274
275 addrs, err := r.allowedImageHostAddrs(ctx, host)
276 if err != nil {
277 return nil, err
278 }
279 if len(addrs) == 0 {
280 return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
281 }
282
283 var lastErr error
284 for _, addr := range addrs {
285 conn, dialErr := dial(ctx, network, net.JoinHostPort(addr.IP.String(), imagePort))
286 if dialErr == nil {
287 return conn, nil
288 }
289 lastErr = dialErr
290 }
291 if lastErr == nil {
292 return nil, fmt.Errorf("fetch image: dial host %q failed", host)
293 }
294 return nil, fmt.Errorf("fetch image: dial host %q: %w", host, lastErr)
295}
296
297type validatedImageRoundTripper struct {
298 base http.RoundTripper
299 validator func(context.Context, *url.URL) error
300}
301
302func (t *validatedImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
303 if t == nil || t.base == nil {
304 return nil, fmt.Errorf("fetch image: transport not available")
305 }
306 if err := t.validator(req.Context(), req.URL); err != nil {
307 return nil, err
308 }
309 return t.base.RoundTrip(req)
310}
311
312func isPrivateImageIP(ip net.IP) bool {
313 return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified())
314}
315
316func validatedImageMimeType(raw string) string {
317 if raw == "" {
318 return ""
319 }
320 mediaType, _, err := mime.ParseMediaType(raw)
321 if err != nil {
322 return ""
323 }
324 mediaType = strings.ToLower(mediaType)
325 if !strings.HasPrefix(mediaType, "image/") {
326 return ""
327 }
328 return mediaType
329}
330
331// Compile-time check that contextMenuResolver implements port.ImageDataResolver.
332var _ port.ImageDataResolver = (*contextMenuResolver)(nil)
333
334// buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext.
335func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext {
336 ctx := port.MenuContext{
337 X: x,
338 Y: y,
339 }
340
341 if wv != nil {
342 ctx.PageURI = wv.URI()
343 ctx.CanGoBack = wv.CanGoBack()
344 ctx.CanGoForward = wv.CanGoForward()
345 }
346
347 if hit == nil {
348 return ctx
349 }
350
351 if hit.ContextIsLink() {
352 ctx.LinkURI = hit.GetLinkUri()
353 }
354 if hit.ContextIsImage() {
355 ctx.ImageURI = hit.GetImageUri()
356 }
357 ctx.HasSelection = hit.ContextIsSelection()
358 ctx.IsEditable = hit.ContextIsEditable()
359
360 return ctx
361}
362
363// contextMenuPipeline holds the dependencies needed to run the shared
364// context menu flow from inside a WebKit context-menu signal handler.
365type contextMenuPipeline struct {
366 builder port.ContextMenuBuilder
367 executorFactory port.ContextMenuActionExecutorFactory
368 clipboard port.Clipboard
369 onCopied func(textLen int)
370 resolver port.ImageDataResolver
371 saver port.ResolvedImageSaver
372 renderer *Renderer
373}
374
375func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExecutor {
376 if p == nil || p.executorFactory == nil {
377 return nil
378 }
379 clipboard := p.clipboard
380 if clipboard != nil && p.onCopied != nil {
381 clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied}
382 }
383 return p.executorFactory.NewContextMenuActionExecutor(
384 clipboard,
385 p.resolver,
386 p.saver,
387 &webkitMenuDelegator{wv: wv},
388 )
389}
390
391var _ port.Clipboard = (*clipboardCopyNotifier)(nil)
392
393type clipboardCopyNotifier struct {
394 clipboard port.Clipboard
395 onCopied func(textLen int)
396}
397
398func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error {
399 if c == nil || c.clipboard == nil {
400 return fmt.Errorf("clipboard not available")
401 }
402 if err := c.clipboard.WriteText(ctx, text); err != nil {
403 return err
404 }
405 if c.onCopied != nil {
406 c.onCopied(utf8.RuneCountInString(text))
407 }
408 return nil
409}
410
411func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error {
412 if c == nil || c.clipboard == nil {
413 return fmt.Errorf("clipboard not available")
414 }
415 return c.clipboard.WriteImage(ctx, image)
416}
417
418func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) {
419 if c == nil || c.clipboard == nil {
420 return "", fmt.Errorf("clipboard not available")
421 }
422 return c.clipboard.ReadText(ctx)
423}
424
425func (c *clipboardCopyNotifier) Clear(ctx context.Context) error {
426 if c == nil || c.clipboard == nil {
427 return fmt.Errorf("clipboard not available")
428 }
429 return c.clipboard.Clear(ctx)
430}
431
432func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) {
433 if c == nil || c.clipboard == nil {
434 return false, fmt.Errorf("clipboard not available")
435 }
436 return c.clipboard.HasText(ctx)
437}
438
439// connectContextMenuSignal wires the WebKit "context-menu" signal to the
440// shared context menu pipeline. When the user right-clicks, WebKit's native
441// menu is suppressed and replaced by the shared GTK popover menu.
442func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
443 if pipeline == nil {
444 return
445 }
446 wv.contextMenu = pipeline
447
448 contextMenuCb := func(_ webkit.WebView, contextMenuPtr uintptr, hitTestPtr uintptr) bool {
449 var contextMenu *webkit.ContextMenu
450 if contextMenuPtr != 0 {
451 contextMenu = webkit.ContextMenuNewFromInternalPtr(contextMenuPtr)
452 }
453 var hit *webkit.HitTestResult
454 if hitTestPtr != 0 {
455 hit = webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
456 }
457
458 x, y := contextMenuPosition(contextMenu)
459 parent := anchorParentWidget(&wv.inner.Widget)
460 wv.logger.Debug().
461 Int("raw_x", x).
462 Int("raw_y", y).
463 Int("anchor_width", wv.inner.Widget.GetAllocatedWidth()).
464 Int("anchor_height", wv.inner.Widget.GetAllocatedHeight()).
465 Int("parent_width", widgetAllocatedWidth(parent)).
466 Int("parent_height", widgetAllocatedHeight(parent)).
467 Msg("webkit: context menu popup request")
468
469 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
470 ctx := logging.WithContext(context.Background(), wv.logger)
471 if pipeline.builder == nil || pipeline.renderer == nil {
472 return false
473 }
474 items := pipeline.builder.Build(ctx, menuContext)
475 if len(items) == 0 {
476 return false
477 }
478
479 executor := pipeline.newExecutor(wv)
480 if executor == nil {
481 return false
482 }
483
484 // Get the widget for anchoring the popover.
485 if wv.inner == nil {
486 return false
487 }
488 anchor := &wv.inner.Widget
489
490 pipeline.renderer.Show(
491 items,
492 anchor,
493 int32(x),
494 int32(y),
495 func(item port.MenuItem) {
496 wv.executeContextMenuAction(ctx, executor, item, menuContext)
497 },
498 nil, // onClose — no action needed
499 )
500
501 return true // suppress native WebKit context menu
502 }
503
504 sigID := wv.inner.ConnectContextMenu(&contextMenuCb)
505 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
506}
507
508func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
509 if menu == nil || menu.GoPointer() == 0 {
510 return 0, 0
511 }
512 event := menu.GetEvent()
513 if event == nil {
514 return 0, 0
515 }
516 var x, y float64
517 if !event.GetPosition(&x, &y) {
518 return 0, 0
519 }
520 return int(x), int(y)
521}
522
523func anchorParentWidget(anchor *gtk.Widget) *gtk.Widget {
524 if anchor == nil {
525 return nil
526 }
527 return anchor.GetParent()
528}
529
530func widgetAllocatedWidth(widget *gtk.Widget) int {
531 if widget == nil {
532 return 0
533 }
534 return widget.GetAllocatedWidth()
535}
536
537func widgetAllocatedHeight(widget *gtk.Widget) int {
538 if widget == nil {
539 return 0
540 }
541 return widget.GetAllocatedHeight()
542}
543
544// executeContextMenuAction runs the selected menu action through the shared
545// executor. GTK-thread-sensitive actions run inline; image fetch/save work
546// stays off-thread.
547func (wv *WebView) executeContextMenuAction(
548 ctx context.Context,
549 executor port.ContextMenuActionExecutor,
550 item port.MenuItem,
551 menuContext port.MenuContext,
552) {
553 if executor == nil {
554 return
555 }
556
557 if needsBackgroundContextMenuAction(item.Action) {
558 go wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
559 return
560 }
561 wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
562}
563
564func (wv *WebView) runContextMenuAction(
565 ctx context.Context,
566 executor port.ContextMenuActionExecutor,
567 action port.MenuAction,
568 menuContext port.MenuContext,
569) {
570 if err := executor.ExecuteMenuAction(ctx, action, menuContext); err != nil {
571 wv.logger.Warn().Err(err).
572 Str("action", string(action)).
573 Msg("context menu action failed")
574 }
575}
576
577func needsBackgroundContextMenuAction(action port.MenuAction) bool {
578 switch action {
579 case port.MenuActionCopyImage, port.MenuActionSaveImage:
580 return true
581 default:
582 return false
583 }
584}
585
586type webkitMenuDelegator struct {
587 wv *WebView
588}
589
590var _ port.MenuActionDelegator = (*webkitMenuDelegator)(nil)
591
592func (d *webkitMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
593 if d == nil || d.wv == nil {
594 return fmt.Errorf("webkit menu delegator: webview not available")
595 }
596 switch action {
597 case port.MenuActionBack:
598 return d.wv.GoBack(ctx)
599 case port.MenuActionForward:
600 return d.wv.GoForward(ctx)
601 case port.MenuActionReload:
602 return d.wv.Reload(ctx)
603 case port.MenuActionOpenLinkNewTab:
604 if menuContext.LinkURI == "" {
605 return fmt.Errorf("open link in new tab: link URI not available")
606 }
607 if d.wv.OnLinkMiddleClick == nil {
608 return fmt.Errorf("open link in new tab: middle-click handler not available")
609 }
610 if !d.wv.OnLinkMiddleClick(menuContext.LinkURI) {
611 return fmt.Errorf("open link in new tab: action not handled")
612 }
613 return nil
614 case port.MenuActionInspectElement:
615 return d.wv.ShowDevTools()
616 case port.MenuActionCopySelection:
617 if !menuContext.HasSelection {
618 return fmt.Errorf("copy selection: selection not available")
619 }
620 d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
621 return nil
622 default:
623 return fmt.Errorf("webkit menu delegator: unsupported action %s", action)
624 }
625}
626
627// NewContextMenuResolver creates a new ImageDataResolver for WebKit
628// that fetches image bytes over HTTP.
629func NewContextMenuResolver() port.ImageDataResolver {
630 return &contextMenuResolver{}
631}