detached 8bed89d

fix(context-menu): finish branch-readiness follow-ups

commit
8bed89d4a9dd6c5c905f04a3c556c662192230bb
author
brice · · 2026-04-13 15:58 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
3ecba54
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +1296 -250
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: {}
@@ -71,6 +74,9 @@ packages:
7174 RuntimeVersionProbe: {}
7275 ScriptRefresher: {}
7376 SnapshotService: {}
77+ ImageDataResolver: {}
78+ ResolvedImageSaver: {}
79+ MenuActionDelegator: {}
7480 TextInputTargetProvider: {}
7581 Texture: {}
7682 ToolkitAvailabilityNotifier: {}
@@ -49,6 +49,9 @@ packages:
49 AccentKeyHandler: {}49 AccentKeyHandler: {}
50 AutoCopyConfig: {}50 AutoCopyConfig: {}
51 Clipboard: {}51 Clipboard: {}
52+ ContextMenuBuilder: {}
53+ ContextMenuActionExecutor: {}
54+ ContextMenuActionExecutorFactory: {}
52 DesktopIntegration: {}55 DesktopIntegration: {}
53 DownloadEventHandler: {}56 DownloadEventHandler: {}
54 DownloadPreparer: {}57 DownloadPreparer: {}
@@ -71,6 +74,9 @@ packages:
71 RuntimeVersionProbe: {}74 RuntimeVersionProbe: {}
72 ScriptRefresher: {}75 ScriptRefresher: {}
73 SnapshotService: {}76 SnapshotService: {}
77+ ImageDataResolver: {}
78+ ResolvedImageSaver: {}
79+ MenuActionDelegator: {}
74 TextInputTargetProvider: {}80 TextInputTargetProvider: {}
75 Texture: {}81 Texture: {}
76 ToolkitAvailabilityNotifier: {}82 ToolkitAvailabilityNotifier: {}
M internal/application/port/context_menu.go +20 -0
@@ -36,6 +36,26 @@ type MenuItem struct {
3636 Label string
3737 }
3838
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+
3959 // MenuActionDelegator handles actions that are not shared application concerns.
4060 type MenuActionDelegator interface {
4161 DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
@@ -36,6 +36,26 @@ type MenuItem struct {
36 Label string36 Label string
37 }37 }
38 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+
39 // MenuActionDelegator handles actions that are not shared application concerns.59 // MenuActionDelegator handles actions that are not shared application concerns.
40 type MenuActionDelegator interface {60 type MenuActionDelegator interface {
41 DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error61 DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error
M internal/application/port/image_data.go +4 -3
@@ -2,9 +2,10 @@ package port
22
33 import "context"
44
5-// ImageData contains resolved image bytes.
5+// ImageData contains resolved image bytes and MIME type metadata.
66 type ImageData struct {
7- Bytes []byte
7+ Bytes []byte
8+ MimeType string
89 }
910
1011 // ImageDataResolver resolves image bytes from a URI.
@@ -14,5 +15,5 @@ type ImageDataResolver interface {
1415
1516 // ResolvedImageSaver persists already resolved image bytes.
1617 type ResolvedImageSaver interface {
17- SaveResolvedImage(ctx context.Context, image ImageData) error
18+ SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error
1819 }
@@ -2,9 +2,10 @@ package port
2 2
3 import "context"3 import "context"
4 4
5-// ImageData contains resolved image bytes.5+// ImageData contains resolved image bytes and MIME type metadata.
6 type ImageData struct {6 type ImageData struct {
7- Bytes []byte7+ Bytes []byte
8+ MimeType string
8 }9 }
9 10
10 // ImageDataResolver resolves image bytes from a URI.11 // ImageDataResolver resolves image bytes from a URI.
@@ -14,5 +15,5 @@ type ImageDataResolver interface {
14 15
15 // ResolvedImageSaver persists already resolved image bytes.16 // ResolvedImageSaver persists already resolved image bytes.
16 type ResolvedImageSaver interface {17 type ResolvedImageSaver interface {
17- SaveResolvedImage(ctx context.Context, image ImageData) error18+ SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error
18 }19 }
M internal/application/port/mocks/mocks.go +564 -101
@@ -370,23 +370,6 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
370370 return _c
371371 }
372372
373-// WriteText provides a mock function for the type MockClipboard
374-func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
375- ret := _mock.Called(ctx, text)
376-
377- if len(ret) == 0 {
378- panic("no return value specified for WriteText")
379- }
380-
381- var r0 error
382- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
383- r0 = returnFunc(ctx, text)
384- } else {
385- r0 = ret.Error(0)
386- }
387- return r0
388-}
389-
390373 // WriteImage provides a mock function for the type MockClipboard
391374 func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {
392375 ret := _mock.Called(ctx, image)
@@ -444,111 +427,44 @@ func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Conte
444427 return _c
445428 }
446429
447-// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
448-type MockClipboard_WriteText_Call struct {
449- *mock.Call
450-}
451-
452-// WriteText is a helper method to define mock.On call
453-// - ctx context.Context
454-// - text string
455-func (_e *MockClipboard_Expecter) WriteText(ctx interface{}, text interface{}) *MockClipboard_WriteText_Call {
456- return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", ctx, text)}
457-}
458-
459-func (_c *MockClipboard_WriteText_Call) Run(run func(ctx context.Context, text string)) *MockClipboard_WriteText_Call {
460- _c.Call.Run(func(args mock.Arguments) {
461- var arg0 context.Context
462- if args[0] != nil {
463- arg0 = args[0].(context.Context)
464- }
465- var arg1 string
466- if args[1] != nil {
467- arg1 = args[1].(string)
468- }
469- run(
470- arg0,
471- arg1,
472- )
473- })
474- return _c
475-}
476-
477-func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
478- _c.Call.Return(err)
479- return _c
480-}
481-
482-func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockClipboard_WriteText_Call {
483- _c.Call.Return(run)
484- return _c
485-}
486-
487-// NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
488-// The first argument is typically a *testing.T value.
489-func NewMockResolvedImageSaver(t interface {
490- mock.TestingT
491- Cleanup(func())
492-}) *MockResolvedImageSaver {
493- mock := &MockResolvedImageSaver{}
494- mock.Mock.Test(t)
495-
496- t.Cleanup(func() { mock.AssertExpectations(t) })
497-
498- return mock
499-}
500-
501-// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
502-type MockResolvedImageSaver struct {
503- mock.Mock
504-}
505-
506-type MockResolvedImageSaver_Expecter struct {
507- mock *mock.Mock
508-}
509-
510-func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
511- return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
512-}
513-
514-// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
515-func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
516- ret := _mock.Called(ctx, image)
430+// WriteText provides a mock function for the type MockClipboard
431+func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
432+ ret := _mock.Called(ctx, text)
517433
518434 if len(ret) == 0 {
519- panic("no return value specified for SaveResolvedImage")
435+ panic("no return value specified for WriteText")
520436 }
521437
522438 var r0 error
523- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
524- r0 = returnFunc(ctx, image)
439+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
440+ r0 = returnFunc(ctx, text)
525441 } else {
526442 r0 = ret.Error(0)
527443 }
528444 return r0
529445 }
530446
531-// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
532-type MockResolvedImageSaver_SaveResolvedImage_Call struct {
447+// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
448+type MockClipboard_WriteText_Call struct {
533449 *mock.Call
534450 }
535451
536-// SaveResolvedImage is a helper method to define mock.On call
452+// WriteText is a helper method to define mock.On call
537453 // - ctx context.Context
538-// - image port.ImageData
539-func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
540- return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)}
454+// - text string
455+func (_e *MockClipboard_Expecter) WriteText(ctx interface{}, text interface{}) *MockClipboard_WriteText_Call {
456+ return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", ctx, text)}
541457 }
542458
543-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call {
459+func (_c *MockClipboard_WriteText_Call) Run(run func(ctx context.Context, text string)) *MockClipboard_WriteText_Call {
544460 _c.Call.Run(func(args mock.Arguments) {
545461 var arg0 context.Context
546462 if args[0] != nil {
547463 arg0 = args[0].(context.Context)
548464 }
549- var arg1 port.ImageData
465+ var arg1 string
550466 if args[1] != nil {
551- arg1 = args[1].(port.ImageData)
467+ arg1 = args[1].(string)
552468 }
553469 run(
554470 arg0,
@@ -558,12 +474,12 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx contex
558474 return _c
559475 }
560476
561-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
477+func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
562478 _c.Call.Return(err)
563479 return _c
564480 }
565481
566-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
482+func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockClipboard_WriteText_Call {
567483 _c.Call.Return(run)
568484 return _c
569485 }
@@ -1800,6 +1716,370 @@ func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx con
18001716 return _c
18011717 }
18021718
1719+// 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.
1720+// The first argument is typically a *testing.T value.
1721+func NewMockContextMenuBuilder(t interface {
1722+ mock.TestingT
1723+ Cleanup(func())
1724+}) *MockContextMenuBuilder {
1725+ mock := &MockContextMenuBuilder{}
1726+ mock.Mock.Test(t)
1727+
1728+ t.Cleanup(func() { mock.AssertExpectations(t) })
1729+
1730+ return mock
1731+}
1732+
1733+// MockContextMenuBuilder is an autogenerated mock type for the ContextMenuBuilder type
1734+type MockContextMenuBuilder struct {
1735+ mock.Mock
1736+}
1737+
1738+type MockContextMenuBuilder_Expecter struct {
1739+ mock *mock.Mock
1740+}
1741+
1742+func (_m *MockContextMenuBuilder) EXPECT() *MockContextMenuBuilder_Expecter {
1743+ return &MockContextMenuBuilder_Expecter{mock: &_m.Mock}
1744+}
1745+
1746+// Build provides a mock function for the type MockContextMenuBuilder
1747+func (_mock *MockContextMenuBuilder) Build(ctx context.Context, menuContext port.MenuContext) []port.MenuItem {
1748+ ret := _mock.Called(ctx, menuContext)
1749+
1750+ if len(ret) == 0 {
1751+ panic("no return value specified for Build")
1752+ }
1753+
1754+ var r0 []port.MenuItem
1755+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuContext) []port.MenuItem); ok {
1756+ r0 = returnFunc(ctx, menuContext)
1757+ } else {
1758+ if ret.Get(0) != nil {
1759+ r0 = ret.Get(0).([]port.MenuItem)
1760+ }
1761+ }
1762+ return r0
1763+}
1764+
1765+// MockContextMenuBuilder_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
1766+type MockContextMenuBuilder_Build_Call struct {
1767+ *mock.Call
1768+}
1769+
1770+// Build is a helper method to define mock.On call
1771+// - ctx context.Context
1772+// - menuContext port.MenuContext
1773+func (_e *MockContextMenuBuilder_Expecter) Build(ctx interface{}, menuContext interface{}) *MockContextMenuBuilder_Build_Call {
1774+ return &MockContextMenuBuilder_Build_Call{Call: _e.mock.On("Build", ctx, menuContext)}
1775+}
1776+
1777+func (_c *MockContextMenuBuilder_Build_Call) Run(run func(ctx context.Context, menuContext port.MenuContext)) *MockContextMenuBuilder_Build_Call {
1778+ _c.Call.Run(func(args mock.Arguments) {
1779+ var arg0 context.Context
1780+ if args[0] != nil {
1781+ arg0 = args[0].(context.Context)
1782+ }
1783+ var arg1 port.MenuContext
1784+ if args[1] != nil {
1785+ arg1 = args[1].(port.MenuContext)
1786+ }
1787+ run(
1788+ arg0,
1789+ arg1,
1790+ )
1791+ })
1792+ return _c
1793+}
1794+
1795+func (_c *MockContextMenuBuilder_Build_Call) Return(menuItems []port.MenuItem) *MockContextMenuBuilder_Build_Call {
1796+ _c.Call.Return(menuItems)
1797+ return _c
1798+}
1799+
1800+func (_c *MockContextMenuBuilder_Build_Call) RunAndReturn(run func(ctx context.Context, menuContext port.MenuContext) []port.MenuItem) *MockContextMenuBuilder_Build_Call {
1801+ _c.Call.Return(run)
1802+ return _c
1803+}
1804+
1805+// 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.
1806+// The first argument is typically a *testing.T value.
1807+func NewMockContextMenuActionExecutor(t interface {
1808+ mock.TestingT
1809+ Cleanup(func())
1810+}) *MockContextMenuActionExecutor {
1811+ mock := &MockContextMenuActionExecutor{}
1812+ mock.Mock.Test(t)
1813+
1814+ t.Cleanup(func() { mock.AssertExpectations(t) })
1815+
1816+ return mock
1817+}
1818+
1819+// MockContextMenuActionExecutor is an autogenerated mock type for the ContextMenuActionExecutor type
1820+type MockContextMenuActionExecutor struct {
1821+ mock.Mock
1822+}
1823+
1824+type MockContextMenuActionExecutor_Expecter struct {
1825+ mock *mock.Mock
1826+}
1827+
1828+func (_m *MockContextMenuActionExecutor) EXPECT() *MockContextMenuActionExecutor_Expecter {
1829+ return &MockContextMenuActionExecutor_Expecter{mock: &_m.Mock}
1830+}
1831+
1832+// ExecuteMenuAction provides a mock function for the type MockContextMenuActionExecutor
1833+func (_mock *MockContextMenuActionExecutor) ExecuteMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
1834+ ret := _mock.Called(ctx, action, menuContext)
1835+
1836+ if len(ret) == 0 {
1837+ panic("no return value specified for ExecuteMenuAction")
1838+ }
1839+
1840+ var r0 error
1841+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
1842+ r0 = returnFunc(ctx, action, menuContext)
1843+ } else {
1844+ r0 = ret.Error(0)
1845+ }
1846+ return r0
1847+}
1848+
1849+// MockContextMenuActionExecutor_ExecuteMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecuteMenuAction'
1850+type MockContextMenuActionExecutor_ExecuteMenuAction_Call struct {
1851+ *mock.Call
1852+}
1853+
1854+// ExecuteMenuAction is a helper method to define mock.On call
1855+// - ctx context.Context
1856+// - action port.MenuAction
1857+// - menuContext port.MenuContext
1858+func (_e *MockContextMenuActionExecutor_Expecter) ExecuteMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1859+ return &MockContextMenuActionExecutor_ExecuteMenuAction_Call{Call: _e.mock.On("ExecuteMenuAction", ctx, action, menuContext)}
1860+}
1861+
1862+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1863+ _c.Call.Run(func(args mock.Arguments) {
1864+ var arg0 context.Context
1865+ if args[0] != nil {
1866+ arg0 = args[0].(context.Context)
1867+ }
1868+ var arg1 port.MenuAction
1869+ if args[1] != nil {
1870+ arg1 = args[1].(port.MenuAction)
1871+ }
1872+ var arg2 port.MenuContext
1873+ if args[2] != nil {
1874+ arg2 = args[2].(port.MenuContext)
1875+ }
1876+ run(
1877+ arg0,
1878+ arg1,
1879+ arg2,
1880+ )
1881+ })
1882+ return _c
1883+}
1884+
1885+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Return(err error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1886+ _c.Call.Return(err)
1887+ return _c
1888+}
1889+
1890+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1891+ _c.Call.Return(run)
1892+ return _c
1893+}
1894+
1895+// 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.
1896+// The first argument is typically a *testing.T value.
1897+func NewMockContextMenuActionExecutorFactory(t interface {
1898+ mock.TestingT
1899+ Cleanup(func())
1900+}) *MockContextMenuActionExecutorFactory {
1901+ mock := &MockContextMenuActionExecutorFactory{}
1902+ mock.Mock.Test(t)
1903+
1904+ t.Cleanup(func() { mock.AssertExpectations(t) })
1905+
1906+ return mock
1907+}
1908+
1909+// MockContextMenuActionExecutorFactory is an autogenerated mock type for the ContextMenuActionExecutorFactory type
1910+type MockContextMenuActionExecutorFactory struct {
1911+ mock.Mock
1912+}
1913+
1914+type MockContextMenuActionExecutorFactory_Expecter struct {
1915+ mock *mock.Mock
1916+}
1917+
1918+func (_m *MockContextMenuActionExecutorFactory) EXPECT() *MockContextMenuActionExecutorFactory_Expecter {
1919+ return &MockContextMenuActionExecutorFactory_Expecter{mock: &_m.Mock}
1920+}
1921+
1922+// NewContextMenuActionExecutor provides a mock function for the type MockContextMenuActionExecutorFactory
1923+func (_mock *MockContextMenuActionExecutorFactory) NewContextMenuActionExecutor(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor {
1924+ ret := _mock.Called(clipboard, resolver, saver, delegator)
1925+
1926+ if len(ret) == 0 {
1927+ panic("no return value specified for NewContextMenuActionExecutor")
1928+ }
1929+
1930+ var r0 port.ContextMenuActionExecutor
1931+ if returnFunc, ok := ret.Get(0).(func(port.Clipboard, port.ImageDataResolver, port.ResolvedImageSaver, port.MenuActionDelegator) port.ContextMenuActionExecutor); ok {
1932+ r0 = returnFunc(clipboard, resolver, saver, delegator)
1933+ } else {
1934+ if ret.Get(0) != nil {
1935+ r0 = ret.Get(0).(port.ContextMenuActionExecutor)
1936+ }
1937+ }
1938+ return r0
1939+}
1940+
1941+// MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewContextMenuActionExecutor'
1942+type MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call struct {
1943+ *mock.Call
1944+}
1945+
1946+// NewContextMenuActionExecutor is a helper method to define mock.On call
1947+// - clipboard port.Clipboard
1948+// - resolver port.ImageDataResolver
1949+// - saver port.ResolvedImageSaver
1950+// - delegator port.MenuActionDelegator
1951+func (_e *MockContextMenuActionExecutorFactory_Expecter) NewContextMenuActionExecutor(clipboard interface{}, resolver interface{}, saver interface{}, delegator interface{}) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1952+ return &MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call{Call: _e.mock.On("NewContextMenuActionExecutor", clipboard, resolver, saver, delegator)}
1953+}
1954+
1955+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Run(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator)) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1956+ _c.Call.Run(func(args mock.Arguments) {
1957+ var arg0 port.Clipboard
1958+ if args[0] != nil {
1959+ arg0 = args[0].(port.Clipboard)
1960+ }
1961+ var arg1 port.ImageDataResolver
1962+ if args[1] != nil {
1963+ arg1 = args[1].(port.ImageDataResolver)
1964+ }
1965+ var arg2 port.ResolvedImageSaver
1966+ if args[2] != nil {
1967+ arg2 = args[2].(port.ResolvedImageSaver)
1968+ }
1969+ var arg3 port.MenuActionDelegator
1970+ if args[3] != nil {
1971+ arg3 = args[3].(port.MenuActionDelegator)
1972+ }
1973+ run(
1974+ arg0,
1975+ arg1,
1976+ arg2,
1977+ arg3,
1978+ )
1979+ })
1980+ return _c
1981+}
1982+
1983+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Return(contextMenuActionExecutor port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1984+ _c.Call.Return(contextMenuActionExecutor)
1985+ return _c
1986+}
1987+
1988+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 {
1989+ _c.Call.Return(run)
1990+ return _c
1991+}
1992+
1993+// 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.
1994+// The first argument is typically a *testing.T value.
1995+func NewMockMenuActionDelegator(t interface {
1996+ mock.TestingT
1997+ Cleanup(func())
1998+}) *MockMenuActionDelegator {
1999+ mock := &MockMenuActionDelegator{}
2000+ mock.Mock.Test(t)
2001+
2002+ t.Cleanup(func() { mock.AssertExpectations(t) })
2003+
2004+ return mock
2005+}
2006+
2007+// MockMenuActionDelegator is an autogenerated mock type for the MenuActionDelegator type
2008+type MockMenuActionDelegator struct {
2009+ mock.Mock
2010+}
2011+
2012+type MockMenuActionDelegator_Expecter struct {
2013+ mock *mock.Mock
2014+}
2015+
2016+func (_m *MockMenuActionDelegator) EXPECT() *MockMenuActionDelegator_Expecter {
2017+ return &MockMenuActionDelegator_Expecter{mock: &_m.Mock}
2018+}
2019+
2020+// DelegateMenuAction provides a mock function for the type MockMenuActionDelegator
2021+func (_mock *MockMenuActionDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
2022+ ret := _mock.Called(ctx, action, menuContext)
2023+
2024+ if len(ret) == 0 {
2025+ panic("no return value specified for DelegateMenuAction")
2026+ }
2027+
2028+ var r0 error
2029+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
2030+ r0 = returnFunc(ctx, action, menuContext)
2031+ } else {
2032+ r0 = ret.Error(0)
2033+ }
2034+ return r0
2035+}
2036+
2037+// MockMenuActionDelegator_DelegateMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelegateMenuAction'
2038+type MockMenuActionDelegator_DelegateMenuAction_Call struct {
2039+ *mock.Call
2040+}
2041+
2042+// DelegateMenuAction is a helper method to define mock.On call
2043+// - ctx context.Context
2044+// - action port.MenuAction
2045+// - menuContext port.MenuContext
2046+func (_e *MockMenuActionDelegator_Expecter) DelegateMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockMenuActionDelegator_DelegateMenuAction_Call {
2047+ return &MockMenuActionDelegator_DelegateMenuAction_Call{Call: _e.mock.On("DelegateMenuAction", ctx, action, menuContext)}
2048+}
2049+
2050+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockMenuActionDelegator_DelegateMenuAction_Call {
2051+ _c.Call.Run(func(args mock.Arguments) {
2052+ var arg0 context.Context
2053+ if args[0] != nil {
2054+ arg0 = args[0].(context.Context)
2055+ }
2056+ var arg1 port.MenuAction
2057+ if args[1] != nil {
2058+ arg1 = args[1].(port.MenuAction)
2059+ }
2060+ var arg2 port.MenuContext
2061+ if args[2] != nil {
2062+ arg2 = args[2].(port.MenuContext)
2063+ }
2064+ run(
2065+ arg0,
2066+ arg1,
2067+ arg2,
2068+ )
2069+ })
2070+ return _c
2071+}
2072+
2073+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Return(err error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2074+ _c.Call.Return(err)
2075+ return _c
2076+}
2077+
2078+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2079+ _c.Call.Return(run)
2080+ return _c
2081+}
2082+
18032083 // 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.
18042084 // The first argument is typically a *testing.T value.
18052085 func NewMockDatabaseProvider(t interface {
@@ -7421,6 +7701,189 @@ func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Co
74217701 return _c
74227702 }
74237703
7704+// 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.
7705+// The first argument is typically a *testing.T value.
7706+func NewMockImageDataResolver(t interface {
7707+ mock.TestingT
7708+ Cleanup(func())
7709+}) *MockImageDataResolver {
7710+ mock := &MockImageDataResolver{}
7711+ mock.Mock.Test(t)
7712+
7713+ t.Cleanup(func() { mock.AssertExpectations(t) })
7714+
7715+ return mock
7716+}
7717+
7718+// MockImageDataResolver is an autogenerated mock type for the ImageDataResolver type
7719+type MockImageDataResolver struct {
7720+ mock.Mock
7721+}
7722+
7723+type MockImageDataResolver_Expecter struct {
7724+ mock *mock.Mock
7725+}
7726+
7727+func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter {
7728+ return &MockImageDataResolver_Expecter{mock: &_m.Mock}
7729+}
7730+
7731+// ResolveImageData provides a mock function for the type MockImageDataResolver
7732+func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
7733+ ret := _mock.Called(ctx, uri)
7734+
7735+ if len(ret) == 0 {
7736+ panic("no return value specified for ResolveImageData")
7737+ }
7738+
7739+ var r0 port.ImageData
7740+ var r1 error
7741+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok {
7742+ return returnFunc(ctx, uri)
7743+ }
7744+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok {
7745+ r0 = returnFunc(ctx, uri)
7746+ } else {
7747+ r0 = ret.Get(0).(port.ImageData)
7748+ }
7749+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
7750+ r1 = returnFunc(ctx, uri)
7751+ } else {
7752+ r1 = ret.Error(1)
7753+ }
7754+ return r0, r1
7755+}
7756+
7757+// MockImageDataResolver_ResolveImageData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveImageData'
7758+type MockImageDataResolver_ResolveImageData_Call struct {
7759+ *mock.Call
7760+}
7761+
7762+// ResolveImageData is a helper method to define mock.On call
7763+// - ctx context.Context
7764+// - uri string
7765+func (_e *MockImageDataResolver_Expecter) ResolveImageData(ctx interface{}, uri interface{}) *MockImageDataResolver_ResolveImageData_Call {
7766+ return &MockImageDataResolver_ResolveImageData_Call{Call: _e.mock.On("ResolveImageData", ctx, uri)}
7767+}
7768+
7769+func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.Context, uri string)) *MockImageDataResolver_ResolveImageData_Call {
7770+ _c.Call.Run(func(args mock.Arguments) {
7771+ var arg0 context.Context
7772+ if args[0] != nil {
7773+ arg0 = args[0].(context.Context)
7774+ }
7775+ var arg1 string
7776+ if args[1] != nil {
7777+ arg1 = args[1].(string)
7778+ }
7779+ run(
7780+ arg0,
7781+ arg1,
7782+ )
7783+ })
7784+ return _c
7785+}
7786+
7787+func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
7788+ _c.Call.Return(imageData, err)
7789+ return _c
7790+}
7791+
7792+func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
7793+ _c.Call.Return(run)
7794+ return _c
7795+}
7796+
7797+// 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.
7798+// The first argument is typically a *testing.T value.
7799+func NewMockResolvedImageSaver(t interface {
7800+ mock.TestingT
7801+ Cleanup(func())
7802+}) *MockResolvedImageSaver {
7803+ mock := &MockResolvedImageSaver{}
7804+ mock.Mock.Test(t)
7805+
7806+ t.Cleanup(func() { mock.AssertExpectations(t) })
7807+
7808+ return mock
7809+}
7810+
7811+// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
7812+type MockResolvedImageSaver struct {
7813+ mock.Mock
7814+}
7815+
7816+type MockResolvedImageSaver_Expecter struct {
7817+ mock *mock.Mock
7818+}
7819+
7820+func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
7821+ return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
7822+}
7823+
7824+// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
7825+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
7826+ ret := _mock.Called(ctx, image, menuContext)
7827+
7828+ if len(ret) == 0 {
7829+ panic("no return value specified for SaveResolvedImage")
7830+ }
7831+
7832+ var r0 error
7833+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok {
7834+ r0 = returnFunc(ctx, image, menuContext)
7835+ } else {
7836+ r0 = ret.Error(0)
7837+ }
7838+ return r0
7839+}
7840+
7841+// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
7842+type MockResolvedImageSaver_SaveResolvedImage_Call struct {
7843+ *mock.Call
7844+}
7845+
7846+// SaveResolvedImage is a helper method to define mock.On call
7847+// - ctx context.Context
7848+// - image port.ImageData
7849+// - menuContext port.MenuContext
7850+func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
7851+ return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}
7852+}
7853+
7854+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
7855+ _c.Call.Run(func(args mock.Arguments) {
7856+ var arg0 context.Context
7857+ if args[0] != nil {
7858+ arg0 = args[0].(context.Context)
7859+ }
7860+ var arg1 port.ImageData
7861+ if args[1] != nil {
7862+ arg1 = args[1].(port.ImageData)
7863+ }
7864+ var arg2 port.MenuContext
7865+ if args[2] != nil {
7866+ arg2 = args[2].(port.MenuContext)
7867+ }
7868+ run(
7869+ arg0,
7870+ arg1,
7871+ arg2,
7872+ )
7873+ })
7874+ return _c
7875+}
7876+
7877+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7878+ _c.Call.Return(err)
7879+ return _c
7880+}
7881+
7882+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7883+ _c.Call.Return(run)
7884+ return _c
7885+}
7886+
74247887 // 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.
74257888 // The first argument is typically a *testing.T value.
74267889 func NewMockKeybindingsProvider(t interface {
@@ -370,23 +370,6 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
370 return _c370 return _c
371 }371 }
372 372
373-// WriteText provides a mock function for the type MockClipboard
374-func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
375- ret := _mock.Called(ctx, text)
376-
377- if len(ret) == 0 {
378- panic("no return value specified for WriteText")
379- }
380-
381- var r0 error
382- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
383- r0 = returnFunc(ctx, text)
384- } else {
385- r0 = ret.Error(0)
386- }
387- return r0
388-}
389-
390 // WriteImage provides a mock function for the type MockClipboard373 // WriteImage provides a mock function for the type MockClipboard
391 func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {374 func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {
392 ret := _mock.Called(ctx, image)375 ret := _mock.Called(ctx, image)
@@ -444,111 +427,44 @@ func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Conte
444 return _c427 return _c
445 }428 }
446 429
447-// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'430+// WriteText provides a mock function for the type MockClipboard
448-type MockClipboard_WriteText_Call struct {431+func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
449- *mock.Call432+ ret := _mock.Called(ctx, text)
450-}
451-
452-// WriteText is a helper method to define mock.On call
453-// - ctx context.Context
454-// - text string
455-func (_e *MockClipboard_Expecter) WriteText(ctx interface{}, text interface{}) *MockClipboard_WriteText_Call {
456- return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", ctx, text)}
457-}
458-
459-func (_c *MockClipboard_WriteText_Call) Run(run func(ctx context.Context, text string)) *MockClipboard_WriteText_Call {
460- _c.Call.Run(func(args mock.Arguments) {
461- var arg0 context.Context
462- if args[0] != nil {
463- arg0 = args[0].(context.Context)
464- }
465- var arg1 string
466- if args[1] != nil {
467- arg1 = args[1].(string)
468- }
469- run(
470- arg0,
471- arg1,
472- )
473- })
474- return _c
475-}
476-
477-func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
478- _c.Call.Return(err)
479- return _c
480-}
481-
482-func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockClipboard_WriteText_Call {
483- _c.Call.Return(run)
484- return _c
485-}
486-
487-// NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
488-// The first argument is typically a *testing.T value.
489-func NewMockResolvedImageSaver(t interface {
490- mock.TestingT
491- Cleanup(func())
492-}) *MockResolvedImageSaver {
493- mock := &MockResolvedImageSaver{}
494- mock.Mock.Test(t)
495-
496- t.Cleanup(func() { mock.AssertExpectations(t) })
497-
498- return mock
499-}
500-
501-// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
502-type MockResolvedImageSaver struct {
503- mock.Mock
504-}
505-
506-type MockResolvedImageSaver_Expecter struct {
507- mock *mock.Mock
508-}
509-
510-func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
511- return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
512-}
513-
514-// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
515-func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
516- ret := _mock.Called(ctx, image)
517 433
518 if len(ret) == 0 {434 if len(ret) == 0 {
519- panic("no return value specified for SaveResolvedImage")435+ panic("no return value specified for WriteText")
520 }436 }
521 437
522 var r0 error438 var r0 error
523- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {439+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
524- r0 = returnFunc(ctx, image)440+ r0 = returnFunc(ctx, text)
525 } else {441 } else {
526 r0 = ret.Error(0)442 r0 = ret.Error(0)
527 }443 }
528 return r0444 return r0
529 }445 }
530 446
531-// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'447+// MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText'
532-type MockResolvedImageSaver_SaveResolvedImage_Call struct {448+type MockClipboard_WriteText_Call struct {
533 *mock.Call449 *mock.Call
534 }450 }
535 451
536-// SaveResolvedImage is a helper method to define mock.On call452+// WriteText is a helper method to define mock.On call
537 // - ctx context.Context453 // - ctx context.Context
538-// - image port.ImageData454+// - text string
539-func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {455+func (_e *MockClipboard_Expecter) WriteText(ctx interface{}, text interface{}) *MockClipboard_WriteText_Call {
540- return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)}456+ return &MockClipboard_WriteText_Call{Call: _e.mock.On("WriteText", ctx, text)}
541 }457 }
542 458
543-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call {459+func (_c *MockClipboard_WriteText_Call) Run(run func(ctx context.Context, text string)) *MockClipboard_WriteText_Call {
544 _c.Call.Run(func(args mock.Arguments) {460 _c.Call.Run(func(args mock.Arguments) {
545 var arg0 context.Context461 var arg0 context.Context
546 if args[0] != nil {462 if args[0] != nil {
547 arg0 = args[0].(context.Context)463 arg0 = args[0].(context.Context)
548 }464 }
549- var arg1 port.ImageData465+ var arg1 string
550 if args[1] != nil {466 if args[1] != nil {
551- arg1 = args[1].(port.ImageData)467+ arg1 = args[1].(string)
552 }468 }
553 run(469 run(
554 arg0,470 arg0,
@@ -558,12 +474,12 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx contex
558 return _c474 return _c
559 }475 }
560 476
561-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {477+func (_c *MockClipboard_WriteText_Call) Return(err error) *MockClipboard_WriteText_Call {
562 _c.Call.Return(err)478 _c.Call.Return(err)
563 return _c479 return _c
564 }480 }
565 481
566-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call {482+func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockClipboard_WriteText_Call {
567 _c.Call.Return(run)483 _c.Call.Return(run)
568 return _c484 return _c
569 }485 }
@@ -1800,6 +1716,370 @@ func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx con
1800 return _c1716 return _c
1801 }1717 }
1802 1718
1719+// 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.
1720+// The first argument is typically a *testing.T value.
1721+func NewMockContextMenuBuilder(t interface {
1722+ mock.TestingT
1723+ Cleanup(func())
1724+}) *MockContextMenuBuilder {
1725+ mock := &MockContextMenuBuilder{}
1726+ mock.Mock.Test(t)
1727+
1728+ t.Cleanup(func() { mock.AssertExpectations(t) })
1729+
1730+ return mock
1731+}
1732+
1733+// MockContextMenuBuilder is an autogenerated mock type for the ContextMenuBuilder type
1734+type MockContextMenuBuilder struct {
1735+ mock.Mock
1736+}
1737+
1738+type MockContextMenuBuilder_Expecter struct {
1739+ mock *mock.Mock
1740+}
1741+
1742+func (_m *MockContextMenuBuilder) EXPECT() *MockContextMenuBuilder_Expecter {
1743+ return &MockContextMenuBuilder_Expecter{mock: &_m.Mock}
1744+}
1745+
1746+// Build provides a mock function for the type MockContextMenuBuilder
1747+func (_mock *MockContextMenuBuilder) Build(ctx context.Context, menuContext port.MenuContext) []port.MenuItem {
1748+ ret := _mock.Called(ctx, menuContext)
1749+
1750+ if len(ret) == 0 {
1751+ panic("no return value specified for Build")
1752+ }
1753+
1754+ var r0 []port.MenuItem
1755+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuContext) []port.MenuItem); ok {
1756+ r0 = returnFunc(ctx, menuContext)
1757+ } else {
1758+ if ret.Get(0) != nil {
1759+ r0 = ret.Get(0).([]port.MenuItem)
1760+ }
1761+ }
1762+ return r0
1763+}
1764+
1765+// MockContextMenuBuilder_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
1766+type MockContextMenuBuilder_Build_Call struct {
1767+ *mock.Call
1768+}
1769+
1770+// Build is a helper method to define mock.On call
1771+// - ctx context.Context
1772+// - menuContext port.MenuContext
1773+func (_e *MockContextMenuBuilder_Expecter) Build(ctx interface{}, menuContext interface{}) *MockContextMenuBuilder_Build_Call {
1774+ return &MockContextMenuBuilder_Build_Call{Call: _e.mock.On("Build", ctx, menuContext)}
1775+}
1776+
1777+func (_c *MockContextMenuBuilder_Build_Call) Run(run func(ctx context.Context, menuContext port.MenuContext)) *MockContextMenuBuilder_Build_Call {
1778+ _c.Call.Run(func(args mock.Arguments) {
1779+ var arg0 context.Context
1780+ if args[0] != nil {
1781+ arg0 = args[0].(context.Context)
1782+ }
1783+ var arg1 port.MenuContext
1784+ if args[1] != nil {
1785+ arg1 = args[1].(port.MenuContext)
1786+ }
1787+ run(
1788+ arg0,
1789+ arg1,
1790+ )
1791+ })
1792+ return _c
1793+}
1794+
1795+func (_c *MockContextMenuBuilder_Build_Call) Return(menuItems []port.MenuItem) *MockContextMenuBuilder_Build_Call {
1796+ _c.Call.Return(menuItems)
1797+ return _c
1798+}
1799+
1800+func (_c *MockContextMenuBuilder_Build_Call) RunAndReturn(run func(ctx context.Context, menuContext port.MenuContext) []port.MenuItem) *MockContextMenuBuilder_Build_Call {
1801+ _c.Call.Return(run)
1802+ return _c
1803+}
1804+
1805+// 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.
1806+// The first argument is typically a *testing.T value.
1807+func NewMockContextMenuActionExecutor(t interface {
1808+ mock.TestingT
1809+ Cleanup(func())
1810+}) *MockContextMenuActionExecutor {
1811+ mock := &MockContextMenuActionExecutor{}
1812+ mock.Mock.Test(t)
1813+
1814+ t.Cleanup(func() { mock.AssertExpectations(t) })
1815+
1816+ return mock
1817+}
1818+
1819+// MockContextMenuActionExecutor is an autogenerated mock type for the ContextMenuActionExecutor type
1820+type MockContextMenuActionExecutor struct {
1821+ mock.Mock
1822+}
1823+
1824+type MockContextMenuActionExecutor_Expecter struct {
1825+ mock *mock.Mock
1826+}
1827+
1828+func (_m *MockContextMenuActionExecutor) EXPECT() *MockContextMenuActionExecutor_Expecter {
1829+ return &MockContextMenuActionExecutor_Expecter{mock: &_m.Mock}
1830+}
1831+
1832+// ExecuteMenuAction provides a mock function for the type MockContextMenuActionExecutor
1833+func (_mock *MockContextMenuActionExecutor) ExecuteMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
1834+ ret := _mock.Called(ctx, action, menuContext)
1835+
1836+ if len(ret) == 0 {
1837+ panic("no return value specified for ExecuteMenuAction")
1838+ }
1839+
1840+ var r0 error
1841+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
1842+ r0 = returnFunc(ctx, action, menuContext)
1843+ } else {
1844+ r0 = ret.Error(0)
1845+ }
1846+ return r0
1847+}
1848+
1849+// MockContextMenuActionExecutor_ExecuteMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecuteMenuAction'
1850+type MockContextMenuActionExecutor_ExecuteMenuAction_Call struct {
1851+ *mock.Call
1852+}
1853+
1854+// ExecuteMenuAction is a helper method to define mock.On call
1855+// - ctx context.Context
1856+// - action port.MenuAction
1857+// - menuContext port.MenuContext
1858+func (_e *MockContextMenuActionExecutor_Expecter) ExecuteMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1859+ return &MockContextMenuActionExecutor_ExecuteMenuAction_Call{Call: _e.mock.On("ExecuteMenuAction", ctx, action, menuContext)}
1860+}
1861+
1862+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1863+ _c.Call.Run(func(args mock.Arguments) {
1864+ var arg0 context.Context
1865+ if args[0] != nil {
1866+ arg0 = args[0].(context.Context)
1867+ }
1868+ var arg1 port.MenuAction
1869+ if args[1] != nil {
1870+ arg1 = args[1].(port.MenuAction)
1871+ }
1872+ var arg2 port.MenuContext
1873+ if args[2] != nil {
1874+ arg2 = args[2].(port.MenuContext)
1875+ }
1876+ run(
1877+ arg0,
1878+ arg1,
1879+ arg2,
1880+ )
1881+ })
1882+ return _c
1883+}
1884+
1885+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Return(err error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1886+ _c.Call.Return(err)
1887+ return _c
1888+}
1889+
1890+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
1891+ _c.Call.Return(run)
1892+ return _c
1893+}
1894+
1895+// 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.
1896+// The first argument is typically a *testing.T value.
1897+func NewMockContextMenuActionExecutorFactory(t interface {
1898+ mock.TestingT
1899+ Cleanup(func())
1900+}) *MockContextMenuActionExecutorFactory {
1901+ mock := &MockContextMenuActionExecutorFactory{}
1902+ mock.Mock.Test(t)
1903+
1904+ t.Cleanup(func() { mock.AssertExpectations(t) })
1905+
1906+ return mock
1907+}
1908+
1909+// MockContextMenuActionExecutorFactory is an autogenerated mock type for the ContextMenuActionExecutorFactory type
1910+type MockContextMenuActionExecutorFactory struct {
1911+ mock.Mock
1912+}
1913+
1914+type MockContextMenuActionExecutorFactory_Expecter struct {
1915+ mock *mock.Mock
1916+}
1917+
1918+func (_m *MockContextMenuActionExecutorFactory) EXPECT() *MockContextMenuActionExecutorFactory_Expecter {
1919+ return &MockContextMenuActionExecutorFactory_Expecter{mock: &_m.Mock}
1920+}
1921+
1922+// NewContextMenuActionExecutor provides a mock function for the type MockContextMenuActionExecutorFactory
1923+func (_mock *MockContextMenuActionExecutorFactory) NewContextMenuActionExecutor(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor {
1924+ ret := _mock.Called(clipboard, resolver, saver, delegator)
1925+
1926+ if len(ret) == 0 {
1927+ panic("no return value specified for NewContextMenuActionExecutor")
1928+ }
1929+
1930+ var r0 port.ContextMenuActionExecutor
1931+ if returnFunc, ok := ret.Get(0).(func(port.Clipboard, port.ImageDataResolver, port.ResolvedImageSaver, port.MenuActionDelegator) port.ContextMenuActionExecutor); ok {
1932+ r0 = returnFunc(clipboard, resolver, saver, delegator)
1933+ } else {
1934+ if ret.Get(0) != nil {
1935+ r0 = ret.Get(0).(port.ContextMenuActionExecutor)
1936+ }
1937+ }
1938+ return r0
1939+}
1940+
1941+// MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewContextMenuActionExecutor'
1942+type MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call struct {
1943+ *mock.Call
1944+}
1945+
1946+// NewContextMenuActionExecutor is a helper method to define mock.On call
1947+// - clipboard port.Clipboard
1948+// - resolver port.ImageDataResolver
1949+// - saver port.ResolvedImageSaver
1950+// - delegator port.MenuActionDelegator
1951+func (_e *MockContextMenuActionExecutorFactory_Expecter) NewContextMenuActionExecutor(clipboard interface{}, resolver interface{}, saver interface{}, delegator interface{}) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1952+ return &MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call{Call: _e.mock.On("NewContextMenuActionExecutor", clipboard, resolver, saver, delegator)}
1953+}
1954+
1955+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Run(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator)) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1956+ _c.Call.Run(func(args mock.Arguments) {
1957+ var arg0 port.Clipboard
1958+ if args[0] != nil {
1959+ arg0 = args[0].(port.Clipboard)
1960+ }
1961+ var arg1 port.ImageDataResolver
1962+ if args[1] != nil {
1963+ arg1 = args[1].(port.ImageDataResolver)
1964+ }
1965+ var arg2 port.ResolvedImageSaver
1966+ if args[2] != nil {
1967+ arg2 = args[2].(port.ResolvedImageSaver)
1968+ }
1969+ var arg3 port.MenuActionDelegator
1970+ if args[3] != nil {
1971+ arg3 = args[3].(port.MenuActionDelegator)
1972+ }
1973+ run(
1974+ arg0,
1975+ arg1,
1976+ arg2,
1977+ arg3,
1978+ )
1979+ })
1980+ return _c
1981+}
1982+
1983+func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Return(contextMenuActionExecutor port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call {
1984+ _c.Call.Return(contextMenuActionExecutor)
1985+ return _c
1986+}
1987+
1988+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 {
1989+ _c.Call.Return(run)
1990+ return _c
1991+}
1992+
1993+// 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.
1994+// The first argument is typically a *testing.T value.
1995+func NewMockMenuActionDelegator(t interface {
1996+ mock.TestingT
1997+ Cleanup(func())
1998+}) *MockMenuActionDelegator {
1999+ mock := &MockMenuActionDelegator{}
2000+ mock.Mock.Test(t)
2001+
2002+ t.Cleanup(func() { mock.AssertExpectations(t) })
2003+
2004+ return mock
2005+}
2006+
2007+// MockMenuActionDelegator is an autogenerated mock type for the MenuActionDelegator type
2008+type MockMenuActionDelegator struct {
2009+ mock.Mock
2010+}
2011+
2012+type MockMenuActionDelegator_Expecter struct {
2013+ mock *mock.Mock
2014+}
2015+
2016+func (_m *MockMenuActionDelegator) EXPECT() *MockMenuActionDelegator_Expecter {
2017+ return &MockMenuActionDelegator_Expecter{mock: &_m.Mock}
2018+}
2019+
2020+// DelegateMenuAction provides a mock function for the type MockMenuActionDelegator
2021+func (_mock *MockMenuActionDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
2022+ ret := _mock.Called(ctx, action, menuContext)
2023+
2024+ if len(ret) == 0 {
2025+ panic("no return value specified for DelegateMenuAction")
2026+ }
2027+
2028+ var r0 error
2029+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok {
2030+ r0 = returnFunc(ctx, action, menuContext)
2031+ } else {
2032+ r0 = ret.Error(0)
2033+ }
2034+ return r0
2035+}
2036+
2037+// MockMenuActionDelegator_DelegateMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelegateMenuAction'
2038+type MockMenuActionDelegator_DelegateMenuAction_Call struct {
2039+ *mock.Call
2040+}
2041+
2042+// DelegateMenuAction is a helper method to define mock.On call
2043+// - ctx context.Context
2044+// - action port.MenuAction
2045+// - menuContext port.MenuContext
2046+func (_e *MockMenuActionDelegator_Expecter) DelegateMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockMenuActionDelegator_DelegateMenuAction_Call {
2047+ return &MockMenuActionDelegator_DelegateMenuAction_Call{Call: _e.mock.On("DelegateMenuAction", ctx, action, menuContext)}
2048+}
2049+
2050+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockMenuActionDelegator_DelegateMenuAction_Call {
2051+ _c.Call.Run(func(args mock.Arguments) {
2052+ var arg0 context.Context
2053+ if args[0] != nil {
2054+ arg0 = args[0].(context.Context)
2055+ }
2056+ var arg1 port.MenuAction
2057+ if args[1] != nil {
2058+ arg1 = args[1].(port.MenuAction)
2059+ }
2060+ var arg2 port.MenuContext
2061+ if args[2] != nil {
2062+ arg2 = args[2].(port.MenuContext)
2063+ }
2064+ run(
2065+ arg0,
2066+ arg1,
2067+ arg2,
2068+ )
2069+ })
2070+ return _c
2071+}
2072+
2073+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Return(err error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2074+ _c.Call.Return(err)
2075+ return _c
2076+}
2077+
2078+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockMenuActionDelegator_DelegateMenuAction_Call {
2079+ _c.Call.Return(run)
2080+ return _c
2081+}
2082+
1803 // 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.2083 // 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.
1804 // The first argument is typically a *testing.T value.2084 // The first argument is typically a *testing.T value.
1805 func NewMockDatabaseProvider(t interface {2085 func NewMockDatabaseProvider(t interface {
@@ -7421,6 +7701,189 @@ func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Co
7421 return _c7701 return _c
7422 }7702 }
7423 7703
7704+// 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.
7705+// The first argument is typically a *testing.T value.
7706+func NewMockImageDataResolver(t interface {
7707+ mock.TestingT
7708+ Cleanup(func())
7709+}) *MockImageDataResolver {
7710+ mock := &MockImageDataResolver{}
7711+ mock.Mock.Test(t)
7712+
7713+ t.Cleanup(func() { mock.AssertExpectations(t) })
7714+
7715+ return mock
7716+}
7717+
7718+// MockImageDataResolver is an autogenerated mock type for the ImageDataResolver type
7719+type MockImageDataResolver struct {
7720+ mock.Mock
7721+}
7722+
7723+type MockImageDataResolver_Expecter struct {
7724+ mock *mock.Mock
7725+}
7726+
7727+func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter {
7728+ return &MockImageDataResolver_Expecter{mock: &_m.Mock}
7729+}
7730+
7731+// ResolveImageData provides a mock function for the type MockImageDataResolver
7732+func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
7733+ ret := _mock.Called(ctx, uri)
7734+
7735+ if len(ret) == 0 {
7736+ panic("no return value specified for ResolveImageData")
7737+ }
7738+
7739+ var r0 port.ImageData
7740+ var r1 error
7741+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok {
7742+ return returnFunc(ctx, uri)
7743+ }
7744+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok {
7745+ r0 = returnFunc(ctx, uri)
7746+ } else {
7747+ r0 = ret.Get(0).(port.ImageData)
7748+ }
7749+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
7750+ r1 = returnFunc(ctx, uri)
7751+ } else {
7752+ r1 = ret.Error(1)
7753+ }
7754+ return r0, r1
7755+}
7756+
7757+// MockImageDataResolver_ResolveImageData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveImageData'
7758+type MockImageDataResolver_ResolveImageData_Call struct {
7759+ *mock.Call
7760+}
7761+
7762+// ResolveImageData is a helper method to define mock.On call
7763+// - ctx context.Context
7764+// - uri string
7765+func (_e *MockImageDataResolver_Expecter) ResolveImageData(ctx interface{}, uri interface{}) *MockImageDataResolver_ResolveImageData_Call {
7766+ return &MockImageDataResolver_ResolveImageData_Call{Call: _e.mock.On("ResolveImageData", ctx, uri)}
7767+}
7768+
7769+func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.Context, uri string)) *MockImageDataResolver_ResolveImageData_Call {
7770+ _c.Call.Run(func(args mock.Arguments) {
7771+ var arg0 context.Context
7772+ if args[0] != nil {
7773+ arg0 = args[0].(context.Context)
7774+ }
7775+ var arg1 string
7776+ if args[1] != nil {
7777+ arg1 = args[1].(string)
7778+ }
7779+ run(
7780+ arg0,
7781+ arg1,
7782+ )
7783+ })
7784+ return _c
7785+}
7786+
7787+func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
7788+ _c.Call.Return(imageData, err)
7789+ return _c
7790+}
7791+
7792+func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
7793+ _c.Call.Return(run)
7794+ return _c
7795+}
7796+
7797+// 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.
7798+// The first argument is typically a *testing.T value.
7799+func NewMockResolvedImageSaver(t interface {
7800+ mock.TestingT
7801+ Cleanup(func())
7802+}) *MockResolvedImageSaver {
7803+ mock := &MockResolvedImageSaver{}
7804+ mock.Mock.Test(t)
7805+
7806+ t.Cleanup(func() { mock.AssertExpectations(t) })
7807+
7808+ return mock
7809+}
7810+
7811+// MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type
7812+type MockResolvedImageSaver struct {
7813+ mock.Mock
7814+}
7815+
7816+type MockResolvedImageSaver_Expecter struct {
7817+ mock *mock.Mock
7818+}
7819+
7820+func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
7821+ return &MockResolvedImageSaver_Expecter{mock: &_m.Mock}
7822+}
7823+
7824+// SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
7825+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
7826+ ret := _mock.Called(ctx, image, menuContext)
7827+
7828+ if len(ret) == 0 {
7829+ panic("no return value specified for SaveResolvedImage")
7830+ }
7831+
7832+ var r0 error
7833+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok {
7834+ r0 = returnFunc(ctx, image, menuContext)
7835+ } else {
7836+ r0 = ret.Error(0)
7837+ }
7838+ return r0
7839+}
7840+
7841+// MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage'
7842+type MockResolvedImageSaver_SaveResolvedImage_Call struct {
7843+ *mock.Call
7844+}
7845+
7846+// SaveResolvedImage is a helper method to define mock.On call
7847+// - ctx context.Context
7848+// - image port.ImageData
7849+// - menuContext port.MenuContext
7850+func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
7851+ return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}
7852+}
7853+
7854+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
7855+ _c.Call.Run(func(args mock.Arguments) {
7856+ var arg0 context.Context
7857+ if args[0] != nil {
7858+ arg0 = args[0].(context.Context)
7859+ }
7860+ var arg1 port.ImageData
7861+ if args[1] != nil {
7862+ arg1 = args[1].(port.ImageData)
7863+ }
7864+ var arg2 port.MenuContext
7865+ if args[2] != nil {
7866+ arg2 = args[2].(port.MenuContext)
7867+ }
7868+ run(
7869+ arg0,
7870+ arg1,
7871+ arg2,
7872+ )
7873+ })
7874+ return _c
7875+}
7876+
7877+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7878+ _c.Call.Return(err)
7879+ return _c
7880+}
7881+
7882+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7883+ _c.Call.Return(run)
7884+ return _c
7885+}
7886+
7424 // 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.7887 // 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.
7425 // The first argument is typically a *testing.T value.7888 // The first argument is typically a *testing.T value.
7426 func NewMockKeybindingsProvider(t interface {7889 func NewMockKeybindingsProvider(t interface {
M internal/application/usecase/build_context_menu.go +6 -0
@@ -9,6 +9,8 @@ import (
99 // BuildContextMenuUseCase normalizes context menu items from menu state.
1010 type BuildContextMenuUseCase struct{}
1111
12+var _ port.ContextMenuBuilder = (*BuildContextMenuUseCase)(nil)
13+
1214 // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.
1315 func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
1416 return &BuildContextMenuUseCase{}
@@ -16,6 +18,10 @@ func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
1618
1719 // Build returns normalized menu items for the supplied context.
1820 func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {
21+ if menuContext.IsEditable {
22+ return nil
23+ }
24+
1925 items := make([]port.MenuItem, 0, 9)
2026
2127 if menuContext.CanGoBack {
@@ -9,6 +9,8 @@ import (
9 // BuildContextMenuUseCase normalizes context menu items from menu state.9 // BuildContextMenuUseCase normalizes context menu items from menu state.
10 type BuildContextMenuUseCase struct{}10 type BuildContextMenuUseCase struct{}
11 11
12+var _ port.ContextMenuBuilder = (*BuildContextMenuUseCase)(nil)
13+
12 // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.14 // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase.
13 func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {15 func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
14 return &BuildContextMenuUseCase{}16 return &BuildContextMenuUseCase{}
@@ -16,6 +18,10 @@ func NewBuildContextMenuUseCase() *BuildContextMenuUseCase {
16 18
17 // Build returns normalized menu items for the supplied context.19 // Build returns normalized menu items for the supplied context.
18 func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {20 func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem {
21+ if menuContext.IsEditable {
22+ return nil
23+ }
24+
19 items := make([]port.MenuItem, 0, 9)25 items := make([]port.MenuItem, 0, 9)
20 26
21 if menuContext.CanGoBack {27 if menuContext.CanGoBack {
M internal/application/usecase/build_context_menu_test.go +5 -0
@@ -16,6 +16,11 @@ func TestBuildContextMenuUseCase_Build(t *testing.T) {
1616 context port.MenuContext
1717 expected []port.MenuAction
1818 }{
19+ {
20+ name: "editable context uses native menu",
21+ context: port.MenuContext{IsEditable: true},
22+ expected: []port.MenuAction{},
23+ },
1924 {
2025 name: "empty context baseline",
2126 context: port.MenuContext{},
@@ -16,6 +16,11 @@ func TestBuildContextMenuUseCase_Build(t *testing.T) {
16 context port.MenuContext16 context port.MenuContext
17 expected []port.MenuAction17 expected []port.MenuAction
18 }{18 }{
19+ {
20+ name: "editable context uses native menu",
21+ context: port.MenuContext{IsEditable: true},
22+ expected: []port.MenuAction{},
23+ },
19 {24 {
20 name: "empty context baseline",25 name: "empty context baseline",
21 context: port.MenuContext{},26 context: port.MenuContext{},
M internal/application/usecase/execute_context_menu_action.go +39 -9
@@ -21,6 +21,8 @@ type ExecuteContextMenuActionUseCase struct {
2121 delegator port.MenuActionDelegator
2222 }
2323
24+var _ port.ContextMenuActionExecutor = (*ExecuteContextMenuActionUseCase)(nil)
25+
2426 // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.
2527 func NewExecuteContextMenuActionUseCase(
2628 clipboard port.Clipboard,
@@ -36,22 +38,50 @@ func NewExecuteContextMenuActionUseCase(
3638 }
3739 }
3840
41+// ContextMenuActionExecutorFactory creates ExecuteContextMenuActionUseCases.
42+type ContextMenuActionExecutorFactory struct{}
43+
44+var _ port.ContextMenuActionExecutorFactory = (*ContextMenuActionExecutorFactory)(nil)
45+
46+// NewContextMenuActionExecutor creates a new shared context menu action executor.
47+func (*ContextMenuActionExecutorFactory) NewContextMenuActionExecutor(
48+ clipboard port.Clipboard,
49+ resolver port.ImageDataResolver,
50+ saver port.ResolvedImageSaver,
51+ delegator port.MenuActionDelegator,
52+) port.ContextMenuActionExecutor {
53+ return NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
54+}
55+
3956 // Execute handles the requested action.
4057 func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {
41- switch input.Action {
58+ return uc.ExecuteMenuAction(ctx, input.Action, input.Context)
59+}
60+
61+// ExecuteMenuAction handles the requested action.
62+func (uc *ExecuteContextMenuActionUseCase) ExecuteMenuAction(
63+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
64+) error {
65+ return uc.executeMenuAction(ctx, action, menuContext)
66+}
67+
68+func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
69+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
70+) error {
71+ switch action {
4272 case port.MenuActionCopyLink:
43- if input.Context.LinkURI == "" {
73+ if menuContext.LinkURI == "" {
4474 return fmt.Errorf("copy link: link URI not available")
4575 }
4676 if uc.clipboard == nil {
4777 return fmt.Errorf("copy link: clipboard not available")
4878 }
49- if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil {
79+ if err := uc.clipboard.WriteText(ctx, menuContext.LinkURI); err != nil {
5080 return fmt.Errorf("copy link: %w", err)
5181 }
5282 return nil
5383 case port.MenuActionCopyImage:
54- image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
84+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
5585 if err != nil {
5686 return fmt.Errorf("copy image: %w", err)
5787 }
@@ -63,24 +93,24 @@ func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input Ex
6393 }
6494 return nil
6595 case port.MenuActionSaveImage:
66- image, err := uc.resolveImageData(ctx, input.Context.ImageURI)
96+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
6797 if err != nil {
6898 return fmt.Errorf("save image: %w", err)
6999 }
70100 if uc.saver == nil {
71101 return fmt.Errorf("save image: image saver not available")
72102 }
73- if err := uc.saver.SaveResolvedImage(ctx, image); err != nil {
103+ if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {
74104 return fmt.Errorf("save image: %w", err)
75105 }
76106 return nil
77107 default:
78108 // All other normalized actions are delegated to the engine/UI layer.
79109 if uc.delegator == nil {
80- return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action)
110+ return fmt.Errorf("delegate action %s: menu action delegator not available", action)
81111 }
82- if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil {
83- return fmt.Errorf("delegate action %s: %w", input.Action, err)
112+ if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil {
113+ return fmt.Errorf("delegate action %s: %w", action, err)
84114 }
85115 return nil
86116 }
@@ -21,6 +21,8 @@ type ExecuteContextMenuActionUseCase struct {
21 delegator port.MenuActionDelegator21 delegator port.MenuActionDelegator
22 }22 }
23 23
24+var _ port.ContextMenuActionExecutor = (*ExecuteContextMenuActionUseCase)(nil)
25+
24 // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.26 // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase.
25 func NewExecuteContextMenuActionUseCase(27 func NewExecuteContextMenuActionUseCase(
26 clipboard port.Clipboard,28 clipboard port.Clipboard,
@@ -36,22 +38,50 @@ func NewExecuteContextMenuActionUseCase(
36 }38 }
37 }39 }
38 40
41+// ContextMenuActionExecutorFactory creates ExecuteContextMenuActionUseCases.
42+type ContextMenuActionExecutorFactory struct{}
43+
44+var _ port.ContextMenuActionExecutorFactory = (*ContextMenuActionExecutorFactory)(nil)
45+
46+// NewContextMenuActionExecutor creates a new shared context menu action executor.
47+func (*ContextMenuActionExecutorFactory) NewContextMenuActionExecutor(
48+ clipboard port.Clipboard,
49+ resolver port.ImageDataResolver,
50+ saver port.ResolvedImageSaver,
51+ delegator port.MenuActionDelegator,
52+) port.ContextMenuActionExecutor {
53+ return NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
54+}
55+
39 // Execute handles the requested action.56 // Execute handles the requested action.
40 func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {57 func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error {
41- switch input.Action {58+ return uc.ExecuteMenuAction(ctx, input.Action, input.Context)
59+}
60+
61+// ExecuteMenuAction handles the requested action.
62+func (uc *ExecuteContextMenuActionUseCase) ExecuteMenuAction(
63+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
64+) error {
65+ return uc.executeMenuAction(ctx, action, menuContext)
66+}
67+
68+func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
69+ ctx context.Context, action port.MenuAction, menuContext port.MenuContext,
70+) error {
71+ switch action {
42 case port.MenuActionCopyLink:72 case port.MenuActionCopyLink:
43- if input.Context.LinkURI == "" {73+ if menuContext.LinkURI == "" {
44 return fmt.Errorf("copy link: link URI not available")74 return fmt.Errorf("copy link: link URI not available")
45 }75 }
46 if uc.clipboard == nil {76 if uc.clipboard == nil {
47 return fmt.Errorf("copy link: clipboard not available")77 return fmt.Errorf("copy link: clipboard not available")
48 }78 }
49- if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil {79+ if err := uc.clipboard.WriteText(ctx, menuContext.LinkURI); err != nil {
50 return fmt.Errorf("copy link: %w", err)80 return fmt.Errorf("copy link: %w", err)
51 }81 }
52 return nil82 return nil
53 case port.MenuActionCopyImage:83 case port.MenuActionCopyImage:
54- image, err := uc.resolveImageData(ctx, input.Context.ImageURI)84+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
55 if err != nil {85 if err != nil {
56 return fmt.Errorf("copy image: %w", err)86 return fmt.Errorf("copy image: %w", err)
57 }87 }
@@ -63,24 +93,24 @@ func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input Ex
63 }93 }
64 return nil94 return nil
65 case port.MenuActionSaveImage:95 case port.MenuActionSaveImage:
66- image, err := uc.resolveImageData(ctx, input.Context.ImageURI)96+ image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
67 if err != nil {97 if err != nil {
68 return fmt.Errorf("save image: %w", err)98 return fmt.Errorf("save image: %w", err)
69 }99 }
70 if uc.saver == nil {100 if uc.saver == nil {
71 return fmt.Errorf("save image: image saver not available")101 return fmt.Errorf("save image: image saver not available")
72 }102 }
73- if err := uc.saver.SaveResolvedImage(ctx, image); err != nil {103+ if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {
74 return fmt.Errorf("save image: %w", err)104 return fmt.Errorf("save image: %w", err)
75 }105 }
76 return nil106 return nil
77 default:107 default:
78 // All other normalized actions are delegated to the engine/UI layer.108 // All other normalized actions are delegated to the engine/UI layer.
79 if uc.delegator == nil {109 if uc.delegator == nil {
80- return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action)110+ return fmt.Errorf("delegate action %s: menu action delegator not available", action)
81 }111 }
82- if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil {112+ if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil {
83- return fmt.Errorf("delegate action %s: %w", input.Action, err)113+ return fmt.Errorf("delegate action %s: %w", action, err)
84 }114 }
85 return nil115 return nil
86 }116 }
M internal/application/usecase/execute_context_menu_action_test.go +9 -6
@@ -46,14 +46,16 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por
4646 }
4747
4848 type fakeResolvedImageSaver struct {
49- saveCalls int
50- image port.ImageData
51- err error
49+ saveCalls int
50+ image port.ImageData
51+ menuContext port.MenuContext
52+ err error
5253 }
5354
54-func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error {
55+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error {
5556 f.saveCalls++
5657 f.image = image
58+ f.menuContext = menuContext
5759 return f.err
5860 }
5961
@@ -93,7 +95,7 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te
9395
9496 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
9597 clipboard := &fakeClipboard{}
96- resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}}
98+ resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
9799 saver := &fakeResolvedImageSaver{}
98100 delegator := &fakeMenuActionDelegator{}
99101 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
@@ -107,7 +109,8 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test
107109 require.Equal(t, 1, resolver.resolveCalls)
108110 require.Equal(t, "https://example.com/image.png", resolver.uri)
109111 require.Equal(t, 1, saver.saveCalls)
110- require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image)
112+ require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
113+ require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)
111114 require.Zero(t, clipboard.writeTextCalls)
112115 require.Zero(t, clipboard.writeImageCalls)
113116 require.Zero(t, delegator.delegateCalls)
@@ -46,14 +46,16 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por
46 }46 }
47 47
48 type fakeResolvedImageSaver struct {48 type fakeResolvedImageSaver struct {
49- saveCalls int49+ saveCalls int
50- image port.ImageData50+ image port.ImageData
51- err error51+ menuContext port.MenuContext
52+ err error
52 }53 }
53 54
54-func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error {55+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error {
55 f.saveCalls++56 f.saveCalls++
56 f.image = image57 f.image = image
58+ f.menuContext = menuContext
57 return f.err59 return f.err
58 }60 }
59 61
@@ -93,7 +95,7 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te
93 95
94 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {96 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
95 clipboard := &fakeClipboard{}97 clipboard := &fakeClipboard{}
96- resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}}98+ resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
97 saver := &fakeResolvedImageSaver{}99 saver := &fakeResolvedImageSaver{}
98 delegator := &fakeMenuActionDelegator{}100 delegator := &fakeMenuActionDelegator{}
99 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)101 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
@@ -107,7 +109,8 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test
107 require.Equal(t, 1, resolver.resolveCalls)109 require.Equal(t, 1, resolver.resolveCalls)
108 require.Equal(t, "https://example.com/image.png", resolver.uri)110 require.Equal(t, "https://example.com/image.png", resolver.uri)
109 require.Equal(t, 1, saver.saveCalls)111 require.Equal(t, 1, saver.saveCalls)
110- require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image)112+ require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
113+ require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)
111 require.Zero(t, clipboard.writeTextCalls)114 require.Zero(t, clipboard.writeTextCalls)
112 require.Zero(t, clipboard.writeImageCalls)115 require.Zero(t, clipboard.writeImageCalls)
113 require.Zero(t, delegator.delegateCalls)116 require.Zero(t, delegator.delegateCalls)
M internal/bootstrap/engine.go +7 -1
@@ -6,6 +6,7 @@ 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"
1112 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -31,6 +32,8 @@ type EngineInput struct {
3132 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
3233 func BuildEngine(input EngineInput) (port.Engine, error) {
3334 cfg := input.Config
35+ contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
36+ contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
3437 engineType := cfg.Engine.ResolveEngineType()
3538 switch engineType {
3639 case config.EngineTypeWebKit:
@@ -43,7 +46,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4346
4447 return webkit.NewEngine(
4548 input.Ctx, cfg, opts, wkCfg,
46- input.ThemeManager, input.ColorResolver, input.Logger,
49+ input.ThemeManager, input.ColorResolver,
50+ contextMenuBuilder, contextMenuExecutorFactory,
51+ input.Logger,
4752 )
4853 case config.EngineTypeCEF:
4954 opts := port.EngineOptions{
@@ -82,6 +87,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
8287 RegisterAccentHandlers: handlers.RegisterAccentHandlers,
8388 CurrentConfigPayload: buildConfigPayload(config.Get),
8489 DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
90+ ContextMenuBuilder: contextMenuBuilder,
8591 MediaClassifier: cef.MediaClassifier{
8692 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
8793 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
9 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"10 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10 "github.com/bnema/dumber/internal/infrastructure/cef"11 "github.com/bnema/dumber/internal/infrastructure/cef"
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -31,6 +32,8 @@ type EngineInput struct {
31 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.32 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
32 func BuildEngine(input EngineInput) (port.Engine, error) {33 func BuildEngine(input EngineInput) (port.Engine, error) {
33 cfg := input.Config34 cfg := input.Config
35+ contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
36+ contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
34 engineType := cfg.Engine.ResolveEngineType()37 engineType := cfg.Engine.ResolveEngineType()
35 switch engineType {38 switch engineType {
36 case config.EngineTypeWebKit:39 case config.EngineTypeWebKit:
@@ -43,7 +46,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
43 46
44 return webkit.NewEngine(47 return webkit.NewEngine(
45 input.Ctx, cfg, opts, wkCfg,48 input.Ctx, cfg, opts, wkCfg,
46- input.ThemeManager, input.ColorResolver, input.Logger,49+ input.ThemeManager, input.ColorResolver,
50+ contextMenuBuilder, contextMenuExecutorFactory,
51+ input.Logger,
47 )52 )
48 case config.EngineTypeCEF:53 case config.EngineTypeCEF:
49 opts := port.EngineOptions{54 opts := port.EngineOptions{
@@ -82,6 +87,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
82 RegisterAccentHandlers: handlers.RegisterAccentHandlers,87 RegisterAccentHandlers: handlers.RegisterAccentHandlers,
83 CurrentConfigPayload: buildConfigPayload(config.Get),88 CurrentConfigPayload: buildConfigPayload(config.Get),
84 DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),89 DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
90+ ContextMenuBuilder: contextMenuBuilder,
85 MediaClassifier: cef.MediaClassifier{91 MediaClassifier: cef.MediaClassifier{
86 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,92 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
87 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,93 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
M internal/infrastructure/cef/context_menu.go +5 -4
@@ -6,7 +6,6 @@ import (
66 "strings"
77
88 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/application/usecase"
109 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
1110 )
1211
@@ -28,12 +27,14 @@ func (h *handlerSet) RunContextMenu(
2827 }
2928 return 1
3029 }
30+ if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
31+ return 0
32+ }
3133
3234 menuContext := buildMenuContext(h.wv, params)
33- items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext)
35+ items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext)
3436 if len(items) == 0 {
35- callback.Cancel()
36- return 1
37+ return 0
3738 }
3839
3940 // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
@@ -6,7 +6,6 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/infrastructure/contextmenu"9 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
11 )10 )
12 11
@@ -28,12 +27,14 @@ func (h *handlerSet) RunContextMenu(
28 }27 }
29 return 128 return 1
30 }29 }
30+ if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
31+ return 0
32+ }
31 33
32 menuContext := buildMenuContext(h.wv, params)34 menuContext := buildMenuContext(h.wv, params)
33- items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext)35+ items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext)
34 if len(items) == 0 {36 if len(items) == 0 {
35- callback.Cancel()37+ return 0
36- return 1
37 }38 }
38 39
39 // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.40 // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
M internal/infrastructure/cef/engine.go +1 -0
@@ -31,6 +31,7 @@ type Engine struct {
3131 registerAccentHandlers AccentHandlerRegistrar
3232 currentConfigPayload func() ([]byte, error)
3333 defaultConfigPayload func() ([]byte, error)
34+ ctxMenuBuilder port.ContextMenuBuilder
3435 mediaClassifier MediaClassifier
3536
3637 // activeWebViews tracks all live webviews for CSS broadcast.
@@ -31,6 +31,7 @@ type Engine struct {
31 registerAccentHandlers AccentHandlerRegistrar31 registerAccentHandlers AccentHandlerRegistrar
32 currentConfigPayload func() ([]byte, error)32 currentConfigPayload func() ([]byte, error)
33 defaultConfigPayload func() ([]byte, error)33 defaultConfigPayload func() ([]byte, error)
34+ ctxMenuBuilder port.ContextMenuBuilder
34 mediaClassifier MediaClassifier35 mediaClassifier MediaClassifier
35 36
36 // activeWebViews tracks all live webviews for CSS broadcast.37 // activeWebViews tracks all live webviews for CSS broadcast.
M internal/infrastructure/cef/engine_init.go +1 -0
@@ -54,6 +54,7 @@ func NewEngine(
5454 registerAccentHandlers: deps.RegisterAccentHandlers,
5555 currentConfigPayload: deps.CurrentConfigPayload,
5656 defaultConfigPayload: deps.DefaultConfigPayload,
57+ ctxMenuBuilder: deps.ContextMenuBuilder,
5758 mediaClassifier: deps.MediaClassifier,
5859 }
5960
@@ -54,6 +54,7 @@ func NewEngine(
54 registerAccentHandlers: deps.RegisterAccentHandlers,54 registerAccentHandlers: deps.RegisterAccentHandlers,
55 currentConfigPayload: deps.CurrentConfigPayload,55 currentConfigPayload: deps.CurrentConfigPayload,
56 defaultConfigPayload: deps.DefaultConfigPayload,56 defaultConfigPayload: deps.DefaultConfigPayload,
57+ ctxMenuBuilder: deps.ContextMenuBuilder,
57 mediaClassifier: deps.MediaClassifier,58 mediaClassifier: deps.MediaClassifier,
58 }59 }
59 60
M internal/infrastructure/cef/runtime_config.go +1 -0
@@ -69,5 +69,6 @@ type EngineDependencies struct {
6969 RegisterAccentHandlers AccentHandlerRegistrar
7070 CurrentConfigPayload func() ([]byte, error)
7171 DefaultConfigPayload func() ([]byte, error)
72+ ContextMenuBuilder port.ContextMenuBuilder
7273 MediaClassifier MediaClassifier
7374 }
@@ -69,5 +69,6 @@ type EngineDependencies struct {
69 RegisterAccentHandlers AccentHandlerRegistrar69 RegisterAccentHandlers AccentHandlerRegistrar
70 CurrentConfigPayload func() ([]byte, error)70 CurrentConfigPayload func() ([]byte, error)
71 DefaultConfigPayload func() ([]byte, error)71 DefaultConfigPayload func() ([]byte, error)
72+ ContextMenuBuilder port.ContextMenuBuilder
72 MediaClassifier MediaClassifier73 MediaClassifier MediaClassifier
73 }74 }
M internal/infrastructure/clipboard/clipboard.go +8 -4
@@ -127,8 +127,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
127127 }
128128
129129 // WriteImage copies image bytes to the clipboard.
130-// This path is PNG-only; xsel is not supported because the adapter has no
131-// reliable binary image mode for it in this codebase.
130+// xsel is not supported because the adapter has no reliable binary image mode
131+// for it in this codebase.
132132 func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
133133 log := logging.FromContext(ctx)
134134
@@ -186,12 +186,16 @@ func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageDat
186186 log.Error().Err(err).Msg("clipboard image write failed")
187187 return err
188188 }
189+ mimeType := image.MimeType
190+ if mimeType == "" {
191+ mimeType = "image/png"
192+ }
189193
190194 var cmd *exec.Cmd
191195 if strings.Contains(a.copyCmd, "wl-copy") {
192- cmd = commandContext(ctx, a.copyCmd, "--type", "image/png")
196+ cmd = commandContext(ctx, a.copyCmd, "--type", mimeType)
193197 } else if strings.Contains(a.copyCmd, "xclip") {
194- cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i")
198+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i")
195199 } else {
196200 err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
197201 log.Error().Err(err).Msg("clipboard image write failed")
@@ -127,8 +127,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
127 }127 }
128 128
129 // WriteImage copies image bytes to the clipboard.129 // WriteImage copies image bytes to the clipboard.
130-// This path is PNG-only; xsel is not supported because the adapter has no130+// xsel is not supported because the adapter has no reliable binary image mode
131-// reliable binary image mode for it in this codebase.131+// for it in this codebase.
132 func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {132 func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
133 log := logging.FromContext(ctx)133 log := logging.FromContext(ctx)
134 134
@@ -186,12 +186,16 @@ func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageDat
186 log.Error().Err(err).Msg("clipboard image write failed")186 log.Error().Err(err).Msg("clipboard image write failed")
187 return err187 return err
188 }188 }
189+ mimeType := image.MimeType
190+ if mimeType == "" {
191+ mimeType = "image/png"
192+ }
189 193
190 var cmd *exec.Cmd194 var cmd *exec.Cmd
191 if strings.Contains(a.copyCmd, "wl-copy") {195 if strings.Contains(a.copyCmd, "wl-copy") {
192- cmd = commandContext(ctx, a.copyCmd, "--type", "image/png")196+ cmd = commandContext(ctx, a.copyCmd, "--type", mimeType)
193 } else if strings.Contains(a.copyCmd, "xclip") {197 } else if strings.Contains(a.copyCmd, "xclip") {
194- cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i")198+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i")
195 } else {199 } else {
196 err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)200 err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
197 log.Error().Err(err).Msg("clipboard image write failed")201 log.Error().Err(err).Msg("clipboard image write failed")
M internal/infrastructure/clipboard/clipboard_test.go +44 -0
@@ -3,6 +3,9 @@ package clipboard
33 import (
44 "context"
55 "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
69 "testing"
710
811 "github.com/bnema/dumber/internal/application/port"
@@ -104,3 +107,44 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
104107 require.Contains(t, err.Error(), "empty image data")
105108 require.Zero(t, fake.writeImageCalls)
106109 }
110+
111+func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
112+ testCases := []struct {
113+ name string
114+ image port.ImageData
115+ expected string
116+ }{
117+ {
118+ name: "uses provided MIME type",
119+ image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
120+ expected: "image/jpeg",
121+ },
122+ {
123+ name: "falls back to PNG",
124+ image: port.ImageData{Bytes: []byte{1, 2, 3}},
125+ expected: "image/png",
126+ },
127+ }
128+
129+ for _, tt := range testCases {
130+ t.Run(tt.name, func(t *testing.T) {
131+ dir := t.TempDir()
132+ scriptPath := filepath.Join(dir, "wl-copy")
133+ argsPath := filepath.Join(dir, "args.txt")
134+ stdinPath := filepath.Join(dir, "stdin.bin")
135+ script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
136+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
137+
138+ adapter := &Adapter{copyCmd: scriptPath}
139+ require.NoError(t, adapter.WriteImage(context.Background(), tt.image))
140+
141+ args, err := os.ReadFile(argsPath)
142+ require.NoError(t, err)
143+ require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n"))
144+
145+ stdin, err := os.ReadFile(stdinPath)
146+ require.NoError(t, err)
147+ require.Equal(t, tt.image.Bytes, stdin)
148+ })
149+ }
150+}
@@ -3,6 +3,9 @@ package clipboard
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
6 "testing"9 "testing"
7 10
8 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
@@ -104,3 +107,44 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
104 require.Contains(t, err.Error(), "empty image data")107 require.Contains(t, err.Error(), "empty image data")
105 require.Zero(t, fake.writeImageCalls)108 require.Zero(t, fake.writeImageCalls)
106 }109 }
110+
111+func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
112+ testCases := []struct {
113+ name string
114+ image port.ImageData
115+ expected string
116+ }{
117+ {
118+ name: "uses provided MIME type",
119+ image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
120+ expected: "image/jpeg",
121+ },
122+ {
123+ name: "falls back to PNG",
124+ image: port.ImageData{Bytes: []byte{1, 2, 3}},
125+ expected: "image/png",
126+ },
127+ }
128+
129+ for _, tt := range testCases {
130+ t.Run(tt.name, func(t *testing.T) {
131+ dir := t.TempDir()
132+ scriptPath := filepath.Join(dir, "wl-copy")
133+ argsPath := filepath.Join(dir, "args.txt")
134+ stdinPath := filepath.Join(dir, "stdin.bin")
135+ script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
136+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
137+
138+ adapter := &Adapter{copyCmd: scriptPath}
139+ require.NoError(t, adapter.WriteImage(context.Background(), tt.image))
140+
141+ args, err := os.ReadFile(argsPath)
142+ require.NoError(t, err)
143+ require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n"))
144+
145+ stdin, err := os.ReadFile(stdinPath)
146+ require.NoError(t, err)
147+ require.Equal(t, tt.image.Bytes, stdin)
148+ })
149+ }
150+}
M internal/infrastructure/contextmenu/resolved_image_saver.go +4 -6
@@ -18,20 +18,18 @@ const (
1818 type ResolvedImageSaver struct {
1919 preparer port.DownloadPreparer
2020 downloadDir string
21- menuCtx port.MenuContext
2221 }
2322
2423 // NewResolvedImageSaver creates a new ResolvedImageSaver helper.
25-func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver {
24+func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *ResolvedImageSaver {
2625 return &ResolvedImageSaver{
2726 preparer: preparer,
2827 downloadDir: downloadDir,
29- menuCtx: menuCtx,
3028 }
3129 }
3230
3331 // SaveResolvedImage resolves a destination path and writes the image bytes.
34-func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {
32+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
3533 if s == nil || s.preparer == nil {
3634 return fmt.Errorf("resolved image saver: download preparer not available")
3735 }
@@ -41,8 +39,8 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I
4139 // Leave SuggestedFilename empty so DownloadPreparer can fall back to
4240 // URI-derived naming when no explicit filename is available.
4341 Response: downloadResponse{
44- mimeType: "image/png",
45- uri: s.menuCtx.ImageURI,
42+ mimeType: image.MimeType,
43+ uri: menuContext.ImageURI,
4644 },
4745 })
4846 if output == nil || output.DestinationPath == "" {
@@ -18,20 +18,18 @@ const (
18 type ResolvedImageSaver struct {18 type ResolvedImageSaver struct {
19 preparer port.DownloadPreparer19 preparer port.DownloadPreparer
20 downloadDir string20 downloadDir string
21- menuCtx port.MenuContext
22 }21 }
23 22
24 // NewResolvedImageSaver creates a new ResolvedImageSaver helper.23 // NewResolvedImageSaver creates a new ResolvedImageSaver helper.
25-func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver {24+func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *ResolvedImageSaver {
26 return &ResolvedImageSaver{25 return &ResolvedImageSaver{
27 preparer: preparer,26 preparer: preparer,
28 downloadDir: downloadDir,27 downloadDir: downloadDir,
29- menuCtx: menuCtx,
30 }28 }
31 }29 }
32 30
33 // SaveResolvedImage resolves a destination path and writes the image bytes.31 // SaveResolvedImage resolves a destination path and writes the image bytes.
34-func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error {32+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
35 if s == nil || s.preparer == nil {33 if s == nil || s.preparer == nil {
36 return fmt.Errorf("resolved image saver: download preparer not available")34 return fmt.Errorf("resolved image saver: download preparer not available")
37 }35 }
@@ -41,8 +39,8 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I
41 // Leave SuggestedFilename empty so DownloadPreparer can fall back to39 // Leave SuggestedFilename empty so DownloadPreparer can fall back to
42 // URI-derived naming when no explicit filename is available.40 // URI-derived naming when no explicit filename is available.
43 Response: downloadResponse{41 Response: downloadResponse{
44- mimeType: "image/png",42+ mimeType: image.MimeType,
45- uri: s.menuCtx.ImageURI,43+ uri: menuContext.ImageURI,
46 },44 },
47 })45 })
48 if output == nil || output.DestinationPath == "" {46 if output == nil || output.DestinationPath == "" {
M internal/infrastructure/contextmenu/resolved_image_saver_test.go +7 -7
@@ -24,30 +24,30 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
2424 downloadDir := t.TempDir()
2525 destPath := filepath.Join(downloadDir, "image.png")
2626 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
27- saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"})
27+ saver := NewResolvedImageSaver(preparer, downloadDir)
2828
29- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}})
29+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
3030 require.NoError(t, err)
3131 require.Equal(t, downloadDir, preparer.input.DownloadDir)
3232 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
33- require.Equal(t, "image/png", preparer.input.Response.GetMimeType())
33+ require.Equal(t, "image/jpeg", preparer.input.Response.GetMimeType())
3434 require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))
3535 }
3636
3737 func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
3838 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
39- saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
39+ saver := NewResolvedImageSaver(preparer, t.TempDir())
4040
41- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}})
41+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{})
4242 require.Error(t, err)
4343 require.Contains(t, err.Error(), "destination path")
4444 }
4545
4646 func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
4747 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
48- saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})
48+ saver := NewResolvedImageSaver(preparer, t.TempDir())
4949
50- err := saver.SaveResolvedImage(context.Background(), port.ImageData{})
50+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{})
5151 require.Error(t, err)
5252 require.Contains(t, err.Error(), "empty image data")
5353 }
@@ -24,30 +24,30 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
24 downloadDir := t.TempDir()24 downloadDir := t.TempDir()
25 destPath := filepath.Join(downloadDir, "image.png")25 destPath := filepath.Join(downloadDir, "image.png")
26 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}26 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
27- saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"})27+ saver := NewResolvedImageSaver(preparer, downloadDir)
28 28
29- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}})29+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
30 require.NoError(t, err)30 require.NoError(t, err)
31 require.Equal(t, downloadDir, preparer.input.DownloadDir)31 require.Equal(t, downloadDir, preparer.input.DownloadDir)
32 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())32 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
33- require.Equal(t, "image/png", preparer.input.Response.GetMimeType())33+ require.Equal(t, "image/jpeg", preparer.input.Response.GetMimeType())
34 require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))34 require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath))
35 }35 }
36 36
37 func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {37 func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
38 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}38 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
39- saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})39+ saver := NewResolvedImageSaver(preparer, t.TempDir())
40 40
41- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}})41+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{})
42 require.Error(t, err)42 require.Error(t, err)
43 require.Contains(t, err.Error(), "destination path")43 require.Contains(t, err.Error(), "destination path")
44 }44 }
45 45
46 func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {46 func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
47 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}47 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
48- saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{})48+ saver := NewResolvedImageSaver(preparer, t.TempDir())
49 49
50- err := saver.SaveResolvedImage(context.Background(), port.ImageData{})50+ err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{})
51 require.Error(t, err)51 require.Error(t, err)
52 require.Contains(t, err.Error(), "empty image data")52 require.Contains(t, err.Error(), "empty image data")
53 }53 }
M internal/infrastructure/webkit/context_menu.go +330 -51
@@ -4,11 +4,14 @@ import (
44 "context"
55 "fmt"
66 "io"
7+ "mime"
8+ "net"
79 "net/http"
10+ "net/url"
11+ "strings"
812 "time"
913
1014 "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/application/usecase"
1215 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
1316 "github.com/bnema/dumber/internal/logging"
1417 "github.com/bnema/puregotk-webkit/webkit"
@@ -18,7 +21,10 @@ import (
1821 // over HTTP. This is the WebKit-specific seam for the shared context menu
1922 // pipeline's copy-image and save-image actions.
2023 type contextMenuResolver struct {
21- client *http.Client
24+ client *http.Client
25+ allowPrivateHosts bool
26+ lookupIPAddr func(context.Context, string) ([]net.IPAddr, error)
27+ dialContext func(context.Context, string, string) (net.Conn, error)
2228 }
2329
2430 var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
@@ -31,12 +37,22 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
3137 return port.ImageData{}, fmt.Errorf("empty image URI")
3238 }
3339
34- client := r.client
35- if client == nil {
36- client = defaultImageFetchClient
40+ parsed, parseErr := url.Parse(imageURI)
41+ if parseErr != nil {
42+ return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
43+ }
44+ switch strings.ToLower(parsed.Scheme) {
45+ case "http", "https":
46+ default:
47+ return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
48+ }
49+ if err := r.validateImageURL(ctx, parsed); err != nil {
50+ return port.ImageData{}, err
3751 }
3852
39- req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody)
53+ client := r.imageFetchClient(ctx)
54+
55+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
4056 if err != nil {
4157 return port.ImageData{}, fmt.Errorf("build request: %w", err)
4258 }
@@ -51,15 +67,196 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
5167 return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
5268 }
5369
54- data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes))
70+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1))
5571 if err != nil {
5672 return port.ImageData{}, fmt.Errorf("read image body: %w", err)
5773 }
5874 if len(data) == 0 {
5975 return port.ImageData{}, fmt.Errorf("read image body: empty image data")
6076 }
77+ if len(data) > maxImageFetchBytes {
78+ return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
79+ }
80+ detectedType := validatedImageMimeType(http.DetectContentType(data))
81+ mimeType := detectedType
82+ if mimeType == "" {
83+ mimeType = validatedImageMimeType(resp.Header.Get("Content-Type"))
84+ }
85+ if mimeType == "" {
86+ return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType)
87+ }
88+
89+ return port.ImageData{Bytes: data, MimeType: mimeType}, nil
90+}
91+
92+func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
93+ base := r.client
94+ if base == nil {
95+ base = defaultImageFetchClient
96+ }
97+ client := *base
98+ transport := client.Transport
99+ if transport == nil {
100+ transport = http.DefaultTransport
101+ }
102+ if stdTransport, ok := transport.(*http.Transport); ok {
103+ cloned := stdTransport.Clone()
104+ cloned.DialContext = r.dialImageHostContext
105+ client.Transport = cloned
106+ } else {
107+ client.Transport = &validatedImageRoundTripper{
108+ base: transport,
109+ validator: r.validateImageURL,
110+ }
111+ }
112+ client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
113+ return r.validateImageURL(req.Context(), req.URL)
114+ }
115+ return &client
116+}
117+
118+func (r *contextMenuResolver) validateImageURL(ctx context.Context, imageURL *url.URL) error {
119+ if imageURL == nil {
120+ return fmt.Errorf("fetch image: missing URL")
121+ }
122+ switch strings.ToLower(imageURL.Scheme) {
123+ case "http", "https":
124+ default:
125+ return fmt.Errorf("fetch image: unsupported URI scheme %q", imageURL.Scheme)
126+ }
127+ if imageURL.Host == "" {
128+ return fmt.Errorf("fetch image: missing host")
129+ }
130+ hostname := imageURL.Hostname()
131+ if err := r.validateImageHost(ctx, hostname); err != nil {
132+ return err
133+ }
134+ return nil
135+}
136+
137+func (r *contextMenuResolver) validateImageHost(ctx context.Context, host string) error {
138+ if host == "" {
139+ return fmt.Errorf("fetch image: missing host")
140+ }
141+ if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") {
142+ if r.allowPrivateHosts {
143+ return nil
144+ }
145+ return fmt.Errorf("fetch image: private host %q not allowed", host)
146+ }
147+ ip := net.ParseIP(host)
148+ if ip == nil {
149+ addrs, err := r.allowedImageHostAddrs(ctx, host)
150+ if err != nil {
151+ return err
152+ }
153+ if len(addrs) == 0 {
154+ return fmt.Errorf("fetch image: private host %q not allowed", host)
155+ }
156+ return nil
157+ }
158+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
159+ return fmt.Errorf("fetch image: private host %q not allowed", host)
160+ }
161+ return nil
162+}
163+
164+func (r *contextMenuResolver) allowedImageHostAddrs(ctx context.Context, host string) ([]net.IPAddr, error) {
165+ lookup := r.lookupIPAddr
166+ if lookup == nil {
167+ lookup = net.DefaultResolver.LookupIPAddr
168+ }
169+ addrs, err := lookup(ctx, host)
170+ if err != nil {
171+ return nil, fmt.Errorf("fetch image: resolve host %q: %w", host, err)
172+ }
173+ if r.allowPrivateHosts {
174+ return addrs, nil
175+ }
176+ allowed := make([]net.IPAddr, 0, len(addrs))
177+ for _, addr := range addrs {
178+ if !isPrivateImageIP(addr.IP) {
179+ allowed = append(allowed, addr)
180+ }
181+ }
182+ return allowed, nil
183+}
184+
185+func (r *contextMenuResolver) dialImageHostContext(ctx context.Context, network, addr string) (net.Conn, error) {
186+ host, imagePort, err := net.SplitHostPort(addr)
187+ if err != nil {
188+ return nil, fmt.Errorf("fetch image: parse dial address %q: %w", addr, err)
189+ }
190+ if host == "" {
191+ return nil, fmt.Errorf("fetch image: missing host")
192+ }
193+
194+ dial := r.dialContext
195+ if dial == nil {
196+ dial = (&net.Dialer{}).DialContext
197+ }
198+
199+ if ip := net.ParseIP(host); ip != nil {
200+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
201+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
202+ }
203+ return dial(ctx, network, net.JoinHostPort(ip.String(), imagePort))
204+ }
205+
206+ addrs, err := r.allowedImageHostAddrs(ctx, host)
207+ if err != nil {
208+ return nil, err
209+ }
210+ if len(addrs) == 0 {
211+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
212+ }
213+
214+ var lastErr error
215+ for _, addr := range addrs {
216+ conn, dialErr := dial(ctx, network, net.JoinHostPort(addr.IP.String(), imagePort))
217+ if dialErr == nil {
218+ return conn, nil
219+ }
220+ lastErr = dialErr
221+ }
222+ if lastErr == nil {
223+ return nil, fmt.Errorf("fetch image: dial host %q failed", host)
224+ }
225+ return nil, fmt.Errorf("fetch image: dial host %q: %w", host, lastErr)
226+}
61227
62- return port.ImageData{Bytes: data}, nil
228+type validatedImageRoundTripper struct {
229+ base http.RoundTripper
230+ validator func(context.Context, *url.URL) error
231+}
232+
233+func (t *validatedImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
234+ if t == nil || t.base == nil {
235+ return nil, fmt.Errorf("fetch image: transport not available")
236+ }
237+ if err := t.validator(req.Context(), req.URL); err != nil {
238+ return nil, err
239+ }
240+ return t.base.RoundTrip(req)
241+}
242+
243+func isPrivateImageIP(ip net.IP) bool {
244+ return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified())
245+}
246+
247+func validatedImageMimeType(raw string) string {
248+ if raw == "" {
249+ return ""
250+ }
251+ mediaType, _, err := mime.ParseMediaType(raw)
252+ if err != nil {
253+ return ""
254+ }
255+ mediaType = strings.ToLower(mediaType)
256+ if !strings.HasPrefix(mediaType, "image/") {
257+ return ""
258+ }
259+ return mediaType
63260 }
64261
65262 // Compile-time check that contextMenuResolver implements port.ImageDataResolver.
@@ -97,9 +294,24 @@ func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y in
97294 // contextMenuPipeline holds the dependencies needed to run the shared
98295 // context menu flow from inside a WebKit context-menu signal handler.
99296 type contextMenuPipeline struct {
100- buildUC *usecase.BuildContextMenuUseCase
101- executeUC *usecase.ExecuteContextMenuActionUseCase
102- renderer *contextmenu.Renderer
297+ builder port.ContextMenuBuilder
298+ executorFactory port.ContextMenuActionExecutorFactory
299+ clipboard port.Clipboard
300+ resolver port.ImageDataResolver
301+ saver port.ResolvedImageSaver
302+ renderer *contextmenu.Renderer
303+}
304+
305+func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExecutor {
306+ if p == nil || p.executorFactory == nil {
307+ return nil
308+ }
309+ return p.executorFactory.NewContextMenuActionExecutor(
310+ p.clipboard,
311+ p.resolver,
312+ p.saver,
313+ &webkitMenuDelegator{wv: wv},
314+ )
103315 }
104316
105317 // connectContextMenuSignal wires the WebKit "context-menu" signal to the
@@ -109,25 +321,38 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
109321 if pipeline == nil {
110322 return
111323 }
324+ wv.contextMenu = pipeline
112325
113- contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu
114- hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
326+ contextMenuCb := func(_ webkit.WebView, contextMenuPtr uintptr, hitTestPtr uintptr) bool {
327+ var contextMenu *webkit.ContextMenu
328+ if contextMenuPtr != 0 {
329+ contextMenu = webkit.ContextMenuNewFromInternalPtr(contextMenuPtr)
330+ }
331+ var hit *webkit.HitTestResult
332+ if hitTestPtr != 0 {
333+ hit = webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
334+ }
115335
116- // Determine click coordinates from the hit test result.
117- // WebKit does not expose x/y directly on the context-menu signal;
118- // we fall back to 0,0 if the hit test lacks positional data.
119- // The renderer uses these as popover anchor coordinates.
120- var x, y int
336+ x, y := contextMenuPosition(contextMenu)
121337
122338 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
123- items := pipeline.buildUC.Build(context.Background(), menuContext)
339+ ctx := logging.WithContext(context.Background(), wv.logger)
340+ if pipeline.builder == nil || pipeline.renderer == nil {
341+ return false
342+ }
343+ items := pipeline.builder.Build(ctx, menuContext)
124344 if len(items) == 0 {
125- return true // suppress native menu, nothing to show
345+ return false
346+ }
347+
348+ executor := pipeline.newExecutor(wv)
349+ if executor == nil {
350+ return false
126351 }
127352
128353 // Get the widget for anchoring the popover.
129354 if wv.inner == nil {
130- return true
355+ return false
131356 }
132357 anchor := &wv.inner.Widget
133358
@@ -137,7 +362,7 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
137362 int32(x),
138363 int32(y),
139364 func(item port.MenuItem) {
140- wv.executeContextMenuAction(pipeline.executeUC, item, menuContext)
365+ wv.executeContextMenuAction(ctx, executor, item, menuContext)
141366 },
142367 nil, // onClose — no action needed
143368 )
@@ -149,48 +374,102 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
149374 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
150375 }
151376
377+func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
378+ if menu == nil || menu.GoPointer() == 0 {
379+ return 0, 0
380+ }
381+ event := menu.GetEvent()
382+ if event == nil {
383+ return 0, 0
384+ }
385+ var x, y float64
386+ if !event.GetPosition(&x, &y) {
387+ return 0, 0
388+ }
389+ return int(x), int(y)
390+}
391+
152392 // executeContextMenuAction runs the selected menu action through the shared
153-// execute use case. Runs in a goroutine to avoid blocking the GTK main thread.
393+// executor. GTK-thread-sensitive actions run inline; image fetch/save work
394+// stays off-thread.
154395 func (wv *WebView) executeContextMenuAction(
155- executeUC *usecase.ExecuteContextMenuActionUseCase,
396+ ctx context.Context,
397+ executor port.ContextMenuActionExecutor,
156398 item port.MenuItem,
157399 menuContext port.MenuContext,
158400 ) {
159- if executeUC == nil {
401+ if executor == nil {
160402 return
161403 }
162404
163- go func() {
164- ctx := logging.WithContext(context.Background(), wv.logger)
165- input := usecase.ExecuteContextMenuActionInput{
166- Action: item.Action,
167- Context: menuContext,
168- }
169- if err := executeUC.Execute(ctx, input); err != nil {
170- wv.logger.Warn().Err(err).
171- Str("action", string(item.Action)).
172- Msg("context menu action failed")
173- }
174- }()
405+ if needsBackgroundContextMenuAction(item.Action) {
406+ go wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
407+ return
408+ }
409+ wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
175410 }
176411
177-// SetupContextMenu configures the context menu pipeline for this WebView.
178-// Called during WebView setup when the shared dependencies are available.
179-func (wv *WebView) SetupContextMenu(
180- buildUC *usecase.BuildContextMenuUseCase,
181- executeUC *usecase.ExecuteContextMenuActionUseCase,
182- renderer *contextmenu.Renderer,
412+func (wv *WebView) runContextMenuAction(
413+ ctx context.Context,
414+ executor port.ContextMenuActionExecutor,
415+ action port.MenuAction,
416+ menuContext port.MenuContext,
183417 ) {
184- if wv == nil || wv.inner == nil {
185- return
418+ if err := executor.ExecuteMenuAction(ctx, action, menuContext); err != nil {
419+ wv.logger.Warn().Err(err).
420+ Str("action", string(action)).
421+ Msg("context menu action failed")
422+ }
423+}
424+
425+func needsBackgroundContextMenuAction(action port.MenuAction) bool {
426+ switch action {
427+ case port.MenuActionCopyImage, port.MenuActionSaveImage:
428+ return true
429+ default:
430+ return false
186431 }
187- pipeline := &contextMenuPipeline{
188- buildUC: buildUC,
189- executeUC: executeUC,
190- renderer: renderer,
432+}
433+
434+type webkitMenuDelegator struct {
435+ wv *WebView
436+}
437+
438+var _ port.MenuActionDelegator = (*webkitMenuDelegator)(nil)
439+
440+func (d *webkitMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
441+ if d == nil || d.wv == nil {
442+ return fmt.Errorf("webkit menu delegator: webview not available")
443+ }
444+ switch action {
445+ case port.MenuActionBack:
446+ return d.wv.GoBack(ctx)
447+ case port.MenuActionForward:
448+ return d.wv.GoForward(ctx)
449+ case port.MenuActionReload:
450+ return d.wv.Reload(ctx)
451+ case port.MenuActionOpenLinkNewTab:
452+ if menuContext.LinkURI == "" {
453+ return fmt.Errorf("open link in new tab: link URI not available")
454+ }
455+ if d.wv.OnLinkMiddleClick == nil {
456+ return fmt.Errorf("open link in new tab: middle-click handler not available")
457+ }
458+ if !d.wv.OnLinkMiddleClick(menuContext.LinkURI) {
459+ return fmt.Errorf("open link in new tab: action not handled")
460+ }
461+ return nil
462+ case port.MenuActionInspectElement:
463+ return d.wv.ShowDevTools()
464+ case port.MenuActionCopySelection:
465+ if !menuContext.HasSelection {
466+ return fmt.Errorf("copy selection: selection not available")
467+ }
468+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
469+ return nil
470+ default:
471+ return fmt.Errorf("webkit menu delegator: unsupported action %s", action)
191472 }
192- wv.contextMenu = pipeline
193- wv.connectContextMenuSignal(pipeline)
194473 }
195474
196475 // NewContextMenuResolver creates a new ImageDataResolver for WebKit
@@ -4,11 +4,14 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "mime"
8+ "net"
7 "net/http"9 "net/http"
10+ "net/url"
11+ "strings"
8 "time"12 "time"
9 13
10 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/infrastructure/contextmenu"15 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
13 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk-webkit/webkit"17 "github.com/bnema/puregotk-webkit/webkit"
@@ -18,7 +21,10 @@ import (
18 // over HTTP. This is the WebKit-specific seam for the shared context menu21 // over HTTP. This is the WebKit-specific seam for the shared context menu
19 // pipeline's copy-image and save-image actions.22 // pipeline's copy-image and save-image actions.
20 type contextMenuResolver struct {23 type contextMenuResolver struct {
21- client *http.Client24+ client *http.Client
25+ allowPrivateHosts bool
26+ lookupIPAddr func(context.Context, string) ([]net.IPAddr, error)
27+ dialContext func(context.Context, string, string) (net.Conn, error)
22 }28 }
23 29
24 var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}30 var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
@@ -31,12 +37,22 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
31 return port.ImageData{}, fmt.Errorf("empty image URI")37 return port.ImageData{}, fmt.Errorf("empty image URI")
32 }38 }
33 39
34- client := r.client40+ parsed, parseErr := url.Parse(imageURI)
35- if client == nil {41+ if parseErr != nil {
36- client = defaultImageFetchClient42+ return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
43+ }
44+ switch strings.ToLower(parsed.Scheme) {
45+ case "http", "https":
46+ default:
47+ return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
48+ }
49+ if err := r.validateImageURL(ctx, parsed); err != nil {
50+ return port.ImageData{}, err
37 }51 }
38 52
39- req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody)53+ client := r.imageFetchClient(ctx)
54+
55+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
40 if err != nil {56 if err != nil {
41 return port.ImageData{}, fmt.Errorf("build request: %w", err)57 return port.ImageData{}, fmt.Errorf("build request: %w", err)
42 }58 }
@@ -51,15 +67,196 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
51 return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)67 return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
52 }68 }
53 69
54- data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes))70+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1))
55 if err != nil {71 if err != nil {
56 return port.ImageData{}, fmt.Errorf("read image body: %w", err)72 return port.ImageData{}, fmt.Errorf("read image body: %w", err)
57 }73 }
58 if len(data) == 0 {74 if len(data) == 0 {
59 return port.ImageData{}, fmt.Errorf("read image body: empty image data")75 return port.ImageData{}, fmt.Errorf("read image body: empty image data")
60 }76 }
77+ if len(data) > maxImageFetchBytes {
78+ return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
79+ }
80+ detectedType := validatedImageMimeType(http.DetectContentType(data))
81+ mimeType := detectedType
82+ if mimeType == "" {
83+ mimeType = validatedImageMimeType(resp.Header.Get("Content-Type"))
84+ }
85+ if mimeType == "" {
86+ return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType)
87+ }
88+
89+ return port.ImageData{Bytes: data, MimeType: mimeType}, nil
90+}
91+
92+func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
93+ base := r.client
94+ if base == nil {
95+ base = defaultImageFetchClient
96+ }
97+ client := *base
98+ transport := client.Transport
99+ if transport == nil {
100+ transport = http.DefaultTransport
101+ }
102+ if stdTransport, ok := transport.(*http.Transport); ok {
103+ cloned := stdTransport.Clone()
104+ cloned.DialContext = r.dialImageHostContext
105+ client.Transport = cloned
106+ } else {
107+ client.Transport = &validatedImageRoundTripper{
108+ base: transport,
109+ validator: r.validateImageURL,
110+ }
111+ }
112+ client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
113+ return r.validateImageURL(req.Context(), req.URL)
114+ }
115+ return &client
116+}
117+
118+func (r *contextMenuResolver) validateImageURL(ctx context.Context, imageURL *url.URL) error {
119+ if imageURL == nil {
120+ return fmt.Errorf("fetch image: missing URL")
121+ }
122+ switch strings.ToLower(imageURL.Scheme) {
123+ case "http", "https":
124+ default:
125+ return fmt.Errorf("fetch image: unsupported URI scheme %q", imageURL.Scheme)
126+ }
127+ if imageURL.Host == "" {
128+ return fmt.Errorf("fetch image: missing host")
129+ }
130+ hostname := imageURL.Hostname()
131+ if err := r.validateImageHost(ctx, hostname); err != nil {
132+ return err
133+ }
134+ return nil
135+}
136+
137+func (r *contextMenuResolver) validateImageHost(ctx context.Context, host string) error {
138+ if host == "" {
139+ return fmt.Errorf("fetch image: missing host")
140+ }
141+ if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") {
142+ if r.allowPrivateHosts {
143+ return nil
144+ }
145+ return fmt.Errorf("fetch image: private host %q not allowed", host)
146+ }
147+ ip := net.ParseIP(host)
148+ if ip == nil {
149+ addrs, err := r.allowedImageHostAddrs(ctx, host)
150+ if err != nil {
151+ return err
152+ }
153+ if len(addrs) == 0 {
154+ return fmt.Errorf("fetch image: private host %q not allowed", host)
155+ }
156+ return nil
157+ }
158+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
159+ return fmt.Errorf("fetch image: private host %q not allowed", host)
160+ }
161+ return nil
162+}
163+
164+func (r *contextMenuResolver) allowedImageHostAddrs(ctx context.Context, host string) ([]net.IPAddr, error) {
165+ lookup := r.lookupIPAddr
166+ if lookup == nil {
167+ lookup = net.DefaultResolver.LookupIPAddr
168+ }
169+ addrs, err := lookup(ctx, host)
170+ if err != nil {
171+ return nil, fmt.Errorf("fetch image: resolve host %q: %w", host, err)
172+ }
173+ if r.allowPrivateHosts {
174+ return addrs, nil
175+ }
176+ allowed := make([]net.IPAddr, 0, len(addrs))
177+ for _, addr := range addrs {
178+ if !isPrivateImageIP(addr.IP) {
179+ allowed = append(allowed, addr)
180+ }
181+ }
182+ return allowed, nil
183+}
184+
185+func (r *contextMenuResolver) dialImageHostContext(ctx context.Context, network, addr string) (net.Conn, error) {
186+ host, imagePort, err := net.SplitHostPort(addr)
187+ if err != nil {
188+ return nil, fmt.Errorf("fetch image: parse dial address %q: %w", addr, err)
189+ }
190+ if host == "" {
191+ return nil, fmt.Errorf("fetch image: missing host")
192+ }
193+
194+ dial := r.dialContext
195+ if dial == nil {
196+ dial = (&net.Dialer{}).DialContext
197+ }
198+
199+ if ip := net.ParseIP(host); ip != nil {
200+ if isPrivateImageIP(ip) && !r.allowPrivateHosts {
201+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
202+ }
203+ return dial(ctx, network, net.JoinHostPort(ip.String(), imagePort))
204+ }
205+
206+ addrs, err := r.allowedImageHostAddrs(ctx, host)
207+ if err != nil {
208+ return nil, err
209+ }
210+ if len(addrs) == 0 {
211+ return nil, fmt.Errorf("fetch image: private host %q not allowed", host)
212+ }
213+
214+ var lastErr error
215+ for _, addr := range addrs {
216+ conn, dialErr := dial(ctx, network, net.JoinHostPort(addr.IP.String(), imagePort))
217+ if dialErr == nil {
218+ return conn, nil
219+ }
220+ lastErr = dialErr
221+ }
222+ if lastErr == nil {
223+ return nil, fmt.Errorf("fetch image: dial host %q failed", host)
224+ }
225+ return nil, fmt.Errorf("fetch image: dial host %q: %w", host, lastErr)
226+}
61 227
62- return port.ImageData{Bytes: data}, nil228+type validatedImageRoundTripper struct {
229+ base http.RoundTripper
230+ validator func(context.Context, *url.URL) error
231+}
232+
233+func (t *validatedImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
234+ if t == nil || t.base == nil {
235+ return nil, fmt.Errorf("fetch image: transport not available")
236+ }
237+ if err := t.validator(req.Context(), req.URL); err != nil {
238+ return nil, err
239+ }
240+ return t.base.RoundTrip(req)
241+}
242+
243+func isPrivateImageIP(ip net.IP) bool {
244+ return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified())
245+}
246+
247+func validatedImageMimeType(raw string) string {
248+ if raw == "" {
249+ return ""
250+ }
251+ mediaType, _, err := mime.ParseMediaType(raw)
252+ if err != nil {
253+ return ""
254+ }
255+ mediaType = strings.ToLower(mediaType)
256+ if !strings.HasPrefix(mediaType, "image/") {
257+ return ""
258+ }
259+ return mediaType
63 }260 }
64 261
65 // Compile-time check that contextMenuResolver implements port.ImageDataResolver.262 // Compile-time check that contextMenuResolver implements port.ImageDataResolver.
@@ -97,9 +294,24 @@ func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y in
97 // contextMenuPipeline holds the dependencies needed to run the shared294 // contextMenuPipeline holds the dependencies needed to run the shared
98 // context menu flow from inside a WebKit context-menu signal handler.295 // context menu flow from inside a WebKit context-menu signal handler.
99 type contextMenuPipeline struct {296 type contextMenuPipeline struct {
100- buildUC *usecase.BuildContextMenuUseCase297+ builder port.ContextMenuBuilder
101- executeUC *usecase.ExecuteContextMenuActionUseCase298+ executorFactory port.ContextMenuActionExecutorFactory
102- renderer *contextmenu.Renderer299+ clipboard port.Clipboard
300+ resolver port.ImageDataResolver
301+ saver port.ResolvedImageSaver
302+ renderer *contextmenu.Renderer
303+}
304+
305+func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExecutor {
306+ if p == nil || p.executorFactory == nil {
307+ return nil
308+ }
309+ return p.executorFactory.NewContextMenuActionExecutor(
310+ p.clipboard,
311+ p.resolver,
312+ p.saver,
313+ &webkitMenuDelegator{wv: wv},
314+ )
103 }315 }
104 316
105 // connectContextMenuSignal wires the WebKit "context-menu" signal to the317 // connectContextMenuSignal wires the WebKit "context-menu" signal to the
@@ -109,25 +321,38 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
109 if pipeline == nil {321 if pipeline == nil {
110 return322 return
111 }323 }
324+ wv.contextMenu = pipeline
112 325
113- contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu326+ contextMenuCb := func(_ webkit.WebView, contextMenuPtr uintptr, hitTestPtr uintptr) bool {
114- hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)327+ var contextMenu *webkit.ContextMenu
328+ if contextMenuPtr != 0 {
329+ contextMenu = webkit.ContextMenuNewFromInternalPtr(contextMenuPtr)
330+ }
331+ var hit *webkit.HitTestResult
332+ if hitTestPtr != 0 {
333+ hit = webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
334+ }
115 335
116- // Determine click coordinates from the hit test result.336+ x, y := contextMenuPosition(contextMenu)
117- // WebKit does not expose x/y directly on the context-menu signal;
118- // we fall back to 0,0 if the hit test lacks positional data.
119- // The renderer uses these as popover anchor coordinates.
120- var x, y int
121 337
122 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)338 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
123- items := pipeline.buildUC.Build(context.Background(), menuContext)339+ ctx := logging.WithContext(context.Background(), wv.logger)
340+ if pipeline.builder == nil || pipeline.renderer == nil {
341+ return false
342+ }
343+ items := pipeline.builder.Build(ctx, menuContext)
124 if len(items) == 0 {344 if len(items) == 0 {
125- return true // suppress native menu, nothing to show345+ return false
346+ }
347+
348+ executor := pipeline.newExecutor(wv)
349+ if executor == nil {
350+ return false
126 }351 }
127 352
128 // Get the widget for anchoring the popover.353 // Get the widget for anchoring the popover.
129 if wv.inner == nil {354 if wv.inner == nil {
130- return true355+ return false
131 }356 }
132 anchor := &wv.inner.Widget357 anchor := &wv.inner.Widget
133 358
@@ -137,7 +362,7 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
137 int32(x),362 int32(x),
138 int32(y),363 int32(y),
139 func(item port.MenuItem) {364 func(item port.MenuItem) {
140- wv.executeContextMenuAction(pipeline.executeUC, item, menuContext)365+ wv.executeContextMenuAction(ctx, executor, item, menuContext)
141 },366 },
142 nil, // onClose — no action needed367 nil, // onClose — no action needed
143 )368 )
@@ -149,48 +374,102 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
149 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))374 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
150 }375 }
151 376
377+func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
378+ if menu == nil || menu.GoPointer() == 0 {
379+ return 0, 0
380+ }
381+ event := menu.GetEvent()
382+ if event == nil {
383+ return 0, 0
384+ }
385+ var x, y float64
386+ if !event.GetPosition(&x, &y) {
387+ return 0, 0
388+ }
389+ return int(x), int(y)
390+}
391+
152 // executeContextMenuAction runs the selected menu action through the shared392 // executeContextMenuAction runs the selected menu action through the shared
153-// execute use case. Runs in a goroutine to avoid blocking the GTK main thread.393+// executor. GTK-thread-sensitive actions run inline; image fetch/save work
394+// stays off-thread.
154 func (wv *WebView) executeContextMenuAction(395 func (wv *WebView) executeContextMenuAction(
155- executeUC *usecase.ExecuteContextMenuActionUseCase,396+ ctx context.Context,
397+ executor port.ContextMenuActionExecutor,
156 item port.MenuItem,398 item port.MenuItem,
157 menuContext port.MenuContext,399 menuContext port.MenuContext,
158 ) {400 ) {
159- if executeUC == nil {401+ if executor == nil {
160 return402 return
161 }403 }
162 404
163- go func() {405+ if needsBackgroundContextMenuAction(item.Action) {
164- ctx := logging.WithContext(context.Background(), wv.logger)406+ go wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
165- input := usecase.ExecuteContextMenuActionInput{407+ return
166- Action: item.Action,408+ }
167- Context: menuContext,409+ wv.runContextMenuAction(ctx, executor, item.Action, menuContext)
168- }
169- if err := executeUC.Execute(ctx, input); err != nil {
170- wv.logger.Warn().Err(err).
171- Str("action", string(item.Action)).
172- Msg("context menu action failed")
173- }
174- }()
175 }410 }
176 411
177-// SetupContextMenu configures the context menu pipeline for this WebView.412+func (wv *WebView) runContextMenuAction(
178-// Called during WebView setup when the shared dependencies are available.413+ ctx context.Context,
179-func (wv *WebView) SetupContextMenu(414+ executor port.ContextMenuActionExecutor,
180- buildUC *usecase.BuildContextMenuUseCase,415+ action port.MenuAction,
181- executeUC *usecase.ExecuteContextMenuActionUseCase,416+ menuContext port.MenuContext,
182- renderer *contextmenu.Renderer,
183 ) {417 ) {
184- if wv == nil || wv.inner == nil {418+ if err := executor.ExecuteMenuAction(ctx, action, menuContext); err != nil {
185- return419+ wv.logger.Warn().Err(err).
420+ Str("action", string(action)).
421+ Msg("context menu action failed")
422+ }
423+}
424+
425+func needsBackgroundContextMenuAction(action port.MenuAction) bool {
426+ switch action {
427+ case port.MenuActionCopyImage, port.MenuActionSaveImage:
428+ return true
429+ default:
430+ return false
186 }431 }
187- pipeline := &contextMenuPipeline{432+}
188- buildUC: buildUC,433+
189- executeUC: executeUC,434+type webkitMenuDelegator struct {
190- renderer: renderer,435+ wv *WebView
436+}
437+
438+var _ port.MenuActionDelegator = (*webkitMenuDelegator)(nil)
439+
440+func (d *webkitMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
441+ if d == nil || d.wv == nil {
442+ return fmt.Errorf("webkit menu delegator: webview not available")
443+ }
444+ switch action {
445+ case port.MenuActionBack:
446+ return d.wv.GoBack(ctx)
447+ case port.MenuActionForward:
448+ return d.wv.GoForward(ctx)
449+ case port.MenuActionReload:
450+ return d.wv.Reload(ctx)
451+ case port.MenuActionOpenLinkNewTab:
452+ if menuContext.LinkURI == "" {
453+ return fmt.Errorf("open link in new tab: link URI not available")
454+ }
455+ if d.wv.OnLinkMiddleClick == nil {
456+ return fmt.Errorf("open link in new tab: middle-click handler not available")
457+ }
458+ if !d.wv.OnLinkMiddleClick(menuContext.LinkURI) {
459+ return fmt.Errorf("open link in new tab: action not handled")
460+ }
461+ return nil
462+ case port.MenuActionInspectElement:
463+ return d.wv.ShowDevTools()
464+ case port.MenuActionCopySelection:
465+ if !menuContext.HasSelection {
466+ return fmt.Errorf("copy selection: selection not available")
467+ }
468+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
469+ return nil
470+ default:
471+ return fmt.Errorf("webkit menu delegator: unsupported action %s", action)
191 }472 }
192- wv.contextMenu = pipeline
193- wv.connectContextMenuSignal(pipeline)
194 }473 }
195 474
196 // NewContextMenuResolver creates a new ImageDataResolver for WebKit475 // NewContextMenuResolver creates a new ImageDataResolver for WebKit
M internal/infrastructure/webkit/context_menu_test.go +191 -4
@@ -2,6 +2,9 @@ package webkit
22
33 import (
44 "context"
5+ "errors"
6+ "io"
7+ "net"
58 "net/http"
69 "net/http/httptest"
710 "testing"
@@ -94,18 +97,126 @@ func TestResolveImageData(t *testing.T) {
9497 assert.Contains(t, err.Error(), "empty image URI")
9598 })
9699
100+ t.Run("rejects unsupported URI scheme", func(t *testing.T) {
101+ resolver := &contextMenuResolver{}
102+ _, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png")
103+ require.Error(t, err)
104+ assert.Contains(t, err.Error(), "unsupported URI scheme")
105+ })
106+
97107 t.Run("fetches image bytes from HTTP", func(t *testing.T) {
98- imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic
108+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
99109 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
100- w.Header().Set("Content-Type", "image/png")
110+ w.Header().Set("Content-Type", "image/jpeg; charset=utf-8")
101111 w.Write(imageBytes)
102112 }))
103113 defer srv.Close()
104114
105- resolver := &contextMenuResolver{}
115+ resolver := &contextMenuResolver{allowPrivateHosts: true}
106116 data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
107117 require.NoError(t, err)
108118 assert.Equal(t, imageBytes, data.Bytes)
119+ assert.Equal(t, "image/png", data.MimeType)
120+ })
121+
122+ t.Run("rejects DNS lookup errors", func(t *testing.T) {
123+ resolver := &contextMenuResolver{
124+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
125+ return nil, errors.New("dns failed")
126+ },
127+ }
128+
129+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
130+ require.Error(t, err)
131+ assert.Contains(t, err.Error(), "dns failed")
132+ })
133+
134+ t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) {
135+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
136+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
137+ w.Header().Set("Content-Type", "application/octet-stream")
138+ w.Write(imageBytes)
139+ }))
140+ defer srv.Close()
141+
142+ resolver := &contextMenuResolver{allowPrivateHosts: true}
143+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
144+ require.NoError(t, err)
145+ assert.Equal(t, imageBytes, data.Bytes)
146+ assert.Equal(t, "image/png", data.MimeType)
147+ })
148+
149+ t.Run("rejects DNS-resolved private hosts", func(t *testing.T) {
150+ resolver := &contextMenuResolver{
151+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
152+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
153+ },
154+ }
155+
156+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
157+ require.Error(t, err)
158+ assert.Contains(t, err.Error(), "private host")
159+ })
160+
161+ t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) {
162+ var dialed []string
163+ resolver := &contextMenuResolver{
164+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
165+ return []net.IPAddr{
166+ {IP: net.ParseIP("127.0.0.1")},
167+ {IP: net.ParseIP("93.184.216.34")},
168+ }, nil
169+ },
170+ dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
171+ dialed = append(dialed, addr)
172+ conn, peer := net.Pipe()
173+ t.Cleanup(func() {
174+ _ = conn.Close()
175+ _ = peer.Close()
176+ })
177+ return conn, nil
178+ },
179+ }
180+
181+ conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443")
182+ require.NoError(t, err)
183+ require.NotNil(t, conn)
184+ assert.Equal(t, []string{"93.184.216.34:443"}, dialed)
185+ })
186+
187+ t.Run("rejects private literal addresses at connect time", func(t *testing.T) {
188+ resolver := &contextMenuResolver{}
189+
190+ _, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443")
191+ require.Error(t, err)
192+ assert.Contains(t, err.Error(), "private host")
193+ })
194+
195+ t.Run("redirect check rejects private targets", func(t *testing.T) {
196+ resolver := &contextMenuResolver{
197+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
198+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
199+ },
200+ }
201+
202+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody)
203+ require.NoError(t, err)
204+ err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil)
205+ require.Error(t, err)
206+ assert.Contains(t, err.Error(), "private host")
207+ })
208+
209+ t.Run("rejects non-image HTTP content", func(t *testing.T) {
210+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
211+ w.Header().Set("Content-Type", "text/html")
212+ w.Write([]byte("<html><body>not an image</body></html>"))
213+ }))
214+ defer srv.Close()
215+
216+ resolver := &contextMenuResolver{allowPrivateHosts: true}
217+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html")
218+ require.Error(t, err)
219+ assert.Contains(t, err.Error(), "not an image")
109220 })
110221
111222 t.Run("returns error on HTTP failure", func(t *testing.T) {
@@ -114,12 +225,31 @@ func TestResolveImageData(t *testing.T) {
114225 }))
115226 defer srv.Close()
116227
117- resolver := &contextMenuResolver{}
228+ resolver := &contextMenuResolver{allowPrivateHosts: true}
118229 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
119230 require.Error(t, err)
120231 assert.Contains(t, err.Error(), "404")
121232 })
122233
234+ t.Run("rejects oversized bodies", func(t *testing.T) {
235+ resolver := &contextMenuResolver{
236+ allowPrivateHosts: true,
237+ client: &http.Client{
238+ Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
239+ return &http.Response{
240+ StatusCode: http.StatusOK,
241+ Header: http.Header{"Content-Type": []string{"image/png"}},
242+ Body: io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)),
243+ }, nil
244+ }),
245+ },
246+ }
247+
248+ _, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png")
249+ require.Error(t, err)
250+ assert.Contains(t, err.Error(), "too large")
251+ })
252+
123253 t.Run("returns error on invalid URL", func(t *testing.T) {
124254 resolver := &contextMenuResolver{}
125255 _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
@@ -127,6 +257,34 @@ func TestResolveImageData(t *testing.T) {
127257 })
128258 }
129259
260+func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) {
261+ var gotURI string
262+ wv := &WebView{
263+ OnLinkMiddleClick: func(uri string) bool {
264+ gotURI = uri
265+ return true
266+ },
267+ }
268+ delegator := &webkitMenuDelegator{wv: wv}
269+
270+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
271+ require.NoError(t, err)
272+ require.Equal(t, "https://example.com/new", gotURI)
273+}
274+
275+func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) {
276+ wv := &WebView{
277+ OnLinkMiddleClick: func(string) bool {
278+ return false
279+ },
280+ }
281+ delegator := &webkitMenuDelegator{wv: wv}
282+
283+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
284+ require.Error(t, err)
285+ assert.Contains(t, err.Error(), "action not handled")
286+}
287+
130288 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
131289 // that works with our stub type instead of requiring real WebKit objects.
132290 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
@@ -148,3 +306,32 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo
148306
149307 return ctx
150308 }
309+
310+type roundTripperFunc func(*http.Request) (*http.Response, error)
311+
312+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
313+ return f(req)
314+}
315+
316+type sizedByteReader struct {
317+ remaining int
318+}
319+
320+func newSizedByteReader(size int) *sizedByteReader {
321+ return &sizedByteReader{remaining: size}
322+}
323+
324+func (r *sizedByteReader) Read(p []byte) (int, error) {
325+ if r.remaining <= 0 {
326+ return 0, io.EOF
327+ }
328+ n := len(p)
329+ if n > r.remaining {
330+ n = r.remaining
331+ }
332+ for i := 0; i < n; i++ {
333+ p[i] = 'x'
334+ }
335+ r.remaining -= n
336+ return n, nil
337+}
@@ -2,6 +2,9 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "io"
7+ "net"
5 "net/http"8 "net/http"
6 "net/http/httptest"9 "net/http/httptest"
7 "testing"10 "testing"
@@ -94,18 +97,126 @@ func TestResolveImageData(t *testing.T) {
94 assert.Contains(t, err.Error(), "empty image URI")97 assert.Contains(t, err.Error(), "empty image URI")
95 })98 })
96 99
100+ t.Run("rejects unsupported URI scheme", func(t *testing.T) {
101+ resolver := &contextMenuResolver{}
102+ _, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png")
103+ require.Error(t, err)
104+ assert.Contains(t, err.Error(), "unsupported URI scheme")
105+ })
106+
97 t.Run("fetches image bytes from HTTP", func(t *testing.T) {107 t.Run("fetches image bytes from HTTP", func(t *testing.T) {
98- imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic108+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
99 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {109 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
100- w.Header().Set("Content-Type", "image/png")110+ w.Header().Set("Content-Type", "image/jpeg; charset=utf-8")
101 w.Write(imageBytes)111 w.Write(imageBytes)
102 }))112 }))
103 defer srv.Close()113 defer srv.Close()
104 114
105- resolver := &contextMenuResolver{}115+ resolver := &contextMenuResolver{allowPrivateHosts: true}
106 data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")116 data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
107 require.NoError(t, err)117 require.NoError(t, err)
108 assert.Equal(t, imageBytes, data.Bytes)118 assert.Equal(t, imageBytes, data.Bytes)
119+ assert.Equal(t, "image/png", data.MimeType)
120+ })
121+
122+ t.Run("rejects DNS lookup errors", func(t *testing.T) {
123+ resolver := &contextMenuResolver{
124+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
125+ return nil, errors.New("dns failed")
126+ },
127+ }
128+
129+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
130+ require.Error(t, err)
131+ assert.Contains(t, err.Error(), "dns failed")
132+ })
133+
134+ t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) {
135+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
136+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
137+ w.Header().Set("Content-Type", "application/octet-stream")
138+ w.Write(imageBytes)
139+ }))
140+ defer srv.Close()
141+
142+ resolver := &contextMenuResolver{allowPrivateHosts: true}
143+ data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
144+ require.NoError(t, err)
145+ assert.Equal(t, imageBytes, data.Bytes)
146+ assert.Equal(t, "image/png", data.MimeType)
147+ })
148+
149+ t.Run("rejects DNS-resolved private hosts", func(t *testing.T) {
150+ resolver := &contextMenuResolver{
151+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
152+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
153+ },
154+ }
155+
156+ _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
157+ require.Error(t, err)
158+ assert.Contains(t, err.Error(), "private host")
159+ })
160+
161+ t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) {
162+ var dialed []string
163+ resolver := &contextMenuResolver{
164+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
165+ return []net.IPAddr{
166+ {IP: net.ParseIP("127.0.0.1")},
167+ {IP: net.ParseIP("93.184.216.34")},
168+ }, nil
169+ },
170+ dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
171+ dialed = append(dialed, addr)
172+ conn, peer := net.Pipe()
173+ t.Cleanup(func() {
174+ _ = conn.Close()
175+ _ = peer.Close()
176+ })
177+ return conn, nil
178+ },
179+ }
180+
181+ conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443")
182+ require.NoError(t, err)
183+ require.NotNil(t, conn)
184+ assert.Equal(t, []string{"93.184.216.34:443"}, dialed)
185+ })
186+
187+ t.Run("rejects private literal addresses at connect time", func(t *testing.T) {
188+ resolver := &contextMenuResolver{}
189+
190+ _, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443")
191+ require.Error(t, err)
192+ assert.Contains(t, err.Error(), "private host")
193+ })
194+
195+ t.Run("redirect check rejects private targets", func(t *testing.T) {
196+ resolver := &contextMenuResolver{
197+ lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
198+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
199+ },
200+ }
201+
202+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody)
203+ require.NoError(t, err)
204+ err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil)
205+ require.Error(t, err)
206+ assert.Contains(t, err.Error(), "private host")
207+ })
208+
209+ t.Run("rejects non-image HTTP content", func(t *testing.T) {
210+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
211+ w.Header().Set("Content-Type", "text/html")
212+ w.Write([]byte("<html><body>not an image</body></html>"))
213+ }))
214+ defer srv.Close()
215+
216+ resolver := &contextMenuResolver{allowPrivateHosts: true}
217+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html")
218+ require.Error(t, err)
219+ assert.Contains(t, err.Error(), "not an image")
109 })220 })
110 221
111 t.Run("returns error on HTTP failure", func(t *testing.T) {222 t.Run("returns error on HTTP failure", func(t *testing.T) {
@@ -114,12 +225,31 @@ func TestResolveImageData(t *testing.T) {
114 }))225 }))
115 defer srv.Close()226 defer srv.Close()
116 227
117- resolver := &contextMenuResolver{}228+ resolver := &contextMenuResolver{allowPrivateHosts: true}
118 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")229 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
119 require.Error(t, err)230 require.Error(t, err)
120 assert.Contains(t, err.Error(), "404")231 assert.Contains(t, err.Error(), "404")
121 })232 })
122 233
234+ t.Run("rejects oversized bodies", func(t *testing.T) {
235+ resolver := &contextMenuResolver{
236+ allowPrivateHosts: true,
237+ client: &http.Client{
238+ Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
239+ return &http.Response{
240+ StatusCode: http.StatusOK,
241+ Header: http.Header{"Content-Type": []string{"image/png"}},
242+ Body: io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)),
243+ }, nil
244+ }),
245+ },
246+ }
247+
248+ _, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png")
249+ require.Error(t, err)
250+ assert.Contains(t, err.Error(), "too large")
251+ })
252+
123 t.Run("returns error on invalid URL", func(t *testing.T) {253 t.Run("returns error on invalid URL", func(t *testing.T) {
124 resolver := &contextMenuResolver{}254 resolver := &contextMenuResolver{}
125 _, err := resolver.ResolveImageData(context.Background(), "://bad-url")255 _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
@@ -127,6 +257,34 @@ func TestResolveImageData(t *testing.T) {
127 })257 })
128 }258 }
129 259
260+func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) {
261+ var gotURI string
262+ wv := &WebView{
263+ OnLinkMiddleClick: func(uri string) bool {
264+ gotURI = uri
265+ return true
266+ },
267+ }
268+ delegator := &webkitMenuDelegator{wv: wv}
269+
270+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
271+ require.NoError(t, err)
272+ require.Equal(t, "https://example.com/new", gotURI)
273+}
274+
275+func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) {
276+ wv := &WebView{
277+ OnLinkMiddleClick: func(string) bool {
278+ return false
279+ },
280+ }
281+ delegator := &webkitMenuDelegator{wv: wv}
282+
283+ err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
284+ require.Error(t, err)
285+ assert.Contains(t, err.Error(), "action not handled")
286+}
287+
130 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest288 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
131 // that works with our stub type instead of requiring real WebKit objects.289 // that works with our stub type instead of requiring real WebKit objects.
132 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {290 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
@@ -148,3 +306,32 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo
148 306
149 return ctx307 return ctx
150 }308 }
309+
310+type roundTripperFunc func(*http.Request) (*http.Response, error)
311+
312+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
313+ return f(req)
314+}
315+
316+type sizedByteReader struct {
317+ remaining int
318+}
319+
320+func newSizedByteReader(size int) *sizedByteReader {
321+ return &sizedByteReader{remaining: size}
322+}
323+
324+func (r *sizedByteReader) Read(p []byte) (int, error) {
325+ if r.remaining <= 0 {
326+ return 0, io.EOF
327+ }
328+ n := len(p)
329+ if n > r.remaining {
330+ n = r.remaining
331+ }
332+ for i := 0; i < n; i++ {
333+ p[i] = 'x'
334+ }
335+ r.remaining -= n
336+ return n, nil
337+}
M internal/infrastructure/webkit/engine.go +29 -43
@@ -5,7 +5,6 @@ import (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/application/usecase"
98 "github.com/bnema/dumber/internal/infrastructure/config"
109 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
1110 "github.com/bnema/dumber/internal/infrastructure/filtering"
@@ -15,18 +14,22 @@ import (
1514
1615 // Engine implements port.Engine for the WebKit browser engine.
1716 type Engine struct {
18- ctx context.Context
19- wkCtx *WebKitContext
20- settings *SettingsManager
21- injector *ContentInjector
22- messageRouter *MessageRouter
23- pool *WebViewPool
24- factory *WebViewFactory
25- filterManager *filtering.Manager
26- schemeHandler *DumbSchemeHandler
27- schemePath string
28- logger zerolog.Logger
29- clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
17+ ctx context.Context
18+ wkCtx *WebKitContext
19+ settings *SettingsManager
20+ injector *ContentInjector
21+ messageRouter *MessageRouter
22+ pool *WebViewPool
23+ factory *WebViewFactory
24+ filterManager *filtering.Manager
25+ schemeHandler *DumbSchemeHandler
26+ schemePath string
27+ logger zerolog.Logger
28+ ctxMenuBuilder port.ContextMenuBuilder
29+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
30+ downloadPath string
31+ downloadPreparer port.DownloadPreparer
32+ clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
3033 }
3134
3235 // Compile-time check that Engine implements port.Engine.
@@ -118,6 +121,8 @@ func (e *Engine) ConfigureDownloads(
118121 }
119122 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
120123 e.wkCtx.SetDownloadHandler(ctx, handler)
124+ e.downloadPath = downloadPath
125+ e.downloadPreparer = preparer
121126
122127 // Wire the shared context menu pipeline now that all dependencies are
123128 // available. RegisterHandlers (called earlier) captured the clipboard;
@@ -164,47 +169,28 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
164169 // deps) have run. Kept on the concrete *Engine type because it is a
165170 // WebKit-specific concern.
166171 func (e *Engine) installContextMenuPipeline() {
167- if e.clipboard == nil {
168- e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline")
169- return
170- }
171-
172- buildUC := usecase.NewBuildContextMenuUseCase()
173172 resolver := NewContextMenuResolver()
174-
175- // The saver is nil for now: the ResolvedImageSaver port interface does not
176- // carry the image URI needed for filename derivation, so save-image will
177- // return a graceful error ("image saver not available"). Copy-image (the
178- // primary goal) works without it.
179- var saver port.ResolvedImageSaver
180-
181- executeUC := usecase.NewExecuteContextMenuActionUseCase(
182- e.clipboard,
183- resolver,
184- saver,
185- nil, // delegator — not yet implemented
186- )
173+ saver := contextmenu.NewResolvedImageSaver(e.downloadPreparer, e.downloadPath)
187174
188175 // WebKit's context-menu signal fires on the GTK main thread, so no
189176 // dispatch wrapper is needed.
190177 renderer := contextmenu.NewRenderer(nil)
191178
192- e.SetContextMenuPipeline(buildUC, executeUC, renderer)
179+ pipeline := &contextMenuPipeline{
180+ builder: e.ctxMenuBuilder,
181+ executorFactory: e.ctxMenuExecutorFactory,
182+ clipboard: e.clipboard,
183+ resolver: resolver,
184+ saver: saver,
185+ renderer: renderer,
186+ }
187+ e.SetContextMenuPipeline(pipeline)
193188 }
194189
195190 // SetContextMenuPipeline configures the context menu pipeline for all WebViews
196191 // created by the factory (and pool). Call this after the use cases and renderer
197192 // are available in the bootstrap layer.
198-func (e *Engine) SetContextMenuPipeline(
199- buildUC *usecase.BuildContextMenuUseCase,
200- executeUC *usecase.ExecuteContextMenuActionUseCase,
201- renderer *contextmenu.Renderer,
202-) {
203- pipeline := &contextMenuPipeline{
204- buildUC: buildUC,
205- executeUC: executeUC,
206- renderer: renderer,
207- }
193+func (e *Engine) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
208194 if e.factory != nil {
209195 e.factory.SetContextMenuPipeline(pipeline)
210196 }
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
10 "github.com/bnema/dumber/internal/infrastructure/contextmenu"9 "github.com/bnema/dumber/internal/infrastructure/contextmenu"
11 "github.com/bnema/dumber/internal/infrastructure/filtering"10 "github.com/bnema/dumber/internal/infrastructure/filtering"
@@ -15,18 +14,22 @@ import (
15 14
16 // Engine implements port.Engine for the WebKit browser engine.15 // Engine implements port.Engine for the WebKit browser engine.
17 type Engine struct {16 type Engine struct {
18- ctx context.Context17+ ctx context.Context
19- wkCtx *WebKitContext18+ wkCtx *WebKitContext
20- settings *SettingsManager19+ settings *SettingsManager
21- injector *ContentInjector20+ injector *ContentInjector
22- messageRouter *MessageRouter21+ messageRouter *MessageRouter
23- pool *WebViewPool22+ pool *WebViewPool
24- factory *WebViewFactory23+ factory *WebViewFactory
25- filterManager *filtering.Manager24+ filterManager *filtering.Manager
26- schemeHandler *DumbSchemeHandler25+ schemeHandler *DumbSchemeHandler
27- schemePath string26+ schemePath string
28- logger zerolog.Logger27+ logger zerolog.Logger
29- clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring28+ ctxMenuBuilder port.ContextMenuBuilder
29+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
30+ downloadPath string
31+ downloadPreparer port.DownloadPreparer
32+ clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
30 }33 }
31 34
32 // Compile-time check that Engine implements port.Engine.35 // Compile-time check that Engine implements port.Engine.
@@ -118,6 +121,8 @@ func (e *Engine) ConfigureDownloads(
118 }121 }
119 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)122 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
120 e.wkCtx.SetDownloadHandler(ctx, handler)123 e.wkCtx.SetDownloadHandler(ctx, handler)
124+ e.downloadPath = downloadPath
125+ e.downloadPreparer = preparer
121 126
122 // Wire the shared context menu pipeline now that all dependencies are127 // Wire the shared context menu pipeline now that all dependencies are
123 // available. RegisterHandlers (called earlier) captured the clipboard;128 // available. RegisterHandlers (called earlier) captured the clipboard;
@@ -164,47 +169,28 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
164 // deps) have run. Kept on the concrete *Engine type because it is a169 // deps) have run. Kept on the concrete *Engine type because it is a
165 // WebKit-specific concern.170 // WebKit-specific concern.
166 func (e *Engine) installContextMenuPipeline() {171 func (e *Engine) installContextMenuPipeline() {
167- if e.clipboard == nil {
168- e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline")
169- return
170- }
171-
172- buildUC := usecase.NewBuildContextMenuUseCase()
173 resolver := NewContextMenuResolver()172 resolver := NewContextMenuResolver()
174-173+ saver := contextmenu.NewResolvedImageSaver(e.downloadPreparer, e.downloadPath)
175- // The saver is nil for now: the ResolvedImageSaver port interface does not
176- // carry the image URI needed for filename derivation, so save-image will
177- // return a graceful error ("image saver not available"). Copy-image (the
178- // primary goal) works without it.
179- var saver port.ResolvedImageSaver
180-
181- executeUC := usecase.NewExecuteContextMenuActionUseCase(
182- e.clipboard,
183- resolver,
184- saver,
185- nil, // delegator — not yet implemented
186- )
187 174
188 // WebKit's context-menu signal fires on the GTK main thread, so no175 // WebKit's context-menu signal fires on the GTK main thread, so no
189 // dispatch wrapper is needed.176 // dispatch wrapper is needed.
190 renderer := contextmenu.NewRenderer(nil)177 renderer := contextmenu.NewRenderer(nil)
191 178
192- e.SetContextMenuPipeline(buildUC, executeUC, renderer)179+ pipeline := &contextMenuPipeline{
180+ builder: e.ctxMenuBuilder,
181+ executorFactory: e.ctxMenuExecutorFactory,
182+ clipboard: e.clipboard,
183+ resolver: resolver,
184+ saver: saver,
185+ renderer: renderer,
186+ }
187+ e.SetContextMenuPipeline(pipeline)
193 }188 }
194 189
195 // SetContextMenuPipeline configures the context menu pipeline for all WebViews190 // SetContextMenuPipeline configures the context menu pipeline for all WebViews
196 // created by the factory (and pool). Call this after the use cases and renderer191 // created by the factory (and pool). Call this after the use cases and renderer
197 // are available in the bootstrap layer.192 // are available in the bootstrap layer.
198-func (e *Engine) SetContextMenuPipeline(193+func (e *Engine) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
199- buildUC *usecase.BuildContextMenuUseCase,
200- executeUC *usecase.ExecuteContextMenuActionUseCase,
201- renderer *contextmenu.Renderer,
202-) {
203- pipeline := &contextMenuPipeline{
204- buildUC: buildUC,
205- executeUC: executeUC,
206- renderer: renderer,
207- }
208 if e.factory != nil {194 if e.factory != nil {
209 e.factory.SetContextMenuPipeline(pipeline)195 e.factory.SetContextMenuPipeline(pipeline)
210 }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
@@ -25,6 +25,8 @@ func NewEngine(
25 wkCfg WebKitEngineConfig,25 wkCfg WebKitEngineConfig,
26 themeManager *theme.Manager,26 themeManager *theme.Manager,
27 colorResolver port.ColorSchemeResolver,27 colorResolver port.ColorSchemeResolver,
28+ contextMenuBuilder port.ContextMenuBuilder,
29+ contextMenuExecutorFactory port.ContextMenuActionExecutorFactory,
28 logger zerolog.Logger,30 logger zerolog.Logger,
29 ) (*Engine, error) {31 ) (*Engine, error) {
30 // --- Hardware survey and performance profile resolution ---32 // --- Hardware survey and performance profile resolution ---
@@ -104,17 +106,19 @@ func NewEngine(
104 106
105 // --- Assemble Engine ---107 // --- Assemble Engine ---
106 engine := &Engine{108 engine := &Engine{
107- ctx: ctx,109+ ctx: ctx,
108- wkCtx: wkCtx,110+ wkCtx: wkCtx,
109- settings: settings,111+ settings: settings,
110- injector: injector,112+ injector: injector,
111- messageRouter: messageRouter,113+ messageRouter: messageRouter,
112- pool: pool,114+ pool: pool,
113- factory: factory,115+ factory: factory,
114- filterManager: filterManager,116+ filterManager: filterManager,
115- schemeHandler: schemeHandler,117+ schemeHandler: schemeHandler,
116- schemePath: "dumb://",118+ schemePath: "dumb://",
117- logger: logger,119+ logger: logger,
120+ ctxMenuBuilder: contextMenuBuilder,
121+ ctxMenuExecutorFactory: contextMenuExecutorFactory,
118 }122 }
119 123
120 return engine, nil124 return engine, nil

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