v0.30.0 eb3068f

context_menu.go RawHistoryFolder

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}

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