detached 201b740

merge: bring main into feature/fresh-browser-windows

commit
201b740f99a08ee04ade90f3c330a9d41bea8df7
author
brice · · 2026-04-14 18:21 UTC
merge of
ede66e0 + a1aa08a · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
53 files · +4252 -245
M .mockery.yaml +6 -0
@@ -49,6 +49,9 @@ packages:
4949 AccentKeyHandler: {}
5050 AutoCopyConfig: {}
5151 Clipboard: {}
52+ ContextMenuBuilder: {}
53+ ContextMenuActionExecutor: {}
54+ ContextMenuActionExecutorFactory: {}
5255 DesktopIntegration: {}
5356 DownloadEventHandler: {}
5457 DownloadPreparer: {}
@@ -73,6 +76,9 @@ packages:
7376 SnapshotService: {}
7477 BrowserWindowOpener: {}
7578 BrowserLaunchRelay: {}
79+ ImageDataResolver: {}
80+ ResolvedImageSaver: {}
81+ MenuActionDelegator: {}
7682 TextInputTargetProvider: {}
7783 Texture: {}
7884 ToolkitAvailabilityNotifier: {}
M go.mod +1 -1
@@ -7,7 +7,7 @@ require (
77 github.com/bnema/purego-ffmpeg v0.3.0
88 github.com/bnema/purego-pipewire v0.1.1
99 github.com/bnema/purego-sqlite v0.1.1
10- github.com/bnema/puregotk v0.3.0
10+ github.com/bnema/puregotk v0.4.0
1111 github.com/bnema/puregotk-webkit v0.0.16
1212 github.com/charmbracelet/bubbles v1.0.0
1313 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +2 -2 generated file: collapsed
M internal/application/port/clipboard.go +8 -1
@@ -1,6 +1,10 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
48
59 // Clipboard defines the port interface for clipboard operations.
610 // This abstracts platform-specific clipboard implementations (GTK, etc.).
@@ -8,6 +12,9 @@ type Clipboard interface {
812 // WriteText copies text to the clipboard.
913 WriteText(ctx context.Context, text string) error
1014
15+ // WriteImage copies encoded image bytes to the clipboard.
16+ WriteImage(ctx context.Context, image entity.ImageData) error
17+
1118 // ReadText reads text from the clipboard.
1219 // Returns empty string if clipboard is empty or contains non-text data.
1320 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 }
A internal/application/port/context_menu.go +62 -0
@@ -0,0 +1,62 @@
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+// ContextMenuBuilder builds normalized menu items from menu state.
40+type ContextMenuBuilder interface {
41+ Build(ctx context.Context, menuContext MenuContext) []MenuItem
42+}
43+
44+// ContextMenuActionExecutor executes normalized context menu actions.
45+type ContextMenuActionExecutor interface {
46+ ExecuteMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
47+}
48+
49+// ContextMenuActionExecutorFactory creates context menu action executors.
50+type ContextMenuActionExecutorFactory interface {
51+ NewContextMenuActionExecutor(
52+ clipboard Clipboard,
53+ resolver ImageDataResolver,
54+ saver ResolvedImageSaver,
55+ delegator MenuActionDelegator,
56+ ) ContextMenuActionExecutor
57+}
58+
59+// MenuActionDelegator handles actions that are not shared application concerns.
60+type MenuActionDelegator interface {
61+ DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
62+}
A internal/application/port/image_data.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// ImageDataResolver resolves image bytes from a URI.
10+type ImageDataResolver interface {
11+ ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error)
12+}
13+
14+// ResolvedImageSaver persists already resolved image bytes.
15+type ResolvedImageSaver interface {
16+ SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error
17+}
M internal/application/port/mocks/mocks.go +644 -0
@@ -616,6 +616,63 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
616616 return _c
617617 }
618618
619+// WriteImage provides a mock function for the type MockClipboard
620+func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
621+ ret := _mock.Called(ctx, image)
622+
623+ if len(ret) == 0 {
624+ panic("no return value specified for WriteImage")
625+ }
626+
627+ var r0 error
628+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok {
629+ r0 = returnFunc(ctx, image)
630+ } else {
631+ r0 = ret.Error(0)
632+ }
633+ return r0
634+}
635+
636+// MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage'
637+type MockClipboard_WriteImage_Call struct {
638+ *mock.Call
639+}
640+
641+// WriteImage is a helper method to define mock.On call
642+// - ctx context.Context
643+// - image entity.ImageData
644+func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {
645+ return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}
646+}
647+
648+func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call {
649+ _c.Call.Run(func(args mock.Arguments) {
650+ var arg0 context.Context
651+ if args[0] != nil {
652+ arg0 = args[0].(context.Context)
653+ }
654+ var arg1 entity.ImageData
655+ if args[1] != nil {
656+ arg1 = args[1].(entity.ImageData)
657+ }
658+ run(
659+ arg0,
660+ arg1,
661+ )
662+ })
663+ return _c
664+}
665+
666+func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call {
667+ _c.Call.Return(err)
668+ return _c
669+}
670+
671+func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call {
672+ _c.Call.Return(run)
673+ return _c
674+}
675+
619676 // WriteText provides a mock function for the type MockClipboard
620677 func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
621678 ret := _mock.Called(ctx, text)
@@ -1667,6 +1724,46 @@ func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run fu
16671724 return _c
16681725 }
16691726
1727+// TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer
1728+func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
1729+ _mock.Called(rawConfig)
1730+ return
1731+}
1732+
1733+// MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig'
1734+type MockConfigTransformer_TransformLegacyEngineConfig_Call struct {
1735+ *mock.Call
1736+}
1737+
1738+// TransformLegacyEngineConfig is a helper method to define mock.On call
1739+// - rawConfig map[string]any
1740+func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1741+ return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)}
1742+}
1743+
1744+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1745+ _c.Call.Run(func(args mock.Arguments) {
1746+ var arg0 map[string]any
1747+ if args[0] != nil {
1748+ arg0 = args[0].(map[string]any)
1749+ }
1750+ run(
1751+ arg0,
1752+ )
1753+ })
1754+ return _c
1755+}
1756+
1757+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1758+ _c.Call.Return()
1759+ return _c
1760+}
1761+
1762+func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call {
1763+ _c.Run(run)
1764+ return _c
1765+}
1766+
16701767 // 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.
16711768 // The first argument is typically a *testing.T value.
16721769 func NewMockContentInjector(t interface {
@@ -1865,6 +1962,370 @@ func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx con
18651962 return _c
18661963 }
18671964
1965+// NewMockContextMenuBuilder creates a new instance of MockContextMenuBuilder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1966+// The first argument is typically a *testing.T value.
1967+func NewMockContextMenuBuilder(t interface {
1968+ mock.TestingT
1969+ Cleanup(func())
1970+}) *MockContextMenuBuilder {
1971+ mock := &MockContextMenuBuilder{}
1972+ mock.Mock.Test(t)
1973+
1974+ t.Cleanup(func() { mock.AssertExpectations(t) })
1975+
1976+ return mock
1977+}
1978+
1979+// MockContextMenuBuilder is an autogenerated mock type for the ContextMenuBuilder type
1980+type MockContextMenuBuilder struct {
1981+ mock.Mock
1982+}
1983+
1984+type MockContextMenuBuilder_Expecter struct {
1985+ mock *mock.Mock
1986+}
1987+
1988+func (_m *MockContextMenuBuilder) EXPECT() *MockContextMenuBuilder_Expecter {
1989+ return &MockContextMenuBuilder_Expecter{mock: &_m.Mock}
1990+}
1991+
1992+// Build provides a mock function for the type MockContextMenuBuilder
1993+func (_mock *MockContextMenuBuilder) Build(ctx context.Context, menuContext port.MenuContext) []port.MenuItem {
1994+ ret := _mock.Called(ctx, menuContext)
1995+
1996+ if len(ret) == 0 {
1997+ panic("no return value specified for Build")
1998+ }
1999+
2000+ var r0 []port.MenuItem
2001+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuContext) []port.MenuItem); ok {
2002+ r0 = returnFunc(ctx, menuContext)
2003+ } else {
2004+ if ret.Get(0) != nil {
2005+ r0 = ret.Get(0).([]port.MenuItem)
2006+ }
2007+ }
2008+ return r0
2009+}
2010+
2011+// MockContextMenuBuilder_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
2012+type MockContextMenuBuilder_Build_Call struct {
2013+ *mock.Call
2014+}
2015+
2016+// Build is a helper method to define mock.On call
2017+// - ctx context.Context
2018+// - menuContext port.MenuContext
2019+func (_e *MockContextMenuBuilder_Expecter) Build(ctx interface{}, menuContext interface{}) *MockContextMenuBuilder_Build_Call {
2020+ return &MockContextMenuBuilder_Build_Call{Call: _e.mock.On("Build", ctx, menuContext)}
2021+}
2022+
2023+func (_c *MockContextMenuBuilder_Build_Call) Run(run func(ctx context.Context, menuContext port.MenuContext)) *MockContextMenuBuilder_Build_Call {
2024+ _c.Call.Run(func(args mock.Arguments) {
2025+ var arg0 context.Context
2026+ if args[0] != nil {
2027+ arg0 = args[0].(context.Context)
2028+ }
2029+ var arg1 port.MenuContext
2030+ if args[1] != nil {
2031+ arg1 = args[1].(port.MenuContext)
2032+ }
2033+ run(
2034+ arg0,
2035+ arg1,
2036+ )
2037+ })
2038+ return _c
2039+}
2040+
2041+func (_c *MockContextMenuBuilder_Build_Call) Return(menuItems []port.MenuItem) *MockContextMenuBuilder_Build_Call {
2042+ _c.Call.Return(menuItems)
2043+ return _c
2044+}
2045+
2046+func (_c *MockContextMenuBuilder_Build_Call) RunAndReturn(run func(ctx context.Context, menuContext port.MenuContext) []port.MenuItem) *MockContextMenuBuilder_Build_Call {
2047+ _c.Call.Return(run)
2048+ return _c
2049+}
2050+
2051+// NewMockContextMenuActionExecutor creates a new instance of MockContextMenuActionExecutor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2052+// The first argument is typically a *testing.T value.
2053+func NewMockContextMenuActionExecutor(t interface {
2054+ mock.TestingT
2055+ Cleanup(func())
2056+}) *MockContextMenuActionExecutor {
2057+ mock := &MockContextMenuActionExecutor{}
2058+ mock.Mock.Test(t)
2059+
2060+ t.Cleanup(func() { mock.AssertExpectations(t) })
2061+
2062+ return mock
2063+}
2064+
2065+// MockContextMenuActionExecutor is an autogenerated mock type for the ContextMenuActionExecutor type
2066+type MockContextMenuActionExecutor struct {
2067+ mock.Mock
2068+}
2069+
2070+type MockContextMenuActionExecutor_Expecter struct {
2071+ mock *mock.Mock
2072+}
2073+
2074+func (_m *MockContextMenuActionExecutor) EXPECT() *MockContextMenuActionExecutor_Expecter {
2075+ return &MockContextMenuActionExecutor_Expecter{mock: &_m.Mock}
2076+}
2077+
2078+// ExecuteMenuAction provides a mock function for the type MockContextMenuActionExecutor
2079+func (_mock *MockContextMenuActionExecutor) ExecuteMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
2080+ ret := _mock.Called(ctx, action, menuContext)
2081+
2082+ if len(ret) == 0 {
2083+ panic("no return value specified for ExecuteMenuAction")
2084+ }
2085+
2086+ var r0 error
2087+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
2088+ r0 = returnFunc(ctx, action, menuContext)
2089+ } else {
2090+ r0 = ret.Error(0)
2091+ }
2092+ return r0
2093+}
2094+
2095+// MockContextMenuActionExecutor_ExecuteMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecuteMenuAction'
2096+type MockContextMenuActionExecutor_ExecuteMenuAction_Call struct {
2097+ *mock.Call
2098+}
2099+
2100+// ExecuteMenuAction is a helper method to define mock.On call
2101+// - ctx context.Context
2102+// - action port.MenuAction
2103+// - menuContext port.MenuContext
2104+func (_e *MockContextMenuActionExecutor_Expecter) ExecuteMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
2105+ return &MockContextMenuActionExecutor_ExecuteMenuAction_Call{Call: _e.mock.On("ExecuteMenuAction", ctx, action, menuContext)}
2106+}
2107+
2108+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
2109+ _c.Call.Run(func(args mock.Arguments) {
2110+ var arg0 context.Context
2111+ if args[0] != nil {
2112+ arg0 = args[0].(context.Context)
2113+ }
2114+ var arg1 port.MenuAction
2115+ if args[1] != nil {
2116+ arg1 = args[1].(port.MenuAction)
2117+ }
2118+ var arg2 port.MenuContext
2119+ if args[2] != nil {
2120+ arg2 = args[2].(port.MenuContext)
2121+ }
2122+ run(
2123+ arg0,
2124+ arg1,
2125+ arg2,
2126+ )
2127+ })
2128+ return _c
2129+}
2130+
2131+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Return(err error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
2132+ _c.Call.Return(err)
2133+ return _c
2134+}
2135+
2136+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
2137+ _c.Call.Return(run)
2138+ return _c
2139+}
2140+
2141+// NewMockContextMenuActionExecutorFactory creates a new instance of MockContextMenuActionExecutorFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2142+// The first argument is typically a *testing.T value.
2143+func NewMockContextMenuActionExecutorFactory(t interface {
2144+ mock.TestingT
2145+ Cleanup(func())
2146+}) *MockContextMenuActionExecutorFactory {
2147+ mock := &MockContextMenuActionExecutorFactory{}
2148+ mock.Mock.Test(t)
2149+
2150+ t.Cleanup(func() { mock.AssertExpectations(t) })
2151+
2152+ return mock
2153+}
2154+
2155+// MockContextMenuActionExecutorFactory is an autogenerated mock type for the ContextMenuActionExecutorFactory type
2156+type MockContextMenuActionExecutorFactory struct {
2157+ mock.Mock
2158+}
2159+
2160+type MockContextMenuActionExecutorFactory_Expecter struct {
2161+ mock *mock.Mock
2162+}
2163+
2164+func (_m *MockContextMenuActionExecutorFactory) EXPECT() *MockContextMenuActionExecutorFactory_Expecter {
2165+ return &MockContextMenuActionExecutorFactory_Expecter{mock: &_m.Mock}
2166+}
2167+
2168+// NewContextMenuActionExecutor provides a mock function for the type MockContextMenuActionExecutorFactory
2169+func (_mock *MockContextMenuActionExecutorFactory) NewContextMenuActionExecutor(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor {
2170+ ret := _mock.Called(clipboard, resolver, saver, delegator)
2171+
2172+ if len(ret) == 0 {
2173+ panic("no return value specified for NewContextMenuActionExecutor")
2174+ }
2175+
2176+ var r0 port.ContextMenuActionExecutor
2177+ if returnFunc, ok := ret.Get(0).(func(port.Clipboard, port.ImageDataResolver, port.ResolvedImageSaver, port.MenuActionDelegator) port.ContextMenuActionExecutor); ok {
2178+ r0 = returnFunc(clipboard, resolver, saver, delegator)
2179+ } else {
2180+ if ret.Get(0) != nil {
2181+ r0 = ret.Get(0).(port.ContextMenuActionExecutor)
2182+ }
2183+ }
2184+ return r0
2185+}
2186+
2187+// MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewContextMenuActionExecutor'
2188+type MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call struct {
2189+ *mock.Call
2190+}
2191+
2192+// NewContextMenuActionExecutor is a helper method to define mock.On call
2193+// - clipboard port.Clipboard
2194+// - resolver port.ImageDataResolver
2195+// - saver port.ResolvedImageSaver
2196+// - delegator port.MenuActionDelegator
2197+func (_e *MockContextMenuActionExecutorFactory_Expecter) NewContextMenuActionExecutor(clipboard interface{}, resolver interface{}, saver interface{}, delegator interface{}) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
2198+ return &MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call{Call: _e.mock.On("NewContextMenuActionExecutor", clipboard, resolver, saver, delegator)}
2199+}
2200+
2201+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Run(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator)) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
2202+ _c.Call.Run(func(args mock.Arguments) {
2203+ var arg0 port.Clipboard
2204+ if args[0] != nil {
2205+ arg0 = args[0].(port.Clipboard)
2206+ }
2207+ var arg1 port.ImageDataResolver
2208+ if args[1] != nil {
2209+ arg1 = args[1].(port.ImageDataResolver)
2210+ }
2211+ var arg2 port.ResolvedImageSaver
2212+ if args[2] != nil {
2213+ arg2 = args[2].(port.ResolvedImageSaver)
2214+ }
2215+ var arg3 port.MenuActionDelegator
2216+ if args[3] != nil {
2217+ arg3 = args[3].(port.MenuActionDelegator)
2218+ }
2219+ run(
2220+ arg0,
2221+ arg1,
2222+ arg2,
2223+ arg3,
2224+ )
2225+ })
2226+ return _c
2227+}
2228+
2229+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Return(contextMenuActionExecutor port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
2230+ _c.Call.Return(contextMenuActionExecutor)
2231+ return _c
2232+}
2233+
2234+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) RunAndReturn(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
2235+ _c.Call.Return(run)
2236+ return _c
2237+}
2238+
2239+// NewMockMenuActionDelegator creates a new instance of MockMenuActionDelegator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2240+// The first argument is typically a *testing.T value.
2241+func NewMockMenuActionDelegator(t interface {
2242+ mock.TestingT
2243+ Cleanup(func())
2244+}) *MockMenuActionDelegator {
2245+ mock := &MockMenuActionDelegator{}
2246+ mock.Mock.Test(t)
2247+
2248+ t.Cleanup(func() { mock.AssertExpectations(t) })
2249+
2250+ return mock
2251+}
2252+
2253+// MockMenuActionDelegator is an autogenerated mock type for the MenuActionDelegator type
2254+type MockMenuActionDelegator struct {
2255+ mock.Mock
2256+}
2257+
2258+type MockMenuActionDelegator_Expecter struct {
2259+ mock *mock.Mock
2260+}
2261+
2262+func (_m *MockMenuActionDelegator) EXPECT() *MockMenuActionDelegator_Expecter {
2263+ return &MockMenuActionDelegator_Expecter{mock: &_m.Mock}
2264+}
2265+
2266+// DelegateMenuAction provides a mock function for the type MockMenuActionDelegator
2267+func (_mock *MockMenuActionDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
2268+ ret := _mock.Called(ctx, action, menuContext)
2269+
2270+ if len(ret) == 0 {
2271+ panic("no return value specified for DelegateMenuAction")
2272+ }
2273+
2274+ var r0 error
2275+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
2276+ r0 = returnFunc(ctx, action, menuContext)
2277+ } else {
2278+ r0 = ret.Error(0)
2279+ }
2280+ return r0
2281+}
2282+
2283+// MockMenuActionDelegator_DelegateMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelegateMenuAction'
2284+type MockMenuActionDelegator_DelegateMenuAction_Call struct {
2285+ *mock.Call
2286+}
2287+
2288+// DelegateMenuAction is a helper method to define mock.On call
2289+// - ctx context.Context
2290+// - action port.MenuAction
2291+// - menuContext port.MenuContext
2292+func (_e *MockMenuActionDelegator_Expecter) DelegateMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockMenuActionDelegator_DelegateMenuAction_Call {
2293+ return &MockMenuActionDelegator_DelegateMenuAction_Call{Call: _e.mock.On("DelegateMenuAction", ctx, action, menuContext)}
2294+}
2295+
2296+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockMenuActionDelegator_DelegateMenuAction_Call {
2297+ _c.Call.Run(func(args mock.Arguments) {
2298+ var arg0 context.Context
2299+ if args[0] != nil {
2300+ arg0 = args[0].(context.Context)
2301+ }
2302+ var arg1 port.MenuAction
2303+ if args[1] != nil {
2304+ arg1 = args[1].(port.MenuAction)
2305+ }
2306+ var arg2 port.MenuContext
2307+ if args[2] != nil {
2308+ arg2 = args[2].(port.MenuContext)
2309+ }
2310+ run(
2311+ arg0,
2312+ arg1,
2313+ arg2,
2314+ )
2315+ })
2316+ return _c
2317+}
2318+
2319+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Return(err error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2320+ _c.Call.Return(err)
2321+ return _c
2322+}
2323+
2324+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2325+ _c.Call.Return(run)
2326+ return _c
2327+}
2328+
18682329 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18692330 // The first argument is typically a *testing.T value.
18702331 func NewMockDatabaseProvider(t interface {
@@ -7486,6 +7947,189 @@ func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Co
74867947 return _c
74877948 }
74887949
7950+// NewMockImageDataResolver creates a new instance of MockImageDataResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
7951+// The first argument is typically a *testing.T value.
7952+func NewMockImageDataResolver(t interface {
7953+ mock.TestingT
7954+ Cleanup(func())
7955+}) *MockImageDataResolver {
7956+ mock := &MockImageDataResolver{}
7957+ mock.Mock.Test(t)
7958+
7959+ t.Cleanup(func() { mock.AssertExpectations(t) })
7960+
7961+ return mock
7962+}
7963+
7964+// MockImageDataResolver is an autogenerated mock type for the ImageDataResolver type
7965+type MockImageDataResolver struct {
7966+ mock.Mock
7967+}
7968+
7969+type MockImageDataResolver_Expecter struct {
7970+ mock *mock.Mock
7971+}
7972+
7973+func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter {
7974+ return &MockImageDataResolver_Expecter{mock: &_m.Mock}
7975+}
7976+
7977+// ResolveImageData provides a mock function for the type MockImageDataResolver
7978+func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
7979+ ret := _mock.Called(ctx, uri)
7980+
7981+ if len(ret) == 0 {
7982+ panic("no return value specified for ResolveImageData")
7983+ }
7984+
7985+ var r0 entity.ImageData
7986+ var r1 error
7987+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok {
7988+ return returnFunc(ctx, uri)
7989+ }
7990+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok {
7991+ r0 = returnFunc(ctx, uri)
7992+ } else {
7993+ r0 = ret.Get(0).(entity.ImageData)
7994+ }
7995+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
7996+ r1 = returnFunc(ctx, uri)
7997+ } else {
7998+ r1 = ret.Error(1)
7999+ }
8000+ return r0, r1
8001+}
8002+
8003+// MockImageDataResolver_ResolveImageData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveImageData'
8004+type MockImageDataResolver_ResolveImageData_Call struct {
8005+ *mock.Call
8006+}
8007+
8008+// ResolveImageData is a helper method to define mock.On call
8009+// - ctx context.Context
8010+// - uri string
8011+func (_e *MockImageDataResolver_Expecter) ResolveImageData(ctx interface{}, uri interface{}) *MockImageDataResolver_ResolveImageData_Call {
8012+ return &MockImageDataResolver_ResolveImageData_Call{Call: _e.mock.On("ResolveImageData", ctx, uri)}
8013+}
8014+
8015+func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.Context, uri string)) *MockImageDataResolver_ResolveImageData_Call {
8016+ _c.Call.Run(func(args mock.Arguments) {
8017+ var arg0 context.Context
8018+ if args[0] != nil {
8019+ arg0 = args[0].(context.Context)
8020+ }
8021+ var arg1 string
8022+ if args[1] != nil {
8023+ arg1 = args[1].(string)
8024+ }
8025+ run(
8026+ arg0,
8027+ arg1,
8028+ )
8029+ })
8030+ return _c
8031+}
8032+
8033+func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
8034+ _c.Call.Return(imageData, err)
8035+ return _c
8036+}
8037+
8038+func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
8039+ _c.Call.Return(run)
8040+ return _c
8041+}
8042+
8043+// 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.
8044+// The first argument is typically a *testing.T value.
8045+func NewMockResolvedImageSaver(t interface {
8046+ mock.TestingT
8047+ Cleanup(func())
8048+}) *MockResolvedImageSaver {
8049+ mock := &MockResolvedImageSaver{}
8050+ mock.Mock.Test(t)
8051+
8052+ t.Cleanup(func() { mock.AssertExpectations(t) })
8053+
8054+ return mock
8055+}
8056+
8057+// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
8058+type MockResolvedImageSaver struct {
8059+ mock.Mock
8060+}
8061+
8062+type MockResolvedImageSaver_Expecter struct {
8063+ mock *mock.Mock
8064+}
8065+
8066+func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
8067+ return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
8068+}
8069+
8070+// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
8071+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
8072+ ret := _mock.Called(ctx, image, menuContext)
8073+
8074+ if len(ret) == 0 {
8075+ panic("no return value specified for SaveResolvedImage")
8076+ }
8077+
8078+ var r0 error
8079+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok {
8080+ r0 = returnFunc(ctx, image, menuContext)
8081+ } else {
8082+ r0 = ret.Error(0)
8083+ }
8084+ return r0
8085+}
8086+
8087+// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
8088+type MockResolvedImageSaver_SaveResolvedImage_Call struct {
8089+ *mock.Call
8090+}
8091+
8092+// SaveResolvedImage is a helper method to define mock.On call
8093+// - ctx context.Context
8094+// - image entity.ImageData
8095+// - menuContext port.MenuContext
8096+func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
8097+ return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}
8098+}
8099+
8100+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
8101+ _c.Call.Run(func(args mock.Arguments) {
8102+ var arg0 context.Context
8103+ if args[0] != nil {
8104+ arg0 = args[0].(context.Context)
8105+ }
8106+ var arg1 entity.ImageData
8107+ if args[1] != nil {
8108+ arg1 = args[1].(entity.ImageData)
8109+ }
8110+ var arg2 port.MenuContext
8111+ if args[2] != nil {
8112+ arg2 = args[2].(port.MenuContext)
8113+ }
8114+ run(
8115+ arg0,
8116+ arg1,
8117+ arg2,
8118+ )
8119+ })
8120+ return _c
8121+}
8122+
8123+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
8124+ _c.Call.Return(err)
8125+ return _c
8126+}
8127+
8128+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
8129+ _c.Call.Return(run)
8130+ return _c
8131+}
8132+
74898133 // NewMockKeybindingsProvider creates a new instance of MockKeybindingsProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
74908134 // The first argument is typically a *testing.T value.
74918135 func NewMockKeybindingsProvider(t interface {
A internal/application/usecase/build_context_menu.go +57 -0
@@ -0,0 +1,57 @@
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+var _ port.ContextMenuBuilder = (*BuildContextMenuUseCase)(nil)
13+
14+// NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.
15+func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
16+ return &BuildContextMenuUseCase{}
17+}
18+
19+// Build returns normalized menu items for the supplied context.
20+func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {
21+ if menuContext.IsEditable {
22+ return nil
23+ }
24+
25+ items := make([]port.MenuItem, 0, 9)
26+
27+ if menuContext.CanGoBack {
28+ items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"})
29+ }
30+ if menuContext.CanGoForward {
31+ items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"})
32+ }
33+
34+ items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"})
35+
36+ if menuContext.LinkURI != "" {
37+ items = append(items,
38+ port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"},
39+ port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"},
40+ )
41+ }
42+
43+ if menuContext.ImageURI != "" {
44+ items = append(items,
45+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
46+ port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"},
47+ )
48+ }
49+
50+ if menuContext.HasSelection {
51+ items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"})
52+ }
53+
54+ items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
55+
56+ return items
57+}
A internal/application/usecase/build_context_menu_test.go +84 -0
@@ -0,0 +1,84 @@
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: "editable context uses native menu",
21+ context: port.MenuContext{IsEditable: true},
22+ expected: []port.MenuAction{},
23+ },
24+ {
25+ name: "empty context baseline",
26+ context: port.MenuContext{},
27+ expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement},
28+ },
29+ {
30+ name: "selection context",
31+ context: port.MenuContext{HasSelection: true},
32+ expected: []port.MenuAction{
33+ port.MenuActionReload,
34+ port.MenuActionCopySelection,
35+ port.MenuActionInspectElement,
36+ },
37+ },
38+ {
39+ name: "image only",
40+ context: port.MenuContext{ImageURI: "https://example.com/image.png"},
41+ expected: []port.MenuAction{
42+ port.MenuActionReload,
43+ port.MenuActionCopyImage,
44+ port.MenuActionSaveImage,
45+ port.MenuActionInspectElement,
46+ },
47+ },
48+ {
49+ name: "link only",
50+ context: port.MenuContext{LinkURI: "https://example.com/link"},
51+ expected: []port.MenuAction{
52+ port.MenuActionReload,
53+ port.MenuActionOpenLinkNewTab,
54+ port.MenuActionCopyLink,
55+ port.MenuActionInspectElement,
56+ },
57+ },
58+ {
59+ name: "navigation enabled states",
60+ context: port.MenuContext{CanGoBack: true, CanGoForward: true},
61+ expected: []port.MenuAction{
62+ port.MenuActionBack,
63+ port.MenuActionForward,
64+ port.MenuActionReload,
65+ port.MenuActionInspectElement,
66+ },
67+ },
68+ }
69+
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ items := uc.Build(context.Background(), tt.context)
73+ require.Equal(t, tt.expected, menuActions(items))
74+ })
75+ }
76+}
77+
78+func menuActions(items []port.MenuItem) []port.MenuAction {
79+ actions := make([]port.MenuAction, 0, len(items))
80+ for _, item := range items {
81+ actions = append(actions, item.Action)
82+ }
83+ return actions
84+}
A internal/application/usecase/execute_context_menu_action.go +137 -0
@@ -0,0 +1,137 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// ExecuteContextMenuActionInput identifies the action and its context.
12+type ExecuteContextMenuActionInput struct {
13+ Action port.MenuAction
14+ Context port.MenuContext
15+}
16+
17+// ExecuteContextMenuActionUseCase handles shared context menu actions.
18+type ExecuteContextMenuActionUseCase struct {
19+ clipboard port.Clipboard
20+ resolver port.ImageDataResolver
21+ saver port.ResolvedImageSaver
22+ delegator port.MenuActionDelegator
23+}
24+
25+var _ port.ContextMenuActionExecutor = (*ExecuteContextMenuActionUseCase)(nil)
26+
27+// NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.
28+func NewExecuteContextMenuActionUseCase(
29+ clipboard port.Clipboard,
30+ resolver port.ImageDataResolver,
31+ saver port.ResolvedImageSaver,
32+ delegator port.MenuActionDelegator,
33+) *ExecuteContextMenuActionUseCase {
34+ return &ExecuteContextMenuActionUseCase{
35+ clipboard: clipboard,
36+ resolver: resolver,
37+ saver: saver,
38+ delegator: delegator,
39+ }
40+}
41+
42+// ContextMenuActionExecutorFactory creates ExecuteContextMenuActionUseCases.
43+type ContextMenuActionExecutorFactory struct{}
44+
45+var _ port.ContextMenuActionExecutorFactory = (*ContextMenuActionExecutorFactory)(nil)
46+
47+// NewContextMenuActionExecutor creates a new shared context menu action executor.
48+func (*ContextMenuActionExecutorFactory) NewContextMenuActionExecutor(
49+ clipboard port.Clipboard,
50+ resolver port.ImageDataResolver,
51+ saver port.ResolvedImageSaver,
52+ delegator port.MenuActionDelegator,
53+) port.ContextMenuActionExecutor {
54+ return NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
55+}
56+
57+// Execute handles the requested action.
58+func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {
59+ return uc.ExecuteMenuAction(ctx, input.Action, input.Context)
60+}
61+
62+// ExecuteMenuAction handles the requested action.
63+func (uc *ExecuteContextMenuActionUseCase) ExecuteMenuAction(
64+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
65+) error {
66+ return uc.executeMenuAction(ctx, action, menuContext)
67+}
68+
69+func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
70+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
71+) error {
72+ switch action {
73+ case port.MenuActionCopyLink:
74+ if menuContext.LinkURI == "" {
75+ return fmt.Errorf("copy link: link URI not available")
76+ }
77+ if uc.clipboard == nil {
78+ return fmt.Errorf("copy link: clipboard not available")
79+ }
80+ if err := uc.clipboard.WriteText(ctx, menuContext.LinkURI); err != nil {
81+ return fmt.Errorf("copy link: %w", err)
82+ }
83+ return nil
84+ case port.MenuActionCopyImage:
85+ if uc.clipboard == nil {
86+ return fmt.Errorf("copy image: clipboard not available")
87+ }
88+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
89+ if err != nil {
90+ return fmt.Errorf("copy image: %w", err)
91+ }
92+ if err := uc.clipboard.WriteImage(ctx, image); err != nil {
93+ return fmt.Errorf("copy image: %w", err)
94+ }
95+ return nil
96+ case port.MenuActionSaveImage:
97+ if uc.saver == nil {
98+ return fmt.Errorf("save image: image saver not available")
99+ }
100+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
101+ if err != nil {
102+ return fmt.Errorf("save image: %w", err)
103+ }
104+ if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {
105+ return fmt.Errorf("save image: %w", err)
106+ }
107+ return nil
108+ default:
109+ // All other normalized actions are delegated to the engine/UI layer.
110+ if uc.delegator == nil {
111+ return fmt.Errorf("delegate action %s: menu action delegator not available", action)
112+ }
113+ if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil {
114+ return fmt.Errorf("delegate action %s: %w", action, err)
115+ }
116+ return nil
117+ }
118+}
119+
120+func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
121+ if uri == "" {
122+ return entity.ImageData{}, fmt.Errorf("image URI not available")
123+ }
124+ if uc.resolver == nil {
125+ return entity.ImageData{}, fmt.Errorf("image data resolver not available")
126+ }
127+
128+ image, err := uc.resolver.ResolveImageData(ctx, uri)
129+ if err != nil {
130+ return entity.ImageData{}, err
131+ }
132+ if len(image.Bytes) == 0 {
133+ return entity.ImageData{}, fmt.Errorf("image data not available")
134+ }
135+
136+ return image, nil
137+}
A internal/application/usecase/execute_context_menu_action_test.go +239 -0
@@ -0,0 +1,239 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type fakeClipboard struct {
14+ writeTextCalls int
15+ writeImageCalls int
16+ text string
17+ image entity.ImageData
18+}
19+
20+func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
21+ f.writeTextCalls++
22+ f.text = text
23+ return nil
24+}
25+
26+func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
27+ f.writeImageCalls++
28+ f.image = image
29+ return nil
30+}
31+
32+func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil }
33+func (*fakeClipboard) Clear(context.Context) error { return nil }
34+func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil }
35+
36+type fakeImageResolver struct {
37+ resolveCalls int
38+ uri string
39+ image entity.ImageData
40+ err error
41+}
42+
43+func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) {
44+ f.resolveCalls++
45+ f.uri = uri
46+ return f.image, f.err
47+}
48+
49+type fakeResolvedImageSaver struct {
50+ saveCalls int
51+ image entity.ImageData
52+ menuContext port.MenuContext
53+ err error
54+}
55+
56+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error {
57+ f.saveCalls++
58+ f.image = image
59+ f.menuContext = menuContext
60+ return f.err
61+}
62+
63+type fakeMenuActionDelegator struct {
64+ delegateCalls int
65+ action port.MenuAction
66+ menuContext port.MenuContext
67+ err error
68+}
69+
70+func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
71+ f.delegateCalls++
72+ f.action = action
73+ f.menuContext = menuContext
74+ return f.err
75+}
76+
77+func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) {
78+ clipboard := &fakeClipboard{}
79+ resolver := &fakeImageResolver{}
80+ saver := &fakeResolvedImageSaver{}
81+ delegator := &fakeMenuActionDelegator{}
82+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
83+
84+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
85+ Action: port.MenuActionCopyImage,
86+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
87+ })
88+
89+ require.Error(t, err)
90+ require.Contains(t, err.Error(), "copy image:")
91+ require.Contains(t, err.Error(), "image data not available")
92+ require.Zero(t, clipboard.writeImageCalls)
93+ require.Zero(t, saver.saveCalls)
94+ require.Zero(t, delegator.delegateCalls)
95+}
96+
97+func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) {
98+ clipboard := &fakeClipboard{}
99+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
100+ saver := &fakeResolvedImageSaver{}
101+ delegator := &fakeMenuActionDelegator{}
102+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
103+
104+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
105+ Action: port.MenuActionCopyImage,
106+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
107+ })
108+
109+ require.NoError(t, err)
110+ require.Equal(t, 1, resolver.resolveCalls)
111+ require.Equal(t, "https://example.com/image.png", resolver.uri)
112+ require.Equal(t, 1, clipboard.writeImageCalls)
113+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image)
114+ require.Zero(t, saver.saveCalls)
115+ require.Zero(t, delegator.delegateCalls)
116+}
117+
118+func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) {
119+ resolver := &fakeImageResolver{}
120+ saver := &fakeResolvedImageSaver{}
121+ delegator := &fakeMenuActionDelegator{}
122+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
123+
124+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
125+ Action: port.MenuActionCopyImage,
126+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
127+ })
128+
129+ require.Error(t, err)
130+ require.Contains(t, err.Error(), "clipboard not available")
131+ require.Zero(t, resolver.resolveCalls)
132+ require.Zero(t, saver.saveCalls)
133+ require.Zero(t, delegator.delegateCalls)
134+}
135+
136+func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
137+ clipboard := &fakeClipboard{}
138+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
139+ saver := &fakeResolvedImageSaver{}
140+ delegator := &fakeMenuActionDelegator{}
141+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
142+
143+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
144+ Action: port.MenuActionSaveImage,
145+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
146+ })
147+
148+ require.NoError(t, err)
149+ require.Equal(t, 1, resolver.resolveCalls)
150+ require.Equal(t, "https://example.com/image.png", resolver.uri)
151+ require.Equal(t, 1, saver.saveCalls)
152+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
153+ require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)
154+ require.Zero(t, clipboard.writeTextCalls)
155+ require.Zero(t, clipboard.writeImageCalls)
156+ require.Zero(t, delegator.delegateCalls)
157+}
158+
159+func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) {
160+ clipboard := &fakeClipboard{}
161+ resolver := &fakeImageResolver{}
162+ delegator := &fakeMenuActionDelegator{}
163+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator)
164+
165+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
166+ Action: port.MenuActionSaveImage,
167+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
168+ })
169+
170+ require.Error(t, err)
171+ require.Contains(t, err.Error(), "image saver not available")
172+ require.Zero(t, resolver.resolveCalls)
173+ require.Zero(t, delegator.delegateCalls)
174+}
175+
176+func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {
177+ clipboard := &fakeClipboard{}
178+ resolver := &fakeImageResolver{}
179+ saver := &fakeResolvedImageSaver{}
180+ delegator := &fakeMenuActionDelegator{}
181+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
182+
183+ menuContext := port.MenuContext{
184+ PageURI: "https://example.com",
185+ X: 17,
186+ Y: 42,
187+ }
188+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
189+ Action: port.MenuActionInspectElement,
190+ Context: menuContext,
191+ })
192+
193+ require.NoError(t, err)
194+ require.Equal(t, 1, delegator.delegateCalls)
195+ require.Equal(t, port.MenuActionInspectElement, delegator.action)
196+ require.Equal(t, menuContext, delegator.menuContext)
197+ require.Zero(t, clipboard.writeTextCalls)
198+ require.Zero(t, clipboard.writeImageCalls)
199+ require.Zero(t, resolver.resolveCalls)
200+ require.Zero(t, saver.saveCalls)
201+}
202+
203+func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) {
204+ clipboard := &fakeClipboard{}
205+ resolver := &fakeImageResolver{}
206+ saver := &fakeResolvedImageSaver{}
207+ delegator := &fakeMenuActionDelegator{err: errors.New("boom")}
208+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
209+
210+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
211+ Action: port.MenuActionCopySelection,
212+ Context: port.MenuContext{HasSelection: true},
213+ })
214+
215+ require.Error(t, err)
216+ require.Contains(t, err.Error(), "delegate action copy_selection:")
217+ require.Contains(t, err.Error(), "boom")
218+}
219+
220+func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
221+ clipboard := &fakeClipboard{}
222+ resolver := &fakeImageResolver{}
223+ saver := &fakeResolvedImageSaver{}
224+ delegator := &fakeMenuActionDelegator{}
225+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
226+
227+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
228+ Action: port.MenuActionCopyLink,
229+ Context: port.MenuContext{LinkURI: "https://example.com/link"},
230+ })
231+
232+ require.NoError(t, err)
233+ require.Equal(t, 1, clipboard.writeTextCalls)
234+ require.Equal(t, "https://example.com/link", clipboard.text)
235+ require.Zero(t, clipboard.writeImageCalls)
236+ require.Zero(t, resolver.resolveCalls)
237+ require.Zero(t, saver.saveCalls)
238+ require.Zero(t, delegator.delegateCalls)
239+}
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 }
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)
M internal/bootstrap/engine.go +21 -12
@@ -6,8 +6,10 @@ import (
66 "fmt"
77
88 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
910 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
1011 "github.com/bnema/dumber/internal/infrastructure/cef"
12+ clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
1113 "github.com/bnema/dumber/internal/infrastructure/config"
1214 "github.com/bnema/dumber/internal/infrastructure/env"
1315 "github.com/bnema/dumber/internal/infrastructure/handlers"
@@ -31,6 +33,8 @@ type EngineInput struct {
3133 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
3234 func BuildEngine(input EngineInput) (port.Engine, error) {
3335 cfg := input.Config
36+ contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
37+ contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
3438 engineType := cfg.Engine.ResolveEngineType()
3539 switch engineType {
3640 case config.EngineTypeWebKit:
@@ -43,7 +47,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4347
4448 return webkit.NewEngine(
4549 input.Ctx, cfg, opts, wkCfg,
46- input.ThemeManager, input.ColorResolver, input.Logger,
50+ input.ThemeManager, input.ColorResolver,
51+ contextMenuBuilder, contextMenuExecutorFactory,
52+ input.Logger,
4753 )
4854 case config.EngineTypeCEF:
4955 opts := port.EngineOptions{
@@ -52,13 +58,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
5258 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
5359 }
5460 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,
61+ CEFDir: cfg.Engine.CEF.CEFDir,
62+ LogFile: cfg.Engine.CEF.LogFile,
63+ LogSeverity: cfg.Engine.CEF.LogSeverity,
64+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
65+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
66+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
6267 }
6368 transcodingCfg := cef.TranscodingRuntimeConfig{
6469 Enabled: cfg.Transcoding.Enabled,
@@ -79,10 +84,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
7984 }
8085 }
8186 deps := cef.EngineDependencies{
82- RegisterHandlers: handlers.RegisterAll,
83- RegisterAccentHandlers: handlers.RegisterAccentHandlers,
84- CurrentConfigPayload: buildConfigPayload(config.Get),
85- DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
87+ RegisterHandlers: handlers.RegisterAll,
88+ RegisterAccentHandlers: handlers.RegisterAccentHandlers,
89+ CurrentConfigPayload: buildConfigPayload(config.Get),
90+ DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
91+ ContextMenuBuilder: contextMenuBuilder,
92+ ContextMenuExecutorFactory: contextMenuExecutorFactory,
93+ Clipboard: clipboardinfra.New(),
94+ ImageDataResolver: webkit.NewContextMenuResolver(),
8695 MediaClassifier: cef.MediaClassifier{
8796 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
8897 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
A internal/domain/entity/image_data.go +7 -0
@@ -0,0 +1,7 @@
1+package entity
2+
3+// ImageData contains resolved image bytes and MIME type metadata.
4+type ImageData struct {
5+ Bytes []byte
6+ MimeType string
7+}
M internal/infrastructure/cef/context_menu.go +251 -67
@@ -1,18 +1,17 @@
11 package cef
22
33 import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
48 purecef "github.com/bnema/purego-cef/cef"
5- "github.com/bnema/puregotk/v4/gdk"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
612 "github.com/bnema/puregotk/v4/gtk"
713 )
814
9-// menuEntry holds a snapshot of one CEF menu item.
10-type menuEntry struct {
11- label string
12- commandID int32
13- isSep bool
14-}
15-
1615 // ===========================================================================
1716 // ContextMenuHandler (implemented on handlerSet)
1817 // ===========================================================================
@@ -25,98 +24,283 @@ func (h *handlerSet) RunContextMenu(
2524 params purecef.ContextMenuParams, model purecef.MenuModel,
2625 callback purecef.RunContextMenuCallback,
2726 ) int32 {
27+ if h == nil || callback == nil || model == nil {
28+ if callback != nil {
29+ callback.Cancel()
30+ }
31+ // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu.
32+ return 1
33+ }
34+ if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
35+ // Returning 0 lets CEF show its native menu when no custom builder is wired.
36+ return 0
37+ }
38+
39+ menuContext := buildMenuContext(h.wv, params)
40+ items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext)
41+ if len(items) == 0 {
42+ return 0
43+ }
44+
45+ // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
2846 count := model.GetCount()
2947 if count == 0 {
3048 callback.Cancel()
3149 return 1
3250 }
33-
34- // Snapshot menu items — model/params pointers are only valid during this call.
35- items := make([]menuEntry, 0, count)
51+ commandIDsByLabel := make(map[string]int32, count)
3652 for i := range count {
3753 t := model.GetTypeAt(i)
3854 if t == purecef.MenuItemTypeMenuitemtypeSeparator {
39- items = append(items, menuEntry{isSep: true})
4055 continue
4156 }
4257 if t == purecef.MenuItemTypeMenuitemtypeCommand {
43- items = append(items, menuEntry{
44- label: model.GetLabelAt(i),
45- commandID: model.GetCommandIDAt(i),
46- })
58+ label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i)))
59+ if label != "" {
60+ commandIDsByLabel[label] = model.GetCommandIDAt(i)
61+ }
4762 }
4863 }
49- if len(items) == 0 {
50- callback.Cancel()
51- return 1
64+ commandIDByAction := make(map[port.MenuAction]int32, len(items))
65+ for _, item := range items {
66+ if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok {
67+ commandIDByAction[item.Action] = cmdID
68+ }
69+ }
70+ if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil {
71+ return 0
5272 }
5373
54- x := params.GetXcoord()
55- y := params.GetYcoord()
74+ x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale)
75+ glArea := h.wv.pipeline.glArea
76+ logContextMenuPopupRequest(h, glArea, params, x, y)
77+ executor := h.contextMenuExecutor()
5678
57- h.wv.runOnGTK(func() {
58- showContextMenu(h.wv, items, x, y, callback)
59- })
79+ NewRenderer(h.wv.runOnGTK).Show(
80+ items,
81+ &glArea.Widget,
82+ x,
83+ y,
84+ func(item port.MenuItem) {
85+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext)
86+ },
87+ func() {
88+ callback.Cancel()
89+ },
90+ )
6091 return 1
6192 }
6293
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()
94+func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
95+ if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil {
96+ return nil
97+ }
98+ return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor(
99+ h.wv.engine.clipboard,
100+ h.wv.engine.resolver,
101+ nil,
102+ &cefMenuDelegator{wv: h.wv},
103+ )
104+}
105+
106+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {
107+ if params == nil {
108+ return 0, 0
109+ }
110+ if scale <= 0 {
111+ scale = 1
112+ }
113+ return params.GetXcoord() / scale, params.GetYcoord() / scale
114+}
115+
116+func logContextMenuPopupRequest(
117+ h *handlerSet,
118+ glArea *gtk.GLArea,
119+ params purecef.ContextMenuParams,
120+ x, y int32,
121+) {
122+ if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {
67123 return
68124 }
125+ rawX, rawY := contextMenuRawPosition(params)
126+ parent := glArea.GetParent()
127+ logging.FromContext(h.wv.ctx).Debug().
128+ Int32("raw_x", rawX).
129+ Int32("raw_y", rawY).
130+ Int32("popup_x", x).
131+ Int32("popup_y", y).
132+ Int32("scale", h.wv.pipeline.scale).
133+ Int("anchor_width", glArea.Widget.GetAllocatedWidth()).
134+ Int("anchor_height", glArea.Widget.GetAllocatedHeight()).
135+ Int("parent_width", cefWidgetAllocatedWidth(parent)).
136+ Int("parent_height", cefWidgetAllocatedHeight(parent)).
137+ Msg("cef: context menu popup request")
138+}
139+
140+func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) {
141+ if params == nil {
142+ return 0, 0
143+ }
144+ return params.GetXcoord(), params.GetYcoord()
145+}
69146
70- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
71- box.AddCssClass("context-menu")
147+func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
148+ if widget == nil {
149+ return 0
150+ }
151+ return widget.GetAllocatedWidth()
152+}
72153
73- selected := false
74- var popover *gtk.Popover
154+func cefWidgetAllocatedHeight(widget *gtk.Widget) int {
155+ if widget == nil {
156+ return 0
157+ }
158+ return widget.GetAllocatedHeight()
159+}
75160
76- for _, item := range items {
77- if item.isSep {
78- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
79- box.Append(&sep.Widget)
80- continue
161+func dispatchContextMenuSelection(
162+ ctx context.Context,
163+ executor port.ContextMenuActionExecutor,
164+ callback purecef.RunContextMenuCallback,
165+ commandIDByAction map[port.MenuAction]int32,
166+ item port.MenuItem,
167+ menuContext port.MenuContext,
168+) {
169+ log := logging.FromContext(ctx)
170+ if callback == nil {
171+ return
172+ }
173+ log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
174+ if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
175+ if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
176+ log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
177+ callback.Cancel()
178+ return
81179 }
180+ log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
181+ callback.Cont(0, 0)
182+ return
183+ }
184+ if cmdID, ok := commandIDByAction[item.Action]; ok {
185+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
186+ callback.Cont(cmdID, 0)
187+ return
188+ }
189+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
190+ callback.Cancel()
191+}
82192
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)
193+func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
194+ switch action {
195+ case port.MenuActionBack,
196+ port.MenuActionForward,
197+ port.MenuActionReload,
198+ port.MenuActionOpenLinkNewTab,
199+ port.MenuActionCopyLink,
200+ port.MenuActionCopyImage,
201+ port.MenuActionInspectElement,
202+ port.MenuActionCopySelection:
203+ return true
204+ default:
205+ return false
100206 }
207+}
101208
102- popover = gtk.NewPopover()
103- popover.SetChild(&box.Widget)
104- popover.SetParent(&glArea.Widget)
105- popover.SetHasArrow(false)
106- popover.SetAutohide(true)
209+type cefMenuDelegator struct {
210+ wv *WebView
211+}
107212
108- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
109- popover.SetPointingTo(rect)
213+func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
214+ if d == nil || d.wv == nil {
215+ return fmt.Errorf("cef menu delegator: webview not available")
216+ }
217+ switch action {
218+ case port.MenuActionBack:
219+ return d.wv.GoBack(ctx)
220+ case port.MenuActionForward:
221+ return d.wv.GoForward(ctx)
222+ case port.MenuActionReload:
223+ return d.wv.Reload(ctx)
224+ case port.MenuActionOpenLinkNewTab:
225+ if menuContext.LinkURI == "" {
226+ return fmt.Errorf("open link in new tab: link URI not available")
227+ }
228+ d.wv.mu.RLock()
229+ cb := d.wv.callbacks
230+ d.wv.mu.RUnlock()
231+ if cb == nil || cb.OnLinkMiddleClick == nil {
232+ return fmt.Errorf("open link in new tab: middle-click handler not available")
233+ }
234+ cb.OnLinkMiddleClick(menuContext.LinkURI)
235+ return nil
236+ case port.MenuActionInspectElement:
237+ d.wv.OpenDevTools()
238+ return nil
239+ case port.MenuActionCopySelection:
240+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
241+ return nil
242+ default:
243+ return fmt.Errorf("cef menu delegator: unsupported action %s", action)
244+ }
245+}
110246
111- closedCb := func(_ gtk.Popover) {
112- if !selected {
113- callback.Cancel()
247+func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
248+ for _, candidate := range contextMenuActionLabels(action, label) {
249+ if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
250+ return cmdID, true
114251 }
115- popover.Unparent()
116252 }
117- popover.ConnectClosed(&closedCb)
253+ return 0, false
254+}
118255
119- popover.Popup()
256+func contextMenuActionLabels(action port.MenuAction, label string) []string {
257+ labels := []string{label}
258+ switch action {
259+ case port.MenuActionBack:
260+ labels = append(labels, "Back")
261+ case port.MenuActionForward:
262+ labels = append(labels, "Forward")
263+ case port.MenuActionReload:
264+ labels = append(labels, "Reload")
265+ case port.MenuActionOpenLinkNewTab:
266+ labels = append(labels, "Open Link in New Tab", "Open link in new tab")
267+ case port.MenuActionCopyLink:
268+ labels = append(labels, "Copy Link", "Copy link address")
269+ case port.MenuActionCopyImage:
270+ labels = append(labels, "Copy Image", "Copy image")
271+ case port.MenuActionSaveImage:
272+ labels = append(labels, "Save Image", "Save image as...")
273+ case port.MenuActionInspectElement:
274+ labels = append(labels, "Inspect Element", "Inspect")
275+ case port.MenuActionCopySelection:
276+ labels = append(labels, "Copy Selection", "Copy")
277+ }
278+ return labels
279+}
280+
281+func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext {
282+ menuContext := port.MenuContext{}
283+ if wv != nil {
284+ menuContext.CanGoBack = wv.CanGoBack()
285+ menuContext.CanGoForward = wv.CanGoForward()
286+ menuContext.PageURI = wv.URI()
287+ }
288+ if params == nil {
289+ return menuContext
290+ }
291+
292+ if pageURI := params.GetPageURL(); pageURI != "" {
293+ menuContext.PageURI = pageURI
294+ }
295+ menuContext.LinkURI = params.GetLinkURL()
296+ if params.HasImageContents() {
297+ menuContext.ImageURI = params.GetSourceURL()
298+ }
299+ menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != ""
300+ menuContext.IsEditable = params.IsEditable()
301+ menuContext.X = int(params.GetXcoord())
302+ menuContext.Y = int(params.GetYcoord())
303+ return menuContext
120304 }
121305
122306 func (h *handlerSet) OnContextMenuCommand(
A internal/infrastructure/cef/context_menu_adapter_test.go +183 -0
@@ -0,0 +1,183 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type stubContextMenuParams struct {
14+ x, y int32
15+ pageURL string
16+ linkURL string
17+ sourceURL string
18+ selection string
19+ editable bool
20+ imageContent bool
21+}
22+
23+func (p stubContextMenuParams) GetXcoord() int32 { return p.x }
24+func (p stubContextMenuParams) GetYcoord() int32 { return p.y }
25+func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 }
26+func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL }
27+func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL }
28+func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL }
29+func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent }
30+func (p stubContextMenuParams) GetTitleText() string { return "" }
31+func (p stubContextMenuParams) GetPageURL() string { return p.pageURL }
32+func (p stubContextMenuParams) GetFrameURL() string { return "" }
33+func (p stubContextMenuParams) GetFrameCharset() string { return "" }
34+func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 }
35+func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags {
36+ return 0
37+}
38+func (p stubContextMenuParams) GetSelectionText() string { return p.selection }
39+func (p stubContextMenuParams) GetMisspelledWord() string { return "" }
40+func (p stubContextMenuParams) GetDictionarySuggestions(uintptr) int32 { return 0 }
41+func (p stubContextMenuParams) IsEditable() bool { return p.editable }
42+func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false }
43+func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags {
44+ return 0
45+}
46+func (p stubContextMenuParams) IsCustomMenu() bool { return false }
47+
48+func TestBuildMenuContextFromCEFParams(t *testing.T) {
49+ wv := &WebView{}
50+ wv.updateURI("https://example.com/page")
51+ wv.updateLoadState(false, true, false)
52+
53+ ctx := buildMenuContext(wv, stubContextMenuParams{
54+ x: 17,
55+ y: 42,
56+ pageURL: "https://example.com/page",
57+ linkURL: "https://example.com/link",
58+ sourceURL: "https://example.com/image.png",
59+ selection: "selected text",
60+ editable: true,
61+ imageContent: true,
62+ })
63+
64+ require.Equal(t, "https://example.com/page", ctx.PageURI)
65+ require.Equal(t, "https://example.com/link", ctx.LinkURI)
66+ require.Equal(t, "https://example.com/image.png", ctx.ImageURI)
67+ require.True(t, ctx.HasSelection)
68+ require.True(t, ctx.IsEditable)
69+ require.True(t, ctx.CanGoBack)
70+ require.False(t, ctx.CanGoForward)
71+ require.Equal(t, 17, ctx.X)
72+ require.Equal(t, 42, ctx.Y)
73+}
74+
75+type stubRunContextMenuCallback struct {
76+ contCalls int
77+ cancelCalls int
78+ commandID int32
79+}
80+
81+type stubContextMenuExecutor struct {
82+ executeCalls int
83+ action port.MenuAction
84+ menuContext port.MenuContext
85+ err error
86+}
87+
88+func (s *stubContextMenuExecutor) ExecuteMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
89+ s.executeCalls++
90+ s.action = action
91+ s.menuContext = menuContext
92+ return s.err
93+}
94+
95+func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {
96+ c.contCalls++
97+ c.commandID = commandID
98+}
99+
100+func (c *stubRunContextMenuCallback) Cancel() {
101+ c.cancelCalls++
102+}
103+
104+func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
105+ callback := &stubRunContextMenuCallback{}
106+
107+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
108+ port.MenuActionReload: 102,
109+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
110+
111+ require.Zero(t, callback.contCalls)
112+ require.Equal(t, 1, callback.cancelCalls)
113+}
114+
115+func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
116+ callback := &stubRunContextMenuCallback{}
117+
118+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
119+ port.MenuActionInspectElement: 204,
120+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
121+
122+ require.Equal(t, 1, callback.contCalls)
123+ require.Zero(t, callback.cancelCalls)
124+ require.Equal(t, int32(204), callback.commandID)
125+}
126+
127+func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testing.T) {
128+ callback := &stubRunContextMenuCallback{}
129+ executor := &stubContextMenuExecutor{}
130+ menuContext := port.MenuContext{PageURI: "https://example.com"}
131+
132+ dispatchContextMenuSelection(
133+ context.Background(),
134+ executor,
135+ callback,
136+ map[port.MenuAction]int32{port.MenuActionReload: 102},
137+ port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
138+ menuContext,
139+ )
140+
141+ require.Equal(t, 1, executor.executeCalls)
142+ require.Equal(t, port.MenuActionReload, executor.action)
143+ require.Equal(t, menuContext, executor.menuContext)
144+ require.Equal(t, 1, callback.contCalls)
145+ require.Zero(t, callback.commandID)
146+ require.Zero(t, callback.cancelCalls)
147+}
148+
149+func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {
150+ callback := &stubRunContextMenuCallback{}
151+ executor := &stubContextMenuExecutor{}
152+ menuContext := port.MenuContext{ImageURI: "https://example.com/image.png"}
153+
154+ dispatchContextMenuSelection(
155+ context.Background(),
156+ executor,
157+ callback,
158+ map[port.MenuAction]int32{},
159+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
160+ menuContext,
161+ )
162+
163+ require.Equal(t, 1, executor.executeCalls)
164+ require.Equal(t, port.MenuActionCopyImage, executor.action)
165+ require.Equal(t, menuContext, executor.menuContext)
166+ require.Equal(t, 1, callback.contCalls)
167+ require.Zero(t, callback.commandID)
168+ require.Zero(t, callback.cancelCalls)
169+}
170+
171+func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
172+ x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2)
173+
174+ require.Equal(t, int32(160), x)
175+ require.Equal(t, int32(90), y)
176+}
177+
178+func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) {
179+ x, y := contextMenuRawPosition(nil)
180+
181+ require.Zero(t, x)
182+ require.Zero(t, y)
183+}
A internal/infrastructure/cef/context_menu_run_context_menu_test.go +25 -0
@@ -0,0 +1,25 @@
1+package cef
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+type stubContextMenuBuilder struct{ items []port.MenuItem }
12+
13+func (b stubContextMenuBuilder) Build(context.Context, port.MenuContext) []port.MenuItem {
14+ return b.items
15+}
16+
17+func TestRunContextMenuReturnsZeroWhenAnchorMissing(t *testing.T) {
18+ h := &handlerSet{wv: &WebView{engine: &Engine{ctxMenuBuilder: stubContextMenuBuilder{items: []port.MenuItem{{Action: port.MenuActionCopySelection, Label: "Copy"}}}}}}
19+ callback := &stubRunContextMenuCallback{}
20+
21+ result := h.RunContextMenu(nil, nil, stubContextMenuParams{}, stubMenuModel{}, callback)
22+
23+ require.Equal(t, int32(0), result)
24+ require.Zero(t, callback.cancelCalls)
25+}
M internal/infrastructure/cef/engine.go +4 -0
@@ -32,6 +32,10 @@ type Engine struct {
3232 registerAccentHandlers AccentHandlerRegistrar
3333 currentConfigPayload func() ([]byte, error)
3434 defaultConfigPayload func() ([]byte, error)
35+ ctxMenuBuilder port.ContextMenuBuilder
36+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
37+ clipboard port.Clipboard
38+ resolver port.ImageDataResolver
3539 mediaClassifier MediaClassifier
3640 alreadyRunningAppRelaunchHandler func(string)
3741
M internal/infrastructure/cef/engine_init.go +10 -8
@@ -54,6 +54,10 @@ func NewEngine(
5454 registerAccentHandlers: deps.RegisterAccentHandlers,
5555 currentConfigPayload: deps.CurrentConfigPayload,
5656 defaultConfigPayload: deps.DefaultConfigPayload,
57+ ctxMenuBuilder: deps.ContextMenuBuilder,
58+ ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
59+ clipboard: deps.Clipboard,
60+ resolver: deps.ImageDataResolver,
5761 mediaClassifier: deps.MediaClassifier,
5862 }
5963
@@ -62,7 +66,6 @@ func NewEngine(
6266 Bool("external_begin_frame", externalBeginFrameEnabled()).
6367 Bool("trace_handlers", cfg.TraceHandlers).
6468 Bool("enable_audio_handler", cfg.EnableAudioHandler).
65- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
6669 Msg("cef: configured engine")
6770
6871 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -170,7 +173,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
170173
171174 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
172175 func wireEngine(
173- ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
176+ ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
174177 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
175178 mediaClassifier MediaClassifier,
176179 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
@@ -212,12 +215,11 @@ func wireEngine(
212215 }
213216
214217 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
215- scale: scale,
216- windowlessFrameRate: windowlessFrameRate,
217- enableContextMenuHandler: cfg.EnableContextMenuHandler,
218- transcoder: mediaTranscoder,
219- mediaClassifier: mediaClassifier,
220- audioOutputFactory: audioFactory,
218+ scale: scale,
219+ windowlessFrameRate: windowlessFrameRate,
220+ transcoder: mediaTranscoder,
221+ mediaClassifier: mediaClassifier,
222+ audioOutputFactory: audioFactory,
221223 })
222224 pool := newWebViewPool(factory)
223225
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()
A internal/infrastructure/cef/renderer.go +9 -0
@@ -0,0 +1,9 @@
1+package cef
2+
3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4+
5+type Renderer = gtkmenu.Renderer
6+
7+func NewRenderer(dispatch func(func())) *Renderer {
8+ return gtkmenu.NewRenderer(dispatch)
9+}
M internal/infrastructure/cef/runtime_config.go +15 -12
@@ -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 {
@@ -66,9 +65,13 @@ func (m MediaClassifier) normalize() MediaClassifier {
6665 }
6766
6867 type EngineDependencies struct {
69- RegisterHandlers HandlerRegistrar
70- RegisterAccentHandlers AccentHandlerRegistrar
71- CurrentConfigPayload func() ([]byte, error)
72- DefaultConfigPayload func() ([]byte, error)
73- MediaClassifier MediaClassifier
68+ RegisterHandlers HandlerRegistrar
69+ RegisterAccentHandlers AccentHandlerRegistrar
70+ CurrentConfigPayload func() ([]byte, error)
71+ DefaultConfigPayload func() ([]byte, error)
72+ ContextMenuBuilder port.ContextMenuBuilder
73+ ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
74+ Clipboard port.Clipboard
75+ ImageDataResolver port.ImageDataResolver
76+ MediaClassifier MediaClassifier
7477 }
A internal/infrastructure/cef/testutil_test.go +98 -0
@@ -0,0 +1,98 @@
1+package cef
2+
3+import (
4+ "unsafe"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+type stubMenuModel struct{}
10+
11+func (stubMenuModel) IsSubMenu() bool { return false }
12+func (stubMenuModel) Clear() int32 { panic("unexpected call") }
13+func (stubMenuModel) GetCount() int { return 1 }
14+func (stubMenuModel) AddSeparator() int32 { panic("unexpected call") }
15+func (stubMenuModel) AddItem(_ int32, _ string) int32 { panic("unexpected call") }
16+func (stubMenuModel) AddCheckItem(_ int32, _ string) int32 {
17+ panic("unexpected call")
18+}
19+func (stubMenuModel) AddRadioItem(_ int32, _ string, _ int32) int32 {
20+ panic("unexpected call")
21+}
22+func (stubMenuModel) AddSubMenu(_ int32, _ string) purecef.MenuModel {
23+ panic("unexpected call")
24+}
25+func (stubMenuModel) InsertSeparatorAt(_ int) int32 { panic("unexpected call") }
26+func (stubMenuModel) InsertItemAt(_ int, _ int32, _ string) int32 {
27+ panic("unexpected call")
28+}
29+func (stubMenuModel) InsertCheckItemAt(_ int, _ int32, _ string) int32 {
30+ panic("unexpected call")
31+}
32+func (stubMenuModel) InsertRadioItemAt(_ int, _ int32, _ string, _ int32) int32 {
33+ panic("unexpected call")
34+}
35+func (stubMenuModel) InsertSubMenuAt(_ int, _ int32, _ string) purecef.MenuModel {
36+ panic("unexpected call")
37+}
38+func (stubMenuModel) Remove(_ int32) int32 { panic("unexpected call") }
39+func (stubMenuModel) RemoveAt(_ int) int32 { panic("unexpected call") }
40+func (stubMenuModel) GetIndexOf(_ int32) int32 { panic("unexpected call") }
41+func (stubMenuModel) GetCommandIDAt(_ int) int32 { return 101 }
42+func (stubMenuModel) SetCommandIDAt(_ int, _ int32) int32 { panic("unexpected call") }
43+func (stubMenuModel) GetLabel(_ int32) string { panic("unexpected call") }
44+func (stubMenuModel) GetLabelAt(_ int) string { return "Copy" }
45+func (stubMenuModel) SetLabel(_ int32, _ string) int32 { panic("unexpected call") }
46+func (stubMenuModel) SetLabelAt(_ int, _ string) int32 { panic("unexpected call") }
47+func (stubMenuModel) GetType(_ int32) purecef.MenuItemType { panic("unexpected call") }
48+func (stubMenuModel) GetTypeAt(_ int) purecef.MenuItemType {
49+ return purecef.MenuItemTypeMenuitemtypeCommand
50+}
51+func (stubMenuModel) GetGroupID(_ int32) int32 { panic("unexpected call") }
52+func (stubMenuModel) GetGroupIDAt(_ int) int32 { panic("unexpected call") }
53+func (stubMenuModel) SetGroupID(_, _ int32) int32 { panic("unexpected call") }
54+func (stubMenuModel) SetGroupIDAt(_ int, _ int32) int32 { panic("unexpected call") }
55+func (stubMenuModel) GetSubMenu(_ int32) purecef.MenuModel { panic("unexpected call") }
56+func (stubMenuModel) GetSubMenuAt(_ int) purecef.MenuModel { panic("unexpected call") }
57+func (stubMenuModel) IsVisible(_ int32) bool { panic("unexpected call") }
58+func (stubMenuModel) IsVisibleAt(_ int) bool { panic("unexpected call") }
59+func (stubMenuModel) SetVisible(_, _ int32) int32 { panic("unexpected call") }
60+func (stubMenuModel) SetVisibleAt(_ int, _ int32) int32 { panic("unexpected call") }
61+func (stubMenuModel) IsEnabled(_ int32) bool { panic("unexpected call") }
62+func (stubMenuModel) IsEnabledAt(_ int) bool { panic("unexpected call") }
63+func (stubMenuModel) SetEnabled(_, _ int32) int32 { panic("unexpected call") }
64+func (stubMenuModel) SetEnabledAt(_ int, _ int32) int32 { panic("unexpected call") }
65+func (stubMenuModel) IsChecked(_ int32) bool { panic("unexpected call") }
66+func (stubMenuModel) IsCheckedAt(_ int) bool { panic("unexpected call") }
67+func (stubMenuModel) SetChecked(_, _ int32) int32 { panic("unexpected call") }
68+func (stubMenuModel) SetCheckedAt(_ int, _ int32) int32 { panic("unexpected call") }
69+func (stubMenuModel) HasAccelerator(_ int32) bool { panic("unexpected call") }
70+func (stubMenuModel) HasAcceleratorAt(_ int) bool { panic("unexpected call") }
71+func (stubMenuModel) SetAccelerator(_, _, _, _, _ int32) int32 {
72+ panic("unexpected call")
73+}
74+func (stubMenuModel) SetAcceleratorAt(_ int, _, _, _, _ int32) int32 {
75+ panic("unexpected call")
76+}
77+func (stubMenuModel) RemoveAccelerator(_ int32) int32 { panic("unexpected call") }
78+func (stubMenuModel) RemoveAcceleratorAt(_ int) int32 { panic("unexpected call") }
79+func (stubMenuModel) GetAccelerator(_ int32, _, _, _, _ unsafe.Pointer) int32 {
80+ panic("unexpected call")
81+}
82+func (stubMenuModel) GetAcceleratorAt(_ int, _, _, _, _ unsafe.Pointer) int32 {
83+ panic("unexpected call")
84+}
85+func (stubMenuModel) SetColor(_ int32, _ purecef.MenuColorType, _ uintptr) int32 {
86+ panic("unexpected call")
87+}
88+func (stubMenuModel) SetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int32 {
89+ panic("unexpected call")
90+}
91+func (stubMenuModel) GetColor(_ int32, _ purecef.MenuColorType, _ uintptr) int32 {
92+ panic("unexpected call")
93+}
94+func (stubMenuModel) GetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int32 {
95+ panic("unexpected call")
96+}
97+func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") }
98+func (stubMenuModel) SetFontListAt(_ int32, _ string) int32 { panic("unexpected call") }
M internal/infrastructure/cef/webview.go +3 -1
@@ -473,7 +473,9 @@ func (wv *WebView) Destroy() {
473473 if !wv.destroyed.CompareAndSwap(false, true) {
474474 return
475475 }
476- wv.resizeReconciler.stop()
476+ if wv.resizeReconciler != nil {
477+ wv.resizeReconciler.stop()
478+ }
477479 wv.closeAudioStream()
478480 wv.scheduleStopBeginFrameLoop()
479481 wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers.go +28 -29
@@ -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
@@ -221,7 +215,8 @@ func (h *handlerSet) OnPaint(
221215 }
222216 log.Msg("cef: OnPaint begin")
223217 }
224- if elementType != purecef.PaintElementTypePetPopup && !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) {
218+ if elementType != purecef.PaintElementTypePetPopup &&
219+ !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) {
225220 return
226221 }
227222 rects := make([]rect, len(dirtyRects))
@@ -254,8 +249,13 @@ func (h *handlerSet) OnPaint(
254249 }
255250 }
256251
257-func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool {
258- resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics()
252+func (h *handlerSet) shouldAcceptMainViewPaint(
253+ width, height int32,
254+ dirtyRects []purecef.Rect,
255+ paintSeq uint64,
256+ resizeSeq uint64,
257+ resizeAgeMs int64,
258+) bool {
259259 expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
260260 sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
261261
@@ -558,7 +558,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
558558 browserID := browser.GetIdentifier()
559559 log.Debug().
560560 Int32("browser_id", browserID).
561- Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
562561 Msg("cef: OnAfterCreated")
563562 if h.wv.engine != nil {
564563 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
M internal/infrastructure/clipboard/clipboard.go +313 -28
@@ -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 Wayland/X11 preference and toolkit fallback.
22 package clipboard
33
44 import (
5+ "bytes"
56 "context"
67 "fmt"
78 "os"
@@ -9,49 +10,266 @@ import (
910 "strings"
1011
1112 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
1214 "github.com/bnema/dumber/internal/logging"
15+ "github.com/bnema/puregotk/v4/gdk"
16+ "github.com/bnema/puregotk/v4/gio"
17+ "github.com/bnema/puregotk/v4/glib"
18+ "github.com/rs/zerolog"
1319 )
1420
15-// Adapter implements port.Clipboard using system clipboard tools.
16-// Uses wl-clipboard for Wayland, falls back to xclip for X11.
21+type toolkitClipboard interface {
22+ ReadText(ctx context.Context) (string, error)
23+ WriteText(ctx context.Context, text string) error
24+ WriteImage(ctx context.Context, image entity.ImageData) error
25+}
26+
27+// Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11.
28+// If no system clipboard tool is available, it falls back to the toolkit clipboard.
1729 type Adapter struct {
30+ toolkit toolkitClipboard
1831 copyCmd string
1932 pasteCmd string
2033 }
2134
35+func (a *Adapter) ensureToolkitClipboard() toolkitClipboard {
36+ if a == nil {
37+ return nil
38+ }
39+ if a.toolkit == nil {
40+ a.toolkit = newToolkitClipboard()
41+ }
42+ return a.toolkit
43+}
44+
45+var (
46+ lookPath = exec.LookPath
47+ commandContext = exec.CommandContext
48+ newToolkitClipboard = defaultToolkitClipboard
49+)
50+
2251 // New creates a new clipboard adapter.
2352 // Detects Wayland vs X11 and selects appropriate clipboard tool.
2453 func New() port.Clipboard {
25- a := &Adapter{}
54+ a := &Adapter{toolkit: newToolkitClipboard()}
55+ a.copyCmd, a.pasteCmd = selectSystemClipboard()
56+ return a
57+}
58+
59+func defaultToolkitClipboard() toolkitClipboard {
60+ display := gdk.DisplayGetDefault()
61+ if display == nil {
62+ return nil
63+ }
64+
65+ clipboard := display.GetClipboard()
66+ if clipboard == nil {
67+ return nil
68+ }
69+
70+ return &gdkToolkitClipboard{clipboard: clipboard}
71+}
72+
73+type gdkToolkitClipboard struct {
74+ clipboard *gdk.Clipboard
75+}
76+
77+func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error {
78+ return withToolkitClipboard(ctx, func() error {
79+ if c == nil || c.clipboard == nil {
80+ return fmt.Errorf("toolkit clipboard not available")
81+ }
82+ c.clipboard.SetText(text)
83+ return nil
84+ })
85+}
86+
87+func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) {
88+ var text string
89+ err := withToolkitClipboard(ctx, func() error {
90+ if c == nil || c.clipboard == nil {
91+ return fmt.Errorf("toolkit clipboard not available")
92+ }
93+
94+ resultCh := make(chan struct {
95+ text string
96+ err error
97+ }, 1)
98+ cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) {
99+ asyncResult := &gio.AsyncResultBase{}
100+ asyncResult.SetGoPointer(result)
101+ readText, readErr := c.clipboard.ReadTextFinish(asyncResult)
102+ resultCh <- struct {
103+ text string
104+ err error
105+ }{text: readText, err: readErr}
106+ })
107+ c.clipboard.ReadTextAsync(nil, &cb, 0)
108+
109+ mainContext := glib.MainContextDefault()
110+ if mainContext == nil {
111+ return fmt.Errorf("toolkit main context not available")
112+ }
26113
27- // Check for Wayland first
114+ for {
115+ select {
116+ case result := <-resultCh:
117+ text = result.text
118+ return result.err
119+ default:
120+ }
121+ if ctx.Err() != nil {
122+ return ctx.Err()
123+ }
124+ mainContext.Iteration(true)
125+ }
126+ })
127+ return text, err
128+}
129+
130+func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
131+ return withToolkitClipboard(ctx, func() error {
132+ if c == nil || c.clipboard == nil {
133+ return fmt.Errorf("toolkit clipboard not available")
134+ }
135+
136+ texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
137+ if err != nil {
138+ return err
139+ }
140+ if texture == nil {
141+ return fmt.Errorf("toolkit clipboard returned nil texture")
142+ }
143+
144+ c.clipboard.SetTexture(texture)
145+ return nil
146+ })
147+}
148+
149+func withToolkitClipboard(ctx context.Context, fn func() error) error {
150+ mainContext := glib.MainContextDefault()
151+ if mainContext == nil || mainContext.IsOwner() {
152+ return fn()
153+ }
154+
155+ resultCh := make(chan error, 1)
156+ cb := glib.SourceFunc(func(_ uintptr) bool {
157+ resultCh <- fn()
158+ return false
159+ })
160+ glib.IdleAdd(&cb, 0)
161+ select {
162+ case err := <-resultCh:
163+ return err
164+ case <-ctx.Done():
165+ return ctx.Err()
166+ }
167+}
168+
169+func selectSystemClipboard() (copyCmd, pasteCmd string) {
170+ // Check for Wayland first.
28171 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
172+ if path, err := lookPath("wl-copy"); err == nil {
173+ copyCmd = path
174+ if pastePath, err := lookPath("wl-paste"); err == nil {
175+ pasteCmd = pastePath
33176 }
34177 }
35178 }
36179
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
180+ // Fall back to X11 if Wayland tools are not available.
181+ if copyCmd == "" && os.Getenv("DISPLAY") != "" {
182+ if path, err := lookPath("xclip"); err == nil {
183+ copyCmd = path
184+ pasteCmd = path
185+ } else if path, err := lookPath("xsel"); err == nil {
186+ copyCmd = path
187+ pasteCmd = path
45188 }
46189 }
47190
48- return a
191+ return copyCmd, pasteCmd
49192 }
50193
51194 // WriteText copies text to the clipboard.
52195 func (a *Adapter) WriteText(ctx context.Context, text string) error {
53196 log := logging.FromContext(ctx)
197+ var commandErr error
198+ var toolkitErr error
199+ if a.copyCmd != "" {
200+ err := a.writeTextWithCommand(ctx, text, log)
201+ if err == nil {
202+ return nil
203+ }
204+ commandErr = err
205+ }
206+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
207+ err := toolkit.WriteText(ctx, text)
208+ if err == nil {
209+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
210+ return nil
211+ }
212+ toolkitErr = err
213+ log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
214+ }
215+ if commandErr != nil {
216+ return commandErr
217+ }
218+ if toolkitErr != nil {
219+ log.Error().Err(toolkitErr).Msg("clipboard write failed")
220+ return toolkitErr
221+ }
222+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
223+ log.Error().Err(err).Msg("clipboard write failed")
224+ return err
225+}
226+
227+// WriteImage copies image bytes to the clipboard.
228+// xsel is not supported because the adapter has no reliable binary image mode
229+// for it in this codebase.
230+func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error {
231+ log := logging.FromContext(ctx)
232+ var commandErr error
233+ var toolkitErr error
234+
235+ if len(image.Bytes) == 0 {
236+ err := fmt.Errorf("empty image data")
237+ log.Error().Err(err).Msg("clipboard image write failed")
238+ return err
239+ }
240+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
241+ err := toolkit.WriteImage(ctx, image)
242+ if err == nil {
243+ log.Debug().
244+ Str("backend", "toolkit").
245+ Str("mime_type", image.MimeType).
246+ Int("bytes", len(image.Bytes)).
247+ Msg("clipboard image write success")
248+ return nil
249+ }
250+ toolkitErr = err
251+ log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
252+ }
253+ if a.copyCmd != "" {
254+ err := a.writeImageWithCommand(ctx, image, log)
255+ if err == nil {
256+ return nil
257+ }
258+ commandErr = err
259+ }
260+ if commandErr != nil {
261+ return commandErr
262+ }
263+ if toolkitErr != nil {
264+ log.Error().Err(toolkitErr).Msg("clipboard image write failed")
265+ return toolkitErr
266+ }
267+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
268+ log.Error().Err(err).Msg("clipboard image write failed")
269+ return err
270+}
54271
272+func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
55273 if a.copyCmd == "" {
56274 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
57275 log.Error().Err(err).Msg("clipboard write failed")
@@ -60,11 +278,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
60278
61279 var cmd *exec.Cmd
62280 if strings.Contains(a.copyCmd, "wl-copy") {
63- cmd = exec.CommandContext(ctx, a.copyCmd)
281+ cmd = commandContext(ctx, a.copyCmd)
64282 } else if strings.Contains(a.copyCmd, "xclip") {
65- cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard")
283+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
66284 } else if strings.Contains(a.copyCmd, "xsel") {
67- cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input")
285+ cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
68286 } else {
69287 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
70288 log.Error().Err(err).Msg("clipboard write failed")
@@ -73,6 +291,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
73291
74292 cmd.Stdin = strings.NewReader(text)
75293 if err := cmd.Run(); err != nil {
294+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
76295 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
77296 return err
78297 }
@@ -81,10 +300,40 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
81300 return nil
82301 }
83302
84-// ReadText reads text from the clipboard.
85-func (a *Adapter) ReadText(ctx context.Context) (string, error) {
86- log := logging.FromContext(ctx)
303+func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error {
304+ if a.copyCmd == "" {
305+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
306+ log.Error().Err(err).Msg("clipboard image write failed")
307+ return err
308+ }
309+ mimeType := image.MimeType
310+ if mimeType == "" {
311+ mimeType = "image/png"
312+ }
313+
314+ var cmd *exec.Cmd
315+ if strings.Contains(a.copyCmd, "wl-copy") {
316+ cmd = commandContext(ctx, a.copyCmd, "--type", mimeType)
317+ } else if strings.Contains(a.copyCmd, "xclip") {
318+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i")
319+ } else {
320+ err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
321+ log.Error().Err(err).Msg("clipboard image write failed")
322+ return err
323+ }
324+
325+ cmd.Stdin = bytes.NewReader(image.Bytes)
326+ if err := cmd.Run(); err != nil {
327+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
328+ log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed")
329+ return err
330+ }
87331
332+ log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
333+ return nil
334+}
335+
336+func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) {
88337 if a.pasteCmd == "" {
89338 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
90339 log.Error().Err(err).Msg("clipboard read failed")
@@ -93,11 +342,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
93342
94343 var cmd *exec.Cmd
95344 if strings.Contains(a.pasteCmd, "wl-paste") {
96- cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline")
345+ cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
97346 } else if strings.Contains(a.pasteCmd, "xclip") {
98- cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
347+ cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
99348 } else if strings.Contains(a.pasteCmd, "xsel") {
100- cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output")
349+ cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
101350 } else {
102351 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
103352 log.Error().Err(err).Msg("clipboard read failed")
@@ -106,7 +355,8 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
106355
107356 out, err := cmd.Output()
108357 if err != nil {
109- log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed (may be empty)")
358+ err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err)
359+ log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed")
110360 return "", err
111361 }
112362
@@ -114,6 +364,41 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
114364 return string(out), nil
115365 }
116366
367+// ReadText reads text from the clipboard.
368+func (a *Adapter) ReadText(ctx context.Context) (string, error) {
369+ log := logging.FromContext(ctx)
370+ var commandErr error
371+
372+ if a.pasteCmd != "" {
373+ text, err := a.readTextWithCommand(ctx, log)
374+ if err == nil {
375+ return text, nil
376+ }
377+ commandErr = err
378+ log.Debug().Err(err).Msg("system clipboard read failed; falling back")
379+ }
380+
381+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
382+ text, err := toolkit.ReadText(ctx)
383+ if err == nil {
384+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success")
385+ return text, nil
386+ }
387+ log.Debug().Err(err).Msg("toolkit clipboard read failed")
388+ if commandErr != nil {
389+ return "", commandErr
390+ }
391+ return "", err
392+ }
393+
394+ if commandErr != nil {
395+ return "", commandErr
396+ }
397+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
398+ log.Error().Err(err).Msg("clipboard read failed")
399+ return "", err
400+}
401+
117402 // Clear clears the clipboard contents.
118403 func (a *Adapter) Clear(ctx context.Context) error {
119404 return a.WriteText(ctx, "")
A internal/infrastructure/clipboard/clipboard_test.go +245 -0
@@ -0,0 +1,245 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type fakeToolkitClipboard struct {
16+ writeTextCalls int
17+ readTextCalls int
18+ writeImageCalls int
19+ text string
20+ image entity.ImageData
21+ writeTextErr error
22+ readTextErr error
23+ writeImageErr error
24+}
25+
26+func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
27+ f.writeTextCalls++
28+ f.text = text
29+ return f.writeTextErr
30+}
31+
32+func (f *fakeToolkitClipboard) ReadText(_ context.Context) (string, error) {
33+ f.readTextCalls++
34+ return f.text, f.readTextErr
35+}
36+
37+func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
38+ f.writeImageCalls++
39+ f.image = image
40+ return f.writeImageErr
41+}
42+
43+func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) {
44+ fake := &fakeToolkitClipboard{text: "toolkit text"}
45+ adapter := &Adapter{toolkit: fake}
46+
47+ text, err := adapter.ReadText(context.Background())
48+
49+ require.NoError(t, err)
50+ require.Equal(t, "toolkit text", text)
51+ require.Equal(t, 1, fake.readTextCalls)
52+}
53+
54+func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) {
55+ fake := &fakeToolkitClipboard{text: "toolkit text"}
56+ adapter := &Adapter{toolkit: fake}
57+
58+ hasText, err := adapter.HasText(context.Background())
59+
60+ require.NoError(t, err)
61+ require.True(t, hasText)
62+ require.Equal(t, 1, fake.readTextCalls)
63+}
64+
65+func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
66+ sentinel := errors.New("toolkit text failed")
67+ fake := &fakeToolkitClipboard{writeTextErr: sentinel}
68+ adapter := &Adapter{toolkit: fake}
69+
70+ err := adapter.WriteText(context.Background(), "hello")
71+
72+ require.Equal(t, sentinel, err)
73+ require.Equal(t, 1, fake.writeTextCalls)
74+ require.Equal(t, "hello", fake.text)
75+}
76+
77+func TestAdapter_WriteImageReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
78+ sentinel := errors.New("toolkit image failed")
79+ fake := &fakeToolkitClipboard{writeImageErr: sentinel}
80+ adapter := &Adapter{toolkit: fake}
81+
82+ err := adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"})
83+
84+ require.Equal(t, sentinel, err)
85+ require.Equal(t, 1, fake.writeImageCalls)
86+ require.Equal(t, entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"}, fake.image)
87+}
88+
89+func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) {
90+ oldToolkitFactory := newToolkitClipboard
91+ oldLookPath := lookPath
92+ t.Cleanup(func() {
93+ newToolkitClipboard = oldToolkitFactory
94+ lookPath = oldLookPath
95+ })
96+
97+ fake := &fakeToolkitClipboard{}
98+ newToolkitClipboard = func() toolkitClipboard {
99+ return fake
100+ }
101+ lookPath = func(_ string) (string, error) {
102+ return "", errors.New("missing")
103+ }
104+
105+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
106+ t.Setenv("DISPLAY", ":0")
107+
108+ adapter := New().(*Adapter)
109+ require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}}))
110+ require.Equal(t, 1, fake.writeImageCalls)
111+ require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
112+}
113+
114+func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) {
115+ oldToolkitFactory := newToolkitClipboard
116+ t.Cleanup(func() {
117+ newToolkitClipboard = oldToolkitFactory
118+ })
119+
120+ dir := t.TempDir()
121+ scriptPath := filepath.Join(dir, "wl-copy")
122+ stdinPath := filepath.Join(dir, "stdin.bin")
123+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
124+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
125+
126+ fake := &fakeToolkitClipboard{}
127+ newToolkitClipboard = func() toolkitClipboard { return fake }
128+ adapter := &Adapter{copyCmd: scriptPath}
129+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
130+
131+ require.NoError(t, adapter.WriteImage(context.Background(), image))
132+ require.Equal(t, 1, fake.writeImageCalls)
133+ require.Equal(t, image, fake.image)
134+
135+ _, err := os.Stat(stdinPath)
136+ require.Error(t, err)
137+ require.True(t, os.IsNotExist(err))
138+}
139+
140+func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) {
141+ dir := t.TempDir()
142+ scriptPath := filepath.Join(dir, "wl-copy")
143+ stdinPath := filepath.Join(dir, "stdin.bin")
144+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
145+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
146+
147+ adapter := &Adapter{copyCmd: scriptPath}
148+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
149+
150+ require.NoError(t, adapter.WriteImage(context.Background(), image))
151+
152+ stdin, err := os.ReadFile(stdinPath)
153+ require.NoError(t, err)
154+ require.Equal(t, image.Bytes, stdin)
155+}
156+
157+func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
158+ oldToolkitFactory := newToolkitClipboard
159+ oldLookPath := lookPath
160+ t.Cleanup(func() {
161+ newToolkitClipboard = oldToolkitFactory
162+ lookPath = oldLookPath
163+ })
164+
165+ newToolkitClipboard = func() toolkitClipboard { return nil }
166+ lookups := make([]string, 0, 2)
167+ lookPath = func(name string) (string, error) {
168+ lookups = append(lookups, name)
169+ switch name {
170+ case "wl-copy":
171+ return "", errors.New("wl-copy missing")
172+ case "xclip":
173+ return "/usr/bin/xclip", nil
174+ default:
175+ return "", errors.New("unexpected lookPath call")
176+ }
177+ }
178+
179+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
180+ t.Setenv("DISPLAY", ":0")
181+
182+ adapter := New().(*Adapter)
183+ require.Equal(t, "/usr/bin/xclip", adapter.copyCmd)
184+ require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd)
185+ require.Equal(t, []string{"wl-copy", "xclip"}, lookups)
186+}
187+
188+func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
189+ oldToolkitFactory := newToolkitClipboard
190+ oldLookPath := lookPath
191+ t.Cleanup(func() {
192+ newToolkitClipboard = oldToolkitFactory
193+ lookPath = oldLookPath
194+ })
195+
196+ fake := &fakeToolkitClipboard{}
197+ newToolkitClipboard = func() toolkitClipboard { return fake }
198+ lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
199+
200+ err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{})
201+ require.Error(t, err)
202+ require.Contains(t, err.Error(), "empty image data")
203+ require.Zero(t, fake.writeImageCalls)
204+}
205+
206+func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
207+ testCases := []struct {
208+ name string
209+ image entity.ImageData
210+ expected string
211+ }{
212+ {
213+ name: "uses provided MIME type",
214+ image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
215+ expected: "image/jpeg",
216+ },
217+ {
218+ name: "falls back to PNG",
219+ image: entity.ImageData{Bytes: []byte{1, 2, 3}},
220+ expected: "image/png",
221+ },
222+ }
223+
224+ for _, tt := range testCases {
225+ t.Run(tt.name, func(t *testing.T) {
226+ dir := t.TempDir()
227+ scriptPath := filepath.Join(dir, "wl-copy")
228+ argsPath := filepath.Join(dir, "args.txt")
229+ stdinPath := filepath.Join(dir, "stdin.bin")
230+ script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
231+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
232+
233+ adapter := &Adapter{copyCmd: scriptPath}
234+ require.NoError(t, adapter.WriteImage(context.Background(), tt.image))
235+
236+ args, err := os.ReadFile(argsPath)
237+ require.NoError(t, err)
238+ require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n"))
239+
240+ stdin, err := os.ReadFile(stdinPath)
241+ require.NoError(t, err)
242+ require.Equal(t, tt.image.Bytes, stdin)
243+ })
244+ }
245+}
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
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 }
M internal/infrastructure/config/loader.go +5 -5
@@ -136,18 +136,19 @@ func (m *Manager) readConfigFile() error {
136136 }
137137 }
138138
139- // Transform legacy action bindings before unmarshaling
140- m.transformLegacyActionBindings()
139+ // Transform legacy action bindings and engine config before unmarshaling.
140+ m.transformLegacyConfig()
141141
142142 return nil
143143 }
144144
145-// transformLegacyActionBindings converts old-format action bindings to new format.
145+// transformLegacyConfig converts old-format action bindings and engine config to the new format.
146146 // This is called after reading config but before unmarshaling.
147-func (m *Manager) transformLegacyActionBindings() {
147+func (m *Manager) transformLegacyConfig() {
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
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)
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
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
M internal/infrastructure/config/transform_legacy_test.go +63 -0
@@ -170,6 +170,69 @@ 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+
191+func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {
192+ transformer := NewLegacyConfigTransformer()
193+ rawConfig := map[string]any{
194+ "engine": map[string]any{
195+ "webkit": map[string]any{"enabled": true},
196+ },
197+ }
198+
199+ transformer.TransformLegacyEngineConfig(rawConfig)
200+
201+ engine, ok := rawConfig["engine"].(map[string]any)
202+ require.True(t, ok)
203+ assert.NotNil(t, engine)
204+ assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"])
205+}
206+
207+func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) {
208+ transformer := NewLegacyConfigTransformer()
209+ rawConfig := map[string]any{
210+ "engine": map[string]any{
211+ "cef": map[string]any{
212+ "other_setting": true,
213+ },
214+ },
215+ }
216+
217+ transformer.TransformLegacyEngineConfig(rawConfig)
218+
219+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
220+ assert.True(t, cef["other_setting"].(bool))
221+ _, exists := cef["enable_context_menu_handler"]
222+ assert.False(t, exists)
223+}
224+
225+func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) {
226+ transformer := NewLegacyConfigTransformer()
227+ rawConfig := map[string]any{
228+ "engine": "invalid",
229+ }
230+
231+ transformer.TransformLegacyEngineConfig(rawConfig)
232+
233+ assert.Equal(t, "invalid", rawConfig["engine"])
234+}
235+
173236 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174237 transformer := NewLegacyConfigTransformer()
175238
A internal/infrastructure/gtkmenu/renderer.go +240 -0
@@ -0,0 +1,240 @@
1+package gtkmenu
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/port"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gobject"
7+ "github.com/bnema/puregotk/v4/gtk"
8+)
9+
10+type renderItem struct {
11+ item port.MenuItem
12+ separator bool
13+}
14+
15+const (
16+ contextMenuBoxClass = "context-menu"
17+ contextMenuPopoverClass = "context-menu-popover"
18+ contextMenuItemClass = "context-menu-item"
19+ contextMenuSeparatorClass = "context-menu-separator"
20+)
21+
22+// Renderer shows GTK context menus on the provided anchor widget.
23+type Renderer struct {
24+ dispatch func(func())
25+}
26+
27+// NewRenderer creates a new Renderer.
28+func NewRenderer(dispatch func(func())) *Renderer {
29+ return &Renderer{dispatch: dispatch}
30+}
31+
32+func BuildButtons(items []port.MenuItem) []renderItem {
33+ renderItems := make([]renderItem, 0, len(items))
34+ for _, item := range items {
35+ if isSeparator(item) {
36+ renderItems = append(renderItems, renderItem{separator: true})
37+ continue
38+ }
39+ renderItems = append(renderItems, renderItem{item: item})
40+ }
41+ return renderItems
42+}
43+
44+func isSeparator(item port.MenuItem) bool {
45+ return item.Action == "" && item.Label == ""
46+}
47+
48+// Show renders a popover menu on the GTK thread.
49+func (r *Renderer) Show(
50+ items []port.MenuItem,
51+ anchor *gtk.Widget,
52+ x, y int32,
53+ onSelect func(port.MenuItem),
54+ onClose func(),
55+) {
56+ if r == nil {
57+ return
58+ }
59+ run := func() {
60+ showMenu(anchor, BuildButtons(items), x, y, onSelect, onClose)
61+ }
62+ if r.dispatch == nil {
63+ run()
64+ return
65+ }
66+ r.dispatch(run)
67+}
68+
69+func showMenu(
70+ anchor *gtk.Widget,
71+ items []renderItem,
72+ x, y int32,
73+ onSelect func(port.MenuItem),
74+ onClose func(),
75+) {
76+ if anchor == nil {
77+ if onClose != nil {
78+ onClose()
79+ }
80+ return
81+ }
82+ if !hasRenderableItems(items) {
83+ if onClose != nil {
84+ onClose()
85+ }
86+ return
87+ }
88+
89+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
90+ box.AddCssClass(contextMenuBoxClass)
91+
92+ selected := false
93+ var popover *gtk.Popover
94+ parent := ChoosePopoverParent(anchor, anchor.GetParent())
95+ popoverHost, overlay := createPopoverHost(parent, x, y)
96+
97+ for _, item := range items {
98+ if item.separator {
99+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
100+ sep.AddCssClass(contextMenuSeparatorClass)
101+ box.Append(&sep.Widget)
102+ continue
103+ }
104+
105+ btn := gtk.NewButton()
106+ btn.SetLabel(item.item.Label)
107+ btn.AddCssClass("flat")
108+ btn.AddCssClass(contextMenuItemClass)
109+
110+ menuItem := item.item
111+ clickCb := func(_ gtk.Button) {
112+ selected = true
113+ if onSelect != nil {
114+ onSelect(menuItem)
115+ }
116+ if popover != nil {
117+ popover.Popdown()
118+ }
119+ }
120+ btn.ConnectClicked(&clickCb)
121+ box.Append(&btn.Widget)
122+ }
123+
124+ popover = gtk.NewPopover()
125+ popover.AddCssClass(contextMenuPopoverClass)
126+ popover.SetChild(&box.Widget)
127+ attachPopover(popover, popoverHost, parent)
128+ popover.SetHasArrow(false)
129+ popover.SetAutohide(true)
130+
131+ if popoverHost == nil {
132+ rect := PopoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
133+ if anchor == nil || parent == nil || anchor == parent {
134+ return srcX, srcY, false
135+ }
136+ var destX, destY float64
137+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
138+ return srcX, srcY, false
139+ }
140+ return destX, destY, true
141+ })
142+ popover.SetPointingTo(rect)
143+ }
144+
145+ closedCb := func(_ gtk.Popover) {
146+ if !selected && onClose != nil {
147+ onClose()
148+ }
149+ cleanupPopover(popover, popoverHost, overlay)
150+ }
151+ popover.ConnectClosed(&closedCb)
152+
153+ popupPopover(popover, popoverHost)
154+}
155+
156+// ChoosePopoverParent picks the best parent for a GTK popover.
157+func ChoosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
158+ if parent != nil {
159+ return parent
160+ }
161+ return anchor
162+}
163+
164+// PopoverPointingRect builds the popover pointing rectangle.
165+func PopoverPointingRect(
166+ x, y int32,
167+ translate func(srcX, srcY float64) (float64, float64, bool),
168+) *gdk.Rectangle {
169+ destX, destY := float64(x), float64(y)
170+ if translate != nil {
171+ if tx, ty, ok := translate(destX, destY); ok {
172+ destX, destY = tx, ty
173+ }
174+ }
175+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
176+}
177+
178+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
179+ overlay := overlayFromWidget(parent)
180+ if overlay == nil {
181+ return nil, nil
182+ }
183+ host := gtk.NewMenuButton()
184+ host.SetCanFocus(false)
185+ host.SetHalign(gtk.AlignStartValue)
186+ host.SetValign(gtk.AlignStartValue)
187+ host.SetMarginStart(int(x))
188+ host.SetMarginTop(int(y))
189+ host.SetOpacity(0)
190+ host.SetSizeRequest(1, 1)
191+ host.SetVisible(true)
192+ overlay.AddOverlay(&host.Widget)
193+ overlay.SetClipOverlay(&host.Widget, false)
194+ overlay.SetMeasureOverlay(&host.Widget, false)
195+ return host, overlay
196+}
197+
198+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
199+ if widget == nil || widget.GoPointer() == 0 {
200+ return nil
201+ }
202+ if !gobject.TypeCheckInstanceIsAPtr(widget.GoPointer(), gtk.OverlayGLibType()) {
203+ return nil
204+ }
205+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
206+}
207+
208+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
209+ if host != nil {
210+ host.SetPopover(popover)
211+ return
212+ }
213+ popover.SetParent(parent)
214+}
215+
216+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
217+ if host != nil && overlay != nil {
218+ host.SetPopover(nil)
219+ overlay.RemoveOverlay(&host.Widget)
220+ return
221+ }
222+ popover.Unparent()
223+}
224+
225+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
226+ if host != nil {
227+ host.Popup()
228+ return
229+ }
230+ popover.Popup()
231+}
232+
233+func hasRenderableItems(items []renderItem) bool {
234+ for _, item := range items {
235+ if !item.separator {
236+ return true
237+ }
238+ }
239+ return false
240+}
A internal/infrastructure/gtkmenu/renderer_test.go +64 -0
@@ -0,0 +1,64 @@
1+package gtkmenu
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestChoosePopoverParent(t *testing.T) {
12+ anchor := &gtk.Widget{}
13+ parent := &gtk.Widget{}
14+
15+ require.Same(t, parent, ChoosePopoverParent(anchor, parent))
16+ require.Same(t, anchor, ChoosePopoverParent(anchor, nil))
17+}
18+
19+func TestPopoverPointingRect(t *testing.T) {
20+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
21+ rect := PopoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
22+ require.InDelta(t, 64.0, srcX, 0.0001)
23+ require.InDelta(t, 96.0, srcY, 0.0001)
24+ return 24, 48, true
25+ })
26+
27+ require.Equal(t, 24, rect.X)
28+ require.Equal(t, 48, rect.Y)
29+ require.Equal(t, 1, rect.Width)
30+ require.Equal(t, 1, rect.Height)
31+ })
32+
33+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
34+ rect := PopoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
35+ return srcX, srcY, false
36+ })
37+
38+ require.Equal(t, 10, rect.X)
39+ require.Equal(t, 20, rect.Y)
40+ })
41+}
42+
43+func TestBuildButtons(t *testing.T) {
44+ items := []port.MenuItem{
45+ {Action: port.MenuActionBack, Label: "Back"},
46+ {},
47+ {Action: port.MenuActionReload, Label: "Reload"},
48+ {Action: port.MenuActionInspectElement, Label: "Inspect Element"},
49+ }
50+
51+ renderItems := BuildButtons(items)
52+ require.Len(t, renderItems, 4)
53+
54+ require.Equal(t, "Back", renderItems[0].item.Label)
55+ require.False(t, renderItems[0].separator)
56+
57+ require.True(t, renderItems[1].separator)
58+
59+ require.Equal(t, "Reload", renderItems[2].item.Label)
60+ require.False(t, renderItems[2].separator)
61+
62+ require.Equal(t, "Inspect Element", renderItems[3].item.Label)
63+ require.False(t, renderItems[3].separator)
64+}
A internal/infrastructure/webkit/context_menu.go +577 -0
@@ -0,0 +1,577 @@
1+package webkit
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/base64"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "mime"
11+ "net"
12+ "net/http"
13+ "net/url"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/dumber/internal/application/port"
18+ "github.com/bnema/dumber/internal/domain/entity"
19+ "github.com/bnema/dumber/internal/logging"
20+ "github.com/bnema/puregotk-webkit/webkit"
21+ "github.com/bnema/puregotk/v4/gtk"
22+)
23+
24+// contextMenuResolver implements port.ImageDataResolver by fetching image bytes
25+// over HTTP. This is the WebKit-specific seam for the shared context menu
26+// pipeline's copy-image and save-image actions.
27+type contextMenuResolver struct {
28+ client *http.Client
29+ allowPrivateHosts bool
30+ lookupIPAddr func(context.Context, string) ([]net.IPAddr, error)
31+ dialContext func(context.Context, string, string) (net.Conn, error)
32+}
33+
34+var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
35+
36+const maxImageFetchBytes = 50 * 1024 * 1024
37+
38+// ResolveImageData fetches raw image bytes from the given URI.
39+func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) {
40+ if imageURI == "" {
41+ return entity.ImageData{}, fmt.Errorf("empty image URI")
42+ }
43+
44+ parsed, parseErr := url.Parse(imageURI)
45+ if parseErr != nil {
46+ return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
47+ }
48+ switch strings.ToLower(parsed.Scheme) {
49+ case "data":
50+ return resolveDataURIImageData(imageURI)
51+ case "http", "https":
52+ default:
53+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
54+ }
55+ if err := r.validateImageURL(ctx, parsed); err != nil {
56+ return entity.ImageData{}, err
57+ }
58+
59+ client := r.imageFetchClient(ctx)
60+
61+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
62+ if err != nil {
63+ return entity.ImageData{}, fmt.Errorf("build request: %w", err)
64+ }
65+
66+ resp, err := client.Do(req)
67+ if err != nil {
68+ return entity.ImageData{}, fmt.Errorf("fetch image: %w", err)
69+ }
70+ defer func() { _ = resp.Body.Close() }()
71+
72+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
73+ return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
74+ }
75+
76+ var buf bytes.Buffer
77+ n, err := io.CopyN(&buf, resp.Body, maxImageFetchBytes+1)
78+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
79+ return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
80+ }
81+ if n == 0 {
82+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
83+ }
84+ if n > maxImageFetchBytes {
85+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
86+ }
87+ data := buf.Bytes()
88+ responseURL := parsed.String()
89+ if resp.Request != nil && resp.Request.URL != nil {
90+ responseURL = resp.Request.URL.String()
91+ }
92+ return resolvedImageData(data, resp.Header.Get("Content-Type"), responseURL)
93+}
94+
95+func resolveDataURIImageData(imageURI string) (entity.ImageData, error) {
96+ payload, ok := strings.CutPrefix(imageURI, "data:")
97+ if !ok {
98+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", "")
99+ }
100+ meta, dataPart, found := strings.Cut(payload, ",")
101+ if !found {
102+ return entity.ImageData{}, fmt.Errorf("parse image URI: invalid data URI")
103+ }
104+
105+ isBase64 := false
106+ mimeType := ""
107+ for _, part := range strings.Split(meta, ";") {
108+ part = strings.TrimSpace(part)
109+ if part == "" {
110+ continue
111+ }
112+ if strings.EqualFold(part, "base64") {
113+ isBase64 = true
114+ continue
115+ }
116+ if mimeType == "" {
117+ mimeType = part
118+ }
119+ }
120+
121+ var data []byte
122+ var err error
123+ if isBase64 {
124+ data, err = base64.StdEncoding.DecodeString(dataPart)
125+ } else {
126+ var decoded string
127+ decoded, err = url.PathUnescape(dataPart)
128+ data = []byte(decoded)
129+ }
130+ if err != nil {
131+ return entity.ImageData{}, fmt.Errorf("read image body: decode data URI: %w", err)
132+ }
133+ return resolvedImageData(data, mimeType, imageURI)
134+}
135+
136+func resolvedImageData(data []byte, headerType, source string) (entity.ImageData, error) {
137+ if len(data) == 0 {
138+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
139+ }
140+ if len(data) > maxImageFetchBytes {
141+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
142+ }
143+ rawDetectedType := strings.ToLower(http.DetectContentType(data))
144+ detectedType := validatedImageMimeType(rawDetectedType)
145+ mimeType := detectedType
146+ if mimeType == "" && rawDetectedType == "application/octet-stream" {
147+ mimeType = validatedImageMimeType(headerType)
148+ }
149+ if mimeType == "" {
150+ return entity.ImageData{}, fmt.Errorf(
151+ "read image body: content is not an image (detected=%q, header=%q, url=%q)",
152+ rawDetectedType,
153+ headerType,
154+ source,
155+ )
156+ }
157+ return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
158+}
159+
160+func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
161+ base := r.client
162+ if base == nil {
163+ base = defaultImageFetchClient
164+ }
165+ client := *base
166+ transport := client.Transport
167+ if transport == nil {
168+ transport = http.DefaultTransport
169+ }
170+ if stdTransport, ok := transport.(*http.Transport); ok {
171+ cloned := stdTransport.Clone()
172+ cloned.DialContext = r.dialImageHostContext
173+ client.Transport = cloned
174+ } else {
175+ client.Transport = &validatedImageRoundTripper{
176+ base: transport,
177+ validator: r.validateImageURL,
178+ }
179+ }
180+ client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
181+ return r.validateImageURL(req.Context(), req.URL)
182+ }
183+ return &client
184+}
185+
186+func (r *contextMenuResolver) validateImageURL(ctx context.Context, imageURL *url.URL) error {
187+ if imageURL == nil {
188+ return fmt.Errorf("fetch image: missing URL")
189+ }
190+ switch strings.ToLower(imageURL.Scheme) {
191+ case "http", "https":
192+ default:
193+ return fmt.Errorf("fetch image: unsupported URI scheme %q", imageURL.Scheme)
194+ }
195+ if imageURL.Host == "" {
196+ return fmt.Errorf("fetch image: missing host")
197+ }
198+ hostname := imageURL.Hostname()
199+ if err := r.validateImageHost(ctx, hostname); err != nil {
200+ return err
201+ }
202+ return nil
203+}
204+
205+func (r *contextMenuResolver) validateImageHost(ctx context.Context, host string) error {
206+ if host == "" {
207+ return fmt.Errorf("fetch image: missing host")
208+ }
209+ if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") {
210+ if r.allowPrivateHosts {
211+ return nil
212+ }
213+ return fmt.Errorf("fetch image: private host %q not allowed", host)
214+ }
215+ ip := net.ParseIP(host)
216+ if ip == nil {
217+ addrs, err := r.allowedImageHostAddrs(ctx, host)
218+ if err != nil {
219+ return err
220+ }
221+ if len(addrs) == 0 {
222+ return fmt.Errorf("fetch image: private host %q not allowed", host)
223+ }
224+ return nil
225+ }
226+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
227+ return fmt.Errorf("fetch image: private host %q not allowed", host)
228+ }
229+ return nil
230+}
231+
232+func (r *contextMenuResolver) allowedImageHostAddrs(ctx context.Context, host string) ([]net.IPAddr, error) {
233+ lookup := r.lookupIPAddr
234+ if lookup == nil {
235+ lookup = net.DefaultResolver.LookupIPAddr
236+ }
237+ addrs, err := lookup(ctx, host)
238+ if err != nil {
239+ return nil, fmt.Errorf("fetch image: resolve host %q: %w", host, err)
240+ }
241+ if r.allowPrivateHosts {
242+ return addrs, nil
243+ }
244+ allowed := make([]net.IPAddr, 0, len(addrs))
245+ for _, addr := range addrs {
246+ if !isPrivateImageIP(addr.IP) {
247+ allowed = append(allowed, addr)
248+ }
249+ }
250+ return allowed, nil
251+}
252+
253+func (r *contextMenuResolver) dialImageHostContext(ctx context.Context, network, addr string) (net.Conn, error) {
254+ host, imagePort, err := net.SplitHostPort(addr)
255+ if err != nil {
256+ return nil, fmt.Errorf("fetch image: parse dial address %q: %w", addr, err)
257+ }
258+ if host == "" {
259+ return nil, fmt.Errorf("fetch image: missing host")
260+ }
261+
262+ dial := r.dialContext
263+ if dial == nil {
264+ dial = (&net.Dialer{}).DialContext
265+ }
266+
267+ if ip := net.ParseIP(host); ip != nil {
268+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
269+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
270+ }
271+ return dial(ctx, network, net.JoinHostPort(ip.String(), imagePort))
272+ }
273+
274+ addrs, err := r.allowedImageHostAddrs(ctx, host)
275+ if err != nil {
276+ return nil, err
277+ }
278+ if len(addrs) == 0 {
279+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
280+ }
281+
282+ var lastErr error
283+ for _, addr := range addrs {
284+ conn, dialErr := dial(ctx, network, net.JoinHostPort(addr.IP.String(), imagePort))
285+ if dialErr == nil {
286+ return conn, nil
287+ }
288+ lastErr = dialErr
289+ }
290+ if lastErr == nil {
291+ return nil, fmt.Errorf("fetch image: dial host %q failed", host)
292+ }
293+ return nil, fmt.Errorf("fetch image: dial host %q: %w", host, lastErr)
294+}
295+
296+type validatedImageRoundTripper struct {
297+ base http.RoundTripper
298+ validator func(context.Context, *url.URL) error
299+}
300+
301+func (t *validatedImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
302+ if t == nil || t.base == nil {
303+ return nil, fmt.Errorf("fetch image: transport not available")
304+ }
305+ if err := t.validator(req.Context(), req.URL); err != nil {
306+ return nil, err
307+ }
308+ return t.base.RoundTrip(req)
309+}
310+
311+func isPrivateImageIP(ip net.IP) bool {
312+ return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified())
313+}
314+
315+func validatedImageMimeType(raw string) string {
316+ if raw == "" {
317+ return ""
318+ }
319+ mediaType, _, err := mime.ParseMediaType(raw)
320+ if err != nil {
321+ return ""
322+ }
323+ mediaType = strings.ToLower(mediaType)
324+ if !strings.HasPrefix(mediaType, "image/") {
325+ return ""
326+ }
327+ return mediaType
328+}
329+
330+// Compile-time check that contextMenuResolver implements port.ImageDataResolver.
331+var _ port.ImageDataResolver = (*contextMenuResolver)(nil)
332+
333+// buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext.
334+func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext {
335+ ctx := port.MenuContext{
336+ X: x,
337+ Y: y,
338+ }
339+
340+ if wv != nil {
341+ ctx.PageURI = wv.URI()
342+ ctx.CanGoBack = wv.CanGoBack()
343+ ctx.CanGoForward = wv.CanGoForward()
344+ }
345+
346+ if hit == nil {
347+ return ctx
348+ }
349+
350+ if hit.ContextIsLink() {
351+ ctx.LinkURI = hit.GetLinkUri()
352+ }
353+ if hit.ContextIsImage() {
354+ ctx.ImageURI = hit.GetImageUri()
355+ }
356+ ctx.HasSelection = hit.ContextIsSelection()
357+ ctx.IsEditable = hit.ContextIsEditable()
358+
359+ return ctx
360+}
361+
362+// contextMenuPipeline holds the dependencies needed to run the shared
363+// context menu flow from inside a WebKit context-menu signal handler.
364+type contextMenuPipeline struct {
365+ builder port.ContextMenuBuilder
366+ executorFactory port.ContextMenuActionExecutorFactory
367+ clipboard port.Clipboard
368+ resolver port.ImageDataResolver
369+ saver port.ResolvedImageSaver
370+ renderer *Renderer
371+}
372+
373+func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExecutor {
374+ if p == nil || p.executorFactory == nil {
375+ return nil
376+ }
377+ return p.executorFactory.NewContextMenuActionExecutor(
378+ p.clipboard,
379+ p.resolver,
380+ p.saver,
381+ &webkitMenuDelegator{wv: wv},
382+ )
383+}
384+
385+// connectContextMenuSignal wires the WebKit "context-menu" signal to the
386+// shared context menu pipeline. When the user right-clicks, WebKit's native
387+// menu is suppressed and replaced by the shared GTK popover menu.
388+func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
389+ if pipeline == nil {
390+ return
391+ }
392+ wv.contextMenu = pipeline
393+
394+ contextMenuCb := func(_ webkit.WebView, contextMenuPtr uintptr, hitTestPtr uintptr) bool {
395+ var contextMenu *webkit.ContextMenu
396+ if contextMenuPtr != 0 {
397+ contextMenu = webkit.ContextMenuNewFromInternalPtr(contextMenuPtr)
398+ }
399+ var hit *webkit.HitTestResult
400+ if hitTestPtr != 0 {
401+ hit = webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
402+ }
403+
404+ x, y := contextMenuPosition(contextMenu)
405+ parent := anchorParentWidget(&wv.inner.Widget)
406+ wv.logger.Debug().
407+ Int("raw_x", x).
408+ Int("raw_y", y).
409+ Int("anchor_width", wv.inner.Widget.GetAllocatedWidth()).
410+ Int("anchor_height", wv.inner.Widget.GetAllocatedHeight()).
411+ Int("parent_width", widgetAllocatedWidth(parent)).
412+ Int("parent_height", widgetAllocatedHeight(parent)).
413+ Msg("webkit: context menu popup request")
414+
415+ menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
416+ ctx := logging.WithContext(context.Background(), wv.logger)
417+ if pipeline.builder == nil || pipeline.renderer == nil {
418+ return false
419+ }
420+ items := pipeline.builder.Build(ctx, menuContext)
421+ if len(items) == 0 {
422+ return false
423+ }
424+
425+ executor := pipeline.newExecutor(wv)
426+ if executor == nil {
427+ return false
428+ }
429+
430+ // Get the widget for anchoring the popover.
431+ if wv.inner == nil {
432+ return false
433+ }
434+ anchor := &wv.inner.Widget
435+
436+ pipeline.renderer.Show(
437+ items,
438+ anchor,
439+ int32(x),
440+ int32(y),
441+ func(item port.MenuItem) {
442+ wv.executeContextMenuAction(ctx, executor, item, menuContext)
443+ },
444+ nil, // onClose — no action needed
445+ )
446+
447+ return true // suppress native WebKit context menu
448+ }
449+
450+ sigID := wv.inner.ConnectContextMenu(&contextMenuCb)
451+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
452+}
453+
454+func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
455+ if menu == nil || menu.GoPointer() == 0 {
456+ return 0, 0
457+ }
458+ event := menu.GetEvent()
459+ if event == nil {
460+ return 0, 0
461+ }
462+ var x, y float64
463+ if !event.GetPosition(&x, &y) {
464+ return 0, 0
465+ }
466+ return int(x), int(y)
467+}
468+
469+func anchorParentWidget(anchor *gtk.Widget) *gtk.Widget {
470+ if anchor == nil {
471+ return nil
472+ }
473+ return anchor.GetParent()
474+}
475+
476+func widgetAllocatedWidth(widget *gtk.Widget) int {
477+ if widget == nil {
478+ return 0
479+ }
480+ return widget.GetAllocatedWidth()
481+}
482+
483+func widgetAllocatedHeight(widget *gtk.Widget) int {
484+ if widget == nil {
485+ return 0
486+ }
487+ return widget.GetAllocatedHeight()
488+}
489+
490+// executeContextMenuAction runs the selected menu action through the shared
491+// executor. GTK-thread-sensitive actions run inline; image fetch/save work
492+// stays off-thread.
493+func (wv *WebView) executeContextMenuAction(
494+ ctx context.Context,
495+ executor port.ContextMenuActionExecutor,
496+ item port.MenuItem,
497+ menuContext port.MenuContext,
498+) {
499+ if executor == nil {
500+ return
501+ }
502+
503+ if needsBackgroundContextMenuAction(item.Action) {
504+ go wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
505+ return
506+ }
507+ wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
508+}
509+
510+func (wv *WebView) runContextMenuAction(
511+ ctx context.Context,
512+ executor port.ContextMenuActionExecutor,
513+ action port.MenuAction,
514+ menuContext port.MenuContext,
515+) {
516+ if err := executor.ExecuteMenuAction(ctx, action, menuContext); err != nil {
517+ wv.logger.Warn().Err(err).
518+ Str("action", string(action)).
519+ Msg("context menu action failed")
520+ }
521+}
522+
523+func needsBackgroundContextMenuAction(action port.MenuAction) bool {
524+ switch action {
525+ case port.MenuActionCopyImage, port.MenuActionSaveImage:
526+ return true
527+ default:
528+ return false
529+ }
530+}
531+
532+type webkitMenuDelegator struct {
533+ wv *WebView
534+}
535+
536+var _ port.MenuActionDelegator = (*webkitMenuDelegator)(nil)
537+
538+func (d *webkitMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
539+ if d == nil || d.wv == nil {
540+ return fmt.Errorf("webkit menu delegator: webview not available")
541+ }
542+ switch action {
543+ case port.MenuActionBack:
544+ return d.wv.GoBack(ctx)
545+ case port.MenuActionForward:
546+ return d.wv.GoForward(ctx)
547+ case port.MenuActionReload:
548+ return d.wv.Reload(ctx)
549+ case port.MenuActionOpenLinkNewTab:
550+ if menuContext.LinkURI == "" {
551+ return fmt.Errorf("open link in new tab: link URI not available")
552+ }
553+ if d.wv.OnLinkMiddleClick == nil {
554+ return fmt.Errorf("open link in new tab: middle-click handler not available")
555+ }
556+ if !d.wv.OnLinkMiddleClick(menuContext.LinkURI) {
557+ return fmt.Errorf("open link in new tab: action not handled")
558+ }
559+ return nil
560+ case port.MenuActionInspectElement:
561+ return d.wv.ShowDevTools()
562+ case port.MenuActionCopySelection:
563+ if !menuContext.HasSelection {
564+ return fmt.Errorf("copy selection: selection not available")
565+ }
566+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
567+ return nil
568+ default:
569+ return fmt.Errorf("webkit menu delegator: unsupported action %s", action)
570+ }
571+}
572+
573+// NewContextMenuResolver creates a new ImageDataResolver for WebKit
574+// that fetches image bytes over HTTP.
575+func NewContextMenuResolver() port.ImageDataResolver {
576+ return &contextMenuResolver{}
577+}
A internal/infrastructure/webkit/context_menu_test.go +365 -0
@@ -0,0 +1,365 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/http/httptest"
11+ "testing"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// stubHitTest simulates a WebKit HitTestResult for testing.
19+type stubHitTest struct {
20+ linkURI string
21+ imageURI string
22+ isLink bool
23+ isImage bool
24+ isEditable bool
25+ isSelection bool
26+}
27+
28+func TestBuildMenuContextFromHitTest(t *testing.T) {
29+ t.Run("image hit test populates ImageURI", func(t *testing.T) {
30+ hit := stubHitTest{
31+ imageURI: "https://example.com/photo.png",
32+ isImage: true,
33+ }
34+ ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200)
35+
36+ assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI)
37+ assert.Equal(t, 100, ctx.X)
38+ assert.Equal(t, 200, ctx.Y)
39+ assert.True(t, ctx.CanGoBack)
40+ assert.False(t, ctx.CanGoForward)
41+ })
42+
43+ t.Run("link hit test populates LinkURI", func(t *testing.T) {
44+ hit := stubHitTest{
45+ linkURI: "https://example.com/page",
46+ isLink: true,
47+ }
48+ ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75)
49+
50+ assert.Equal(t, "https://example.com/page", ctx.LinkURI)
51+ assert.Empty(t, ctx.ImageURI)
52+ assert.False(t, ctx.CanGoBack)
53+ assert.True(t, ctx.CanGoForward)
54+ })
55+
56+ t.Run("editable and selection flags propagate", func(t *testing.T) {
57+ hit := stubHitTest{
58+ isEditable: true,
59+ isSelection: true,
60+ }
61+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
62+
63+ assert.True(t, ctx.IsEditable)
64+ assert.True(t, ctx.HasSelection)
65+ })
66+
67+ t.Run("combined image and link", func(t *testing.T) {
68+ hit := stubHitTest{
69+ linkURI: "https://example.com/link",
70+ imageURI: "https://example.com/img.jpg",
71+ isLink: true,
72+ isImage: true,
73+ }
74+ ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20)
75+
76+ assert.Equal(t, "https://example.com/link", ctx.LinkURI)
77+ assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI)
78+ assert.True(t, ctx.CanGoBack)
79+ assert.True(t, ctx.CanGoForward)
80+ })
81+
82+ t.Run("empty hit test produces empty context", func(t *testing.T) {
83+ hit := stubHitTest{}
84+ ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
85+
86+ assert.Empty(t, ctx.LinkURI)
87+ assert.Empty(t, ctx.ImageURI)
88+ assert.False(t, ctx.HasSelection)
89+ assert.False(t, ctx.IsEditable)
90+ })
91+}
92+
93+func TestResolveImageData(t *testing.T) {
94+ t.Run("rejects empty image URI", func(t *testing.T) {
95+ resolver := &contextMenuResolver{}
96+ _, err := resolver.ResolveImageData(context.Background(), "")
97+ require.Error(t, err)
98+ assert.Contains(t, err.Error(), "empty image URI")
99+ })
100+
101+ t.Run("rejects unsupported URI scheme", func(t *testing.T) {
102+ resolver := &contextMenuResolver{}
103+ _, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png")
104+ require.Error(t, err)
105+ assert.Contains(t, err.Error(), "unsupported URI scheme")
106+ })
107+
108+ t.Run("decodes data URI images", func(t *testing.T) {
109+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
110+ uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
111+
112+ resolver := &contextMenuResolver{}
113+ data, err := resolver.ResolveImageData(context.Background(), uri)
114+ require.NoError(t, err)
115+ assert.Equal(t, imageBytes, data.Bytes)
116+ assert.Equal(t, "image/png", data.MimeType)
117+ })
118+
119+ t.Run("fetches image bytes from HTTP", func(t *testing.T) {
120+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
122+ w.Header().Set("Content-Type", "image/jpeg; charset=utf-8")
123+ w.Write(imageBytes)
124+ }))
125+ defer srv.Close()
126+
127+ resolver := &contextMenuResolver{allowPrivateHosts: true}
128+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
129+ require.NoError(t, err)
130+ assert.Equal(t, imageBytes, data.Bytes)
131+ assert.Equal(t, "image/png", data.MimeType)
132+ })
133+
134+ t.Run("rejects DNS lookup errors", func(t *testing.T) {
135+ resolver := &contextMenuResolver{
136+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
137+ return nil, errors.New("dns failed")
138+ },
139+ }
140+
141+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
142+ require.Error(t, err)
143+ assert.Contains(t, err.Error(), "dns failed")
144+ })
145+
146+ t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) {
147+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
148+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
149+ w.Header().Set("Content-Type", "application/octet-stream")
150+ w.Write(imageBytes)
151+ }))
152+ defer srv.Close()
153+
154+ resolver := &contextMenuResolver{allowPrivateHosts: true}
155+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
156+ require.NoError(t, err)
157+ assert.Equal(t, imageBytes, data.Bytes)
158+ assert.Equal(t, "image/png", data.MimeType)
159+ })
160+
161+ t.Run("rejects DNS-resolved private hosts", func(t *testing.T) {
162+ resolver := &contextMenuResolver{
163+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
164+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
165+ },
166+ }
167+
168+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
169+ require.Error(t, err)
170+ assert.Contains(t, err.Error(), "private host")
171+ })
172+
173+ t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) {
174+ var dialed []string
175+ resolver := &contextMenuResolver{
176+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
177+ return []net.IPAddr{
178+ {IP: net.ParseIP("127.0.0.1")},
179+ {IP: net.ParseIP("93.184.216.34")},
180+ }, nil
181+ },
182+ dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
183+ dialed = append(dialed, addr)
184+ conn, peer := net.Pipe()
185+ t.Cleanup(func() {
186+ _ = conn.Close()
187+ _ = peer.Close()
188+ })
189+ return conn, nil
190+ },
191+ }
192+
193+ conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443")
194+ require.NoError(t, err)
195+ require.NotNil(t, conn)
196+ assert.Equal(t, []string{"93.184.216.34:443"}, dialed)
197+ })
198+
199+ t.Run("rejects private literal addresses at connect time", func(t *testing.T) {
200+ resolver := &contextMenuResolver{}
201+
202+ _, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443")
203+ require.Error(t, err)
204+ assert.Contains(t, err.Error(), "private host")
205+ })
206+
207+ t.Run("redirect check rejects private targets", func(t *testing.T) {
208+ resolver := &contextMenuResolver{
209+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
210+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
211+ },
212+ }
213+
214+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody)
215+ require.NoError(t, err)
216+ err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil)
217+ require.Error(t, err)
218+ assert.Contains(t, err.Error(), "private host")
219+ })
220+
221+ t.Run("rejects non-image HTTP content", func(t *testing.T) {
222+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
223+ w.Header().Set("Content-Type", "text/html")
224+ w.Write([]byte("<html><body>not an image</body></html>"))
225+ }))
226+ defer srv.Close()
227+
228+ resolver := &contextMenuResolver{allowPrivateHosts: true}
229+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html")
230+ require.Error(t, err)
231+ assert.Contains(t, err.Error(), "not an image")
232+ })
233+
234+ t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) {
235+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
236+ w.Header().Set("Content-Type", "image/png")
237+ w.Write([]byte("<html><body>not really an image</body></html>"))
238+ }))
239+ defer srv.Close()
240+
241+ resolver := &contextMenuResolver{allowPrivateHosts: true}
242+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png")
243+ require.Error(t, err)
244+ assert.Contains(t, err.Error(), "not an image")
245+ assert.Contains(t, err.Error(), "text/html")
246+ assert.Contains(t, err.Error(), "image/png")
247+ assert.Contains(t, err.Error(), "/spoofed.png")
248+ })
249+
250+ t.Run("returns error on HTTP failure", func(t *testing.T) {
251+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
252+ w.WriteHeader(http.StatusNotFound)
253+ }))
254+ defer srv.Close()
255+
256+ resolver := &contextMenuResolver{allowPrivateHosts: true}
257+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
258+ require.Error(t, err)
259+ assert.Contains(t, err.Error(), "404")
260+ })
261+
262+ t.Run("rejects oversized bodies", func(t *testing.T) {
263+ resolver := &contextMenuResolver{
264+ allowPrivateHosts: true,
265+ client: &http.Client{
266+ Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
267+ return &http.Response{
268+ StatusCode: http.StatusOK,
269+ Header: http.Header{"Content-Type": []string{"image/png"}},
270+ Body: io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)),
271+ }, nil
272+ }),
273+ },
274+ }
275+
276+ _, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png")
277+ require.Error(t, err)
278+ assert.Contains(t, err.Error(), "too large")
279+ })
280+
281+ t.Run("returns error on invalid URL", func(t *testing.T) {
282+ resolver := &contextMenuResolver{}
283+ _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
284+ require.Error(t, err)
285+ })
286+}
287+
288+func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) {
289+ var gotURI string
290+ wv := &WebView{
291+ OnLinkMiddleClick: func(uri string) bool {
292+ gotURI = uri
293+ return true
294+ },
295+ }
296+ delegator := &webkitMenuDelegator{wv: wv}
297+
298+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
299+ require.NoError(t, err)
300+ require.Equal(t, "https://example.com/new", gotURI)
301+}
302+
303+func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) {
304+ wv := &WebView{
305+ OnLinkMiddleClick: func(string) bool {
306+ return false
307+ },
308+ }
309+ delegator := &webkitMenuDelegator{wv: wv}
310+
311+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
312+ require.Error(t, err)
313+ assert.Contains(t, err.Error(), "action not handled")
314+}
315+
316+// buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
317+// that works with our stub type instead of requiring real WebKit objects.
318+func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
319+ ctx := port.MenuContext{
320+ CanGoBack: canGoBack,
321+ CanGoForward: canGoForward,
322+ X: x,
323+ Y: y,
324+ }
325+
326+ if hit.isLink {
327+ ctx.LinkURI = hit.linkURI
328+ }
329+ if hit.isImage {
330+ ctx.ImageURI = hit.imageURI
331+ }
332+ ctx.HasSelection = hit.isSelection
333+ ctx.IsEditable = hit.isEditable
334+
335+ return ctx
336+}
337+
338+type roundTripperFunc func(*http.Request) (*http.Response, error)
339+
340+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
341+ return f(req)
342+}
343+
344+type sizedByteReader struct {
345+ remaining int
346+}
347+
348+func newSizedByteReader(size int) *sizedByteReader {
349+ return &sizedByteReader{remaining: size}
350+}
351+
352+func (r *sizedByteReader) Read(p []byte) (int, error) {
353+ if r.remaining <= 0 {
354+ return 0, io.EOF
355+ }
356+ n := len(p)
357+ if n > r.remaining {
358+ n = r.remaining
359+ }
360+ for i := 0; i < n; i++ {
361+ p[i] = 'x'
362+ }
363+ r.remaining -= n
364+ return n, nil
365+}
M internal/infrastructure/webkit/engine.go +59 -11
@@ -13,17 +13,22 @@ import (
1313
1414 // Engine implements port.Engine for the WebKit browser engine.
1515 type Engine struct {
16- ctx context.Context
17- wkCtx *WebKitContext
18- settings *SettingsManager
19- injector *ContentInjector
20- messageRouter *MessageRouter
21- pool *WebViewPool
22- factory *WebViewFactory
23- filterManager *filtering.Manager
24- schemeHandler *DumbSchemeHandler
25- schemePath string
26- logger zerolog.Logger
16+ ctx context.Context
17+ wkCtx *WebKitContext
18+ settings *SettingsManager
19+ injector *ContentInjector
20+ messageRouter *MessageRouter
21+ pool *WebViewPool
22+ factory *WebViewFactory
23+ filterManager *filtering.Manager
24+ schemeHandler *DumbSchemeHandler
25+ schemePath string
26+ logger zerolog.Logger
27+ ctxMenuBuilder port.ContextMenuBuilder
28+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
29+ downloadPath string
30+ downloadPreparer port.DownloadPreparer
31+ clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
2732 }
2833
2934 // Compile-time check that Engine implements port.Engine.
@@ -92,6 +97,8 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
9297 if e.messageRouter == nil {
9398 return fmt.Errorf("message router not initialized")
9499 }
100+ // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
101+ e.clipboard = deps.Clipboard
95102 return handlers.RegisterAll(ctx, e.messageRouter, deps)
96103 }
97104
@@ -113,6 +120,14 @@ func (e *Engine) ConfigureDownloads(
113120 }
114121 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
115122 e.wkCtx.SetDownloadHandler(ctx, handler)
123+ e.downloadPath = downloadPath
124+ e.downloadPreparer = preparer
125+
126+ // Wire the shared context menu pipeline now that all dependencies are
127+ // available. RegisterHandlers (called earlier) captured the clipboard;
128+ // ConfigureDownloads supplies the download preparer and path.
129+ e.installContextMenuPipeline()
130+
116131 return nil
117132 }
118133
@@ -146,3 +161,36 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
146161 }
147162 return nil
148163 }
164+
165+// installContextMenuPipeline creates the shared context menu pipeline and
166+// propagates it to the factory (and pool). This is called once during startup,
167+// after both RegisterHandlers (clipboard) and ConfigureDownloads (download
168+// deps) have run. Kept on the concrete *Engine type because it is a
169+// WebKit-specific concern.
170+func (e *Engine) installContextMenuPipeline() {
171+ resolver := NewContextMenuResolver()
172+ saver := NewResolvedImageSaver(e.downloadPreparer, e.downloadPath)
173+
174+ // WebKit's context-menu signal fires on the GTK main thread, so no
175+ // dispatch wrapper is needed.
176+ renderer := NewRenderer(nil)
177+
178+ pipeline := &contextMenuPipeline{
179+ builder: e.ctxMenuBuilder,
180+ executorFactory: e.ctxMenuExecutorFactory,
181+ clipboard: e.clipboard,
182+ resolver: resolver,
183+ saver: saver,
184+ renderer: renderer,
185+ }
186+ e.SetContextMenuPipeline(pipeline)
187+}
188+
189+// SetContextMenuPipeline configures the context menu pipeline for all WebViews
190+// created by the factory (and pool). Call this after the use cases and renderer
191+// are available in the bootstrap layer.
192+func (e *Engine) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
193+ if e.factory != nil {
194+ e.factory.SetContextMenuPipeline(pipeline)
195+ }
196+}
M internal/infrastructure/webkit/engine_init.go +15 -11
@@ -25,6 +25,8 @@ func NewEngine(
2525 wkCfg WebKitEngineConfig,
2626 themeManager *theme.Manager,
2727 colorResolver port.ColorSchemeResolver,
28+ contextMenuBuilder port.ContextMenuBuilder,
29+ contextMenuExecutorFactory port.ContextMenuActionExecutorFactory,
2830 logger zerolog.Logger,
2931 ) (*Engine, error) {
3032 // --- Hardware survey and performance profile resolution ---
@@ -104,17 +106,19 @@ func NewEngine(
104106
105107 // --- Assemble Engine ---
106108 engine := &Engine{
107- ctx: ctx,
108- wkCtx: wkCtx,
109- settings: settings,
110- injector: injector,
111- messageRouter: messageRouter,
112- pool: pool,
113- factory: factory,
114- filterManager: filterManager,
115- schemeHandler: schemeHandler,
116- schemePath: "dumb://",
117- logger: logger,
109+ ctx: ctx,
110+ wkCtx: wkCtx,
111+ settings: settings,
112+ injector: injector,
113+ messageRouter: messageRouter,
114+ pool: pool,
115+ factory: factory,
116+ filterManager: filterManager,
117+ schemeHandler: schemeHandler,
118+ schemePath: "dumb://",
119+ logger: logger,
120+ ctxMenuBuilder: contextMenuBuilder,
121+ ctxMenuExecutorFactory: contextMenuExecutorFactory,
118122 }
119123
120124 return engine, nil
M internal/infrastructure/webkit/factory.go +21 -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,11 @@ 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.connectContextMenuSignal(f.ctxMenu)
135+ }
136+
122137 log.Debug().
123138 Uint64("id", uint64(wv.ID())).
124139 Uint64("parent_id", uint64(parentID)).
@@ -152,6 +167,11 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
152167 f.filterApplier.ApplyTo(ctx, wv.ucm)
153168 }
154169
170+ // Wire context menu if configured
171+ if f.ctxMenu != nil {
172+ wv.connectContextMenuSignal(f.ctxMenu)
173+ }
174+
155175 log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
156176 return wv, nil
157177 }
M internal/infrastructure/webkit/pool.go +33 -1
@@ -65,9 +65,11 @@ type WebViewPool struct {
6565
6666 pool chan *WebView
6767 poolMu sync.Mutex
68+ ctxMenuMu sync.RWMutex
6869 injector *ContentInjector
6970 router *MessageRouter
70- filterApplier FilterApplier // Optional content filter applier
71+ filterApplier FilterApplier // Optional content filter applier
72+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
7173
7274 // Background color for WebViews (eliminates white flash)
7375 bg bgColor
@@ -145,6 +147,13 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
145147 p.bg.set(r, g, b, a)
146148 }
147149
150+// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
151+func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
152+ p.ctxMenuMu.Lock()
153+ p.ctxMenu = pipeline
154+ p.ctxMenuMu.Unlock()
155+}
156+
148157 // Acquire gets a WebView from the pool or creates a new one.
149158 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
150159 log := logging.FromContext(ctx)
@@ -171,6 +180,16 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
171180 if p.filterApplier != nil {
172181 p.filterApplier.ApplyTo(ctx, wv.ucm)
173182 }
183+ // Ensure context menu pipeline is set (may not have been available during prewarm)
184+ p.ctxMenuMu.RLock()
185+ ctxMenu := p.ctxMenu
186+ p.ctxMenuMu.RUnlock()
187+ if ctxMenu != nil {
188+ wv.contextMenu = ctxMenu
189+ if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
190+ wv.connectContextMenuSignal(ctxMenu)
191+ }
192+ }
174193 wv.ReconnectSignals()
175194 p.acquireCount.Add(1)
176195 p.acquirePoolHits.Add(1)
@@ -214,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
214233 return wv, nil
215234 }
216235
236+func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
237+ return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
238+}
239+
217240 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
218241 log := logging.FromContext(ctx)
219242
@@ -237,6 +260,15 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
237260 p.filterApplier.ApplyTo(ctx, wv.ucm)
238261 }
239262
263+ // Wire context menu if configured
264+ p.ctxMenuMu.RLock()
265+ ctxMenu := p.ctxMenu
266+ p.ctxMenuMu.RUnlock()
267+ if ctxMenu != nil {
268+ wv.contextMenu = ctxMenu
269+ wv.connectContextMenuSignal(ctxMenu)
270+ }
271+
240272 return wv, nil
241273 }
242274
A internal/infrastructure/webkit/pool_context_menu_test.go +26 -0
@@ -0,0 +1,26 @@
1+package webkit
2+
3+import "testing"
4+
5+func TestNeedsExplicitContextMenuSignalOnAcquire(t *testing.T) {
6+ t.Run("true for prewarmed view that already has other signals", func(t *testing.T) {
7+ wv := &WebView{signalIDs: []uintptr{1, 2, 3}}
8+ if !needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
9+ t.Fatal("expected prewarmed webview to require explicit context-menu signal connection")
10+ }
11+ })
12+
13+ t.Run("false when pipeline missing", func(t *testing.T) {
14+ wv := &WebView{signalIDs: []uintptr{1}}
15+ if needsExplicitContextMenuSignalOnAcquire(wv, nil) {
16+ t.Fatal("expected missing pipeline to skip explicit context-menu signal connection")
17+ }
18+ })
19+
20+ t.Run("false for reused view with disconnected signals", func(t *testing.T) {
21+ wv := &WebView{}
22+ if needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
23+ t.Fatal("expected reused webview to rely on full signal reconnection")
24+ }
25+ })
26+}
A internal/infrastructure/webkit/renderer.go +9 -0
@@ -0,0 +1,9 @@
1+package webkit
2+
3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4+
5+type Renderer = gtkmenu.Renderer
6+
7+func NewRenderer(dispatch func(func())) *Renderer {
8+ return gtkmenu.NewRenderer(dispatch)
9+}
A internal/infrastructure/webkit/resolved_image_saver.go +71 -0
@@ -0,0 +1,71 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+const (
14+ resolvedImageDirPerm = 0o755
15+ resolvedImageFilePerm = 0o644
16+)
17+
18+// ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk.
19+type ResolvedImageSaver struct {
20+ preparer port.DownloadPreparer
21+ downloadDir string
22+}
23+
24+// NewResolvedImageSaver creates a new ResolvedImageSaver helper.
25+func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *ResolvedImageSaver {
26+ return &ResolvedImageSaver{
27+ preparer: preparer,
28+ downloadDir: downloadDir,
29+ }
30+}
31+
32+// SaveResolvedImage resolves a destination path and writes the image bytes.
33+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
34+ if s == nil || s.preparer == nil {
35+ return fmt.Errorf("resolved image saver: download preparer not available")
36+ }
37+ if len(image.Bytes) == 0 {
38+ return fmt.Errorf("resolved image saver: empty image data")
39+ }
40+
41+ output := s.preparer.Execute(ctx, port.DownloadPrepareInput{
42+ DownloadDir: s.downloadDir,
43+ // Leave SuggestedFilename empty so DownloadPreparer can fall back to
44+ // URI-derived naming when no explicit filename is available.
45+ Response: downloadResponse{
46+ mimeType: image.MimeType,
47+ uri: menuContext.ImageURI,
48+ },
49+ })
50+ if output == nil || output.DestinationPath == "" {
51+ return fmt.Errorf("resolved image saver: destination path not prepared")
52+ }
53+
54+ if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {
55+ return err
56+ }
57+ if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil {
58+ return err
59+ }
60+ return nil
61+}
62+
63+type downloadResponse struct {
64+ mimeType string
65+ suggestedFilename string
66+ uri string
67+}
68+
69+func (r downloadResponse) GetMimeType() string { return r.mimeType }
70+func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename }
71+func (r downloadResponse) GetUri() string { return r.uri }
A internal/infrastructure/webkit/resolved_image_saver_test.go +64 -0
@@ -0,0 +1,64 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type fakeDownloadPreparer struct {
15+ calls int
16+ input port.DownloadPrepareInput
17+ output *port.DownloadPrepareOutput
18+}
19+
20+func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {
21+ f.calls++
22+ f.input = input
23+ return f.output
24+}
25+
26+func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
27+ downloadDir := t.TempDir()
28+ destPath := filepath.Join(downloadDir, "image.png")
29+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
30+ saver := NewResolvedImageSaver(preparer, downloadDir)
31+
32+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
33+ require.NoError(t, err)
34+ require.Equal(t, downloadDir, preparer.input.DownloadDir)
35+ require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
36+ require.Equal(t, "image/jpeg", preparer.input.Response.GetMimeType())
37+ require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))
38+}
39+
40+func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
41+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
42+ saver := NewResolvedImageSaver(preparer, t.TempDir())
43+
44+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{})
45+ require.Error(t, err)
46+ require.Contains(t, err.Error(), "destination path")
47+}
48+
49+func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
50+ preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
51+ saver := NewResolvedImageSaver(preparer, t.TempDir())
52+
53+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{})
54+ require.Error(t, err)
55+ require.Contains(t, err.Error(), "empty image data")
56+ require.Zero(t, preparer.calls)
57+}
58+
59+func readFile(t *testing.T, path string) []byte {
60+ t.Helper()
61+ data, err := os.ReadFile(path)
62+ require.NoError(t, err)
63+ return data
64+}
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() {
M internal/ui/coordinator/workspace.go +9 -12
@@ -682,12 +682,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
682682 Str("old_active_pane_id", string(oldActivePaneID)).
683683 Int("new_split_widget_alloc_width", newSplitWidth).
684684 Int("new_split_widget_alloc_height", newSplitHeight).
685- Str("orientation", func() string {
686- if orientation == layout.OrientationHorizontal {
687- return "horizontal"
688- }
689- return "vertical"
690- }()).
685+ Str("orientation", orientationString(orientation)).
691686 Msg("incremental split: replaced root with new split view")
692687
693688 return nil
@@ -760,17 +755,19 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
760755 Bool("was_start_child", isStartChild).
761756 Int("new_split_widget_alloc_width", newSplitWidth).
762757 Int("new_split_widget_alloc_height", newSplitHeight).
763- Str("orientation", func() string {
764- if orientation == layout.OrientationHorizontal {
765- return "horizontal"
766- }
767- return "vertical"
768- }()).
758+ Str("orientation", orientationString(orientation)).
769759 Msg("incremental split: replaced pane in grandparent with new split view")
770760
771761 return nil
772762 }
773763
764+func orientationString(orientation layout.Orientation) string {
765+ if orientation == layout.OrientationHorizontal {
766+ return "horizontal"
767+ }
768+ return "vertical"
769+}
770+
774771 // ClosePane closes the active pane.
775772 func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
776773 log := logging.FromContext(ctx)
M internal/ui/theme/css.go +63 -0
@@ -114,6 +114,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
114114 sb.WriteString(generateSessionManagerCSS(p))
115115 sb.WriteString("\n")
116116
117+ // Context menu styling
118+ sb.WriteString(generateContextMenuCSS())
119+ sb.WriteString("\n")
120+
117121 // Tab picker styling
118122 sb.WriteString(generateTabPickerCSS(p))
119123 sb.WriteString("\n")
@@ -902,6 +906,65 @@ row:selected .session-manager-row {
902906 `
903907 }
904908
909+// generateContextMenuCSS creates shared context menu styles.
910+func generateContextMenuCSS() string {
911+ return `/* ===== Context Menu Styling ===== */
912+
913+popover.context-menu-popover {
914+ background-color: transparent;
915+ background-image: none;
916+}
917+
918+popover.context-menu-popover > contents {
919+ background-color: var(--surface);
920+ background-image: none;
921+ border: 0.0625em solid alpha(var(--border), 0.85);
922+ border-radius: 0.25em;
923+ box-shadow: 0 0.75em 1.75em alpha(var(--bg), 0.28), 0 0.0625em 0 alpha(var(--border), 0.18) inset;
924+ padding: 0;
925+}
926+
927+.context-menu {
928+ padding: 0.125em 0;
929+ min-width: 10.5em;
930+}
931+
932+.context-menu button.context-menu-item {
933+ background-color: transparent;
934+ background-image: none;
935+ border: none;
936+ border-radius: 0;
937+ color: var(--text);
938+ padding: 0.25em 0.75em;
939+ margin: 0;
940+ min-height: 1.75em;
941+ transition: background-color 120ms ease-in-out, color 120ms ease-in-out;
942+}
943+
944+.context-menu button.context-menu-item:hover,
945+.context-menu button.context-menu-item:focus,
946+.context-menu button.context-menu-item:active {
947+ background-color: alpha(var(--surface-variant), 0.9);
948+ color: var(--text);
949+}
950+
951+.context-menu button.context-menu-item:active {
952+ background-color: alpha(var(--accent), 0.16);
953+}
954+
955+.context-menu button.context-menu-item:disabled {
956+ color: var(--muted);
957+ opacity: 0.7;
958+}
959+
960+.context-menu-separator {
961+ background-color: alpha(var(--border), 0.55);
962+ margin: 0.25em 0.5em;
963+ min-height: 0.0625em;
964+}
965+`
966+}
967+
905968 // generateToasterCSS creates toast notification styles.
906969 // Uses em units for scalable UI.
907970 func generateToasterCSS(p Palette) string {
M internal/ui/theme/css_test.go +47 -0
@@ -108,6 +108,53 @@ func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
108108 }
109109 }
110110
111+func TestGenerateCSS_ContextMenuUsesCompactSingleFrameStyling(t *testing.T) {
112+ css := GenerateCSS(DefaultDarkPalette())
113+
114+ assert.Contains(t, css, "popover.context-menu-popover > contents {")
115+ assert.Contains(t, css, ".context-menu {")
116+ assert.Contains(t, css, ".context-menu button.context-menu-item {")
117+ assert.Contains(t, css, ".context-menu button.context-menu-item:hover,")
118+ assert.Contains(t, css, ".context-menu button.context-menu-item:disabled {")
119+ assert.Contains(t, css, ".context-menu-separator {")
120+
121+ popoverBlockRe := regexp.MustCompile(`(?s)popover\.context-menu-popover\s*>\s*contents\s*\{[^}]*\}`)
122+ popoverBlock := popoverBlockRe.FindString(css)
123+ if popoverBlock == "" {
124+ t.Fatal("expected context menu popover contents block in CSS")
125+ }
126+
127+ assert.Contains(t, popoverBlock, "background-color: var(--surface);")
128+ assert.Contains(t, popoverBlock, "border: 0.0625em solid alpha(var(--border), 0.85);")
129+ assert.Contains(t, popoverBlock, "border-radius: 0.25em;")
130+ assert.Contains(t, popoverBlock, "box-shadow:")
131+
132+ blockRe := regexp.MustCompile(`(?s)\.context-menu\s*\{[^}]*\}`)
133+ block := blockRe.FindString(css)
134+ if block == "" {
135+ t.Fatal("expected context menu block in CSS")
136+ }
137+
138+ assert.NotContains(t, block, "background-color:")
139+ assert.Contains(t, block, "padding: 0.125em 0;")
140+ assert.Contains(t, block, "min-width: 10.5em;")
141+ assert.NotContains(t, block, "border:")
142+ assert.NotContains(t, block, "border-radius:")
143+ assert.NotContains(t, block, "box-shadow:")
144+ assert.NotContains(t, block, "#")
145+ assert.NotContains(t, block, "black")
146+ assert.NotContains(t, block, "white")
147+
148+ itemBlockRe := regexp.MustCompile(`(?s)\.context-menu button\.context-menu-item\s*\{[^}]*\}`)
149+ itemBlock := itemBlockRe.FindString(css)
150+ if itemBlock == "" {
151+ t.Fatal("expected context menu item block in CSS")
152+ }
153+
154+ assert.Contains(t, itemBlock, "padding: 0.25em 0.75em;")
155+ assert.Contains(t, itemBlock, "min-height: 1.75em;")
156+}
157+
111158 func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
112159 css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
113160

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