detached 3ecba54

feat(context-menu): unify WebKit and CEF right-click handling

commit
3ecba54bc867b85ecc00ea6b42ed6a7b9a1f0559
author
brice · · 2026-04-13 14:35 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
d7aff0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
38 files · +1969 -188
M internal/application/port/clipboard.go +3 -0
@@ -8,6 +8,9 @@ type Clipboard interface {
88 // WriteText copies text to the clipboard.
99 WriteText(ctx context.Context, text string) error
1010
11+ // WriteImage copies encoded image bytes to the clipboard.
12+ WriteImage(ctx context.Context, image ImageData) error
13+
1114 // ReadText reads text from the clipboard.
1215 // Returns empty string if clipboard is empty or contains non-text data.
1316 ReadText(ctx context.Context) (string, error)
@@ -8,6 +8,9 @@ type Clipboard interface {
8 // WriteText copies text to the clipboard.8 // WriteText copies text to the clipboard.
9 WriteText(ctx context.Context, text string) error9 WriteText(ctx context.Context, text string) error
10 10
11+ // WriteImage copies encoded image bytes to the clipboard.
12+ WriteImage(ctx context.Context, image ImageData) error
13+
11 // ReadText reads text from the clipboard.14 // ReadText reads text from the clipboard.
12 // Returns empty string if clipboard is empty or contains non-text data.15 // Returns empty string if clipboard is empty or contains non-text data.
13 ReadText(ctx context.Context) (string, error)16 ReadText(ctx context.Context) (string, error)
M internal/application/port/config_transformer.go +4 -0
@@ -6,4 +6,8 @@ type ConfigTransformer interface {
66 // to new ActionBinding format (map with keys and desc).
77 // Modifies the rawConfig map in place.
88 TransformLegacyActions(rawConfig map[string]any)
9+
10+ // TransformLegacyEngineConfig removes deprecated engine config keys.
11+ // Modifies the rawConfig map in place.
12+ TransformLegacyEngineConfig(rawConfig map[string]any)
913 }
@@ -6,4 +6,8 @@ type ConfigTransformer interface {
6 // to new ActionBinding format (map with keys and desc).6 // to new ActionBinding format (map with keys and desc).
7 // Modifies the rawConfig map in place.7 // Modifies the rawConfig map in place.
8 TransformLegacyActions(rawConfig map[string]any)8 TransformLegacyActions(rawConfig map[string]any)
9+
10+ // TransformLegacyEngineConfig removes deprecated engine config keys.
11+ // Modifies the rawConfig map in place.
12+ TransformLegacyEngineConfig(rawConfig map[string]any)
9 }13 }
A internal/application/port/context_menu.go +42 -0
@@ -0,0 +1,42 @@
1+package port
2+
3+import "context"
4+
5+// MenuAction identifies a normalized context menu action.
6+type MenuAction string
7+
8+const (
9+ MenuActionBack MenuAction = "back"
10+ MenuActionForward MenuAction = "forward"
11+ MenuActionReload MenuAction = "reload"
12+ MenuActionOpenLinkNewTab MenuAction = "open_link_new_tab"
13+ MenuActionCopyLink MenuAction = "copy_link"
14+ MenuActionCopyImage MenuAction = "copy_image"
15+ MenuActionSaveImage MenuAction = "save_image"
16+ MenuActionInspectElement MenuAction = "inspect_element"
17+ MenuActionCopySelection MenuAction = "copy_selection"
18+)
19+
20+// MenuContext captures the state needed to build and execute a context menu.
21+type MenuContext struct {
22+ PageURI string
23+ LinkURI string
24+ ImageURI string
25+ HasSelection bool
26+ IsEditable bool
27+ CanGoBack bool
28+ CanGoForward bool
29+ X int
30+ Y int
31+}
32+
33+// MenuItem is a normalized context menu entry.
34+type MenuItem struct {
35+ Action MenuAction
36+ Label string
37+}
38+
39+// MenuActionDelegator handles actions that are not shared application concerns.
40+type MenuActionDelegator interface {
41+ DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
42+}
@@ -0,0 +1,42 @@
1+package port
2+
3+import "context"
4+
5+// MenuAction identifies a normalized context menu action.
6+type MenuAction string
7+
8+const (
9+ MenuActionBack MenuAction = "back"
10+ MenuActionForward MenuAction = "forward"
11+ MenuActionReload MenuAction = "reload"
12+ MenuActionOpenLinkNewTab MenuAction = "open_link_new_tab"
13+ MenuActionCopyLink MenuAction = "copy_link"
14+ MenuActionCopyImage MenuAction = "copy_image"
15+ MenuActionSaveImage MenuAction = "save_image"
16+ MenuActionInspectElement MenuAction = "inspect_element"
17+ MenuActionCopySelection MenuAction = "copy_selection"
18+)
19+
20+// MenuContext captures the state needed to build and execute a context menu.
21+type MenuContext struct {
22+ PageURI string
23+ LinkURI string
24+ ImageURI string
25+ HasSelection bool
26+ IsEditable bool
27+ CanGoBack bool
28+ CanGoForward bool
29+ X int
30+ Y int
31+}
32+
33+// MenuItem is a normalized context menu entry.
34+type MenuItem struct {
35+ Action MenuAction
36+ Label string
37+}
38+
39+// MenuActionDelegator handles actions that are not shared application concerns.
40+type MenuActionDelegator interface {
41+ DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
42+}
A internal/application/port/image_data.go +18 -0
@@ -0,0 +1,18 @@
1+package port
2+
3+import "context"
4+
5+// ImageData contains resolved image bytes.
6+type ImageData struct {
7+ Bytes []byte
8+}
9+
10+// ImageDataResolver resolves image bytes from a URI.
11+type ImageDataResolver interface {
12+ ResolveImageData(ctx context.Context, uri string) (ImageData, error)
13+}
14+
15+// ResolvedImageSaver persists already resolved image bytes.
16+type ResolvedImageSaver interface {
17+ SaveResolvedImage(ctx context.Context, image ImageData) error
18+}
@@ -0,0 +1,18 @@
1+package port
2+
3+import "context"
4+
5+// ImageData contains resolved image bytes.
6+type ImageData struct {
7+ Bytes []byte
8+}
9+
10+// ImageDataResolver resolves image bytes from a URI.
11+type ImageDataResolver interface {
12+ ResolveImageData(ctx context.Context, uri string) (ImageData, error)
13+}
14+
15+// ResolvedImageSaver persists already resolved image bytes.
16+type ResolvedImageSaver interface {
17+ SaveResolvedImage(ctx context.Context, image ImageData) error
18+}
M internal/application/port/mocks/mocks.go +181 -0
@@ -387,6 +387,63 @@ func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
387387 return r0
388388 }
389389
390+// WriteImage provides a mock function for the type MockClipboard
391+func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {
392+ ret := _mock.Called(ctx, image)
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for WriteImage")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
400+ r0 = returnFunc(ctx, image)
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage'
408+type MockClipboard_WriteImage_Call struct {
409+ *mock.Call
410+}
411+
412+// WriteImage is a helper method to define mock.On call
413+// - ctx context.Context
414+// - image port.ImageData
415+func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {
416+ return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}
417+}
418+
419+func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call {
420+ _c.Call.Run(func(args mock.Arguments) {
421+ var arg0 context.Context
422+ if args[0] != nil {
423+ arg0 = args[0].(context.Context)
424+ }
425+ var arg1 port.ImageData
426+ if args[1] != nil {
427+ arg1 = args[1].(port.ImageData)
428+ }
429+ run(
430+ arg0,
431+ arg1,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call {
438+ _c.Call.Return(err)
439+ return _c
440+}
441+
442+func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
390447 // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
391448 type MockClipboard_WriteText_Call struct {
392449 *mock.Call
@@ -427,6 +484,90 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
427484 return _c
428485 }
429486
487+// NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
488+// The first argument is typically a *testing.T value.
489+func NewMockResolvedImageSaver(t interface {
490+ mock.TestingT
491+ Cleanup(func())
492+}) *MockResolvedImageSaver {
493+ mock := &MockResolvedImageSaver{}
494+ mock.Mock.Test(t)
495+
496+ t.Cleanup(func() { mock.AssertExpectations(t) })
497+
498+ return mock
499+}
500+
501+// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
502+type MockResolvedImageSaver struct {
503+ mock.Mock
504+}
505+
506+type MockResolvedImageSaver_Expecter struct {
507+ mock *mock.Mock
508+}
509+
510+func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
511+ return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
512+}
513+
514+// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
515+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
516+ ret := _mock.Called(ctx, image)
517+
518+ if len(ret) == 0 {
519+ panic("no return value specified for SaveResolvedImage")
520+ }
521+
522+ var r0 error
523+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
524+ r0 = returnFunc(ctx, image)
525+ } else {
526+ r0 = ret.Error(0)
527+ }
528+ return r0
529+}
530+
531+// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
532+type MockResolvedImageSaver_SaveResolvedImage_Call struct {
533+ *mock.Call
534+}
535+
536+// SaveResolvedImage is a helper method to define mock.On call
537+// - ctx context.Context
538+// - image port.ImageData
539+func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
540+ return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)}
541+}
542+
543+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call {
544+ _c.Call.Run(func(args mock.Arguments) {
545+ var arg0 context.Context
546+ if args[0] != nil {
547+ arg0 = args[0].(context.Context)
548+ }
549+ var arg1 port.ImageData
550+ if args[1] != nil {
551+ arg1 = args[1].(port.ImageData)
552+ }
553+ run(
554+ arg0,
555+ arg1,
556+ )
557+ })
558+ return _c
559+}
560+
561+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
562+ _c.Call.Return(err)
563+ return _c
564+}
565+
566+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
567+ _c.Call.Return(run)
568+ return _c
569+}
570+
430571 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
431572 // The first argument is typically a *testing.T value.
432573 func NewMockColorSchemeDetector(t interface {
@@ -1421,6 +1562,46 @@ func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run fu
14211562 return _c
14221563 }
14231564
1565+// TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer
1566+func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
1567+ _mock.Called(rawConfig)
1568+ return
1569+}
1570+
1571+// MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig'
1572+type MockConfigTransformer_TransformLegacyEngineConfig_Call struct {
1573+ *mock.Call
1574+}
1575+
1576+// TransformLegacyEngineConfig is a helper method to define mock.On call
1577+// - rawConfig map[string]any
1578+func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1579+ return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)}
1580+}
1581+
1582+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1583+ _c.Call.Run(func(args mock.Arguments) {
1584+ var arg0 map[string]any
1585+ if args[0] != nil {
1586+ arg0 = args[0].(map[string]any)
1587+ }
1588+ run(
1589+ arg0,
1590+ )
1591+ })
1592+ return _c
1593+}
1594+
1595+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1596+ _c.Call.Return()
1597+ return _c
1598+}
1599+
1600+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1601+ _c.Run(run)
1602+ return _c
1603+}
1604+
14241605 // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14251606 // The first argument is typically a *testing.T value.
14261607 func NewMockContentInjector(t interface {
@@ -387,6 +387,63 @@ func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
387 return r0387 return r0
388 }388 }
389 389
390+// WriteImage provides a mock function for the type MockClipboard
391+func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {
392+ ret := _mock.Called(ctx, image)
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for WriteImage")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
400+ r0 = returnFunc(ctx, image)
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage'
408+type MockClipboard_WriteImage_Call struct {
409+ *mock.Call
410+}
411+
412+// WriteImage is a helper method to define mock.On call
413+// - ctx context.Context
414+// - image port.ImageData
415+func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {
416+ return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}
417+}
418+
419+func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call {
420+ _c.Call.Run(func(args mock.Arguments) {
421+ var arg0 context.Context
422+ if args[0] != nil {
423+ arg0 = args[0].(context.Context)
424+ }
425+ var arg1 port.ImageData
426+ if args[1] != nil {
427+ arg1 = args[1].(port.ImageData)
428+ }
429+ run(
430+ arg0,
431+ arg1,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call {
438+ _c.Call.Return(err)
439+ return _c
440+}
441+
442+func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
390 // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'447 // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
391 type MockClipboard_WriteText_Call struct {448 type MockClipboard_WriteText_Call struct {
392 *mock.Call449 *mock.Call
@@ -427,6 +484,90 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
427 return _c484 return _c
428 }485 }
429 486
487+// NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
488+// The first argument is typically a *testing.T value.
489+func NewMockResolvedImageSaver(t interface {
490+ mock.TestingT
491+ Cleanup(func())
492+}) *MockResolvedImageSaver {
493+ mock := &MockResolvedImageSaver{}
494+ mock.Mock.Test(t)
495+
496+ t.Cleanup(func() { mock.AssertExpectations(t) })
497+
498+ return mock
499+}
500+
501+// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
502+type MockResolvedImageSaver struct {
503+ mock.Mock
504+}
505+
506+type MockResolvedImageSaver_Expecter struct {
507+ mock *mock.Mock
508+}
509+
510+func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
511+ return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
512+}
513+
514+// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
515+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
516+ ret := _mock.Called(ctx, image)
517+
518+ if len(ret) == 0 {
519+ panic("no return value specified for SaveResolvedImage")
520+ }
521+
522+ var r0 error
523+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
524+ r0 = returnFunc(ctx, image)
525+ } else {
526+ r0 = ret.Error(0)
527+ }
528+ return r0
529+}
530+
531+// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
532+type MockResolvedImageSaver_SaveResolvedImage_Call struct {
533+ *mock.Call
534+}
535+
536+// SaveResolvedImage is a helper method to define mock.On call
537+// - ctx context.Context
538+// - image port.ImageData
539+func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
540+ return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)}
541+}
542+
543+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call {
544+ _c.Call.Run(func(args mock.Arguments) {
545+ var arg0 context.Context
546+ if args[0] != nil {
547+ arg0 = args[0].(context.Context)
548+ }
549+ var arg1 port.ImageData
550+ if args[1] != nil {
551+ arg1 = args[1].(port.ImageData)
552+ }
553+ run(
554+ arg0,
555+ arg1,
556+ )
557+ })
558+ return _c
559+}
560+
561+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
562+ _c.Call.Return(err)
563+ return _c
564+}
565+
566+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
567+ _c.Call.Return(run)
568+ return _c
569+}
570+
430 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.571 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
431 // The first argument is typically a *testing.T value.572 // The first argument is typically a *testing.T value.
432 func NewMockColorSchemeDetector(t interface {573 func NewMockColorSchemeDetector(t interface {
@@ -1421,6 +1562,46 @@ func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run fu
1421 return _c1562 return _c
1422 }1563 }
1423 1564
1565+// TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer
1566+func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
1567+ _mock.Called(rawConfig)
1568+ return
1569+}
1570+
1571+// MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig'
1572+type MockConfigTransformer_TransformLegacyEngineConfig_Call struct {
1573+ *mock.Call
1574+}
1575+
1576+// TransformLegacyEngineConfig is a helper method to define mock.On call
1577+// - rawConfig map[string]any
1578+func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1579+ return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)}
1580+}
1581+
1582+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1583+ _c.Call.Run(func(args mock.Arguments) {
1584+ var arg0 map[string]any
1585+ if args[0] != nil {
1586+ arg0 = args[0].(map[string]any)
1587+ }
1588+ run(
1589+ arg0,
1590+ )
1591+ })
1592+ return _c
1593+}
1594+
1595+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1596+ _c.Call.Return()
1597+ return _c
1598+}
1599+
1600+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1601+ _c.Run(run)
1602+ return _c
1603+}
1604+
1424 // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1605 // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1425 // The first argument is typically a *testing.T value.1606 // The first argument is typically a *testing.T value.
1426 func NewMockContentInjector(t interface {1607 func NewMockContentInjector(t interface {
A internal/application/usecase/build_context_menu.go +51 -0
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+// BuildContextMenuUseCase normalizes context menu items from menu state.
10+type BuildContextMenuUseCase struct{}
11+
12+// NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.
13+func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
14+ return &BuildContextMenuUseCase{}
15+}
16+
17+// Build returns normalized menu items for the supplied context.
18+func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {
19+ items := make([]port.MenuItem, 0, 9)
20+
21+ if menuContext.CanGoBack {
22+ items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"})
23+ }
24+ if menuContext.CanGoForward {
25+ items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"})
26+ }
27+
28+ items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"})
29+
30+ if menuContext.LinkURI != "" {
31+ items = append(items,
32+ port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"},
33+ port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"},
34+ )
35+ }
36+
37+ if menuContext.ImageURI != "" {
38+ items = append(items,
39+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
40+ port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"},
41+ )
42+ }
43+
44+ if menuContext.HasSelection {
45+ items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"})
46+ }
47+
48+ items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
49+
50+ return items
51+}
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+// BuildContextMenuUseCase normalizes context menu items from menu state.
10+type BuildContextMenuUseCase struct{}
11+
12+// NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.
13+func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
14+ return &BuildContextMenuUseCase{}
15+}
16+
17+// Build returns normalized menu items for the supplied context.
18+func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {
19+ items := make([]port.MenuItem, 0, 9)
20+
21+ if menuContext.CanGoBack {
22+ items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"})
23+ }
24+ if menuContext.CanGoForward {
25+ items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"})
26+ }
27+
28+ items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"})
29+
30+ if menuContext.LinkURI != "" {
31+ items = append(items,
32+ port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"},
33+ port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"},
34+ )
35+ }
36+
37+ if menuContext.ImageURI != "" {
38+ items = append(items,
39+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
40+ port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"},
41+ )
42+ }
43+
44+ if menuContext.HasSelection {
45+ items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"})
46+ }
47+
48+ items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
49+
50+ return items
51+}
A internal/application/usecase/build_context_menu_test.go +70 -0
@@ -0,0 +1,70 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildContextMenuUseCase_Build(t *testing.T) {
12+ uc := NewBuildContextMenuUseCase()
13+
14+ tests := []struct {
15+ name string
16+ context port.MenuContext
17+ expected []port.MenuAction
18+ }{
19+ {
20+ name: "empty context baseline",
21+ context: port.MenuContext{},
22+ expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement},
23+ },
24+ {
25+ name: "image only",
26+ context: port.MenuContext{ImageURI: "https://example.com/image.png"},
27+ expected: []port.MenuAction{
28+ port.MenuActionReload,
29+ port.MenuActionCopyImage,
30+ port.MenuActionSaveImage,
31+ port.MenuActionInspectElement,
32+ },
33+ },
34+ {
35+ name: "link only",
36+ context: port.MenuContext{LinkURI: "https://example.com/link"},
37+ expected: []port.MenuAction{
38+ port.MenuActionReload,
39+ port.MenuActionOpenLinkNewTab,
40+ port.MenuActionCopyLink,
41+ port.MenuActionInspectElement,
42+ },
43+ },
44+ {
45+ name: "navigation enabled states",
46+ context: port.MenuContext{CanGoBack: true, CanGoForward: true},
47+ expected: []port.MenuAction{
48+ port.MenuActionBack,
49+ port.MenuActionForward,
50+ port.MenuActionReload,
51+ port.MenuActionInspectElement,
52+ },
53+ },
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ items := uc.Build(context.Background(), tt.context)
59+ require.Equal(t, tt.expected, menuActions(items))
60+ })
61+ }
62+}
63+
64+func menuActions(items []port.MenuItem) []port.MenuAction {
65+ actions := make([]port.MenuAction, 0, len(items))
66+ for _, item := range items {
67+ actions = append(actions, item.Action)
68+ }
69+ return actions
70+}
@@ -0,0 +1,70 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildContextMenuUseCase_Build(t *testing.T) {
12+ uc := NewBuildContextMenuUseCase()
13+
14+ tests := []struct {
15+ name string
16+ context port.MenuContext
17+ expected []port.MenuAction
18+ }{
19+ {
20+ name: "empty context baseline",
21+ context: port.MenuContext{},
22+ expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement},
23+ },
24+ {
25+ name: "image only",
26+ context: port.MenuContext{ImageURI: "https://example.com/image.png"},
27+ expected: []port.MenuAction{
28+ port.MenuActionReload,
29+ port.MenuActionCopyImage,
30+ port.MenuActionSaveImage,
31+ port.MenuActionInspectElement,
32+ },
33+ },
34+ {
35+ name: "link only",
36+ context: port.MenuContext{LinkURI: "https://example.com/link"},
37+ expected: []port.MenuAction{
38+ port.MenuActionReload,
39+ port.MenuActionOpenLinkNewTab,
40+ port.MenuActionCopyLink,
41+ port.MenuActionInspectElement,
42+ },
43+ },
44+ {
45+ name: "navigation enabled states",
46+ context: port.MenuContext{CanGoBack: true, CanGoForward: true},
47+ expected: []port.MenuAction{
48+ port.MenuActionBack,
49+ port.MenuActionForward,
50+ port.MenuActionReload,
51+ port.MenuActionInspectElement,
52+ },
53+ },
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ items := uc.Build(context.Background(), tt.context)
59+ require.Equal(t, tt.expected, menuActions(items))
60+ })
61+ }
62+}
63+
64+func menuActions(items []port.MenuItem) []port.MenuAction {
65+ actions := make([]port.MenuAction, 0, len(items))
66+ for _, item := range items {
67+ actions = append(actions, item.Action)
68+ }
69+ return actions
70+}
A internal/application/usecase/execute_context_menu_action.go +106 -0
@@ -0,0 +1,106 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// ExecuteContextMenuActionInput identifies the action and its context.
11+type ExecuteContextMenuActionInput struct {
12+ Action port.MenuAction
13+ Context port.MenuContext
14+}
15+
16+// ExecuteContextMenuActionUseCase handles shared context menu actions.
17+type ExecuteContextMenuActionUseCase struct {
18+ clipboard port.Clipboard
19+ resolver port.ImageDataResolver
20+ saver port.ResolvedImageSaver
21+ delegator port.MenuActionDelegator
22+}
23+
24+// NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.
25+func NewExecuteContextMenuActionUseCase(
26+ clipboard port.Clipboard,
27+ resolver port.ImageDataResolver,
28+ saver port.ResolvedImageSaver,
29+ delegator port.MenuActionDelegator,
30+) *ExecuteContextMenuActionUseCase {
31+ return &ExecuteContextMenuActionUseCase{
32+ clipboard: clipboard,
33+ resolver: resolver,
34+ saver: saver,
35+ delegator: delegator,
36+ }
37+}
38+
39+// Execute handles the requested action.
40+func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {
41+ switch input.Action {
42+ case port.MenuActionCopyLink:
43+ if input.Context.LinkURI == "" {
44+ return fmt.Errorf("copy link: link URI not available")
45+ }
46+ if uc.clipboard == nil {
47+ return fmt.Errorf("copy link: clipboard not available")
48+ }
49+ if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil {
50+ return fmt.Errorf("copy link: %w", err)
51+ }
52+ return nil
53+ case port.MenuActionCopyImage:
54+ image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
55+ if err != nil {
56+ return fmt.Errorf("copy image: %w", err)
57+ }
58+ if uc.clipboard == nil {
59+ return fmt.Errorf("copy image: clipboard not available")
60+ }
61+ if err := uc.clipboard.WriteImage(ctx, image); err != nil {
62+ return fmt.Errorf("copy image: %w", err)
63+ }
64+ return nil
65+ case port.MenuActionSaveImage:
66+ image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
67+ if err != nil {
68+ return fmt.Errorf("save image: %w", err)
69+ }
70+ if uc.saver == nil {
71+ return fmt.Errorf("save image: image saver not available")
72+ }
73+ if err := uc.saver.SaveResolvedImage(ctx, image); err != nil {
74+ return fmt.Errorf("save image: %w", err)
75+ }
76+ return nil
77+ default:
78+ // All other normalized actions are delegated to the engine/UI layer.
79+ if uc.delegator == nil {
80+ return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action)
81+ }
82+ if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil {
83+ return fmt.Errorf("delegate action %s: %w", input.Action, err)
84+ }
85+ return nil
86+ }
87+}
88+
89+func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
90+ if uri == "" {
91+ return port.ImageData{}, fmt.Errorf("image URI not available")
92+ }
93+ if uc.resolver == nil {
94+ return port.ImageData{}, fmt.Errorf("image data resolver not available")
95+ }
96+
97+ image, err := uc.resolver.ResolveImageData(ctx, uri)
98+ if err != nil {
99+ return port.ImageData{}, err
100+ }
101+ if len(image.Bytes) == 0 {
102+ return port.ImageData{}, fmt.Errorf("image data not available")
103+ }
104+
105+ return image, nil
106+}
@@ -0,0 +1,106 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// ExecuteContextMenuActionInput identifies the action and its context.
11+type ExecuteContextMenuActionInput struct {
12+ Action port.MenuAction
13+ Context port.MenuContext
14+}
15+
16+// ExecuteContextMenuActionUseCase handles shared context menu actions.
17+type ExecuteContextMenuActionUseCase struct {
18+ clipboard port.Clipboard
19+ resolver port.ImageDataResolver
20+ saver port.ResolvedImageSaver
21+ delegator port.MenuActionDelegator
22+}
23+
24+// NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.
25+func NewExecuteContextMenuActionUseCase(
26+ clipboard port.Clipboard,
27+ resolver port.ImageDataResolver,
28+ saver port.ResolvedImageSaver,
29+ delegator port.MenuActionDelegator,
30+) *ExecuteContextMenuActionUseCase {
31+ return &ExecuteContextMenuActionUseCase{
32+ clipboard: clipboard,
33+ resolver: resolver,
34+ saver: saver,
35+ delegator: delegator,
36+ }
37+}
38+
39+// Execute handles the requested action.
40+func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {
41+ switch input.Action {
42+ case port.MenuActionCopyLink:
43+ if input.Context.LinkURI == "" {
44+ return fmt.Errorf("copy link: link URI not available")
45+ }
46+ if uc.clipboard == nil {
47+ return fmt.Errorf("copy link: clipboard not available")
48+ }
49+ if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil {
50+ return fmt.Errorf("copy link: %w", err)
51+ }
52+ return nil
53+ case port.MenuActionCopyImage:
54+ image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
55+ if err != nil {
56+ return fmt.Errorf("copy image: %w", err)
57+ }
58+ if uc.clipboard == nil {
59+ return fmt.Errorf("copy image: clipboard not available")
60+ }
61+ if err := uc.clipboard.WriteImage(ctx, image); err != nil {
62+ return fmt.Errorf("copy image: %w", err)
63+ }
64+ return nil
65+ case port.MenuActionSaveImage:
66+ image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
67+ if err != nil {
68+ return fmt.Errorf("save image: %w", err)
69+ }
70+ if uc.saver == nil {
71+ return fmt.Errorf("save image: image saver not available")
72+ }
73+ if err := uc.saver.SaveResolvedImage(ctx, image); err != nil {
74+ return fmt.Errorf("save image: %w", err)
75+ }
76+ return nil
77+ default:
78+ // All other normalized actions are delegated to the engine/UI layer.
79+ if uc.delegator == nil {
80+ return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action)
81+ }
82+ if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil {
83+ return fmt.Errorf("delegate action %s: %w", input.Action, err)
84+ }
85+ return nil
86+ }
87+}
88+
89+func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
90+ if uri == "" {
91+ return port.ImageData{}, fmt.Errorf("image URI not available")
92+ }
93+ if uc.resolver == nil {
94+ return port.ImageData{}, fmt.Errorf("image data resolver not available")
95+ }
96+
97+ image, err := uc.resolver.ResolveImageData(ctx, uri)
98+ if err != nil {
99+ return port.ImageData{}, err
100+ }
101+ if len(image.Bytes) == 0 {
102+ return port.ImageData{}, fmt.Errorf("image data not available")
103+ }
104+
105+ return image, nil
106+}
A internal/application/usecase/execute_context_menu_action_test.go +179 -0
@@ -0,0 +1,179 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type fakeClipboard struct {
13+ writeTextCalls int
14+ writeImageCalls int
15+ text string
16+ image port.ImageData
17+}
18+
19+func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
20+ f.writeTextCalls++
21+ f.text = text
22+ return nil
23+}
24+
25+func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error {
26+ f.writeImageCalls++
27+ f.image = image
28+ return nil
29+}
30+
31+func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil }
32+func (*fakeClipboard) Clear(context.Context) error { return nil }
33+func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil }
34+
35+type fakeImageResolver struct {
36+ resolveCalls int
37+ uri string
38+ image port.ImageData
39+ err error
40+}
41+
42+func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) {
43+ f.resolveCalls++
44+ f.uri = uri
45+ return f.image, f.err
46+}
47+
48+type fakeResolvedImageSaver struct {
49+ saveCalls int
50+ image port.ImageData
51+ err error
52+}
53+
54+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error {
55+ f.saveCalls++
56+ f.image = image
57+ return f.err
58+}
59+
60+type fakeMenuActionDelegator struct {
61+ delegateCalls int
62+ action port.MenuAction
63+ menuContext port.MenuContext
64+ err error
65+}
66+
67+func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
68+ f.delegateCalls++
69+ f.action = action
70+ f.menuContext = menuContext
71+ return f.err
72+}
73+
74+func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) {
75+ clipboard := &fakeClipboard{}
76+ resolver := &fakeImageResolver{}
77+ saver := &fakeResolvedImageSaver{}
78+ delegator := &fakeMenuActionDelegator{}
79+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
80+
81+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
82+ Action: port.MenuActionCopyImage,
83+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
84+ })
85+
86+ require.Error(t, err)
87+ require.Contains(t, err.Error(), "copy image:")
88+ require.Contains(t, err.Error(), "image data not available")
89+ require.Zero(t, clipboard.writeImageCalls)
90+ require.Zero(t, saver.saveCalls)
91+ require.Zero(t, delegator.delegateCalls)
92+}
93+
94+func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
95+ clipboard := &fakeClipboard{}
96+ resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}}
97+ saver := &fakeResolvedImageSaver{}
98+ delegator := &fakeMenuActionDelegator{}
99+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
100+
101+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
102+ Action: port.MenuActionSaveImage,
103+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
104+ })
105+
106+ require.NoError(t, err)
107+ require.Equal(t, 1, resolver.resolveCalls)
108+ require.Equal(t, "https://example.com/image.png", resolver.uri)
109+ require.Equal(t, 1, saver.saveCalls)
110+ require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image)
111+ require.Zero(t, clipboard.writeTextCalls)
112+ require.Zero(t, clipboard.writeImageCalls)
113+ require.Zero(t, delegator.delegateCalls)
114+}
115+
116+func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {
117+ clipboard := &fakeClipboard{}
118+ resolver := &fakeImageResolver{}
119+ saver := &fakeResolvedImageSaver{}
120+ delegator := &fakeMenuActionDelegator{}
121+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
122+
123+ menuContext := port.MenuContext{
124+ PageURI: "https://example.com",
125+ X: 17,
126+ Y: 42,
127+ }
128+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
129+ Action: port.MenuActionInspectElement,
130+ Context: menuContext,
131+ })
132+
133+ require.NoError(t, err)
134+ require.Equal(t, 1, delegator.delegateCalls)
135+ require.Equal(t, port.MenuActionInspectElement, delegator.action)
136+ require.Equal(t, menuContext, delegator.menuContext)
137+ require.Zero(t, clipboard.writeTextCalls)
138+ require.Zero(t, clipboard.writeImageCalls)
139+ require.Zero(t, resolver.resolveCalls)
140+ require.Zero(t, saver.saveCalls)
141+}
142+
143+func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) {
144+ clipboard := &fakeClipboard{}
145+ resolver := &fakeImageResolver{}
146+ saver := &fakeResolvedImageSaver{}
147+ delegator := &fakeMenuActionDelegator{err: errors.New("boom")}
148+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
149+
150+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
151+ Action: port.MenuActionCopySelection,
152+ Context: port.MenuContext{HasSelection: true},
153+ })
154+
155+ require.Error(t, err)
156+ require.Contains(t, err.Error(), "delegate action copy_selection:")
157+ require.Contains(t, err.Error(), "boom")
158+}
159+
160+func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
161+ clipboard := &fakeClipboard{}
162+ resolver := &fakeImageResolver{}
163+ saver := &fakeResolvedImageSaver{}
164+ delegator := &fakeMenuActionDelegator{}
165+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
166+
167+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
168+ Action: port.MenuActionCopyLink,
169+ Context: port.MenuContext{LinkURI: "https://example.com/link"},
170+ })
171+
172+ require.NoError(t, err)
173+ require.Equal(t, 1, clipboard.writeTextCalls)
174+ require.Equal(t, "https://example.com/link", clipboard.text)
175+ require.Zero(t, clipboard.writeImageCalls)
176+ require.Zero(t, resolver.resolveCalls)
177+ require.Zero(t, saver.saveCalls)
178+ require.Zero(t, delegator.delegateCalls)
179+}
@@ -0,0 +1,179 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type fakeClipboard struct {
13+ writeTextCalls int
14+ writeImageCalls int
15+ text string
16+ image port.ImageData
17+}
18+
19+func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
20+ f.writeTextCalls++
21+ f.text = text
22+ return nil
23+}
24+
25+func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error {
26+ f.writeImageCalls++
27+ f.image = image
28+ return nil
29+}
30+
31+func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil }
32+func (*fakeClipboard) Clear(context.Context) error { return nil }
33+func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil }
34+
35+type fakeImageResolver struct {
36+ resolveCalls int
37+ uri string
38+ image port.ImageData
39+ err error
40+}
41+
42+func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) {
43+ f.resolveCalls++
44+ f.uri = uri
45+ return f.image, f.err
46+}
47+
48+type fakeResolvedImageSaver struct {
49+ saveCalls int
50+ image port.ImageData
51+ err error
52+}
53+
54+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error {
55+ f.saveCalls++
56+ f.image = image
57+ return f.err
58+}
59+
60+type fakeMenuActionDelegator struct {
61+ delegateCalls int
62+ action port.MenuAction
63+ menuContext port.MenuContext
64+ err error
65+}
66+
67+func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
68+ f.delegateCalls++
69+ f.action = action
70+ f.menuContext = menuContext
71+ return f.err
72+}
73+
74+func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) {
75+ clipboard := &fakeClipboard{}
76+ resolver := &fakeImageResolver{}
77+ saver := &fakeResolvedImageSaver{}
78+ delegator := &fakeMenuActionDelegator{}
79+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
80+
81+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
82+ Action: port.MenuActionCopyImage,
83+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
84+ })
85+
86+ require.Error(t, err)
87+ require.Contains(t, err.Error(), "copy image:")
88+ require.Contains(t, err.Error(), "image data not available")
89+ require.Zero(t, clipboard.writeImageCalls)
90+ require.Zero(t, saver.saveCalls)
91+ require.Zero(t, delegator.delegateCalls)
92+}
93+
94+func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
95+ clipboard := &fakeClipboard{}
96+ resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}}
97+ saver := &fakeResolvedImageSaver{}
98+ delegator := &fakeMenuActionDelegator{}
99+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
100+
101+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
102+ Action: port.MenuActionSaveImage,
103+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
104+ })
105+
106+ require.NoError(t, err)
107+ require.Equal(t, 1, resolver.resolveCalls)
108+ require.Equal(t, "https://example.com/image.png", resolver.uri)
109+ require.Equal(t, 1, saver.saveCalls)
110+ require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image)
111+ require.Zero(t, clipboard.writeTextCalls)
112+ require.Zero(t, clipboard.writeImageCalls)
113+ require.Zero(t, delegator.delegateCalls)
114+}
115+
116+func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {
117+ clipboard := &fakeClipboard{}
118+ resolver := &fakeImageResolver{}
119+ saver := &fakeResolvedImageSaver{}
120+ delegator := &fakeMenuActionDelegator{}
121+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
122+
123+ menuContext := port.MenuContext{
124+ PageURI: "https://example.com",
125+ X: 17,
126+ Y: 42,
127+ }
128+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
129+ Action: port.MenuActionInspectElement,
130+ Context: menuContext,
131+ })
132+
133+ require.NoError(t, err)
134+ require.Equal(t, 1, delegator.delegateCalls)
135+ require.Equal(t, port.MenuActionInspectElement, delegator.action)
136+ require.Equal(t, menuContext, delegator.menuContext)
137+ require.Zero(t, clipboard.writeTextCalls)
138+ require.Zero(t, clipboard.writeImageCalls)
139+ require.Zero(t, resolver.resolveCalls)
140+ require.Zero(t, saver.saveCalls)
141+}
142+
143+func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) {
144+ clipboard := &fakeClipboard{}
145+ resolver := &fakeImageResolver{}
146+ saver := &fakeResolvedImageSaver{}
147+ delegator := &fakeMenuActionDelegator{err: errors.New("boom")}
148+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
149+
150+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
151+ Action: port.MenuActionCopySelection,
152+ Context: port.MenuContext{HasSelection: true},
153+ })
154+
155+ require.Error(t, err)
156+ require.Contains(t, err.Error(), "delegate action copy_selection:")
157+ require.Contains(t, err.Error(), "boom")
158+}
159+
160+func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
161+ clipboard := &fakeClipboard{}
162+ resolver := &fakeImageResolver{}
163+ saver := &fakeResolvedImageSaver{}
164+ delegator := &fakeMenuActionDelegator{}
165+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
166+
167+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
168+ Action: port.MenuActionCopyLink,
169+ Context: port.MenuContext{LinkURI: "https://example.com/link"},
170+ })
171+
172+ require.NoError(t, err)
173+ require.Equal(t, 1, clipboard.writeTextCalls)
174+ require.Equal(t, "https://example.com/link", clipboard.text)
175+ require.Zero(t, clipboard.writeImageCalls)
176+ require.Zero(t, resolver.resolveCalls)
177+ require.Zero(t, saver.saveCalls)
178+ require.Zero(t, delegator.delegateCalls)
179+}
M internal/application/usecase/transform_legacy_config.go +1 -0
@@ -15,4 +15,5 @@ func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *Transf
1515 // Execute transforms legacy config format in place.
1616 func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
1717 uc.transformer.TransformLegacyActions(rawConfig)
18+ uc.transformer.TransformLegacyEngineConfig(rawConfig)
1819 }
@@ -15,4 +15,5 @@ func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *Transf
15 // Execute transforms legacy config format in place.15 // Execute transforms legacy config format in place.
16 func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {16 func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
17 uc.transformer.TransformLegacyActions(rawConfig)17 uc.transformer.TransformLegacyActions(rawConfig)
18+ uc.transformer.TransformLegacyEngineConfig(rawConfig)
18 }19 }
M internal/application/usecase/transform_legacy_config_test.go +2 -1
@@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
1313 rawConfig := map[string]any{"test": "data"}
1414
1515 mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+ mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return()
1617
1718 uc := NewTransformLegacyConfigUseCase(mockTransformer)
1819 uc.Execute(rawConfig)
@@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
3334 },
3435 }
3536
36- // Verify the transformer receives the exact rawConfig
3737 mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+ mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return()
3839
3940 uc := NewTransformLegacyConfigUseCase(mockTransformer)
4041 uc.Execute(rawConfig)
@@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
13 rawConfig := map[string]any{"test": "data"}13 rawConfig := map[string]any{"test": "data"}
14 14
15 mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()15 mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+ mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return()
16 17
17 uc := NewTransformLegacyConfigUseCase(mockTransformer)18 uc := NewTransformLegacyConfigUseCase(mockTransformer)
18 uc.Execute(rawConfig)19 uc.Execute(rawConfig)
@@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
33 },34 },
34 }35 }
35 36
36- // Verify the transformer receives the exact rawConfig
37 mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()37 mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+ mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return()
38 39
39 uc := NewTransformLegacyConfigUseCase(mockTransformer)40 uc := NewTransformLegacyConfigUseCase(mockTransformer)
40 uc.Execute(rawConfig)41 uc.Execute(rawConfig)
M internal/bootstrap/engine.go +6 -7
@@ -52,13 +52,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
5252 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
5353 }
5454 cefCfg := cef.RuntimeConfig{
55- CEFDir: cfg.Engine.CEF.CEFDir,
56- LogFile: cfg.Engine.CEF.LogFile,
57- LogSeverity: cfg.Engine.CEF.LogSeverity,
58- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
59- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
60- EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler,
61- TraceHandlers: cfg.Engine.CEF.TraceHandlers,
55+ CEFDir: cfg.Engine.CEF.CEFDir,
56+ LogFile: cfg.Engine.CEF.LogFile,
57+ LogSeverity: cfg.Engine.CEF.LogSeverity,
58+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
59+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
60+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
6261 }
6362 transcodingCfg := cef.TranscodingRuntimeConfig{
6463 Enabled: cfg.Transcoding.Enabled,
@@ -52,13 +52,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
52 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),52 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
53 }53 }
54 cefCfg := cef.RuntimeConfig{54 cefCfg := cef.RuntimeConfig{
55- CEFDir: cfg.Engine.CEF.CEFDir,55+ CEFDir: cfg.Engine.CEF.CEFDir,
56- LogFile: cfg.Engine.CEF.LogFile,56+ LogFile: cfg.Engine.CEF.LogFile,
57- LogSeverity: cfg.Engine.CEF.LogSeverity,57+ LogSeverity: cfg.Engine.CEF.LogSeverity,
58- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,58+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
59- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,59+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
60- EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler,60+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
61- TraceHandlers: cfg.Engine.CEF.TraceHandlers,
62 }61 }
63 transcodingCfg := cef.TranscodingRuntimeConfig{62 transcodingCfg := cef.TranscodingRuntimeConfig{
64 Enabled: cfg.Transcoding.Enabled,63 Enabled: cfg.Transcoding.Enabled,
M internal/infrastructure/cef/context_menu.go +108 -70
@@ -1,17 +1,14 @@
11 package cef
22
33 import (
4+ "context"
45 purecef "github.com/bnema/purego-cef/cef"
5- "github.com/bnema/puregotk/v4/gdk"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
6+ "strings"
87
9-// menuEntry holds a snapshot of one CEF menu item.
10-type menuEntry struct {
11- label string
12- commandID int32
13- isSep bool
14-}
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
11+)
1512
1613 // ===========================================================================
1714 // ContextMenuHandler (implemented on handlerSet)
@@ -25,98 +22,139 @@ func (h *handlerSet) RunContextMenu(
2522 params purecef.ContextMenuParams, model purecef.MenuModel,
2623 callback purecef.RunContextMenuCallback,
2724 ) int32 {
25+ if h == nil || callback == nil || model == nil {
26+ if callback != nil {
27+ callback.Cancel()
28+ }
29+ return 1
30+ }
31+
32+ menuContext := buildMenuContext(h.wv, params)
33+ items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext)
34+ if len(items) == 0 {
35+ callback.Cancel()
36+ return 1
37+ }
38+
39+ // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
2840 count := model.GetCount()
2941 if count == 0 {
3042 callback.Cancel()
3143 return 1
3244 }
33-
34- // Snapshot menu items — model/params pointers are only valid during this call.
35- items := make([]menuEntry, 0, count)
45+ commandIDsByLabel := make(map[string]int32, count)
3646 for i := range count {
3747 t := model.GetTypeAt(i)
3848 if t == purecef.MenuItemTypeMenuitemtypeSeparator {
39- items = append(items, menuEntry{isSep: true})
4049 continue
4150 }
4251 if t == purecef.MenuItemTypeMenuitemtypeCommand {
43- items = append(items, menuEntry{
44- label: model.GetLabelAt(i),
45- commandID: model.GetCommandIDAt(i),
46- })
52+ label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i)))
53+ if label != "" {
54+ commandIDsByLabel[label] = model.GetCommandIDAt(i)
55+ }
4756 }
4857 }
49- if len(items) == 0 {
58+ commandIDByAction := make(map[port.MenuAction]int32, len(items))
59+ for _, item := range items {
60+ if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok {
61+ commandIDByAction[item.Action] = cmdID
62+ }
63+ }
64+ if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil {
5065 callback.Cancel()
5166 return 1
5267 }
5368
5469 x := params.GetXcoord()
5570 y := params.GetYcoord()
56-
57- h.wv.runOnGTK(func() {
58- showContextMenu(h.wv, items, x, y, callback)
59- })
71+ glArea := h.wv.pipeline.glArea
72+
73+ contextmenu.NewRenderer(h.wv.runOnGTK).Show(
74+ items,
75+ &glArea.Widget,
76+ x,
77+ y,
78+ func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) },
79+ func() {
80+ callback.Cancel()
81+ },
82+ )
6083 return 1
6184 }
6285
63-func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) {
64- glArea := wv.pipeline.glArea
65- if glArea == nil {
66- callback.Cancel()
86+func dispatchContextMenuSelection(
87+ callback purecef.RunContextMenuCallback,
88+ commandIDByAction map[port.MenuAction]int32,
89+ item port.MenuItem,
90+) {
91+ if callback == nil {
6792 return
6893 }
94+ if cmdID, ok := commandIDByAction[item.Action]; ok {
95+ callback.Cont(cmdID, 0)
96+ return
97+ }
98+ callback.Cancel()
99+}
69100
70- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
71- box.AddCssClass("context-menu")
72-
73- selected := false
74- var popover *gtk.Popover
75-
76- for _, item := range items {
77- if item.isSep {
78- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
79- box.Append(&sep.Widget)
80- continue
101+func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
102+ for _, candidate := range contextMenuActionLabels(action, label) {
103+ if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
104+ return cmdID, true
81105 }
82-
83- btn := gtk.NewButton()
84- btn.SetLabel(item.label)
85- btn.AddCssClass("flat")
86-
87- cmdID := item.commandID
88- // clickCb captures popover by reference; this is safe because the
89- // button cannot be clicked until after Popup() is called below,
90- // at which point popover is fully initialized.
91- clickCb := func(_ gtk.Button) {
92- selected = true
93- callback.Cont(cmdID, 0)
94- if popover != nil {
95- popover.Popdown()
96- }
97- }
98- btn.ConnectClicked(&clickCb)
99- box.Append(&btn.Widget)
100106 }
107+ return 0, false
108+}
101109
102- popover = gtk.NewPopover()
103- popover.SetChild(&box.Widget)
104- popover.SetParent(&glArea.Widget)
105- popover.SetHasArrow(false)
106- popover.SetAutohide(true)
107-
108- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
109- popover.SetPointingTo(rect)
110+func contextMenuActionLabels(action port.MenuAction, label string) []string {
111+ labels := []string{label}
112+ switch action {
113+ case port.MenuActionBack:
114+ labels = append(labels, "Back")
115+ case port.MenuActionForward:
116+ labels = append(labels, "Forward")
117+ case port.MenuActionReload:
118+ labels = append(labels, "Reload")
119+ case port.MenuActionOpenLinkNewTab:
120+ labels = append(labels, "Open Link in New Tab", "Open link in new tab")
121+ case port.MenuActionCopyLink:
122+ labels = append(labels, "Copy Link", "Copy link address")
123+ case port.MenuActionCopyImage:
124+ labels = append(labels, "Copy Image", "Copy image")
125+ case port.MenuActionSaveImage:
126+ labels = append(labels, "Save Image", "Save image as...")
127+ case port.MenuActionInspectElement:
128+ labels = append(labels, "Inspect Element", "Inspect")
129+ case port.MenuActionCopySelection:
130+ labels = append(labels, "Copy Selection", "Copy")
131+ }
132+ return labels
133+}
110134
111- closedCb := func(_ gtk.Popover) {
112- if !selected {
113- callback.Cancel()
114- }
115- popover.Unparent()
135+func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext {
136+ menuContext := port.MenuContext{}
137+ if wv != nil {
138+ menuContext.CanGoBack = wv.CanGoBack()
139+ menuContext.CanGoForward = wv.CanGoForward()
140+ menuContext.PageURI = wv.URI()
141+ }
142+ if params == nil {
143+ return menuContext
116144 }
117- popover.ConnectClosed(&closedCb)
118145
119- popover.Popup()
146+ if pageURI := params.GetPageURL(); pageURI != "" {
147+ menuContext.PageURI = pageURI
148+ }
149+ menuContext.LinkURI = params.GetLinkURL()
150+ if params.HasImageContents() {
151+ menuContext.ImageURI = params.GetSourceURL()
152+ }
153+ menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != ""
154+ menuContext.IsEditable = params.IsEditable()
155+ menuContext.X = int(params.GetXcoord())
156+ menuContext.Y = int(params.GetYcoord())
157+ return menuContext
120158 }
121159
122160 func (h *handlerSet) OnContextMenuCommand(
@@ -1,17 +1,14 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 purecef "github.com/bnema/purego-cef/cef"5 purecef "github.com/bnema/purego-cef/cef"
5- "github.com/bnema/puregotk/v4/gdk"6+ "strings"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
8 7
9-// menuEntry holds a snapshot of one CEF menu item.8+ "github.com/bnema/dumber/internal/application/port"
10-type menuEntry struct {9+ "github.com/bnema/dumber/internal/application/usecase"
11- label string10+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
12- commandID int3211+)
13- isSep bool
14-}
15 12
16 // ===========================================================================13 // ===========================================================================
17 // ContextMenuHandler (implemented on handlerSet)14 // ContextMenuHandler (implemented on handlerSet)
@@ -25,98 +22,139 @@ func (h *handlerSet) RunContextMenu(
25 params purecef.ContextMenuParams, model purecef.MenuModel,22 params purecef.ContextMenuParams, model purecef.MenuModel,
26 callback purecef.RunContextMenuCallback,23 callback purecef.RunContextMenuCallback,
27 ) int32 {24 ) int32 {
25+ if h == nil || callback == nil || model == nil {
26+ if callback != nil {
27+ callback.Cancel()
28+ }
29+ return 1
30+ }
31+
32+ menuContext := buildMenuContext(h.wv, params)
33+ items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext)
34+ if len(items) == 0 {
35+ callback.Cancel()
36+ return 1
37+ }
38+
39+ // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
28 count := model.GetCount()40 count := model.GetCount()
29 if count == 0 {41 if count == 0 {
30 callback.Cancel()42 callback.Cancel()
31 return 143 return 1
32 }44 }
33-45+ commandIDsByLabel := make(map[string]int32, count)
34- // Snapshot menu items — model/params pointers are only valid during this call.
35- items := make([]menuEntry, 0, count)
36 for i := range count {46 for i := range count {
37 t := model.GetTypeAt(i)47 t := model.GetTypeAt(i)
38 if t == purecef.MenuItemTypeMenuitemtypeSeparator {48 if t == purecef.MenuItemTypeMenuitemtypeSeparator {
39- items = append(items, menuEntry{isSep: true})
40 continue49 continue
41 }50 }
42 if t == purecef.MenuItemTypeMenuitemtypeCommand {51 if t == purecef.MenuItemTypeMenuitemtypeCommand {
43- items = append(items, menuEntry{52+ label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i)))
44- label: model.GetLabelAt(i),53+ if label != "" {
45- commandID: model.GetCommandIDAt(i),54+ commandIDsByLabel[label] = model.GetCommandIDAt(i)
46- })55+ }
47 }56 }
48 }57 }
49- if len(items) == 0 {58+ commandIDByAction := make(map[port.MenuAction]int32, len(items))
59+ for _, item := range items {
60+ if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok {
61+ commandIDByAction[item.Action] = cmdID
62+ }
63+ }
64+ if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil {
50 callback.Cancel()65 callback.Cancel()
51 return 166 return 1
52 }67 }
53 68
54 x := params.GetXcoord()69 x := params.GetXcoord()
55 y := params.GetYcoord()70 y := params.GetYcoord()
56-71+ glArea := h.wv.pipeline.glArea
57- h.wv.runOnGTK(func() {72+
58- showContextMenu(h.wv, items, x, y, callback)73+ contextmenu.NewRenderer(h.wv.runOnGTK).Show(
59- })74+ items,
75+ &glArea.Widget,
76+ x,
77+ y,
78+ func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) },
79+ func() {
80+ callback.Cancel()
81+ },
82+ )
60 return 183 return 1
61 }84 }
62 85
63-func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) {86+func dispatchContextMenuSelection(
64- glArea := wv.pipeline.glArea87+ callback purecef.RunContextMenuCallback,
65- if glArea == nil {88+ commandIDByAction map[port.MenuAction]int32,
66- callback.Cancel()89+ item port.MenuItem,
90+) {
91+ if callback == nil {
67 return92 return
68 }93 }
94+ if cmdID, ok := commandIDByAction[item.Action]; ok {
95+ callback.Cont(cmdID, 0)
96+ return
97+ }
98+ callback.Cancel()
99+}
69 100
70- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)101+func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
71- box.AddCssClass("context-menu")102+ for _, candidate := range contextMenuActionLabels(action, label) {
72-103+ if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
73- selected := false104+ return cmdID, true
74- var popover *gtk.Popover
75-
76- for _, item := range items {
77- if item.isSep {
78- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
79- box.Append(&sep.Widget)
80- continue
81 }105 }
82-
83- btn := gtk.NewButton()
84- btn.SetLabel(item.label)
85- btn.AddCssClass("flat")
86-
87- cmdID := item.commandID
88- // clickCb captures popover by reference; this is safe because the
89- // button cannot be clicked until after Popup() is called below,
90- // at which point popover is fully initialized.
91- clickCb := func(_ gtk.Button) {
92- selected = true
93- callback.Cont(cmdID, 0)
94- if popover != nil {
95- popover.Popdown()
96- }
97- }
98- btn.ConnectClicked(&clickCb)
99- box.Append(&btn.Widget)
100 }106 }
107+ return 0, false
108+}
101 109
102- popover = gtk.NewPopover()110+func contextMenuActionLabels(action port.MenuAction, label string) []string {
103- popover.SetChild(&box.Widget)111+ labels := []string{label}
104- popover.SetParent(&glArea.Widget)112+ switch action {
105- popover.SetHasArrow(false)113+ case port.MenuActionBack:
106- popover.SetAutohide(true)114+ labels = append(labels, "Back")
107-115+ case port.MenuActionForward:
108- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}116+ labels = append(labels, "Forward")
109- popover.SetPointingTo(rect)117+ case port.MenuActionReload:
118+ labels = append(labels, "Reload")
119+ case port.MenuActionOpenLinkNewTab:
120+ labels = append(labels, "Open Link in New Tab", "Open link in new tab")
121+ case port.MenuActionCopyLink:
122+ labels = append(labels, "Copy Link", "Copy link address")
123+ case port.MenuActionCopyImage:
124+ labels = append(labels, "Copy Image", "Copy image")
125+ case port.MenuActionSaveImage:
126+ labels = append(labels, "Save Image", "Save image as...")
127+ case port.MenuActionInspectElement:
128+ labels = append(labels, "Inspect Element", "Inspect")
129+ case port.MenuActionCopySelection:
130+ labels = append(labels, "Copy Selection", "Copy")
131+ }
132+ return labels
133+}
110 134
111- closedCb := func(_ gtk.Popover) {135+func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext {
112- if !selected {136+ menuContext := port.MenuContext{}
113- callback.Cancel()137+ if wv != nil {
114- }138+ menuContext.CanGoBack = wv.CanGoBack()
115- popover.Unparent()139+ menuContext.CanGoForward = wv.CanGoForward()
140+ menuContext.PageURI = wv.URI()
141+ }
142+ if params == nil {
143+ return menuContext
116 }144 }
117- popover.ConnectClosed(&closedCb)
118 145
119- popover.Popup()146+ if pageURI := params.GetPageURL(); pageURI != "" {
147+ menuContext.PageURI = pageURI
148+ }
149+ menuContext.LinkURI = params.GetLinkURL()
150+ if params.HasImageContents() {
151+ menuContext.ImageURI = params.GetSourceURL()
152+ }
153+ menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != ""
154+ menuContext.IsEditable = params.IsEditable()
155+ menuContext.X = int(params.GetXcoord())
156+ menuContext.Y = int(params.GetYcoord())
157+ return menuContext
120 }158 }
121 159
122 func (h *handlerSet) OnContextMenuCommand(160 func (h *handlerSet) OnContextMenuCommand(
A internal/infrastructure/cef/context_menu_adapter_test.go +98 -0
@@ -0,0 +1,98 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+type stubContextMenuParams struct {
13+ x, y int32
14+ pageURL string
15+ linkURL string
16+ sourceURL string
17+ selection string
18+ editable bool
19+ imageContent bool
20+}
21+
22+func (p stubContextMenuParams) GetXcoord() int32 { return p.x }
23+func (p stubContextMenuParams) GetYcoord() int32 { return p.y }
24+func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 }
25+func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL }
26+func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL }
27+func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL }
28+func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent }
29+func (p stubContextMenuParams) GetTitleText() string { return "" }
30+func (p stubContextMenuParams) GetPageURL() string { return p.pageURL }
31+func (p stubContextMenuParams) GetFrameURL() string { return "" }
32+func (p stubContextMenuParams) GetFrameCharset() string { return "" }
33+func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 }
34+func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags {
35+ return 0
36+}
37+func (p stubContextMenuParams) GetSelectionText() string { return p.selection }
38+func (p stubContextMenuParams) GetMisspelledWord() string { return "" }
39+func (p stubContextMenuParams) GetDictionarySuggestions(uintptr) int32 { return 0 }
40+func (p stubContextMenuParams) IsEditable() bool { return p.editable }
41+func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false }
42+func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags {
43+ return 0
44+}
45+func (p stubContextMenuParams) IsCustomMenu() bool { return false }
46+
47+func TestBuildMenuContextFromCEFParams(t *testing.T) {
48+ wv := &WebView{}
49+ wv.updateURI("https://example.com/page")
50+ wv.updateLoadState(false, true, false)
51+
52+ ctx := buildMenuContext(wv, stubContextMenuParams{
53+ x: 17,
54+ y: 42,
55+ pageURL: "https://example.com/page",
56+ linkURL: "https://example.com/link",
57+ sourceURL: "https://example.com/image.png",
58+ selection: "selected text",
59+ editable: true,
60+ imageContent: true,
61+ })
62+
63+ require.Equal(t, "https://example.com/page", ctx.PageURI)
64+ require.Equal(t, "https://example.com/link", ctx.LinkURI)
65+ require.Equal(t, "https://example.com/image.png", ctx.ImageURI)
66+ require.True(t, ctx.HasSelection)
67+ require.True(t, ctx.IsEditable)
68+ require.True(t, ctx.CanGoBack)
69+ require.False(t, ctx.CanGoForward)
70+ require.Equal(t, 17, ctx.X)
71+ require.Equal(t, 42, ctx.Y)
72+}
73+
74+type stubRunContextMenuCallback struct {
75+ contCalls int
76+ cancelCalls int
77+ commandID int32
78+}
79+
80+func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {
81+ c.contCalls++
82+ c.commandID = commandID
83+}
84+
85+func (c *stubRunContextMenuCallback) Cancel() {
86+ c.cancelCalls++
87+}
88+
89+func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
90+ callback := &stubRunContextMenuCallback{}
91+
92+ dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
93+ port.MenuActionReload: 102,
94+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
95+
96+ require.Zero(t, callback.contCalls)
97+ require.Equal(t, 1, callback.cancelCalls)
98+}
@@ -0,0 +1,98 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+type stubContextMenuParams struct {
13+ x, y int32
14+ pageURL string
15+ linkURL string
16+ sourceURL string
17+ selection string
18+ editable bool
19+ imageContent bool
20+}
21+
22+func (p stubContextMenuParams) GetXcoord() int32 { return p.x }
23+func (p stubContextMenuParams) GetYcoord() int32 { return p.y }
24+func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 }
25+func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL }
26+func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL }
27+func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL }
28+func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent }
29+func (p stubContextMenuParams) GetTitleText() string { return "" }
30+func (p stubContextMenuParams) GetPageURL() string { return p.pageURL }
31+func (p stubContextMenuParams) GetFrameURL() string { return "" }
32+func (p stubContextMenuParams) GetFrameCharset() string { return "" }
33+func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 }
34+func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags {
35+ return 0
36+}
37+func (p stubContextMenuParams) GetSelectionText() string { return p.selection }
38+func (p stubContextMenuParams) GetMisspelledWord() string { return "" }
39+func (p stubContextMenuParams) GetDictionarySuggestions(uintptr) int32 { return 0 }
40+func (p stubContextMenuParams) IsEditable() bool { return p.editable }
41+func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false }
42+func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags {
43+ return 0
44+}
45+func (p stubContextMenuParams) IsCustomMenu() bool { return false }
46+
47+func TestBuildMenuContextFromCEFParams(t *testing.T) {
48+ wv := &WebView{}
49+ wv.updateURI("https://example.com/page")
50+ wv.updateLoadState(false, true, false)
51+
52+ ctx := buildMenuContext(wv, stubContextMenuParams{
53+ x: 17,
54+ y: 42,
55+ pageURL: "https://example.com/page",
56+ linkURL: "https://example.com/link",
57+ sourceURL: "https://example.com/image.png",
58+ selection: "selected text",
59+ editable: true,
60+ imageContent: true,
61+ })
62+
63+ require.Equal(t, "https://example.com/page", ctx.PageURI)
64+ require.Equal(t, "https://example.com/link", ctx.LinkURI)
65+ require.Equal(t, "https://example.com/image.png", ctx.ImageURI)
66+ require.True(t, ctx.HasSelection)
67+ require.True(t, ctx.IsEditable)
68+ require.True(t, ctx.CanGoBack)
69+ require.False(t, ctx.CanGoForward)
70+ require.Equal(t, 17, ctx.X)
71+ require.Equal(t, 42, ctx.Y)
72+}
73+
74+type stubRunContextMenuCallback struct {
75+ contCalls int
76+ cancelCalls int
77+ commandID int32
78+}
79+
80+func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {
81+ c.contCalls++
82+ c.commandID = commandID
83+}
84+
85+func (c *stubRunContextMenuCallback) Cancel() {
86+ c.cancelCalls++
87+}
88+
89+func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
90+ callback := &stubRunContextMenuCallback{}
91+
92+ dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
93+ port.MenuActionReload: 102,
94+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
95+
96+ require.Zero(t, callback.contCalls)
97+ require.Equal(t, 1, callback.cancelCalls)
98+}
M internal/infrastructure/cef/engine_init.go +6 -8
@@ -62,7 +62,6 @@ func NewEngine(
6262 Bool("external_begin_frame", externalBeginFrameEnabled()).
6363 Bool("trace_handlers", cfg.TraceHandlers).
6464 Bool("enable_audio_handler", cfg.EnableAudioHandler).
65- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
6665 Msg("cef: configured engine")
6766
6867 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -170,7 +169,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
170169
171170 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
172171 func wireEngine(
173- ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
172+ ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
174173 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
175174 mediaClassifier MediaClassifier,
176175 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
@@ -212,12 +211,11 @@ func wireEngine(
212211 }
213212
214213 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
215- scale: scale,
216- windowlessFrameRate: windowlessFrameRate,
217- enableContextMenuHandler: cfg.EnableContextMenuHandler,
218- transcoder: mediaTranscoder,
219- mediaClassifier: mediaClassifier,
220- audioOutputFactory: audioFactory,
214+ scale: scale,
215+ windowlessFrameRate: windowlessFrameRate,
216+ transcoder: mediaTranscoder,
217+ mediaClassifier: mediaClassifier,
218+ audioOutputFactory: audioFactory,
221219 })
222220 pool := newWebViewPool(factory)
223221
@@ -62,7 +62,6 @@ func NewEngine(
62 Bool("external_begin_frame", externalBeginFrameEnabled()).62 Bool("external_begin_frame", externalBeginFrameEnabled()).
63 Bool("trace_handlers", cfg.TraceHandlers).63 Bool("trace_handlers", cfg.TraceHandlers).
64 Bool("enable_audio_handler", cfg.EnableAudioHandler).64 Bool("enable_audio_handler", cfg.EnableAudioHandler).
65- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
66 Msg("cef: configured engine")65 Msg("cef: configured engine")
67 66
68 if err := initializeCEF(eng, settings, logger); err != nil {67 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -170,7 +169,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
170 169
171 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.170 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
172 func wireEngine(171 func wireEngine(
173- ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,172+ ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
174 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,173 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
175 mediaClassifier MediaClassifier,174 mediaClassifier MediaClassifier,
176 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),175 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
@@ -212,12 +211,11 @@ func wireEngine(
212 }211 }
213 212
214 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{213 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
215- scale: scale,214+ scale: scale,
216- windowlessFrameRate: windowlessFrameRate,215+ windowlessFrameRate: windowlessFrameRate,
217- enableContextMenuHandler: cfg.EnableContextMenuHandler,216+ transcoder: mediaTranscoder,
218- transcoder: mediaTranscoder,217+ mediaClassifier: mediaClassifier,
219- mediaClassifier: mediaClassifier,218+ audioOutputFactory: audioFactory,
220- audioOutputFactory: audioFactory,
221 })219 })
222 pool := newWebViewPool(factory)220 pool := newWebViewPool(factory)
223 221
M internal/infrastructure/cef/factory.go +23 -27
@@ -17,25 +17,23 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
1717 // gets a unique ID, its own renderPipeline and inputBridge, and an
1818 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
1919 type WebViewFactory struct {
20- engine *Engine
21- gl *glLoader
22- nextID atomic.Uint64
23- scale int32
24- windowlessFrameRate int32
25- enableContextMenuHandler bool
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27- transcoder port.MediaTranscoder
28- mediaClassifier MediaClassifier
29- audioOutputFactory port.AudioOutputFactory
20+ engine *Engine
21+ gl *glLoader
22+ nextID atomic.Uint64
23+ scale int32
24+ windowlessFrameRate int32
25+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
26+ transcoder port.MediaTranscoder
27+ mediaClassifier MediaClassifier
28+ audioOutputFactory port.AudioOutputFactory
3029 }
3130
3231 type webViewFactoryOptions struct {
33- scale int32
34- windowlessFrameRate int32
35- enableContextMenuHandler bool
36- transcoder port.MediaTranscoder
37- mediaClassifier MediaClassifier
38- audioOutputFactory port.AudioOutputFactory
32+ scale int32
33+ windowlessFrameRate int32
34+ transcoder port.MediaTranscoder
35+ mediaClassifier MediaClassifier
36+ audioOutputFactory port.AudioOutputFactory
3937 }
4038
4139 type resizeNotifiableBrowserHost interface {
@@ -53,14 +51,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
5351 opts.windowlessFrameRate = 60
5452 }
5553 return &WebViewFactory{
56- engine: engine,
57- gl: gl,
58- scale: opts.scale,
59- windowlessFrameRate: opts.windowlessFrameRate,
60- enableContextMenuHandler: opts.enableContextMenuHandler,
61- transcoder: opts.transcoder,
62- mediaClassifier: opts.mediaClassifier,
63- audioOutputFactory: opts.audioOutputFactory,
54+ engine: engine,
55+ gl: gl,
56+ scale: opts.scale,
57+ windowlessFrameRate: opts.windowlessFrameRate,
58+ transcoder: opts.transcoder,
59+ mediaClassifier: opts.mediaClassifier,
60+ audioOutputFactory: opts.audioOutputFactory,
6461 }
6562 }
6663
@@ -99,9 +96,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
9996 }
10097
10198 handlers := &handlerSet{
102- wv: wv,
103- enableContextMenuHandler: f.enableContextMenuHandler,
104- transcodingHandler: transcodingHandler,
99+ wv: wv,
100+ transcodingHandler: transcodingHandler,
105101 }
106102 wv.handlers = handlers
107103 wv.findCtrl = newFindController()
@@ -17,25 +17,23 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
17 // gets a unique ID, its own renderPipeline and inputBridge, and an17 // gets a unique ID, its own renderPipeline and inputBridge, and an
18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
19 type WebViewFactory struct {19 type WebViewFactory struct {
20- engine *Engine20+ engine *Engine
21- gl *glLoader21+ gl *glLoader
22- nextID atomic.Uint6422+ nextID atomic.Uint64
23- scale int3223+ scale int32
24- windowlessFrameRate int3224+ windowlessFrameRate int32
25- enableContextMenuHandler bool25+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26+ transcoder port.MediaTranscoder
27- transcoder port.MediaTranscoder27+ mediaClassifier MediaClassifier
28- mediaClassifier MediaClassifier28+ audioOutputFactory port.AudioOutputFactory
29- audioOutputFactory port.AudioOutputFactory
30 }29 }
31 30
32 type webViewFactoryOptions struct {31 type webViewFactoryOptions struct {
33- scale int3232+ scale int32
34- windowlessFrameRate int3233+ windowlessFrameRate int32
35- enableContextMenuHandler bool34+ transcoder port.MediaTranscoder
36- transcoder port.MediaTranscoder35+ mediaClassifier MediaClassifier
37- mediaClassifier MediaClassifier36+ audioOutputFactory port.AudioOutputFactory
38- audioOutputFactory port.AudioOutputFactory
39 }37 }
40 38
41 type resizeNotifiableBrowserHost interface {39 type resizeNotifiableBrowserHost interface {
@@ -53,14 +51,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
53 opts.windowlessFrameRate = 6051 opts.windowlessFrameRate = 60
54 }52 }
55 return &WebViewFactory{53 return &WebViewFactory{
56- engine: engine,54+ engine: engine,
57- gl: gl,55+ gl: gl,
58- scale: opts.scale,56+ scale: opts.scale,
59- windowlessFrameRate: opts.windowlessFrameRate,57+ windowlessFrameRate: opts.windowlessFrameRate,
60- enableContextMenuHandler: opts.enableContextMenuHandler,58+ transcoder: opts.transcoder,
61- transcoder: opts.transcoder,59+ mediaClassifier: opts.mediaClassifier,
62- mediaClassifier: opts.mediaClassifier,60+ audioOutputFactory: opts.audioOutputFactory,
63- audioOutputFactory: opts.audioOutputFactory,
64 }61 }
65 }62 }
66 63
@@ -99,9 +96,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
99 }96 }
100 97
101 handlers := &handlerSet{98 handlers := &handlerSet{
102- wv: wv,99+ wv: wv,
103- enableContextMenuHandler: f.enableContextMenuHandler,100+ transcodingHandler: transcodingHandler,
104- transcodingHandler: transcodingHandler,
105 }101 }
106 wv.handlers = handlers102 wv.handlers = handlers
107 wv.findCtrl = newFindController()103 wv.findCtrl = newFindController()
M internal/infrastructure/cef/runtime_config.go +6 -7
@@ -14,13 +14,12 @@ var (
1414 )
1515
1616 type RuntimeConfig struct {
17- CEFDir string
18- LogFile string
19- LogSeverity int32
20- WindowlessFrameRate int32
21- EnableAudioHandler bool
22- EnableContextMenuHandler bool
23- TraceHandlers bool
17+ CEFDir string
18+ LogFile string
19+ LogSeverity int32
20+ WindowlessFrameRate int32
21+ EnableAudioHandler bool
22+ TraceHandlers bool
2423 }
2524
2625 type TranscodingRuntimeConfig struct {
@@ -14,13 +14,12 @@ var (
14 )14 )
15 15
16 type RuntimeConfig struct {16 type RuntimeConfig struct {
17- CEFDir string17+ CEFDir string
18- LogFile string18+ LogFile string
19- LogSeverity int3219+ LogSeverity int32
20- WindowlessFrameRate int3220+ WindowlessFrameRate int32
21- EnableAudioHandler bool21+ EnableAudioHandler bool
22- EnableContextMenuHandler bool22+ TraceHandlers bool
23- TraceHandlers bool
24 }23 }
25 24
26 type TranscodingRuntimeConfig struct {25 type TranscodingRuntimeConfig struct {
M internal/infrastructure/cef/webview_handlers.go +19 -26
@@ -21,9 +21,8 @@ const (
2121 // owning WebView. A single struct is used so that the Client's Get*Handler
2222 // methods can return the same receiver, avoiding extra allocations.
2323 type handlerSet struct {
24- wv *WebView
25- enableContextMenuHandler bool
26- transcodingHandler purecef.ResourceRequestHandler
24+ wv *WebView
25+ transcodingHandler purecef.ResourceRequestHandler
2726 }
2827
2928 // Compile-time interface checks.
@@ -50,28 +49,23 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
5049 // audio backend (PipeWire/PulseAudio) is wired up.
5150 return h
5251 }
53-func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
54-func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {
55- if h.enableContextMenuHandler {
56- return h
57- }
58- return nil
59-}
60-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
61-func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
62-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
63-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
64-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
65-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
66-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
67-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
68-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
69-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
70-func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
71-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
72-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
73-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
74-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
52+func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
53+func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
54+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
55+func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
56+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
57+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
58+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
59+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
60+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
61+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
62+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
63+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
64+func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
65+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
66+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
67+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
68+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
7569
7670 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {
7771 return 0
@@ -558,7 +552,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
558552 browserID := browser.GetIdentifier()
559553 log.Debug().
560554 Int32("browser_id", browserID).
561- Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
562555 Msg("cef: OnAfterCreated")
563556 if h.wv.engine != nil {
564557 h.wv.engine.recordBrowserAfterCreated(browser)
@@ -21,9 +21,8 @@ const (
21 // owning WebView. A single struct is used so that the Client's Get*Handler21 // owning WebView. A single struct is used so that the Client's Get*Handler
22 // methods can return the same receiver, avoiding extra allocations.22 // methods can return the same receiver, avoiding extra allocations.
23 type handlerSet struct {23 type handlerSet struct {
24- wv *WebView24+ wv *WebView
25- enableContextMenuHandler bool25+ transcodingHandler purecef.ResourceRequestHandler
26- transcodingHandler purecef.ResourceRequestHandler
27 }26 }
28 27
29 // Compile-time interface checks.28 // Compile-time interface checks.
@@ -50,28 +49,23 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
50 // audio backend (PipeWire/PulseAudio) is wired up.49 // audio backend (PipeWire/PulseAudio) is wired up.
51 return h50 return h
52 }51 }
53-func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }52+func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
54-func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {53+func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
55- if h.enableContextMenuHandler {54+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
56- return h55+func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
57- }56+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
58- return nil57+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
59-}58+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
60-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }59+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
61-func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }60+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
62-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }61+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
63-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }62+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
64-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }63+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
65-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }64+func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
66-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }65+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
67-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }66+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
68-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }67+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
69-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }68+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
70-func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
71-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
72-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
73-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
74-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
75 69
76 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {70 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {
77 return 071 return 0
@@ -558,7 +552,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
558 browserID := browser.GetIdentifier()552 browserID := browser.GetIdentifier()
559 log.Debug().553 log.Debug().
560 Int32("browser_id", browserID).554 Int32("browser_id", browserID).
561- Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
562 Msg("cef: OnAfterCreated")555 Msg("cef: OnAfterCreated")
563 if h.wv.engine != nil {556 if h.wv.engine != nil {
564 h.wv.engine.recordBrowserAfterCreated(browser)557 h.wv.engine.recordBrowserAfterCreated(browser)
M internal/infrastructure/cef/webview_handlers_test.go +1 -5
@@ -126,15 +126,11 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
126126 require.Equal(t, int32(300), info.AvailableRect.Height)
127127 }
128128
129-func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
129+func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
130130 h := &handlerSet{}
131131
132132 // AudioHandler is always enabled (required for media decoding).
133133 require.Same(t, h, h.GetAudioHandler())
134- require.Nil(t, h.GetContextMenuHandler())
135-
136- h.enableContextMenuHandler = true
137-
138134 require.Same(t, h, h.GetContextMenuHandler())
139135 }
140136
@@ -126,15 +126,11 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
126 require.Equal(t, int32(300), info.AvailableRect.Height)126 require.Equal(t, int32(300), info.AvailableRect.Height)
127 }127 }
128 128
129-func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {129+func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
130 h := &handlerSet{}130 h := &handlerSet{}
131 131
132 // AudioHandler is always enabled (required for media decoding).132 // AudioHandler is always enabled (required for media decoding).
133 require.Same(t, h, h.GetAudioHandler())133 require.Same(t, h, h.GetAudioHandler())
134- require.Nil(t, h.GetContextMenuHandler())
135-
136- h.enableContextMenuHandler = true
137-
138 require.Same(t, h, h.GetContextMenuHandler())134 require.Same(t, h, h.GetContextMenuHandler())
139 }135 }
140 136
M internal/infrastructure/clipboard/clipboard.go +152 -24
@@ -1,7 +1,8 @@
1-// Package clipboard provides a clipboard adapter using wl-clipboard (Wayland) with X11 fallback.
1+// Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback.
22 package clipboard
33
44 import (
5+ "bytes"
56 "context"
67 "fmt"
78 "os"
@@ -10,48 +11,145 @@ import (
1011
1112 "github.com/bnema/dumber/internal/application/port"
1213 "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/puregotk/v4/gdk"
15+ "github.com/bnema/puregotk/v4/glib"
16+ "github.com/rs/zerolog"
1317 )
1418
15-// Adapter implements port.Clipboard using system clipboard tools.
16-// Uses wl-clipboard for Wayland, falls back to xclip for X11.
19+type toolkitClipboard interface {
20+ WriteText(ctx context.Context, text string) error
21+ WriteImage(ctx context.Context, image port.ImageData) error
22+}
23+
24+// Adapter implements port.Clipboard using the toolkit clipboard when available.
25+// It falls back to wl-clipboard for Wayland, then xclip/xsel for X11.
1726 type Adapter struct {
27+ toolkit toolkitClipboard
1828 copyCmd string
1929 pasteCmd string
2030 }
2131
32+var (
33+ lookPath = exec.LookPath
34+ commandContext = exec.CommandContext
35+ newToolkitClipboard = defaultToolkitClipboard
36+)
37+
2238 // New creates a new clipboard adapter.
2339 // Detects Wayland vs X11 and selects appropriate clipboard tool.
2440 func New() port.Clipboard {
25- a := &Adapter{}
41+ a := &Adapter{toolkit: newToolkitClipboard()}
42+ a.copyCmd, a.pasteCmd = selectSystemClipboard()
43+ return a
44+}
45+
46+func defaultToolkitClipboard() toolkitClipboard {
47+ display := gdk.DisplayGetDefault()
48+ if display == nil {
49+ return nil
50+ }
2651
27- // Check for Wayland first
52+ clipboard := display.GetClipboard()
53+ if clipboard == nil {
54+ return nil
55+ }
56+
57+ return &gdkToolkitClipboard{clipboard: clipboard}
58+}
59+
60+type gdkToolkitClipboard struct {
61+ clipboard *gdk.Clipboard
62+}
63+
64+func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error {
65+ if c == nil || c.clipboard == nil {
66+ return fmt.Errorf("toolkit clipboard not available")
67+ }
68+ c.clipboard.SetText(text)
69+ return nil
70+}
71+
72+func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
73+ if c == nil || c.clipboard == nil {
74+ return fmt.Errorf("toolkit clipboard not available")
75+ }
76+
77+ texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
78+ if err != nil {
79+ return err
80+ }
81+ if texture == nil {
82+ return fmt.Errorf("toolkit clipboard returned nil texture")
83+ }
84+
85+ c.clipboard.SetTexture(texture)
86+ return nil
87+}
88+
89+func selectSystemClipboard() (copyCmd, pasteCmd string) {
90+ // Check for Wayland first.
2891 if os.Getenv("WAYLAND_DISPLAY") != "" {
29- if path, err := exec.LookPath("wl-copy"); err == nil {
30- a.copyCmd = path
31- if pastePath, err := exec.LookPath("wl-paste"); err == nil {
32- a.pasteCmd = pastePath
92+ if path, err := lookPath("wl-copy"); err == nil {
93+ copyCmd = path
94+ if pastePath, err := lookPath("wl-paste"); err == nil {
95+ pasteCmd = pastePath
3396 }
3497 }
3598 }
3699
37- // Fall back to X11 if Wayland tools not available
38- if a.copyCmd == "" && os.Getenv("DISPLAY") != "" {
39- if path, err := exec.LookPath("xclip"); err == nil {
40- a.copyCmd = path
41- a.pasteCmd = path
42- } else if path, err := exec.LookPath("xsel"); err == nil {
43- a.copyCmd = path
44- a.pasteCmd = path
100+ // Fall back to X11 if Wayland tools are not available.
101+ if copyCmd == "" && os.Getenv("DISPLAY") != "" {
102+ if path, err := lookPath("xclip"); err == nil {
103+ copyCmd = path
104+ pasteCmd = path
105+ } else if path, err := lookPath("xsel"); err == nil {
106+ copyCmd = path
107+ pasteCmd = path
45108 }
46109 }
47110
48- return a
111+ return copyCmd, pasteCmd
49112 }
50113
51114 // WriteText copies text to the clipboard.
52115 func (a *Adapter) WriteText(ctx context.Context, text string) error {
53116 log := logging.FromContext(ctx)
117+ if a.toolkit != nil {
118+ err := a.toolkit.WriteText(ctx, text)
119+ if err == nil {
120+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
121+ return nil
122+ }
123+ log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
124+ }
54125
126+ return a.writeTextWithCommand(ctx, text, log)
127+}
128+
129+// WriteImage copies image bytes to the clipboard.
130+// This path is PNG-only; xsel is not supported because the adapter has no
131+// reliable binary image mode for it in this codebase.
132+func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
133+ log := logging.FromContext(ctx)
134+
135+ if len(image.Bytes) == 0 {
136+ err := fmt.Errorf("empty image data")
137+ log.Error().Err(err).Msg("clipboard image write failed")
138+ return err
139+ }
140+ if a.toolkit != nil {
141+ err := a.toolkit.WriteImage(ctx, image)
142+ if err == nil {
143+ log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
144+ return nil
145+ }
146+ log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
147+ }
148+
149+ return a.writeImageWithCommand(ctx, image, log)
150+}
151+
152+func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
55153 if a.copyCmd == "" {
56154 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
57155 log.Error().Err(err).Msg("clipboard write failed")
@@ -60,11 +158,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
60158
61159 var cmd *exec.Cmd
62160 if strings.Contains(a.copyCmd, "wl-copy") {
63- cmd = exec.CommandContext(ctx, a.copyCmd)
161+ cmd = commandContext(ctx, a.copyCmd)
64162 } else if strings.Contains(a.copyCmd, "xclip") {
65- cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard")
163+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
66164 } else if strings.Contains(a.copyCmd, "xsel") {
67- cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input")
165+ cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
68166 } else {
69167 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
70168 log.Error().Err(err).Msg("clipboard write failed")
@@ -73,6 +171,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
73171
74172 cmd.Stdin = strings.NewReader(text)
75173 if err := cmd.Run(); err != nil {
174+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
76175 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
77176 return err
78177 }
@@ -81,6 +180,35 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
81180 return nil
82181 }
83182
183+func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error {
184+ if a.copyCmd == "" {
185+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
186+ log.Error().Err(err).Msg("clipboard image write failed")
187+ return err
188+ }
189+
190+ var cmd *exec.Cmd
191+ if strings.Contains(a.copyCmd, "wl-copy") {
192+ cmd = commandContext(ctx, a.copyCmd, "--type", "image/png")
193+ } else if strings.Contains(a.copyCmd, "xclip") {
194+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i")
195+ } else {
196+ err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
197+ log.Error().Err(err).Msg("clipboard image write failed")
198+ return err
199+ }
200+
201+ cmd.Stdin = bytes.NewReader(image.Bytes)
202+ if err := cmd.Run(); err != nil {
203+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
204+ log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed")
205+ return err
206+ }
207+
208+ log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
209+ return nil
210+}
211+
84212 // ReadText reads text from the clipboard.
85213 func (a *Adapter) ReadText(ctx context.Context) (string, error) {
86214 log := logging.FromContext(ctx)
@@ -93,11 +221,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
93221
94222 var cmd *exec.Cmd
95223 if strings.Contains(a.pasteCmd, "wl-paste") {
96- cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline")
224+ cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
97225 } else if strings.Contains(a.pasteCmd, "xclip") {
98- cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
226+ cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
99227 } else if strings.Contains(a.pasteCmd, "xsel") {
100- cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output")
228+ cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
101229 } else {
102230 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
103231 log.Error().Err(err).Msg("clipboard read failed")
@@ -1,7 +1,8 @@
1-// Package clipboard provides a clipboard adapter using wl-clipboard (Wayland) with X11 fallback.1+// Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback.
2 package clipboard2 package clipboard
3 3
4 import (4 import (
5+ "bytes"
5 "context"6 "context"
6 "fmt"7 "fmt"
7 "os"8 "os"
@@ -10,48 +11,145 @@ import (
10 11
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/puregotk/v4/gdk"
15+ "github.com/bnema/puregotk/v4/glib"
16+ "github.com/rs/zerolog"
13 )17 )
14 18
15-// Adapter implements port.Clipboard using system clipboard tools.19+type toolkitClipboard interface {
16-// Uses wl-clipboard for Wayland, falls back to xclip for X11.20+ WriteText(ctx context.Context, text string) error
21+ WriteImage(ctx context.Context, image port.ImageData) error
22+}
23+
24+// Adapter implements port.Clipboard using the toolkit clipboard when available.
25+// It falls back to wl-clipboard for Wayland, then xclip/xsel for X11.
17 type Adapter struct {26 type Adapter struct {
27+ toolkit toolkitClipboard
18 copyCmd string28 copyCmd string
19 pasteCmd string29 pasteCmd string
20 }30 }
21 31
32+var (
33+ lookPath = exec.LookPath
34+ commandContext = exec.CommandContext
35+ newToolkitClipboard = defaultToolkitClipboard
36+)
37+
22 // New creates a new clipboard adapter.38 // New creates a new clipboard adapter.
23 // Detects Wayland vs X11 and selects appropriate clipboard tool.39 // Detects Wayland vs X11 and selects appropriate clipboard tool.
24 func New() port.Clipboard {40 func New() port.Clipboard {
25- a := &Adapter{}41+ a := &Adapter{toolkit: newToolkitClipboard()}
42+ a.copyCmd, a.pasteCmd = selectSystemClipboard()
43+ return a
44+}
45+
46+func defaultToolkitClipboard() toolkitClipboard {
47+ display := gdk.DisplayGetDefault()
48+ if display == nil {
49+ return nil
50+ }
26 51
27- // Check for Wayland first52+ clipboard := display.GetClipboard()
53+ if clipboard == nil {
54+ return nil
55+ }
56+
57+ return &gdkToolkitClipboard{clipboard: clipboard}
58+}
59+
60+type gdkToolkitClipboard struct {
61+ clipboard *gdk.Clipboard
62+}
63+
64+func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error {
65+ if c == nil || c.clipboard == nil {
66+ return fmt.Errorf("toolkit clipboard not available")
67+ }
68+ c.clipboard.SetText(text)
69+ return nil
70+}
71+
72+func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
73+ if c == nil || c.clipboard == nil {
74+ return fmt.Errorf("toolkit clipboard not available")
75+ }
76+
77+ texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
78+ if err != nil {
79+ return err
80+ }
81+ if texture == nil {
82+ return fmt.Errorf("toolkit clipboard returned nil texture")
83+ }
84+
85+ c.clipboard.SetTexture(texture)
86+ return nil
87+}
88+
89+func selectSystemClipboard() (copyCmd, pasteCmd string) {
90+ // Check for Wayland first.
28 if os.Getenv("WAYLAND_DISPLAY") != "" {91 if os.Getenv("WAYLAND_DISPLAY") != "" {
29- if path, err := exec.LookPath("wl-copy"); err == nil {92+ if path, err := lookPath("wl-copy"); err == nil {
30- a.copyCmd = path93+ copyCmd = path
31- if pastePath, err := exec.LookPath("wl-paste"); err == nil {94+ if pastePath, err := lookPath("wl-paste"); err == nil {
32- a.pasteCmd = pastePath95+ pasteCmd = pastePath
33 }96 }
34 }97 }
35 }98 }
36 99
37- // Fall back to X11 if Wayland tools not available100+ // Fall back to X11 if Wayland tools are not available.
38- if a.copyCmd == "" && os.Getenv("DISPLAY") != "" {101+ if copyCmd == "" && os.Getenv("DISPLAY") != "" {
39- if path, err := exec.LookPath("xclip"); err == nil {102+ if path, err := lookPath("xclip"); err == nil {
40- a.copyCmd = path103+ copyCmd = path
41- a.pasteCmd = path104+ pasteCmd = path
42- } else if path, err := exec.LookPath("xsel"); err == nil {105+ } else if path, err := lookPath("xsel"); err == nil {
43- a.copyCmd = path106+ copyCmd = path
44- a.pasteCmd = path107+ pasteCmd = path
45 }108 }
46 }109 }
47 110
48- return a111+ return copyCmd, pasteCmd
49 }112 }
50 113
51 // WriteText copies text to the clipboard.114 // WriteText copies text to the clipboard.
52 func (a *Adapter) WriteText(ctx context.Context, text string) error {115 func (a *Adapter) WriteText(ctx context.Context, text string) error {
53 log := logging.FromContext(ctx)116 log := logging.FromContext(ctx)
117+ if a.toolkit != nil {
118+ err := a.toolkit.WriteText(ctx, text)
119+ if err == nil {
120+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
121+ return nil
122+ }
123+ log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
124+ }
54 125
126+ return a.writeTextWithCommand(ctx, text, log)
127+}
128+
129+// WriteImage copies image bytes to the clipboard.
130+// This path is PNG-only; xsel is not supported because the adapter has no
131+// reliable binary image mode for it in this codebase.
132+func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
133+ log := logging.FromContext(ctx)
134+
135+ if len(image.Bytes) == 0 {
136+ err := fmt.Errorf("empty image data")
137+ log.Error().Err(err).Msg("clipboard image write failed")
138+ return err
139+ }
140+ if a.toolkit != nil {
141+ err := a.toolkit.WriteImage(ctx, image)
142+ if err == nil {
143+ log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
144+ return nil
145+ }
146+ log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
147+ }
148+
149+ return a.writeImageWithCommand(ctx, image, log)
150+}
151+
152+func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
55 if a.copyCmd == "" {153 if a.copyCmd == "" {
56 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")154 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
57 log.Error().Err(err).Msg("clipboard write failed")155 log.Error().Err(err).Msg("clipboard write failed")
@@ -60,11 +158,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
60 158
61 var cmd *exec.Cmd159 var cmd *exec.Cmd
62 if strings.Contains(a.copyCmd, "wl-copy") {160 if strings.Contains(a.copyCmd, "wl-copy") {
63- cmd = exec.CommandContext(ctx, a.copyCmd)161+ cmd = commandContext(ctx, a.copyCmd)
64 } else if strings.Contains(a.copyCmd, "xclip") {162 } else if strings.Contains(a.copyCmd, "xclip") {
65- cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard")163+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
66 } else if strings.Contains(a.copyCmd, "xsel") {164 } else if strings.Contains(a.copyCmd, "xsel") {
67- cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input")165+ cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
68 } else {166 } else {
69 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)167 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
70 log.Error().Err(err).Msg("clipboard write failed")168 log.Error().Err(err).Msg("clipboard write failed")
@@ -73,6 +171,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
73 171
74 cmd.Stdin = strings.NewReader(text)172 cmd.Stdin = strings.NewReader(text)
75 if err := cmd.Run(); err != nil {173 if err := cmd.Run(); err != nil {
174+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
76 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")175 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
77 return err176 return err
78 }177 }
@@ -81,6 +180,35 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
81 return nil180 return nil
82 }181 }
83 182
183+func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error {
184+ if a.copyCmd == "" {
185+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
186+ log.Error().Err(err).Msg("clipboard image write failed")
187+ return err
188+ }
189+
190+ var cmd *exec.Cmd
191+ if strings.Contains(a.copyCmd, "wl-copy") {
192+ cmd = commandContext(ctx, a.copyCmd, "--type", "image/png")
193+ } else if strings.Contains(a.copyCmd, "xclip") {
194+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i")
195+ } else {
196+ err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
197+ log.Error().Err(err).Msg("clipboard image write failed")
198+ return err
199+ }
200+
201+ cmd.Stdin = bytes.NewReader(image.Bytes)
202+ if err := cmd.Run(); err != nil {
203+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
204+ log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed")
205+ return err
206+ }
207+
208+ log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
209+ return nil
210+}
211+
84 // ReadText reads text from the clipboard.212 // ReadText reads text from the clipboard.
85 func (a *Adapter) ReadText(ctx context.Context) (string, error) {213 func (a *Adapter) ReadText(ctx context.Context) (string, error) {
86 log := logging.FromContext(ctx)214 log := logging.FromContext(ctx)
@@ -93,11 +221,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
93 221
94 var cmd *exec.Cmd222 var cmd *exec.Cmd
95 if strings.Contains(a.pasteCmd, "wl-paste") {223 if strings.Contains(a.pasteCmd, "wl-paste") {
96- cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline")224+ cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
97 } else if strings.Contains(a.pasteCmd, "xclip") {225 } else if strings.Contains(a.pasteCmd, "xclip") {
98- cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")226+ cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
99 } else if strings.Contains(a.pasteCmd, "xsel") {227 } else if strings.Contains(a.pasteCmd, "xsel") {
100- cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output")228+ cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
101 } else {229 } else {
102 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)230 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
103 log.Error().Err(err).Msg("clipboard read failed")231 log.Error().Err(err).Msg("clipboard read failed")
A internal/infrastructure/clipboard/clipboard_test.go +106 -0
@@ -0,0 +1,106 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type fakeToolkitClipboard struct {
13+ writeTextCalls int
14+ writeImageCalls int
15+ text string
16+ image port.ImageData
17+}
18+
19+func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
20+ f.writeTextCalls++
21+ f.text = text
22+ return nil
23+}
24+
25+func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
26+ f.writeImageCalls++
27+ f.image = image
28+ return nil
29+}
30+
31+func TestNew_PrefersToolkitClipboard(t *testing.T) {
32+ oldToolkitFactory := newToolkitClipboard
33+ oldLookPath := lookPath
34+ t.Cleanup(func() {
35+ newToolkitClipboard = oldToolkitFactory
36+ lookPath = oldLookPath
37+ })
38+
39+ toolkitChecked := false
40+ fake := &fakeToolkitClipboard{}
41+ newToolkitClipboard = func() toolkitClipboard {
42+ toolkitChecked = true
43+ return fake
44+ }
45+ lookPath = func(_ string) (string, error) {
46+ require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools")
47+ return "", errors.New("unexpected call")
48+ }
49+
50+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
51+ t.Setenv("DISPLAY", ":0")
52+
53+ adapter := New().(*Adapter)
54+ require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}}))
55+ require.Equal(t, 1, fake.writeImageCalls)
56+ require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
57+}
58+
59+func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
60+ oldToolkitFactory := newToolkitClipboard
61+ oldLookPath := lookPath
62+ t.Cleanup(func() {
63+ newToolkitClipboard = oldToolkitFactory
64+ lookPath = oldLookPath
65+ })
66+
67+ newToolkitClipboard = func() toolkitClipboard { return nil }
68+ lookups := make([]string, 0, 2)
69+ lookPath = func(name string) (string, error) {
70+ lookups = append(lookups, name)
71+ switch name {
72+ case "wl-copy":
73+ return "", errors.New("wl-copy missing")
74+ case "xclip":
75+ return "/usr/bin/xclip", nil
76+ default:
77+ return "", errors.New("unexpected lookPath call")
78+ }
79+ }
80+
81+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
82+ t.Setenv("DISPLAY", ":0")
83+
84+ adapter := New().(*Adapter)
85+ require.Equal(t, "/usr/bin/xclip", adapter.copyCmd)
86+ require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd)
87+ require.Equal(t, []string{"wl-copy", "xclip"}, lookups)
88+}
89+
90+func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
91+ oldToolkitFactory := newToolkitClipboard
92+ oldLookPath := lookPath
93+ t.Cleanup(func() {
94+ newToolkitClipboard = oldToolkitFactory
95+ lookPath = oldLookPath
96+ })
97+
98+ fake := &fakeToolkitClipboard{}
99+ newToolkitClipboard = func() toolkitClipboard { return fake }
100+ lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
101+
102+ err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{})
103+ require.Error(t, err)
104+ require.Contains(t, err.Error(), "empty image data")
105+ require.Zero(t, fake.writeImageCalls)
106+}
@@ -0,0 +1,106 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type fakeToolkitClipboard struct {
13+ writeTextCalls int
14+ writeImageCalls int
15+ text string
16+ image port.ImageData
17+}
18+
19+func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
20+ f.writeTextCalls++
21+ f.text = text
22+ return nil
23+}
24+
25+func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
26+ f.writeImageCalls++
27+ f.image = image
28+ return nil
29+}
30+
31+func TestNew_PrefersToolkitClipboard(t *testing.T) {
32+ oldToolkitFactory := newToolkitClipboard
33+ oldLookPath := lookPath
34+ t.Cleanup(func() {
35+ newToolkitClipboard = oldToolkitFactory
36+ lookPath = oldLookPath
37+ })
38+
39+ toolkitChecked := false
40+ fake := &fakeToolkitClipboard{}
41+ newToolkitClipboard = func() toolkitClipboard {
42+ toolkitChecked = true
43+ return fake
44+ }
45+ lookPath = func(_ string) (string, error) {
46+ require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools")
47+ return "", errors.New("unexpected call")
48+ }
49+
50+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
51+ t.Setenv("DISPLAY", ":0")
52+
53+ adapter := New().(*Adapter)
54+ require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}}))
55+ require.Equal(t, 1, fake.writeImageCalls)
56+ require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
57+}
58+
59+func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
60+ oldToolkitFactory := newToolkitClipboard
61+ oldLookPath := lookPath
62+ t.Cleanup(func() {
63+ newToolkitClipboard = oldToolkitFactory
64+ lookPath = oldLookPath
65+ })
66+
67+ newToolkitClipboard = func() toolkitClipboard { return nil }
68+ lookups := make([]string, 0, 2)
69+ lookPath = func(name string) (string, error) {
70+ lookups = append(lookups, name)
71+ switch name {
72+ case "wl-copy":
73+ return "", errors.New("wl-copy missing")
74+ case "xclip":
75+ return "/usr/bin/xclip", nil
76+ default:
77+ return "", errors.New("unexpected lookPath call")
78+ }
79+ }
80+
81+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
82+ t.Setenv("DISPLAY", ":0")
83+
84+ adapter := New().(*Adapter)
85+ require.Equal(t, "/usr/bin/xclip", adapter.copyCmd)
86+ require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd)
87+ require.Equal(t, []string{"wl-copy", "xclip"}, lookups)
88+}
89+
90+func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
91+ oldToolkitFactory := newToolkitClipboard
92+ oldLookPath := lookPath
93+ t.Cleanup(func() {
94+ newToolkitClipboard = oldToolkitFactory
95+ lookPath = oldLookPath
96+ })
97+
98+ fake := &fakeToolkitClipboard{}
99+ newToolkitClipboard = func() toolkitClipboard { return fake }
100+ lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
101+
102+ err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{})
103+ require.Error(t, err)
104+ require.Contains(t, err.Error(), "empty image data")
105+ require.Zero(t, fake.writeImageCalls)
106+}
M internal/infrastructure/config/defaults_test.go +0 -1
@@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2323 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
2424 assert.Empty(t, cfg.Engine.CEF.LogFile)
2525 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
26- assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
2726 assert.False(t, cfg.Engine.CEF.TraceHandlers)
2827 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
2928
@@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24 assert.Empty(t, cfg.Engine.CEF.LogFile)24 assert.Empty(t, cfg.Engine.CEF.LogFile)
25 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)25 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
26- assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
27 assert.False(t, cfg.Engine.CEF.TraceHandlers)26 assert.False(t, cfg.Engine.CEF.TraceHandlers)
28 assert.True(t, cfg.Engine.WebKit.ITPEnabled)27 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
29 28
M internal/infrastructure/config/engine.go +0 -2
@@ -95,8 +95,6 @@ type CEFEngineConfig struct {
9595 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
9696 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
9797 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
98- // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
99- EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit
10098 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
10199 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
102100 }
@@ -95,8 +95,6 @@ type CEFEngineConfig struct {
95 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`95 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
96 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.96 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
97 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`97 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
98- // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
99- EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit
100 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.98 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
101 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`99 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
102 }100 }
M internal/infrastructure/config/loader.go +1 -1
@@ -148,6 +148,7 @@ func (m *Manager) transformLegacyActionBindings() {
148148 rawConfig := m.viper.AllSettings()
149149 transformer := NewLegacyConfigTransformer()
150150 transformer.TransformLegacyActions(rawConfig)
151+ transformer.TransformLegacyEngineConfig(rawConfig)
151152
152153 // Apply transformed config back to viper
153154 for key, value := range rawConfig {
@@ -576,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
576577 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
577578 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
578579 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
579- m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
580580 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
581581
582582 wk := e.WebKit
@@ -148,6 +148,7 @@ func (m *Manager) transformLegacyActionBindings() {
148 rawConfig := m.viper.AllSettings()148 rawConfig := m.viper.AllSettings()
149 transformer := NewLegacyConfigTransformer()149 transformer := NewLegacyConfigTransformer()
150 transformer.TransformLegacyActions(rawConfig)150 transformer.TransformLegacyActions(rawConfig)
151+ transformer.TransformLegacyEngineConfig(rawConfig)
151 152
152 // Apply transformed config back to viper153 // Apply transformed config back to viper
153 for key, value := range rawConfig {154 for key, value := range rawConfig {
@@ -576,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
576 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)577 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
577 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())578 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
578 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)579 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
579- m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
580 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)580 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
581 581
582 wk := e.WebKit582 wk := e.WebKit
M internal/infrastructure/config/migrate.go +2 -0
@@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) {
380380 // Transform legacy action bindings (slice -> ActionBinding struct)
381381 transformer := NewLegacyConfigTransformer()
382382 transformer.TransformLegacyActions(rawConfig)
383+ transformer.TransformLegacyEngineConfig(rawConfig)
383384
384385 // Build sets of keys to remove and renames to apply
385386 keysToRemove := make(map[string]bool)
@@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
670671 // Transform legacy action bindings before flattening
671672 transformer := NewLegacyConfigTransformer()
672673 transformer.TransformLegacyActions(rawConfig)
674+ transformer.TransformLegacyEngineConfig(rawConfig)
673675
674676 // Flatten the map to dot-notation keys with values
675677 result := make(map[string]any)
@@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) {
380 // Transform legacy action bindings (slice -> ActionBinding struct)380 // Transform legacy action bindings (slice -> ActionBinding struct)
381 transformer := NewLegacyConfigTransformer()381 transformer := NewLegacyConfigTransformer()
382 transformer.TransformLegacyActions(rawConfig)382 transformer.TransformLegacyActions(rawConfig)
383+ transformer.TransformLegacyEngineConfig(rawConfig)
383 384
384 // Build sets of keys to remove and renames to apply385 // Build sets of keys to remove and renames to apply
385 keysToRemove := make(map[string]bool)386 keysToRemove := make(map[string]bool)
@@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
670 // Transform legacy action bindings before flattening671 // Transform legacy action bindings before flattening
671 transformer := NewLegacyConfigTransformer()672 transformer := NewLegacyConfigTransformer()
672 transformer.TransformLegacyActions(rawConfig)673 transformer.TransformLegacyActions(rawConfig)
674+ transformer.TransformLegacyEngineConfig(rawConfig)
673 675
674 // Flatten the map to dot-notation keys with values676 // Flatten the map to dot-notation keys with values
675 result := make(map[string]any)677 result := make(map[string]any)
M internal/infrastructure/config/schema_provider.go +0 -7
@@ -887,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
887887 Description: "Enable the experimental CEF audio handler bridge",
888888 Section: SectionDebug,
889889 },
890- {
891- Key: "engine.cef.enable_context_menu_handler",
892- Type: "bool",
893- Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler),
894- Description: "Enable the experimental CEF context menu handler bridge",
895- Section: SectionDebug,
896- },
897890 }
898891 }
899892
@@ -887,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
887 Description: "Enable the experimental CEF audio handler bridge",887 Description: "Enable the experimental CEF audio handler bridge",
888 Section: SectionDebug,888 Section: SectionDebug,
889 },889 },
890- {
891- Key: "engine.cef.enable_context_menu_handler",
892- Type: "bool",
893- Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler),
894- Description: "Enable the experimental CEF context menu handler bridge",
895- Section: SectionDebug,
896- },
897 }890 }
898 }891 }
899 892
M internal/infrastructure/config/transform_legacy.go +15 -0
@@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an
4141 }
4242 }
4343
44+// TransformLegacyEngineConfig removes deprecated engine config keys.
45+func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
46+ engine, ok := rawConfig["engine"].(map[string]any)
47+ if !ok {
48+ return
49+ }
50+
51+ cef, ok := engine["cef"].(map[string]any)
52+ if !ok {
53+ return
54+ }
55+
56+ delete(cef, "enable_context_menu_handler")
57+}
58+
4459 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
4560 // Navigate to the parent of the actions section
4661 current := rawConfig
@@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an
41 }41 }
42 }42 }
43 43
44+// TransformLegacyEngineConfig removes deprecated engine config keys.
45+func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
46+ engine, ok := rawConfig["engine"].(map[string]any)
47+ if !ok {
48+ return
49+ }
50+
51+ cef, ok := engine["cef"].(map[string]any)
52+ if !ok {
53+ return
54+ }
55+
56+ delete(cef, "enable_context_menu_handler")
57+}
58+
44 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {59 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
45 // Navigate to the parent of the actions section60 // Navigate to the parent of the actions section
46 current := rawConfig61 current := rawConfig
M internal/infrastructure/config/transform_legacy_test.go +18 -0
@@ -170,6 +170,24 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) {
170170 require.NotNil(t, rawConfig["workspace"])
171171 }
172172
173+func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *testing.T) {
174+ transformer := NewLegacyConfigTransformer()
175+
176+ rawConfig := map[string]any{
177+ "engine": map[string]any{
178+ "cef": map[string]any{
179+ "enable_context_menu_handler": true,
180+ },
181+ },
182+ }
183+
184+ transformer.TransformLegacyEngineConfig(rawConfig)
185+
186+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
187+ _, exists := cef["enable_context_menu_handler"]
188+ assert.False(t, exists)
189+}
190+
173191 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174192 transformer := NewLegacyConfigTransformer()
175193
@@ -170,6 +170,24 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) {
170 require.NotNil(t, rawConfig["workspace"])170 require.NotNil(t, rawConfig["workspace"])
171 }171 }
172 172
173+func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *testing.T) {
174+ transformer := NewLegacyConfigTransformer()
175+
176+ rawConfig := map[string]any{
177+ "engine": map[string]any{
178+ "cef": map[string]any{
179+ "enable_context_menu_handler": true,
180+ },
181+ },
182+ }
183+
184+ transformer.TransformLegacyEngineConfig(rawConfig)
185+
186+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
187+ _, exists := cef["enable_context_menu_handler"]
188+ assert.False(t, exists)
189+}
190+
173 func TestTransformLegacyActions_UnknownAction(t *testing.T) {191 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174 transformer := NewLegacyConfigTransformer()192 transformer := NewLegacyConfigTransformer()
175 193
A internal/infrastructure/contextmenu/renderer.go +131 -0
@@ -0,0 +1,131 @@
1+package contextmenu
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/port"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+type renderItem struct {
10+ item port.MenuItem
11+ separator bool
12+}
13+
14+// Renderer shows GTK context menus on the provided anchor widget.
15+type Renderer struct {
16+ dispatch func(func())
17+}
18+
19+// NewRenderer creates a new Renderer.
20+func NewRenderer(dispatch func(func())) *Renderer {
21+ return &Renderer{dispatch: dispatch}
22+}
23+
24+func buildButtons(items []port.MenuItem) []renderItem {
25+ renderItems := make([]renderItem, 0, len(items))
26+ for _, item := range items {
27+ if isSeparator(item) {
28+ renderItems = append(renderItems, renderItem{separator: true})
29+ continue
30+ }
31+ renderItems = append(renderItems, renderItem{item: item})
32+ }
33+ return renderItems
34+}
35+
36+func isSeparator(item port.MenuItem) bool {
37+ return item.Action == "" && item.Label == ""
38+}
39+
40+// Show renders a popover menu on the GTK thread.
41+func (r *Renderer) Show(
42+ items []port.MenuItem,
43+ anchor *gtk.Widget,
44+ x, y int32,
45+ onSelect func(port.MenuItem),
46+ onClose func(),
47+) {
48+ if r == nil {
49+ return
50+ }
51+ run := func() {
52+ showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53+ }
54+ if r.dispatch == nil {
55+ run()
56+ return
57+ }
58+ r.dispatch(run)
59+}
60+
61+func showMenu(
62+ anchor *gtk.Widget,
63+ items []renderItem,
64+ x, y int32,
65+ onSelect func(port.MenuItem),
66+ onClose func(),
67+) {
68+ if anchor == nil {
69+ if onClose != nil {
70+ onClose()
71+ }
72+ return
73+ }
74+
75+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
76+ box.AddCssClass("context-menu")
77+
78+ selected := false
79+ var popover *gtk.Popover
80+
81+ for _, item := range items {
82+ if item.separator {
83+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
84+ box.Append(&sep.Widget)
85+ continue
86+ }
87+
88+ btn := gtk.NewButton()
89+ btn.SetLabel(item.item.Label)
90+ btn.AddCssClass("flat")
91+
92+ menuItem := item.item
93+ clickCb := func(_ gtk.Button) {
94+ selected = true
95+ if onSelect != nil {
96+ onSelect(menuItem)
97+ }
98+ if popover != nil {
99+ popover.Popdown()
100+ }
101+ }
102+ btn.ConnectClicked(&clickCb)
103+ box.Append(&btn.Widget)
104+ }
105+
106+ if len(items) == 0 {
107+ if onClose != nil {
108+ onClose()
109+ }
110+ return
111+ }
112+
113+ popover = gtk.NewPopover()
114+ popover.SetChild(&box.Widget)
115+ popover.SetParent(anchor)
116+ popover.SetHasArrow(false)
117+ popover.SetAutohide(true)
118+
119+ rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
120+ popover.SetPointingTo(rect)
121+
122+ closedCb := func(_ gtk.Popover) {
123+ if !selected && onClose != nil {
124+ onClose()
125+ }
126+ popover.Unparent()
127+ }
128+ popover.ConnectClosed(&closedCb)
129+
130+ popover.Popup()
131+}
@@ -0,0 +1,131 @@
1+package contextmenu
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/port"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+type renderItem struct {
10+ item port.MenuItem
11+ separator bool
12+}
13+
14+// Renderer shows GTK context menus on the provided anchor widget.
15+type Renderer struct {
16+ dispatch func(func())
17+}
18+
19+// NewRenderer creates a new Renderer.
20+func NewRenderer(dispatch func(func())) *Renderer {
21+ return &Renderer{dispatch: dispatch}
22+}
23+
24+func buildButtons(items []port.MenuItem) []renderItem {
25+ renderItems := make([]renderItem, 0, len(items))
26+ for _, item := range items {
27+ if isSeparator(item) {
28+ renderItems = append(renderItems, renderItem{separator: true})
29+ continue
30+ }
31+ renderItems = append(renderItems, renderItem{item: item})
32+ }
33+ return renderItems
34+}
35+
36+func isSeparator(item port.MenuItem) bool {
37+ return item.Action == "" && item.Label == ""
38+}
39+
40+// Show renders a popover menu on the GTK thread.
41+func (r *Renderer) Show(
42+ items []port.MenuItem,
43+ anchor *gtk.Widget,
44+ x, y int32,
45+ onSelect func(port.MenuItem),
46+ onClose func(),
47+) {
48+ if r == nil {
49+ return
50+ }
51+ run := func() {
52+ showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53+ }
54+ if r.dispatch == nil {
55+ run()
56+ return
57+ }
58+ r.dispatch(run)
59+}
60+
61+func showMenu(
62+ anchor *gtk.Widget,
63+ items []renderItem,
64+ x, y int32,
65+ onSelect func(port.MenuItem),
66+ onClose func(),
67+) {
68+ if anchor == nil {
69+ if onClose != nil {
70+ onClose()
71+ }
72+ return
73+ }
74+
75+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
76+ box.AddCssClass("context-menu")
77+
78+ selected := false
79+ var popover *gtk.Popover
80+
81+ for _, item := range items {
82+ if item.separator {
83+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
84+ box.Append(&sep.Widget)
85+ continue
86+ }
87+
88+ btn := gtk.NewButton()
89+ btn.SetLabel(item.item.Label)
90+ btn.AddCssClass("flat")
91+
92+ menuItem := item.item
93+ clickCb := func(_ gtk.Button) {
94+ selected = true
95+ if onSelect != nil {
96+ onSelect(menuItem)
97+ }
98+ if popover != nil {
99+ popover.Popdown()
100+ }
101+ }
102+ btn.ConnectClicked(&clickCb)
103+ box.Append(&btn.Widget)
104+ }
105+
106+ if len(items) == 0 {
107+ if onClose != nil {
108+ onClose()
109+ }
110+ return
111+ }
112+
113+ popover = gtk.NewPopover()
114+ popover.SetChild(&box.Widget)
115+ popover.SetParent(anchor)
116+ popover.SetHasArrow(false)
117+ popover.SetAutohide(true)
118+
119+ rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
120+ popover.SetPointingTo(rect)
121+
122+ closedCb := func(_ gtk.Popover) {
123+ if !selected && onClose != nil {
124+ onClose()
125+ }
126+ popover.Unparent()
127+ }
128+ popover.ConnectClosed(&closedCb)
129+
130+ popover.Popup()
131+}
A internal/infrastructure/contextmenu/renderer_test.go +31 -0
@@ -0,0 +1,31 @@
1+package contextmenu
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestBuildButtons(t *testing.T) {
11+ items := []port.MenuItem{
12+ {Action: port.MenuActionBack, Label: "Back"},
13+ {},
14+ {Action: port.MenuActionReload, Label: "Reload"},
15+ {Action: port.MenuActionInspectElement, Label: "Inspect Element"},
16+ }
17+
18+ renderItems := buildButtons(items)
19+ require.Len(t, renderItems, 4)
20+
21+ require.Equal(t, "Back", renderItems[0].item.Label)
22+ require.False(t, renderItems[0].separator)
23+
24+ require.True(t, renderItems[1].separator)
25+
26+ require.Equal(t, "Reload", renderItems[2].item.Label)
27+ require.False(t, renderItems[2].separator)
28+
29+ require.Equal(t, "Inspect Element", renderItems[3].item.Label)
30+ require.False(t, renderItems[3].separator)
31+}
@@ -0,0 +1,31 @@
1+package contextmenu
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestBuildButtons(t *testing.T) {
11+ items := []port.MenuItem{
12+ {Action: port.MenuActionBack, Label: "Back"},
13+ {},
14+ {Action: port.MenuActionReload, Label: "Reload"},
15+ {Action: port.MenuActionInspectElement, Label: "Inspect Element"},
16+ }
17+
18+ renderItems := buildButtons(items)
19+ require.Len(t, renderItems, 4)
20+
21+ require.Equal(t, "Back", renderItems[0].item.Label)
22+ require.False(t, renderItems[0].separator)
23+
24+ require.True(t, renderItems[1].separator)
25+
26+ require.Equal(t, "Reload", renderItems[2].item.Label)
27+ require.False(t, renderItems[2].separator)
28+
29+ require.Equal(t, "Inspect Element", renderItems[3].item.Label)
30+ require.False(t, renderItems[3].separator)
31+}
A internal/infrastructure/contextmenu/resolved_image_saver.go +72 -0
@@ -0,0 +1,72 @@
1+package contextmenu
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ resolvedImageDirPerm = 0o755
14+ resolvedImageFilePerm = 0o644
15+)
16+
17+// ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk.
18+type ResolvedImageSaver struct {
19+ preparer port.DownloadPreparer
20+ downloadDir string
21+ menuCtx port.MenuContext
22+}
23+
24+// NewResolvedImageSaver creates a new ResolvedImageSaver helper.
25+func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver {
26+ return &ResolvedImageSaver{
27+ preparer: preparer,
28+ downloadDir: downloadDir,
29+ menuCtx: menuCtx,
30+ }
31+}
32+
33+// SaveResolvedImage resolves a destination path and writes the image bytes.
34+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
35+ if s == nil || s.preparer == nil {
36+ return fmt.Errorf("resolved image saver: download preparer not available")
37+ }
38+
39+ output := s.preparer.Execute(ctx, port.DownloadPrepareInput{
40+ DownloadDir: s.downloadDir,
41+ // Leave SuggestedFilename empty so DownloadPreparer can fall back to
42+ // URI-derived naming when no explicit filename is available.
43+ Response: downloadResponse{
44+ mimeType: "image/png",
45+ uri: s.menuCtx.ImageURI,
46+ },
47+ })
48+ if output == nil || output.DestinationPath == "" {
49+ return fmt.Errorf("resolved image saver: destination path not prepared")
50+ }
51+ if len(image.Bytes) == 0 {
52+ return fmt.Errorf("resolved image saver: empty image data")
53+ }
54+
55+ if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {
56+ return err
57+ }
58+ if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil {
59+ return err
60+ }
61+ return nil
62+}
63+
64+type downloadResponse struct {
65+ mimeType string
66+ suggestedFilename string
67+ uri string
68+}
69+
70+func (r downloadResponse) GetMimeType() string { return r.mimeType }
71+func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename }
72+func (r downloadResponse) GetUri() string { return r.uri }
@@ -0,0 +1,72 @@
1+package contextmenu
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ resolvedImageDirPerm = 0o755
14+ resolvedImageFilePerm = 0o644
15+)
16+
17+// ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk.
18+type ResolvedImageSaver struct {
19+ preparer port.DownloadPreparer
20+ downloadDir string
21+ menuCtx port.MenuContext
22+}
23+
24+// NewResolvedImageSaver creates a new ResolvedImageSaver helper.
25+func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver {
26+ return &ResolvedImageSaver{
27+ preparer: preparer,
28+ downloadDir: downloadDir,
29+ menuCtx: menuCtx,
30+ }
31+}
32+
33+// SaveResolvedImage resolves a destination path and writes the image bytes.
34+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
35+ if s == nil || s.preparer == nil {
36+ return fmt.Errorf("resolved image saver: download preparer not available")
37+ }
38+
39+ output := s.preparer.Execute(ctx, port.DownloadPrepareInput{
40+ DownloadDir: s.downloadDir,
41+ // Leave SuggestedFilename empty so DownloadPreparer can fall back to
42+ // URI-derived naming when no explicit filename is available.
43+ Response: downloadResponse{
44+ mimeType: "image/png",
45+ uri: s.menuCtx.ImageURI,
46+ },
47+ })
48+ if output == nil || output.DestinationPath == "" {
49+ return fmt.Errorf("resolved image saver: destination path not prepared")
50+ }
51+ if len(image.Bytes) == 0 {
52+ return fmt.Errorf("resolved image saver: empty image data")
53+ }
54+
55+ if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {
56+ return err
57+ }
58+ if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil {
59+ return err
60+ }
61+ return nil
62+}
63+
64+type downloadResponse struct {
65+ mimeType string
66+ suggestedFilename string
67+ uri string
68+}
69+
70+func (r downloadResponse) GetMimeType() string { return r.mimeType }
71+func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename }
72+func (r downloadResponse) GetUri() string { return r.uri }
A internal/infrastructure/contextmenu/resolved_image_saver_test.go +60 -0
@@ -0,0 +1,60 @@
1+package contextmenu
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type fakeDownloadPreparer struct {
14+ input port.DownloadPrepareInput
15+ output *port.DownloadPrepareOutput
16+}
17+
18+func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {
19+ f.input = input
20+ return f.output
21+}
22+
23+func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
24+ downloadDir := t.TempDir()
25+ destPath := filepath.Join(downloadDir, "image.png")
26+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
27+ saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"})
28+
29+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}})
30+ require.NoError(t, err)
31+ require.Equal(t, downloadDir, preparer.input.DownloadDir)
32+ require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
33+ require.Equal(t, "image/png", preparer.input.Response.GetMimeType())
34+ require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))
35+}
36+
37+func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
38+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
39+ saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
40+
41+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}})
42+ require.Error(t, err)
43+ require.Contains(t, err.Error(), "destination path")
44+}
45+
46+func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
47+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
48+ saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
49+
50+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{})
51+ require.Error(t, err)
52+ require.Contains(t, err.Error(), "empty image data")
53+}
54+
55+func readFile(t *testing.T, path string) []byte {
56+ t.Helper()
57+ data, err := os.ReadFile(path)
58+ require.NoError(t, err)
59+ return data
60+}
@@ -0,0 +1,60 @@
1+package contextmenu
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type fakeDownloadPreparer struct {
14+ input port.DownloadPrepareInput
15+ output *port.DownloadPrepareOutput
16+}
17+
18+func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {
19+ f.input = input
20+ return f.output
21+}
22+
23+func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
24+ downloadDir := t.TempDir()
25+ destPath := filepath.Join(downloadDir, "image.png")
26+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
27+ saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"})
28+
29+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}})
30+ require.NoError(t, err)
31+ require.Equal(t, downloadDir, preparer.input.DownloadDir)
32+ require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
33+ require.Equal(t, "image/png", preparer.input.Response.GetMimeType())
34+ require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))
35+}
36+
37+func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
38+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
39+ saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
40+
41+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}})
42+ require.Error(t, err)
43+ require.Contains(t, err.Error(), "destination path")
44+}
45+
46+func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
47+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
48+ saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
49+
50+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{})
51+ require.Error(t, err)
52+ require.Contains(t, err.Error(), "empty image data")
53+}
54+
55+func readFile(t *testing.T, path string) []byte {
56+ t.Helper()
57+ data, err := os.ReadFile(path)
58+ require.NoError(t, err)
59+ return data
60+}
A internal/infrastructure/webkit/context_menu.go +200 -0
@@ -0,0 +1,200 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/puregotk-webkit/webkit"
15+)
16+
17+// contextMenuResolver implements port.ImageDataResolver by fetching image bytes
18+// over HTTP. This is the WebKit-specific seam for the shared context menu
19+// pipeline's copy-image and save-image actions.
20+type contextMenuResolver struct {
21+ client *http.Client
22+}
23+
24+var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
25+
26+const maxImageFetchBytes = 50 * 1024 * 1024
27+
28+// ResolveImageData fetches raw image bytes from the given URI.
29+func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) {
30+ if imageURI == "" {
31+ return port.ImageData{}, fmt.Errorf("empty image URI")
32+ }
33+
34+ client := r.client
35+ if client == nil {
36+ client = defaultImageFetchClient
37+ }
38+
39+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody)
40+ if err != nil {
41+ return port.ImageData{}, fmt.Errorf("build request: %w", err)
42+ }
43+
44+ resp, err := client.Do(req)
45+ if err != nil {
46+ return port.ImageData{}, fmt.Errorf("fetch image: %w", err)
47+ }
48+ defer func() { _ = resp.Body.Close() }()
49+
50+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
51+ return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
52+ }
53+
54+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes))
55+ if err != nil {
56+ return port.ImageData{}, fmt.Errorf("read image body: %w", err)
57+ }
58+ if len(data) == 0 {
59+ return port.ImageData{}, fmt.Errorf("read image body: empty image data")
60+ }
61+
62+ return port.ImageData{Bytes: data}, nil
63+}
64+
65+// Compile-time check that contextMenuResolver implements port.ImageDataResolver.
66+var _ port.ImageDataResolver = (*contextMenuResolver)(nil)
67+
68+// buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext.
69+func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext {
70+ ctx := port.MenuContext{
71+ X: x,
72+ Y: y,
73+ }
74+
75+ if wv != nil {
76+ ctx.PageURI = wv.URI()
77+ ctx.CanGoBack = wv.CanGoBack()
78+ ctx.CanGoForward = wv.CanGoForward()
79+ }
80+
81+ if hit == nil {
82+ return ctx
83+ }
84+
85+ if hit.ContextIsLink() {
86+ ctx.LinkURI = hit.GetLinkUri()
87+ }
88+ if hit.ContextIsImage() {
89+ ctx.ImageURI = hit.GetImageUri()
90+ }
91+ ctx.HasSelection = hit.ContextIsSelection()
92+ ctx.IsEditable = hit.ContextIsEditable()
93+
94+ return ctx
95+}
96+
97+// contextMenuPipeline holds the dependencies needed to run the shared
98+// context menu flow from inside a WebKit context-menu signal handler.
99+type contextMenuPipeline struct {
100+ buildUC *usecase.BuildContextMenuUseCase
101+ executeUC *usecase.ExecuteContextMenuActionUseCase
102+ renderer *contextmenu.Renderer
103+}
104+
105+// connectContextMenuSignal wires the WebKit "context-menu" signal to the
106+// shared context menu pipeline. When the user right-clicks, WebKit's native
107+// menu is suppressed and replaced by the shared GTK popover menu.
108+func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
109+ if pipeline == nil {
110+ return
111+ }
112+
113+ contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu
114+ hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
115+
116+ // Determine click coordinates from the hit test result.
117+ // WebKit does not expose x/y directly on the context-menu signal;
118+ // we fall back to 0,0 if the hit test lacks positional data.
119+ // The renderer uses these as popover anchor coordinates.
120+ var x, y int
121+
122+ menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
123+ items := pipeline.buildUC.Build(context.Background(), menuContext)
124+ if len(items) == 0 {
125+ return true // suppress native menu, nothing to show
126+ }
127+
128+ // Get the widget for anchoring the popover.
129+ if wv.inner == nil {
130+ return true
131+ }
132+ anchor := &wv.inner.Widget
133+
134+ pipeline.renderer.Show(
135+ items,
136+ anchor,
137+ int32(x),
138+ int32(y),
139+ func(item port.MenuItem) {
140+ wv.executeContextMenuAction(pipeline.executeUC, item, menuContext)
141+ },
142+ nil, // onClose — no action needed
143+ )
144+
145+ return true // suppress native WebKit context menu
146+ }
147+
148+ sigID := wv.inner.ConnectContextMenu(&contextMenuCb)
149+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
150+}
151+
152+// executeContextMenuAction runs the selected menu action through the shared
153+// execute use case. Runs in a goroutine to avoid blocking the GTK main thread.
154+func (wv *WebView) executeContextMenuAction(
155+ executeUC *usecase.ExecuteContextMenuActionUseCase,
156+ item port.MenuItem,
157+ menuContext port.MenuContext,
158+) {
159+ if executeUC == nil {
160+ return
161+ }
162+
163+ go func() {
164+ ctx := logging.WithContext(context.Background(), wv.logger)
165+ input := usecase.ExecuteContextMenuActionInput{
166+ Action: item.Action,
167+ Context: menuContext,
168+ }
169+ if err := executeUC.Execute(ctx, input); err != nil {
170+ wv.logger.Warn().Err(err).
171+ Str("action", string(item.Action)).
172+ Msg("context menu action failed")
173+ }
174+ }()
175+}
176+
177+// SetupContextMenu configures the context menu pipeline for this WebView.
178+// Called during WebView setup when the shared dependencies are available.
179+func (wv *WebView) SetupContextMenu(
180+ buildUC *usecase.BuildContextMenuUseCase,
181+ executeUC *usecase.ExecuteContextMenuActionUseCase,
182+ renderer *contextmenu.Renderer,
183+) {
184+ if wv == nil || wv.inner == nil {
185+ return
186+ }
187+ pipeline := &contextMenuPipeline{
188+ buildUC: buildUC,
189+ executeUC: executeUC,
190+ renderer: renderer,
191+ }
192+ wv.contextMenu = pipeline
193+ wv.connectContextMenuSignal(pipeline)
194+}
195+
196+// NewContextMenuResolver creates a new ImageDataResolver for WebKit
197+// that fetches image bytes over HTTP.
198+func NewContextMenuResolver() port.ImageDataResolver {
199+ return &contextMenuResolver{}
200+}
@@ -0,0 +1,200 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/puregotk-webkit/webkit"
15+)
16+
17+// contextMenuResolver implements port.ImageDataResolver by fetching image bytes
18+// over HTTP. This is the WebKit-specific seam for the shared context menu
19+// pipeline's copy-image and save-image actions.
20+type contextMenuResolver struct {
21+ client *http.Client
22+}
23+
24+var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
25+
26+const maxImageFetchBytes = 50 * 1024 * 1024
27+
28+// ResolveImageData fetches raw image bytes from the given URI.
29+func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) {
30+ if imageURI == "" {
31+ return port.ImageData{}, fmt.Errorf("empty image URI")
32+ }
33+
34+ client := r.client
35+ if client == nil {
36+ client = defaultImageFetchClient
37+ }
38+
39+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody)
40+ if err != nil {
41+ return port.ImageData{}, fmt.Errorf("build request: %w", err)
42+ }
43+
44+ resp, err := client.Do(req)
45+ if err != nil {
46+ return port.ImageData{}, fmt.Errorf("fetch image: %w", err)
47+ }
48+ defer func() { _ = resp.Body.Close() }()
49+
50+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
51+ return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
52+ }
53+
54+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes))
55+ if err != nil {
56+ return port.ImageData{}, fmt.Errorf("read image body: %w", err)
57+ }
58+ if len(data) == 0 {
59+ return port.ImageData{}, fmt.Errorf("read image body: empty image data")
60+ }
61+
62+ return port.ImageData{Bytes: data}, nil
63+}
64+
65+// Compile-time check that contextMenuResolver implements port.ImageDataResolver.
66+var _ port.ImageDataResolver = (*contextMenuResolver)(nil)
67+
68+// buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext.
69+func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext {
70+ ctx := port.MenuContext{
71+ X: x,
72+ Y: y,
73+ }
74+
75+ if wv != nil {
76+ ctx.PageURI = wv.URI()
77+ ctx.CanGoBack = wv.CanGoBack()
78+ ctx.CanGoForward = wv.CanGoForward()
79+ }
80+
81+ if hit == nil {
82+ return ctx
83+ }
84+
85+ if hit.ContextIsLink() {
86+ ctx.LinkURI = hit.GetLinkUri()
87+ }
88+ if hit.ContextIsImage() {
89+ ctx.ImageURI = hit.GetImageUri()
90+ }
91+ ctx.HasSelection = hit.ContextIsSelection()
92+ ctx.IsEditable = hit.ContextIsEditable()
93+
94+ return ctx
95+}
96+
97+// contextMenuPipeline holds the dependencies needed to run the shared
98+// context menu flow from inside a WebKit context-menu signal handler.
99+type contextMenuPipeline struct {
100+ buildUC *usecase.BuildContextMenuUseCase
101+ executeUC *usecase.ExecuteContextMenuActionUseCase
102+ renderer *contextmenu.Renderer
103+}
104+
105+// connectContextMenuSignal wires the WebKit "context-menu" signal to the
106+// shared context menu pipeline. When the user right-clicks, WebKit's native
107+// menu is suppressed and replaced by the shared GTK popover menu.
108+func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
109+ if pipeline == nil {
110+ return
111+ }
112+
113+ contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu
114+ hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
115+
116+ // Determine click coordinates from the hit test result.
117+ // WebKit does not expose x/y directly on the context-menu signal;
118+ // we fall back to 0,0 if the hit test lacks positional data.
119+ // The renderer uses these as popover anchor coordinates.
120+ var x, y int
121+
122+ menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
123+ items := pipeline.buildUC.Build(context.Background(), menuContext)
124+ if len(items) == 0 {
125+ return true // suppress native menu, nothing to show
126+ }
127+
128+ // Get the widget for anchoring the popover.
129+ if wv.inner == nil {
130+ return true
131+ }
132+ anchor := &wv.inner.Widget
133+
134+ pipeline.renderer.Show(
135+ items,
136+ anchor,
137+ int32(x),
138+ int32(y),
139+ func(item port.MenuItem) {
140+ wv.executeContextMenuAction(pipeline.executeUC, item, menuContext)
141+ },
142+ nil, // onClose — no action needed
143+ )
144+
145+ return true // suppress native WebKit context menu
146+ }
147+
148+ sigID := wv.inner.ConnectContextMenu(&contextMenuCb)
149+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
150+}
151+
152+// executeContextMenuAction runs the selected menu action through the shared
153+// execute use case. Runs in a goroutine to avoid blocking the GTK main thread.
154+func (wv *WebView) executeContextMenuAction(
155+ executeUC *usecase.ExecuteContextMenuActionUseCase,
156+ item port.MenuItem,
157+ menuContext port.MenuContext,
158+) {
159+ if executeUC == nil {
160+ return
161+ }
162+
163+ go func() {
164+ ctx := logging.WithContext(context.Background(), wv.logger)
165+ input := usecase.ExecuteContextMenuActionInput{
166+ Action: item.Action,
167+ Context: menuContext,
168+ }
169+ if err := executeUC.Execute(ctx, input); err != nil {
170+ wv.logger.Warn().Err(err).
171+ Str("action", string(item.Action)).
172+ Msg("context menu action failed")
173+ }
174+ }()
175+}
176+
177+// SetupContextMenu configures the context menu pipeline for this WebView.
178+// Called during WebView setup when the shared dependencies are available.
179+func (wv *WebView) SetupContextMenu(
180+ buildUC *usecase.BuildContextMenuUseCase,
181+ executeUC *usecase.ExecuteContextMenuActionUseCase,
182+ renderer *contextmenu.Renderer,
183+) {
184+ if wv == nil || wv.inner == nil {
185+ return
186+ }
187+ pipeline := &contextMenuPipeline{
188+ buildUC: buildUC,
189+ executeUC: executeUC,
190+ renderer: renderer,
191+ }
192+ wv.contextMenu = pipeline
193+ wv.connectContextMenuSignal(pipeline)
194+}
195+
196+// NewContextMenuResolver creates a new ImageDataResolver for WebKit
197+// that fetches image bytes over HTTP.
198+func NewContextMenuResolver() port.ImageDataResolver {
199+ return &contextMenuResolver{}
200+}
A internal/infrastructure/webkit/context_menu_test.go +150 -0
@@ -0,0 +1,150 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// stubHitTest simulates a WebKit HitTestResult for testing.
15+type stubHitTest struct {
16+ linkURI string
17+ imageURI string
18+ isLink bool
19+ isImage bool
20+ isEditable bool
21+ isSelection bool
22+}
23+
24+func TestBuildMenuContextFromHitTest(t *testing.T) {
25+ t.Run("image hit test populates ImageURI", func(t *testing.T) {
26+ hit := stubHitTest{
27+ imageURI: "https://example.com/photo.png",
28+ isImage: true,
29+ }
30+ ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200)
31+
32+ assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI)
33+ assert.Equal(t, 100, ctx.X)
34+ assert.Equal(t, 200, ctx.Y)
35+ assert.True(t, ctx.CanGoBack)
36+ assert.False(t, ctx.CanGoForward)
37+ })
38+
39+ t.Run("link hit test populates LinkURI", func(t *testing.T) {
40+ hit := stubHitTest{
41+ linkURI: "https://example.com/page",
42+ isLink: true,
43+ }
44+ ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75)
45+
46+ assert.Equal(t, "https://example.com/page", ctx.LinkURI)
47+ assert.Empty(t, ctx.ImageURI)
48+ assert.False(t, ctx.CanGoBack)
49+ assert.True(t, ctx.CanGoForward)
50+ })
51+
52+ t.Run("editable and selection flags propagate", func(t *testing.T) {
53+ hit := stubHitTest{
54+ isEditable: true,
55+ isSelection: true,
56+ }
57+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
58+
59+ assert.True(t, ctx.IsEditable)
60+ assert.True(t, ctx.HasSelection)
61+ })
62+
63+ t.Run("combined image and link", func(t *testing.T) {
64+ hit := stubHitTest{
65+ linkURI: "https://example.com/link",
66+ imageURI: "https://example.com/img.jpg",
67+ isLink: true,
68+ isImage: true,
69+ }
70+ ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20)
71+
72+ assert.Equal(t, "https://example.com/link", ctx.LinkURI)
73+ assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI)
74+ assert.True(t, ctx.CanGoBack)
75+ assert.True(t, ctx.CanGoForward)
76+ })
77+
78+ t.Run("empty hit test produces empty context", func(t *testing.T) {
79+ hit := stubHitTest{}
80+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
81+
82+ assert.Empty(t, ctx.LinkURI)
83+ assert.Empty(t, ctx.ImageURI)
84+ assert.False(t, ctx.HasSelection)
85+ assert.False(t, ctx.IsEditable)
86+ })
87+}
88+
89+func TestResolveImageData(t *testing.T) {
90+ t.Run("rejects empty image URI", func(t *testing.T) {
91+ resolver := &contextMenuResolver{}
92+ _, err := resolver.ResolveImageData(context.Background(), "")
93+ require.Error(t, err)
94+ assert.Contains(t, err.Error(), "empty image URI")
95+ })
96+
97+ t.Run("fetches image bytes from HTTP", func(t *testing.T) {
98+ imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic
99+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
100+ w.Header().Set("Content-Type", "image/png")
101+ w.Write(imageBytes)
102+ }))
103+ defer srv.Close()
104+
105+ resolver := &contextMenuResolver{}
106+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
107+ require.NoError(t, err)
108+ assert.Equal(t, imageBytes, data.Bytes)
109+ })
110+
111+ t.Run("returns error on HTTP failure", func(t *testing.T) {
112+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
113+ w.WriteHeader(http.StatusNotFound)
114+ }))
115+ defer srv.Close()
116+
117+ resolver := &contextMenuResolver{}
118+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
119+ require.Error(t, err)
120+ assert.Contains(t, err.Error(), "404")
121+ })
122+
123+ t.Run("returns error on invalid URL", func(t *testing.T) {
124+ resolver := &contextMenuResolver{}
125+ _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
126+ require.Error(t, err)
127+ })
128+}
129+
130+// buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
131+// that works with our stub type instead of requiring real WebKit objects.
132+func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
133+ ctx := port.MenuContext{
134+ CanGoBack: canGoBack,
135+ CanGoForward: canGoForward,
136+ X: x,
137+ Y: y,
138+ }
139+
140+ if hit.isLink {
141+ ctx.LinkURI = hit.linkURI
142+ }
143+ if hit.isImage {
144+ ctx.ImageURI = hit.imageURI
145+ }
146+ ctx.HasSelection = hit.isSelection
147+ ctx.IsEditable = hit.isEditable
148+
149+ return ctx
150+}
@@ -0,0 +1,150 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// stubHitTest simulates a WebKit HitTestResult for testing.
15+type stubHitTest struct {
16+ linkURI string
17+ imageURI string
18+ isLink bool
19+ isImage bool
20+ isEditable bool
21+ isSelection bool
22+}
23+
24+func TestBuildMenuContextFromHitTest(t *testing.T) {
25+ t.Run("image hit test populates ImageURI", func(t *testing.T) {
26+ hit := stubHitTest{
27+ imageURI: "https://example.com/photo.png",
28+ isImage: true,
29+ }
30+ ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200)
31+
32+ assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI)
33+ assert.Equal(t, 100, ctx.X)
34+ assert.Equal(t, 200, ctx.Y)
35+ assert.True(t, ctx.CanGoBack)
36+ assert.False(t, ctx.CanGoForward)
37+ })
38+
39+ t.Run("link hit test populates LinkURI", func(t *testing.T) {
40+ hit := stubHitTest{
41+ linkURI: "https://example.com/page",
42+ isLink: true,
43+ }
44+ ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75)
45+
46+ assert.Equal(t, "https://example.com/page", ctx.LinkURI)
47+ assert.Empty(t, ctx.ImageURI)
48+ assert.False(t, ctx.CanGoBack)
49+ assert.True(t, ctx.CanGoForward)
50+ })
51+
52+ t.Run("editable and selection flags propagate", func(t *testing.T) {
53+ hit := stubHitTest{
54+ isEditable: true,
55+ isSelection: true,
56+ }
57+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
58+
59+ assert.True(t, ctx.IsEditable)
60+ assert.True(t, ctx.HasSelection)
61+ })
62+
63+ t.Run("combined image and link", func(t *testing.T) {
64+ hit := stubHitTest{
65+ linkURI: "https://example.com/link",
66+ imageURI: "https://example.com/img.jpg",
67+ isLink: true,
68+ isImage: true,
69+ }
70+ ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20)
71+
72+ assert.Equal(t, "https://example.com/link", ctx.LinkURI)
73+ assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI)
74+ assert.True(t, ctx.CanGoBack)
75+ assert.True(t, ctx.CanGoForward)
76+ })
77+
78+ t.Run("empty hit test produces empty context", func(t *testing.T) {
79+ hit := stubHitTest{}
80+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
81+
82+ assert.Empty(t, ctx.LinkURI)
83+ assert.Empty(t, ctx.ImageURI)
84+ assert.False(t, ctx.HasSelection)
85+ assert.False(t, ctx.IsEditable)
86+ })
87+}
88+
89+func TestResolveImageData(t *testing.T) {
90+ t.Run("rejects empty image URI", func(t *testing.T) {
91+ resolver := &contextMenuResolver{}
92+ _, err := resolver.ResolveImageData(context.Background(), "")
93+ require.Error(t, err)
94+ assert.Contains(t, err.Error(), "empty image URI")
95+ })
96+
97+ t.Run("fetches image bytes from HTTP", func(t *testing.T) {
98+ imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic
99+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
100+ w.Header().Set("Content-Type", "image/png")
101+ w.Write(imageBytes)
102+ }))
103+ defer srv.Close()
104+
105+ resolver := &contextMenuResolver{}
106+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
107+ require.NoError(t, err)
108+ assert.Equal(t, imageBytes, data.Bytes)
109+ })
110+
111+ t.Run("returns error on HTTP failure", func(t *testing.T) {
112+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
113+ w.WriteHeader(http.StatusNotFound)
114+ }))
115+ defer srv.Close()
116+
117+ resolver := &contextMenuResolver{}
118+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
119+ require.Error(t, err)
120+ assert.Contains(t, err.Error(), "404")
121+ })
122+
123+ t.Run("returns error on invalid URL", func(t *testing.T) {
124+ resolver := &contextMenuResolver{}
125+ _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
126+ require.Error(t, err)
127+ })
128+}
129+
130+// buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
131+// that works with our stub type instead of requiring real WebKit objects.
132+func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
133+ ctx := port.MenuContext{
134+ CanGoBack: canGoBack,
135+ CanGoForward: canGoForward,
136+ X: x,
137+ Y: y,
138+ }
139+
140+ if hit.isLink {
141+ ctx.LinkURI = hit.linkURI
142+ }
143+ if hit.isImage {
144+ ctx.ImageURI = hit.imageURI
145+ }
146+ ctx.HasSelection = hit.isSelection
147+ ctx.IsEditable = hit.isEditable
148+
149+ return ctx
150+}
M internal/infrastructure/webkit/engine.go +63 -0
@@ -5,7 +5,9 @@ import (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
911 "github.com/bnema/dumber/internal/infrastructure/filtering"
1012 "github.com/bnema/dumber/internal/infrastructure/handlers"
1113 "github.com/rs/zerolog"
@@ -24,6 +26,7 @@ type Engine struct {
2426 schemeHandler *DumbSchemeHandler
2527 schemePath string
2628 logger zerolog.Logger
29+ clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
2730 }
2831
2932 // Compile-time check that Engine implements port.Engine.
@@ -92,6 +95,8 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
9295 if e.messageRouter == nil {
9396 return fmt.Errorf("message router not initialized")
9497 }
98+ // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
99+ e.clipboard = deps.Clipboard
95100 return handlers.RegisterAll(ctx, e.messageRouter, deps)
96101 }
97102
@@ -113,6 +118,12 @@ func (e *Engine) ConfigureDownloads(
113118 }
114119 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
115120 e.wkCtx.SetDownloadHandler(ctx, handler)
121+
122+ // Wire the shared context menu pipeline now that all dependencies are
123+ // available. RegisterHandlers (called earlier) captured the clipboard;
124+ // ConfigureDownloads supplies the download preparer and path.
125+ e.installContextMenuPipeline()
126+
116127 return nil
117128 }
118129
@@ -146,3 +157,55 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
146157 }
147158 return nil
148159 }
160+
161+// installContextMenuPipeline creates the shared context menu pipeline and
162+// propagates it to the factory (and pool). This is called once during startup,
163+// after both RegisterHandlers (clipboard) and ConfigureDownloads (download
164+// deps) have run. Kept on the concrete *Engine type because it is a
165+// WebKit-specific concern.
166+func (e *Engine) installContextMenuPipeline() {
167+ if e.clipboard == nil {
168+ e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline")
169+ return
170+ }
171+
172+ buildUC := usecase.NewBuildContextMenuUseCase()
173+ resolver := NewContextMenuResolver()
174+
175+ // The saver is nil for now: the ResolvedImageSaver port interface does not
176+ // carry the image URI needed for filename derivation, so save-image will
177+ // return a graceful error ("image saver not available"). Copy-image (the
178+ // primary goal) works without it.
179+ var saver port.ResolvedImageSaver
180+
181+ executeUC := usecase.NewExecuteContextMenuActionUseCase(
182+ e.clipboard,
183+ resolver,
184+ saver,
185+ nil, // delegator — not yet implemented
186+ )
187+
188+ // WebKit's context-menu signal fires on the GTK main thread, so no
189+ // dispatch wrapper is needed.
190+ renderer := contextmenu.NewRenderer(nil)
191+
192+ e.SetContextMenuPipeline(buildUC, executeUC, renderer)
193+}
194+
195+// SetContextMenuPipeline configures the context menu pipeline for all WebViews
196+// created by the factory (and pool). Call this after the use cases and renderer
197+// are available in the bootstrap layer.
198+func (e *Engine) SetContextMenuPipeline(
199+ buildUC *usecase.BuildContextMenuUseCase,
200+ executeUC *usecase.ExecuteContextMenuActionUseCase,
201+ renderer *contextmenu.Renderer,
202+) {
203+ pipeline := &contextMenuPipeline{
204+ buildUC: buildUC,
205+ executeUC: executeUC,
206+ renderer: renderer,
207+ }
208+ if e.factory != nil {
209+ e.factory.SetContextMenuPipeline(pipeline)
210+ }
211+}
@@ -5,7 +5,9 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/contextmenu"
9 "github.com/bnema/dumber/internal/infrastructure/filtering"11 "github.com/bnema/dumber/internal/infrastructure/filtering"
10 "github.com/bnema/dumber/internal/infrastructure/handlers"12 "github.com/bnema/dumber/internal/infrastructure/handlers"
11 "github.com/rs/zerolog"13 "github.com/rs/zerolog"
@@ -24,6 +26,7 @@ type Engine struct {
24 schemeHandler *DumbSchemeHandler26 schemeHandler *DumbSchemeHandler
25 schemePath string27 schemePath string
26 logger zerolog.Logger28 logger zerolog.Logger
29+ clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
27 }30 }
28 31
29 // Compile-time check that Engine implements port.Engine.32 // Compile-time check that Engine implements port.Engine.
@@ -92,6 +95,8 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
92 if e.messageRouter == nil {95 if e.messageRouter == nil {
93 return fmt.Errorf("message router not initialized")96 return fmt.Errorf("message router not initialized")
94 }97 }
98+ // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
99+ e.clipboard = deps.Clipboard
95 return handlers.RegisterAll(ctx, e.messageRouter, deps)100 return handlers.RegisterAll(ctx, e.messageRouter, deps)
96 }101 }
97 102
@@ -113,6 +118,12 @@ func (e *Engine) ConfigureDownloads(
113 }118 }
114 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)119 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
115 e.wkCtx.SetDownloadHandler(ctx, handler)120 e.wkCtx.SetDownloadHandler(ctx, handler)
121+
122+ // Wire the shared context menu pipeline now that all dependencies are
123+ // available. RegisterHandlers (called earlier) captured the clipboard;
124+ // ConfigureDownloads supplies the download preparer and path.
125+ e.installContextMenuPipeline()
126+
116 return nil127 return nil
117 }128 }
118 129
@@ -146,3 +157,55 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
146 }157 }
147 return nil158 return nil
148 }159 }
160+
161+// installContextMenuPipeline creates the shared context menu pipeline and
162+// propagates it to the factory (and pool). This is called once during startup,
163+// after both RegisterHandlers (clipboard) and ConfigureDownloads (download
164+// deps) have run. Kept on the concrete *Engine type because it is a
165+// WebKit-specific concern.
166+func (e *Engine) installContextMenuPipeline() {
167+ if e.clipboard == nil {
168+ e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline")
169+ return
170+ }
171+
172+ buildUC := usecase.NewBuildContextMenuUseCase()
173+ resolver := NewContextMenuResolver()
174+
175+ // The saver is nil for now: the ResolvedImageSaver port interface does not
176+ // carry the image URI needed for filename derivation, so save-image will
177+ // return a graceful error ("image saver not available"). Copy-image (the
178+ // primary goal) works without it.
179+ var saver port.ResolvedImageSaver
180+
181+ executeUC := usecase.NewExecuteContextMenuActionUseCase(
182+ e.clipboard,
183+ resolver,
184+ saver,
185+ nil, // delegator — not yet implemented
186+ )
187+
188+ // WebKit's context-menu signal fires on the GTK main thread, so no
189+ // dispatch wrapper is needed.
190+ renderer := contextmenu.NewRenderer(nil)
191+
192+ e.SetContextMenuPipeline(buildUC, executeUC, renderer)
193+}
194+
195+// SetContextMenuPipeline configures the context menu pipeline for all WebViews
196+// created by the factory (and pool). Call this after the use cases and renderer
197+// are available in the bootstrap layer.
198+func (e *Engine) SetContextMenuPipeline(
199+ buildUC *usecase.BuildContextMenuUseCase,
200+ executeUC *usecase.ExecuteContextMenuActionUseCase,
201+ renderer *contextmenu.Renderer,
202+) {
203+ pipeline := &contextMenuPipeline{
204+ buildUC: buildUC,
205+ executeUC: executeUC,
206+ renderer: renderer,
207+ }
208+ if e.factory != nil {
209+ e.factory.SetContextMenuPipeline(pipeline)
210+ }
211+}
M internal/infrastructure/webkit/factory.go +23 -1
@@ -17,7 +17,8 @@ type WebViewFactory struct {
1717 pool *WebViewPool
1818 injector *ContentInjector
1919 router *MessageRouter
20- filterApplier FilterApplier // Optional content filter applier
20+ filterApplier FilterApplier // Optional content filter applier
21+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
2122
2223 // Background color for WebViews (eliminates white flash)
2324 bg bgColor
@@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
6162 }
6263 }
6364
65+// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
66+// Propagates to the pool if present.
67+func (f *WebViewFactory) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
68+ f.ctxMenu = pipeline
69+ if f.pool != nil {
70+ f.pool.SetContextMenuPipeline(pipeline)
71+ }
72+}
73+
6474 // Create creates a new WebView instance.
6575 // If a pool is configured, it will try to acquire from the pool first.
6676 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -119,6 +129,12 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
119129 f.filterApplier.ApplyTo(ctx, wv.ucm)
120130 }
121131
132+ // Wire context menu if configured
133+ if f.ctxMenu != nil {
134+ wv.contextMenu = f.ctxMenu
135+ wv.connectContextMenuSignal(f.ctxMenu)
136+ }
137+
122138 log.Debug().
123139 Uint64("id", uint64(wv.ID())).
124140 Uint64("parent_id", uint64(parentID)).
@@ -152,6 +168,12 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
152168 f.filterApplier.ApplyTo(ctx, wv.ucm)
153169 }
154170
171+ // Wire context menu if configured
172+ if f.ctxMenu != nil {
173+ wv.contextMenu = f.ctxMenu
174+ wv.connectContextMenuSignal(f.ctxMenu)
175+ }
176+
155177 log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
156178 return wv, nil
157179 }
@@ -17,7 +17,8 @@ type WebViewFactory struct {
17 pool *WebViewPool17 pool *WebViewPool
18 injector *ContentInjector18 injector *ContentInjector
19 router *MessageRouter19 router *MessageRouter
20- filterApplier FilterApplier // Optional content filter applier20+ filterApplier FilterApplier // Optional content filter applier
21+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
21 22
22 // Background color for WebViews (eliminates white flash)23 // Background color for WebViews (eliminates white flash)
23 bg bgColor24 bg bgColor
@@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
61 }62 }
62 }63 }
63 64
65+// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
66+// Propagates to the pool if present.
67+func (f *WebViewFactory) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
68+ f.ctxMenu = pipeline
69+ if f.pool != nil {
70+ f.pool.SetContextMenuPipeline(pipeline)
71+ }
72+}
73+
64 // Create creates a new WebView instance.74 // Create creates a new WebView instance.
65 // If a pool is configured, it will try to acquire from the pool first.75 // If a pool is configured, it will try to acquire from the pool first.
66 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {76 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -119,6 +129,12 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
119 f.filterApplier.ApplyTo(ctx, wv.ucm)129 f.filterApplier.ApplyTo(ctx, wv.ucm)
120 }130 }
121 131
132+ // Wire context menu if configured
133+ if f.ctxMenu != nil {
134+ wv.contextMenu = f.ctxMenu
135+ wv.connectContextMenuSignal(f.ctxMenu)
136+ }
137+
122 log.Debug().138 log.Debug().
123 Uint64("id", uint64(wv.ID())).139 Uint64("id", uint64(wv.ID())).
124 Uint64("parent_id", uint64(parentID)).140 Uint64("parent_id", uint64(parentID)).
@@ -152,6 +168,12 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
152 f.filterApplier.ApplyTo(ctx, wv.ucm)168 f.filterApplier.ApplyTo(ctx, wv.ucm)
153 }169 }
154 170
171+ // Wire context menu if configured
172+ if f.ctxMenu != nil {
173+ wv.contextMenu = f.ctxMenu
174+ wv.connectContextMenuSignal(f.ctxMenu)
175+ }
176+
155 log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")177 log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
156 return wv, nil178 return wv, nil
157 }179 }
M internal/infrastructure/webkit/pool.go +17 -1
@@ -67,7 +67,8 @@ type WebViewPool struct {
6767 poolMu sync.Mutex
6868 injector *ContentInjector
6969 router *MessageRouter
70- filterApplier FilterApplier // Optional content filter applier
70+ filterApplier FilterApplier // Optional content filter applier
71+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
7172
7273 // Background color for WebViews (eliminates white flash)
7374 bg bgColor
@@ -145,6 +146,11 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
145146 p.bg.set(r, g, b, a)
146147 }
147148
149+// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
150+func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
151+ p.ctxMenu = pipeline
152+}
153+
148154 // Acquire gets a WebView from the pool or creates a new one.
149155 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
150156 log := logging.FromContext(ctx)
@@ -171,6 +177,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
171177 if p.filterApplier != nil {
172178 p.filterApplier.ApplyTo(ctx, wv.ucm)
173179 }
180+ // Ensure context menu pipeline is set (may not have been available during prewarm)
181+ if p.ctxMenu != nil {
182+ wv.contextMenu = p.ctxMenu
183+ }
174184 wv.ReconnectSignals()
175185 p.acquireCount.Add(1)
176186 p.acquirePoolHits.Add(1)
@@ -237,6 +247,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
237247 p.filterApplier.ApplyTo(ctx, wv.ucm)
238248 }
239249
250+ // Wire context menu if configured
251+ if p.ctxMenu != nil {
252+ wv.contextMenu = p.ctxMenu
253+ wv.connectContextMenuSignal(p.ctxMenu)
254+ }
255+
240256 return wv, nil
241257 }
242258
@@ -67,7 +67,8 @@ type WebViewPool struct {
67 poolMu sync.Mutex67 poolMu sync.Mutex
68 injector *ContentInjector68 injector *ContentInjector
69 router *MessageRouter69 router *MessageRouter
70- filterApplier FilterApplier // Optional content filter applier70+ filterApplier FilterApplier // Optional content filter applier
71+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
71 72
72 // Background color for WebViews (eliminates white flash)73 // Background color for WebViews (eliminates white flash)
73 bg bgColor74 bg bgColor
@@ -145,6 +146,11 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
145 p.bg.set(r, g, b, a)146 p.bg.set(r, g, b, a)
146 }147 }
147 148
149+// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
150+func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
151+ p.ctxMenu = pipeline
152+}
153+
148 // Acquire gets a WebView from the pool or creates a new one.154 // Acquire gets a WebView from the pool or creates a new one.
149 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {155 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
150 log := logging.FromContext(ctx)156 log := logging.FromContext(ctx)
@@ -171,6 +177,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
171 if p.filterApplier != nil {177 if p.filterApplier != nil {
172 p.filterApplier.ApplyTo(ctx, wv.ucm)178 p.filterApplier.ApplyTo(ctx, wv.ucm)
173 }179 }
180+ // Ensure context menu pipeline is set (may not have been available during prewarm)
181+ if p.ctxMenu != nil {
182+ wv.contextMenu = p.ctxMenu
183+ }
174 wv.ReconnectSignals()184 wv.ReconnectSignals()
175 p.acquireCount.Add(1)185 p.acquireCount.Add(1)
176 p.acquirePoolHits.Add(1)186 p.acquirePoolHits.Add(1)
@@ -237,6 +247,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
237 p.filterApplier.ApplyTo(ctx, wv.ucm)247 p.filterApplier.ApplyTo(ctx, wv.ucm)
238 }248 }
239 249
250+ // Wire context menu if configured
251+ if p.ctxMenu != nil {
252+ wv.contextMenu = p.ctxMenu
253+ wv.connectContextMenuSignal(p.ctxMenu)
254+ }
255+
240 return wv, nil256 return wv, nil
241 }257 }
242 258
M internal/infrastructure/webkit/webview.go +4 -0
@@ -185,6 +185,9 @@ type WebView struct {
185185
186186 backForwardList *webkit.BackForwardList
187187 backForwardListSignalID uintptr
188+
189+ // contextMenu holds the optional context menu pipeline for reconnection.
190+ contextMenu *contextMenuPipeline
188191 }
189192
190193 type runJSErrorStat struct {
@@ -429,6 +432,7 @@ func (wv *WebView) connectSignals() {
429432 wv.connectBackForwardListChangedSignal()
430433 wv.connectWebProcessTerminatedSignal()
431434 wv.connectPermissionRequestSignal()
435+ wv.connectContextMenuSignal(wv.contextMenu)
432436 }
433437
434438 func (wv *WebView) connectLoadChangedSignal() {
@@ -185,6 +185,9 @@ type WebView struct {
185 185
186 backForwardList *webkit.BackForwardList186 backForwardList *webkit.BackForwardList
187 backForwardListSignalID uintptr187 backForwardListSignalID uintptr
188+
189+ // contextMenu holds the optional context menu pipeline for reconnection.
190+ contextMenu *contextMenuPipeline
188 }191 }
189 192
190 type runJSErrorStat struct {193 type runJSErrorStat struct {
@@ -429,6 +432,7 @@ func (wv *WebView) connectSignals() {
429 wv.connectBackForwardListChangedSignal()432 wv.connectBackForwardListChangedSignal()
430 wv.connectWebProcessTerminatedSignal()433 wv.connectWebProcessTerminatedSignal()
431 wv.connectPermissionRequestSignal()434 wv.connectPermissionRequestSignal()
435+ wv.connectContextMenuSignal(wv.contextMenu)
432 }436 }
433 437
434 func (wv *WebView) connectLoadChangedSignal() {438 func (wv *WebView) connectLoadChangedSignal() {

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