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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +1296 -250
M .mockery.yaml +6 -0
| @@ -49,6 +49,9 @@ packages: | |||
| 49 | 49 | AccentKeyHandler: {} | |
| 50 | 50 | AutoCopyConfig: {} | |
| 51 | 51 | Clipboard: {} | |
| 52 | + | ContextMenuBuilder: {} | |
| 53 | + | ContextMenuActionExecutor: {} | |
| 54 | + | ContextMenuActionExecutorFactory: {} | |
| 52 | 55 | DesktopIntegration: {} | |
| 53 | 56 | DownloadEventHandler: {} | |
| 54 | 57 | DownloadPreparer: {} | |
| @@ -71,6 +74,9 @@ packages: | |||
| 71 | 74 | RuntimeVersionProbe: {} | |
| 72 | 75 | ScriptRefresher: {} | |
| 73 | 76 | SnapshotService: {} | |
| 77 | + | ImageDataResolver: {} | |
| 78 | + | ResolvedImageSaver: {} | |
| 79 | + | MenuActionDelegator: {} | |
| 74 | 80 | TextInputTargetProvider: {} | |
| 75 | 81 | Texture: {} | |
| 76 | 82 | 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 { | |||
| 36 | 36 | 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 | 59 | // MenuActionDelegator handles actions that are not shared application concerns. | |
| 40 | 60 | type MenuActionDelegator interface { | |
| 41 | 61 | DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | |
| @@ -36,6 +36,26 @@ type MenuItem struct { | |||||
| 36 | Label string | 36 | 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) error | 61 | DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | ||
M internal/application/port/image_data.go +4 -3
| @@ -2,9 +2,10 @@ package port | |||
| 2 | 2 | ||
| 3 | 3 | import "context" | |
| 4 | 4 | ||
| 5 | - | // ImageData contains resolved image bytes. | |
| 5 | + | // ImageData contains resolved image bytes and MIME type metadata. | |
| 6 | 6 | type ImageData struct { | |
| 7 | - | Bytes []byte | |
| 7 | + | Bytes []byte | |
| 8 | + | MimeType string | |
| 8 | 9 | } | |
| 9 | 10 | ||
| 10 | 11 | // ImageDataResolver resolves image bytes from a URI. | |
| @@ -14,5 +15,5 @@ type ImageDataResolver interface { | |||
| 14 | 15 | ||
| 15 | 16 | // ResolvedImageSaver persists already resolved image bytes. | |
| 16 | 17 | type ResolvedImageSaver interface { | |
| 17 | - | SaveResolvedImage(ctx context.Context, image ImageData) error | |
| 18 | + | SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error | |
| 18 | 19 | } | |
| @@ -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 []byte | 7 | + | 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) error | 18 | + | 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 | |||
| 370 | 370 | 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 | 373 | // WriteImage provides a mock function for the type MockClipboard | |
| 391 | 374 | func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 392 | 375 | ret := _mock.Called(ctx, image) | |
| @@ -444,111 +427,44 @@ func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Conte | |||
| 444 | 427 | 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' | |
| 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) | |
| 517 | 433 | ||
| 518 | 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 | 438 | 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) | |
| 525 | 441 | } else { | |
| 526 | 442 | r0 = ret.Error(0) | |
| 527 | 443 | } | |
| 528 | 444 | 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' | |
| 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 { | |
| 533 | 449 | *mock.Call | |
| 534 | 450 | } | |
| 535 | 451 | ||
| 536 | - | // SaveResolvedImage is a helper method to define mock.On call | |
| 452 | + | // WriteText is a helper method to define mock.On call | |
| 537 | 453 | // - 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)} | |
| 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 | 460 | _c.Call.Run(func(args mock.Arguments) { | |
| 545 | 461 | var arg0 context.Context | |
| 546 | 462 | if args[0] != nil { | |
| 547 | 463 | arg0 = args[0].(context.Context) | |
| 548 | 464 | } | |
| 549 | - | var arg1 port.ImageData | |
| 465 | + | var arg1 string | |
| 550 | 466 | if args[1] != nil { | |
| 551 | - | arg1 = args[1].(port.ImageData) | |
| 467 | + | arg1 = args[1].(string) | |
| 552 | 468 | } | |
| 553 | 469 | run( | |
| 554 | 470 | arg0, | |
| @@ -558,12 +474,12 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx contex | |||
| 558 | 474 | 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 | 478 | _c.Call.Return(err) | |
| 563 | 479 | 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 | 483 | _c.Call.Return(run) | |
| 568 | 484 | return _c | |
| 569 | 485 | } | |
| @@ -1800,6 +1716,370 @@ func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx con | |||
| 1800 | 1716 | 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 | 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 | 2084 | // The first argument is typically a *testing.T value. | |
| 1805 | 2085 | func NewMockDatabaseProvider(t interface { | |
| @@ -7421,6 +7701,189 @@ func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Co | |||
| 7421 | 7701 | 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 | 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 | 7888 | // The first argument is typically a *testing.T value. | |
| 7426 | 7889 | func NewMockKeybindingsProvider(t interface { | |
| @@ -370,23 +370,6 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context | |||||
| 370 | return _c | 370 | 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 MockClipboard | 373 | // 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 _c | 427 | 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.Call | 432 | + | 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 error | 438 | 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 r0 | 444 | 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.Call | 449 | *mock.Call | ||
| 534 | } | 450 | } | ||
| 535 | 451 | ||||
| 536 | - | // SaveResolvedImage is a helper method to define mock.On call | 452 | + | // WriteText is a helper method to define mock.On call |
| 537 | // - ctx context.Context | 453 | // - ctx context.Context | ||
| 538 | - | // - image port.ImageData | 454 | + | // - 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.Context | 461 | 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.ImageData | 465 | + | 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 _c | 474 | 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 _c | 479 | 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 _c | 484 | return _c | ||
| 569 | } | 485 | } | ||
| @@ -1800,6 +1716,370 @@ func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx con | |||||
| 1800 | return _c | 1716 | 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 _c | 7701 | 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 ( | |||
| 9 | 9 | // BuildContextMenuUseCase normalizes context menu items from menu state. | |
| 10 | 10 | type BuildContextMenuUseCase struct{} | |
| 11 | 11 | ||
| 12 | + | var _ port.ContextMenuBuilder = (*BuildContextMenuUseCase)(nil) | |
| 13 | + | ||
| 12 | 14 | // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase. | |
| 13 | 15 | func NewBuildContextMenuUseCase() *BuildContextMenuUseCase { | |
| 14 | 16 | return &BuildContextMenuUseCase{} | |
| @@ -16,6 +18,10 @@ func NewBuildContextMenuUseCase() *BuildContextMenuUseCase { | |||
| 16 | 18 | ||
| 17 | 19 | // Build returns normalized menu items for the supplied context. | |
| 18 | 20 | func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem { | |
| 21 | + | if menuContext.IsEditable { | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 19 | 25 | items := make([]port.MenuItem, 0, 9) | |
| 20 | 26 | ||
| 21 | 27 | 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) { | |||
| 16 | 16 | context port.MenuContext | |
| 17 | 17 | 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 | 25 | name: "empty context baseline", | |
| 21 | 26 | context: port.MenuContext{}, | |
| @@ -16,6 +16,11 @@ func TestBuildContextMenuUseCase_Build(t *testing.T) { | |||||
| 16 | context port.MenuContext | 16 | context port.MenuContext | ||
| 17 | expected []port.MenuAction | 17 | 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 { | |||
| 21 | 21 | delegator port.MenuActionDelegator | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | var _ port.ContextMenuActionExecutor = (*ExecuteContextMenuActionUseCase)(nil) | |
| 25 | + | ||
| 24 | 26 | // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase. | |
| 25 | 27 | func NewExecuteContextMenuActionUseCase( | |
| 26 | 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 | 56 | // Execute handles the requested action. | |
| 40 | 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 | 72 | case port.MenuActionCopyLink: | |
| 43 | - | if input.Context.LinkURI == "" { | |
| 73 | + | if menuContext.LinkURI == "" { | |
| 44 | 74 | return fmt.Errorf("copy link: link URI not available") | |
| 45 | 75 | } | |
| 46 | 76 | if uc.clipboard == nil { | |
| 47 | 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 | 80 | return fmt.Errorf("copy link: %w", err) | |
| 51 | 81 | } | |
| 52 | 82 | return nil | |
| 53 | 83 | case port.MenuActionCopyImage: | |
| 54 | - | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |
| 84 | + | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 55 | 85 | if err != nil { | |
| 56 | 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 | 94 | return nil | |
| 65 | 95 | case port.MenuActionSaveImage: | |
| 66 | - | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |
| 96 | + | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 67 | 97 | if err != nil { | |
| 68 | 98 | return fmt.Errorf("save image: %w", err) | |
| 69 | 99 | } | |
| 70 | 100 | if uc.saver == nil { | |
| 71 | 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 | 104 | return fmt.Errorf("save image: %w", err) | |
| 75 | 105 | } | |
| 76 | 106 | return nil | |
| 77 | 107 | default: | |
| 78 | 108 | // All other normalized actions are delegated to the engine/UI layer. | |
| 79 | 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 { | |
| 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) | |
| 84 | 114 | } | |
| 85 | 115 | return nil | |
| 86 | 116 | } | |
| @@ -21,6 +21,8 @@ type ExecuteContextMenuActionUseCase struct { | |||||
| 21 | delegator port.MenuActionDelegator | 21 | 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 nil | 82 | 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 nil | 94 | 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 nil | 106 | 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 nil | 115 | 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 | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | 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 | |
| 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 | 56 | f.saveCalls++ | |
| 56 | 57 | f.image = image | |
| 58 | + | f.menuContext = menuContext | |
| 57 | 59 | return f.err | |
| 58 | 60 | } | |
| 59 | 61 | ||
| @@ -93,7 +95,7 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te | |||
| 93 | 95 | ||
| 94 | 96 | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | |
| 95 | 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 | 99 | saver := &fakeResolvedImageSaver{} | |
| 98 | 100 | delegator := &fakeMenuActionDelegator{} | |
| 99 | 101 | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| @@ -107,7 +109,8 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test | |||
| 107 | 109 | require.Equal(t, 1, resolver.resolveCalls) | |
| 108 | 110 | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 109 | 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 | 114 | require.Zero(t, clipboard.writeTextCalls) | |
| 112 | 115 | require.Zero(t, clipboard.writeImageCalls) | |
| 113 | 116 | 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 int | 49 | + | saveCalls int |
| 50 | - | image port.ImageData | 50 | + | image port.ImageData |
| 51 | - | err error | 51 | + | 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 = image | 57 | f.image = image | ||
| 58 | + | f.menuContext = menuContext | |||
| 57 | return f.err | 59 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 10 | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -31,6 +32,8 @@ type EngineInput struct { | |||
| 31 | 32 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | |
| 32 | 33 | func BuildEngine(input EngineInput) (port.Engine, error) { | |
| 33 | 34 | cfg := input.Config | |
| 35 | + | contextMenuBuilder := usecase.NewBuildContextMenuUseCase() | |
| 36 | + | contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{} | |
| 34 | 37 | engineType := cfg.Engine.ResolveEngineType() | |
| 35 | 38 | switch engineType { | |
| 36 | 39 | case config.EngineTypeWebKit: | |
| @@ -43,7 +46,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 43 | 46 | ||
| 44 | 47 | return webkit.NewEngine( | |
| 45 | 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 | 53 | case config.EngineTypeCEF: | |
| 49 | 54 | opts := port.EngineOptions{ | |
| @@ -82,6 +87,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 82 | 87 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | |
| 83 | 88 | CurrentConfigPayload: buildConfigPayload(config.Get), | |
| 84 | 89 | DefaultConfigPayload: buildConfigPayload(config.DefaultConfig), | |
| 90 | + | ContextMenuBuilder: contextMenuBuilder, | |
| 85 | 91 | MediaClassifier: cef.MediaClassifier{ | |
| 86 | 92 | IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME, | |
| 87 | 93 | 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.Config | 34 | 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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 9 | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| @@ -28,12 +27,14 @@ func (h *handlerSet) RunContextMenu( | |||
| 28 | 27 | } | |
| 29 | 28 | 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 | 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 | 36 | if len(items) == 0 { | |
| 35 | - | callback.Cancel() | |
| 36 | - | return 1 | |
| 37 | + | return 0 | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 39 | 40 | // 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 1 | 28 | 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 { | |||
| 31 | 31 | registerAccentHandlers AccentHandlerRegistrar | |
| 32 | 32 | currentConfigPayload func() ([]byte, error) | |
| 33 | 33 | defaultConfigPayload func() ([]byte, error) | |
| 34 | + | ctxMenuBuilder port.ContextMenuBuilder | |
| 34 | 35 | mediaClassifier MediaClassifier | |
| 35 | 36 | ||
| 36 | 37 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| @@ -31,6 +31,7 @@ type Engine struct { | |||||
| 31 | registerAccentHandlers AccentHandlerRegistrar | 31 | 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 MediaClassifier | 35 | 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( | |||
| 54 | 54 | registerAccentHandlers: deps.RegisterAccentHandlers, | |
| 55 | 55 | currentConfigPayload: deps.CurrentConfigPayload, | |
| 56 | 56 | defaultConfigPayload: deps.DefaultConfigPayload, | |
| 57 | + | ctxMenuBuilder: deps.ContextMenuBuilder, | |
| 57 | 58 | mediaClassifier: deps.MediaClassifier, | |
| 58 | 59 | } | |
| 59 | 60 | ||
| @@ -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 { | |||
| 69 | 69 | RegisterAccentHandlers AccentHandlerRegistrar | |
| 70 | 70 | CurrentConfigPayload func() ([]byte, error) | |
| 71 | 71 | DefaultConfigPayload func() ([]byte, error) | |
| 72 | + | ContextMenuBuilder port.ContextMenuBuilder | |
| 72 | 73 | MediaClassifier MediaClassifier | |
| 73 | 74 | } | |
| @@ -69,5 +69,6 @@ type EngineDependencies struct { | |||||
| 69 | RegisterAccentHandlers AccentHandlerRegistrar | 69 | 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 MediaClassifier | 73 | 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 { | |||
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | // WriteImage copies image bytes to the clipboard. | |
| 130 | - | // This path is PNG-only; xsel is not supported because the adapter has no | |
| 131 | - | // reliable binary image mode for it in this codebase. | |
| 130 | + | // xsel is not supported because the adapter has no reliable binary image mode | |
| 131 | + | // for it in this codebase. | |
| 132 | 132 | func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 133 | 133 | log := logging.FromContext(ctx) | |
| 134 | 134 | ||
| @@ -186,12 +186,16 @@ func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageDat | |||
| 186 | 186 | log.Error().Err(err).Msg("clipboard image write failed") | |
| 187 | 187 | return err | |
| 188 | 188 | } | |
| 189 | + | mimeType := image.MimeType | |
| 190 | + | if mimeType == "" { | |
| 191 | + | mimeType = "image/png" | |
| 192 | + | } | |
| 189 | 193 | ||
| 190 | 194 | var cmd *exec.Cmd | |
| 191 | 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 | 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 | 199 | } else { | |
| 196 | 200 | err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd) | |
| 197 | 201 | 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 no | 130 | + | // 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 err | 187 | return err | ||
| 188 | } | 188 | } | ||
| 189 | + | mimeType := image.MimeType | |||
| 190 | + | if mimeType == "" { | |||
| 191 | + | mimeType = "image/png" | |||
| 192 | + | } | |||
| 189 | 193 | ||||
| 190 | var cmd *exec.Cmd | 194 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 6 | 9 | "testing" | |
| 7 | 10 | ||
| 8 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -104,3 +107,44 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||
| 104 | 107 | require.Contains(t, err.Error(), "empty image data") | |
| 105 | 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 | + | } | |
| @@ -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 ( | |||
| 18 | 18 | type ResolvedImageSaver struct { | |
| 19 | 19 | preparer port.DownloadPreparer | |
| 20 | 20 | downloadDir string | |
| 21 | - | menuCtx port.MenuContext | |
| 22 | 21 | } | |
| 23 | 22 | ||
| 24 | 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 | 25 | return &ResolvedImageSaver{ | |
| 27 | 26 | preparer: preparer, | |
| 28 | 27 | downloadDir: downloadDir, | |
| 29 | - | menuCtx: menuCtx, | |
| 30 | 28 | } | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 33 | 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 | 33 | if s == nil || s.preparer == nil { | |
| 36 | 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 | 39 | // Leave SuggestedFilename empty so DownloadPreparer can fall back to | |
| 42 | 40 | // URI-derived naming when no explicit filename is available. | |
| 43 | 41 | Response: downloadResponse{ | |
| 44 | - | mimeType: "image/png", | |
| 45 | - | uri: s.menuCtx.ImageURI, | |
| 42 | + | mimeType: image.MimeType, | |
| 43 | + | uri: menuContext.ImageURI, | |
| 46 | 44 | }, | |
| 47 | 45 | }) | |
| 48 | 46 | if output == nil || output.DestinationPath == "" { | |
| @@ -18,20 +18,18 @@ const ( | |||||
| 18 | type ResolvedImageSaver struct { | 18 | type ResolvedImageSaver struct { | ||
| 19 | preparer port.DownloadPreparer | 19 | preparer port.DownloadPreparer | ||
| 20 | downloadDir string | 20 | 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 to | 39 | // 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) { | |||
| 24 | 24 | downloadDir := t.TempDir() | |
| 25 | 25 | destPath := filepath.Join(downloadDir, "image.png") | |
| 26 | 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 | 30 | require.NoError(t, err) | |
| 31 | 31 | require.Equal(t, downloadDir, preparer.input.DownloadDir) | |
| 32 | 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 | 34 | require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath)) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |
| 38 | 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 | 42 | require.Error(t, err) | |
| 43 | 43 | require.Contains(t, err.Error(), "destination path") | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |
| 47 | 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 | 51 | require.Error(t, err) | |
| 52 | 52 | require.Contains(t, err.Error(), "empty image data") | |
| 53 | 53 | } | |
| @@ -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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "mime" | |
| 8 | + | "net" | |
| 7 | 9 | "net/http" | |
| 10 | + | "net/url" | |
| 11 | + | "strings" | |
| 8 | 12 | "time" | |
| 9 | 13 | ||
| 10 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 15 | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 13 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 17 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -18,7 +21,10 @@ import ( | |||
| 18 | 21 | // over HTTP. This is the WebKit-specific seam for the shared context menu | |
| 19 | 22 | // pipeline's copy-image and save-image actions. | |
| 20 | 23 | 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) | |
| 22 | 28 | } | |
| 23 | 29 | ||
| 24 | 30 | var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |
| @@ -31,12 +37,22 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str | |||
| 31 | 37 | return port.ImageData{}, fmt.Errorf("empty image URI") | |
| 32 | 38 | } | |
| 33 | 39 | ||
| 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 | |
| 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 | 56 | if err != nil { | |
| 41 | 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 | 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 | 71 | if err != nil { | |
| 56 | 72 | return port.ImageData{}, fmt.Errorf("read image body: %w", err) | |
| 57 | 73 | } | |
| 58 | 74 | if len(data) == 0 { | |
| 59 | 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}, 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 | |
| 63 | 260 | } | |
| 64 | 261 | ||
| 65 | 262 | // Compile-time check that contextMenuResolver implements port.ImageDataResolver. | |
| @@ -97,9 +294,24 @@ func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y in | |||
| 97 | 294 | // contextMenuPipeline holds the dependencies needed to run the shared | |
| 98 | 295 | // context menu flow from inside a WebKit context-menu signal handler. | |
| 99 | 296 | 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 | + | ) | |
| 103 | 315 | } | |
| 104 | 316 | ||
| 105 | 317 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |
| @@ -109,25 +321,38 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |||
| 109 | 321 | if pipeline == nil { | |
| 110 | 322 | return | |
| 111 | 323 | } | |
| 324 | + | wv.contextMenu = pipeline | |
| 112 | 325 | ||
| 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 | + | } | |
| 115 | 335 | ||
| 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) | |
| 121 | 337 | ||
| 122 | 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 | 344 | 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 | |
| 126 | 351 | } | |
| 127 | 352 | ||
| 128 | 353 | // Get the widget for anchoring the popover. | |
| 129 | 354 | if wv.inner == nil { | |
| 130 | - | return true | |
| 355 | + | return false | |
| 131 | 356 | } | |
| 132 | 357 | anchor := &wv.inner.Widget | |
| 133 | 358 | ||
| @@ -137,7 +362,7 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |||
| 137 | 362 | int32(x), | |
| 138 | 363 | int32(y), | |
| 139 | 364 | func(item port.MenuItem) { | |
| 140 | - | wv.executeContextMenuAction(pipeline.executeUC, item, menuContext) | |
| 365 | + | wv.executeContextMenuAction(ctx, executor, item, menuContext) | |
| 141 | 366 | }, | |
| 142 | 367 | nil, // onClose — no action needed | |
| 143 | 368 | ) | |
| @@ -149,48 +374,102 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |||
| 149 | 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 | 392 | // 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 | 395 | func (wv *WebView) executeContextMenuAction( | |
| 155 | - | executeUC *usecase.ExecuteContextMenuActionUseCase, | |
| 396 | + | ctx context.Context, | |
| 397 | + | executor port.ContextMenuActionExecutor, | |
| 156 | 398 | item port.MenuItem, | |
| 157 | 399 | menuContext port.MenuContext, | |
| 158 | 400 | ) { | |
| 159 | - | if executeUC == nil { | |
| 401 | + | if executor == nil { | |
| 160 | 402 | return | |
| 161 | 403 | } | |
| 162 | 404 | ||
| 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) | |
| 175 | 410 | } | |
| 176 | 411 | ||
| 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, | |
| 183 | 417 | ) { | |
| 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 | |
| 186 | 431 | } | |
| 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) | |
| 191 | 472 | } | |
| 192 | - | wv.contextMenu = pipeline | |
| 193 | - | wv.connectContextMenuSignal(pipeline) | |
| 194 | 473 | } | |
| 195 | 474 | ||
| 196 | 475 | // 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 menu | 21 | // 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.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) | |||
| 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.client | 40 | + | parsed, parseErr := url.Parse(imageURI) |
| 35 | - | if client == nil { | 41 | + | if parseErr != nil { |
| 36 | - | client = defaultImageFetchClient | 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 | |||
| 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}, 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 | |||
| 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 shared | 294 | // 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.BuildContextMenuUseCase | 297 | + | builder port.ContextMenuBuilder |
| 101 | - | executeUC *usecase.ExecuteContextMenuActionUseCase | 298 | + | executorFactory port.ContextMenuActionExecutorFactory |
| 102 | - | renderer *contextmenu.Renderer | 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 | + | ) | |||
| 103 | } | 315 | } | ||
| 104 | 316 | ||||
| 105 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | 317 | // 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 | return | 322 | return | ||
| 111 | } | 323 | } | ||
| 324 | + | wv.contextMenu = pipeline | |||
| 112 | 325 | ||||
| 113 | - | contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu | 326 | + | 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 show | 345 | + | 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 true | 355 | + | return false |
| 131 | } | 356 | } | ||
| 132 | anchor := &wv.inner.Widget | 357 | 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 needed | 367 | 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 shared | 392 | // 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 | return | 402 | 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 | - | return | 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 | |||
| 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 WebKit | 475 | // NewContextMenuResolver creates a new ImageDataResolver for WebKit | ||
M internal/infrastructure/webkit/context_menu_test.go +191 -4
| @@ -2,6 +2,9 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "net" | |
| 5 | 8 | "net/http" | |
| 6 | 9 | "net/http/httptest" | |
| 7 | 10 | "testing" | |
| @@ -94,18 +97,126 @@ func TestResolveImageData(t *testing.T) { | |||
| 94 | 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 | 107 | 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} | |
| 99 | 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 | 111 | w.Write(imageBytes) | |
| 102 | 112 | })) | |
| 103 | 113 | defer srv.Close() | |
| 104 | 114 | ||
| 105 | - | resolver := &contextMenuResolver{} | |
| 115 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 106 | 116 | data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png") | |
| 107 | 117 | require.NoError(t, err) | |
| 108 | 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 | 222 | t.Run("returns error on HTTP failure", func(t *testing.T) { | |
| @@ -114,12 +225,31 @@ func TestResolveImageData(t *testing.T) { | |||
| 114 | 225 | })) | |
| 115 | 226 | defer srv.Close() | |
| 116 | 227 | ||
| 117 | - | resolver := &contextMenuResolver{} | |
| 228 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 118 | 229 | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png") | |
| 119 | 230 | require.Error(t, err) | |
| 120 | 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 | 253 | t.Run("returns error on invalid URL", func(t *testing.T) { | |
| 124 | 254 | resolver := &contextMenuResolver{} | |
| 125 | 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 | 288 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |
| 131 | 289 | // that works with our stub type instead of requiring real WebKit objects. | |
| 132 | 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 | 307 | 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 | + | } | |
| @@ -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 magic | 108 | + | 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 buildMenuContextFromHitTest | 288 | // 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 ctx | 307 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | 9 | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 11 | 10 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| @@ -15,18 +14,22 @@ import ( | |||
| 15 | 14 | ||
| 16 | 15 | // Engine implements port.Engine for the WebKit browser engine. | |
| 17 | 16 | 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 | |
| 30 | 33 | } | |
| 31 | 34 | ||
| 32 | 35 | // Compile-time check that Engine implements port.Engine. | |
| @@ -118,6 +121,8 @@ func (e *Engine) ConfigureDownloads( | |||
| 118 | 121 | } | |
| 119 | 122 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | |
| 120 | 123 | e.wkCtx.SetDownloadHandler(ctx, handler) | |
| 124 | + | e.downloadPath = downloadPath | |
| 125 | + | e.downloadPreparer = preparer | |
| 121 | 126 | ||
| 122 | 127 | // Wire the shared context menu pipeline now that all dependencies are | |
| 123 | 128 | // available. RegisterHandlers (called earlier) captured the clipboard; | |
| @@ -164,47 +169,28 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU | |||
| 164 | 169 | // deps) have run. Kept on the concrete *Engine type because it is a | |
| 165 | 170 | // WebKit-specific concern. | |
| 166 | 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 | 172 | 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) | |
| 187 | 174 | ||
| 188 | 175 | // WebKit's context-menu signal fires on the GTK main thread, so no | |
| 189 | 176 | // dispatch wrapper is needed. | |
| 190 | 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 | 190 | // SetContextMenuPipeline configures the context menu pipeline for all WebViews | |
| 196 | 191 | // created by the factory (and pool). Call this after the use cases and renderer | |
| 197 | 192 | // 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) { | |
| 208 | 194 | if e.factory != nil { | |
| 209 | 195 | e.factory.SetContextMenuPipeline(pipeline) | |
| 210 | 196 | } | |
| @@ -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.Context | 17 | + | ctx context.Context |
| 19 | - | wkCtx *WebKitContext | 18 | + | wkCtx *WebKitContext |
| 20 | - | settings *SettingsManager | 19 | + | settings *SettingsManager |
| 21 | - | injector *ContentInjector | 20 | + | injector *ContentInjector |
| 22 | - | messageRouter *MessageRouter | 21 | + | messageRouter *MessageRouter |
| 23 | - | pool *WebViewPool | 22 | + | pool *WebViewPool |
| 24 | - | factory *WebViewFactory | 23 | + | factory *WebViewFactory |
| 25 | - | filterManager *filtering.Manager | 24 | + | filterManager *filtering.Manager |
| 26 | - | schemeHandler *DumbSchemeHandler | 25 | + | schemeHandler *DumbSchemeHandler |
| 27 | - | schemePath string | 26 | + | schemePath string |
| 28 | - | logger zerolog.Logger | 27 | + | logger zerolog.Logger |
| 29 | - | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | 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 | |||
| 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 are | 127 | // 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 a | 169 | // 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 no | 175 | // 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 WebViews | 190 | // SetContextMenuPipeline configures the context menu pipeline for all WebViews | ||
| 196 | // created by the factory (and pool). Call this after the use cases and renderer | 191 | // 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( | |||
| 25 | 25 | wkCfg WebKitEngineConfig, | |
| 26 | 26 | themeManager *theme.Manager, | |
| 27 | 27 | colorResolver port.ColorSchemeResolver, | |
| 28 | + | contextMenuBuilder port.ContextMenuBuilder, | |
| 29 | + | contextMenuExecutorFactory port.ContextMenuActionExecutorFactory, | |
| 28 | 30 | logger zerolog.Logger, | |
| 29 | 31 | ) (*Engine, error) { | |
| 30 | 32 | // --- Hardware survey and performance profile resolution --- | |
| @@ -104,17 +106,19 @@ func NewEngine( | |||
| 104 | 106 | ||
| 105 | 107 | // --- Assemble Engine --- | |
| 106 | 108 | 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, | |
| 118 | 122 | } | |
| 119 | 123 | ||
| 120 | 124 | 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, nil | 124 | return engine, nil | ||