fix(context-menu): address review follow-ups
Harden image validation and clipboard execution, move image data out of the port layer, and add the missing context-menu regression coverage raised during PR review.
- commit
- e0d4a99348d843a7d7e67c28751e5b4a50d5a528
- author
- brice · · 2026-04-13 18:22 UTC
- committer
- brice · · 2026-04-14 12:06 UTC
- parent
- e1b799b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +404 -150
M internal/application/port/clipboard.go +6 -2
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package port | |
| 2 | 2 | ||
| 3 | - | import "context" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // Clipboard defines the port interface for clipboard operations. | |
| 6 | 10 | // This abstracts platform-specific clipboard implementations (GTK, etc.). | |
| @@ -9,7 +13,7 @@ type Clipboard interface { | |||
| 9 | 13 | WriteText(ctx context.Context, text string) error | |
| 10 | 14 | ||
| 11 | 15 | // WriteImage copies encoded image bytes to the clipboard. | |
| 12 | - | WriteImage(ctx context.Context, image ImageData) error | |
| 16 | + | WriteImage(ctx context.Context, image entity.ImageData) error | |
| 13 | 17 | ||
| 14 | 18 | // ReadText reads text from the clipboard. | |
| 15 | 19 | // Returns empty string if clipboard is empty or contains non-text data. | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package port | 1 | package port | ||
| 2 | 2 | ||||
| 3 | - | import "context" | 3 | + | import ( |
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | // Clipboard defines the port interface for clipboard operations. | 9 | // Clipboard defines the port interface for clipboard operations. | ||
| 6 | // This abstracts platform-specific clipboard implementations (GTK, etc.). | 10 | // This abstracts platform-specific clipboard implementations (GTK, etc.). | ||
| @@ -9,7 +13,7 @@ type Clipboard interface { | |||||
| 9 | WriteText(ctx context.Context, text string) error | 13 | WriteText(ctx context.Context, text string) error | ||
| 10 | 14 | ||||
| 11 | // WriteImage copies encoded image bytes to the clipboard. | 15 | // WriteImage copies encoded image bytes to the clipboard. | ||
| 12 | - | WriteImage(ctx context.Context, image ImageData) error | 16 | + | WriteImage(ctx context.Context, image entity.ImageData) error |
| 13 | 17 | ||||
| 14 | // ReadText reads text from the clipboard. | 18 | // ReadText reads text from the clipboard. | ||
| 15 | // Returns empty string if clipboard is empty or contains non-text data. | 19 | // Returns empty string if clipboard is empty or contains non-text data. | ||
M internal/application/port/image_data.go +6 -8
| @@ -1,19 +1,17 @@ | |||
| 1 | 1 | package port | |
| 2 | 2 | ||
| 3 | - | import "context" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 4 | 5 | ||
| 5 | - | // ImageData contains resolved image bytes and MIME type metadata. | |
| 6 | - | type ImageData struct { | |
| 7 | - | Bytes []byte | |
| 8 | - | MimeType string | |
| 9 | - | } | |
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 10 | 8 | ||
| 11 | 9 | // ImageDataResolver resolves image bytes from a URI. | |
| 12 | 10 | type ImageDataResolver interface { | |
| 13 | - | ResolveImageData(ctx context.Context, uri string) (ImageData, error) | |
| 11 | + | ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) | |
| 14 | 12 | } | |
| 15 | 13 | ||
| 16 | 14 | // ResolvedImageSaver persists already resolved image bytes. | |
| 17 | 15 | type ResolvedImageSaver interface { | |
| 18 | - | SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error | |
| 16 | + | SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error | |
| 19 | 17 | } | |
| @@ -1,19 +1,17 @@ | |||||
| 1 | package port | 1 | package port | ||
| 2 | 2 | ||||
| 3 | - | import "context" | 3 | + | import ( |
| 4 | + | "context" | |||
| 4 | 5 | ||||
| 5 | - | // ImageData contains resolved image bytes and MIME type metadata. | 6 | + | "github.com/bnema/dumber/internal/domain/entity" |
| 6 | - | type ImageData struct { | 7 | + | ) |
| 7 | - | Bytes []byte | |||
| 8 | - | MimeType string | |||
| 9 | - | } | |||
| 10 | 8 | ||||
| 11 | // ImageDataResolver resolves image bytes from a URI. | 9 | // ImageDataResolver resolves image bytes from a URI. | ||
| 12 | type ImageDataResolver interface { | 10 | type ImageDataResolver interface { | ||
| 13 | - | ResolveImageData(ctx context.Context, uri string) (ImageData, error) | 11 | + | ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) |
| 14 | } | 12 | } | ||
| 15 | 13 | ||||
| 16 | // ResolvedImageSaver persists already resolved image bytes. | 14 | // ResolvedImageSaver persists already resolved image bytes. | ||
| 17 | type ResolvedImageSaver interface { | 15 | type ResolvedImageSaver interface { | ||
| 18 | - | SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error | 16 | + | SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error |
| 19 | } | 17 | } | ||
M internal/application/port/mocks/mocks.go +21 -21
| @@ -371,7 +371,7 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context | |||
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 373 | // WriteImage provides a mock function for the type MockClipboard | |
| 374 | - | func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 374 | + | func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 375 | 375 | ret := _mock.Called(ctx, image) | |
| 376 | 376 | ||
| 377 | 377 | if len(ret) == 0 { | |
| @@ -379,7 +379,7 @@ func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData | |||
| 379 | 379 | } | |
| 380 | 380 | ||
| 381 | 381 | var r0 error | |
| 382 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | |
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok { | |
| 383 | 383 | r0 = returnFunc(ctx, image) | |
| 384 | 384 | } else { | |
| 385 | 385 | r0 = ret.Error(0) | |
| @@ -394,20 +394,20 @@ type MockClipboard_WriteImage_Call struct { | |||
| 394 | 394 | ||
| 395 | 395 | // WriteImage is a helper method to define mock.On call | |
| 396 | 396 | // - ctx context.Context | |
| 397 | - | // - image port.ImageData | |
| 397 | + | // - image entity.ImageData | |
| 398 | 398 | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | |
| 399 | 399 | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | |
| 400 | 400 | } | |
| 401 | 401 | ||
| 402 | - | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call { | |
| 402 | + | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call { | |
| 403 | 403 | _c.Call.Run(func(args mock.Arguments) { | |
| 404 | 404 | var arg0 context.Context | |
| 405 | 405 | if args[0] != nil { | |
| 406 | 406 | arg0 = args[0].(context.Context) | |
| 407 | 407 | } | |
| 408 | - | var arg1 port.ImageData | |
| 408 | + | var arg1 entity.ImageData | |
| 409 | 409 | if args[1] != nil { | |
| 410 | - | arg1 = args[1].(port.ImageData) | |
| 410 | + | arg1 = args[1].(entity.ImageData) | |
| 411 | 411 | } | |
| 412 | 412 | run( | |
| 413 | 413 | arg0, | |
| @@ -422,7 +422,7 @@ func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteI | |||
| 422 | 422 | return _c | |
| 423 | 423 | } | |
| 424 | 424 | ||
| 425 | - | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call { | |
| 425 | + | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call { | |
| 426 | 426 | _c.Call.Return(run) | |
| 427 | 427 | return _c | |
| 428 | 428 | } | |
| @@ -7729,22 +7729,22 @@ func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter { | |||
| 7729 | 7729 | } | |
| 7730 | 7730 | ||
| 7731 | 7731 | // ResolveImageData provides a mock function for the type MockImageDataResolver | |
| 7732 | - | func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | |
| 7732 | + | func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | |
| 7733 | 7733 | ret := _mock.Called(ctx, uri) | |
| 7734 | 7734 | ||
| 7735 | 7735 | if len(ret) == 0 { | |
| 7736 | 7736 | panic("no return value specified for ResolveImageData") | |
| 7737 | 7737 | } | |
| 7738 | 7738 | ||
| 7739 | - | var r0 port.ImageData | |
| 7739 | + | var r0 entity.ImageData | |
| 7740 | 7740 | var r1 error | |
| 7741 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok { | |
| 7741 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok { | |
| 7742 | 7742 | return returnFunc(ctx, uri) | |
| 7743 | 7743 | } | |
| 7744 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok { | |
| 7744 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok { | |
| 7745 | 7745 | r0 = returnFunc(ctx, uri) | |
| 7746 | 7746 | } else { | |
| 7747 | - | r0 = ret.Get(0).(port.ImageData) | |
| 7747 | + | r0 = ret.Get(0).(entity.ImageData) | |
| 7748 | 7748 | } | |
| 7749 | 7749 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 7750 | 7750 | r1 = returnFunc(ctx, uri) | |
| @@ -7784,12 +7784,12 @@ func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context. | |||
| 7784 | 7784 | return _c | |
| 7785 | 7785 | } | |
| 7786 | 7786 | ||
| 7787 | - | func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call { | |
| 7787 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call { | |
| 7788 | 7788 | _c.Call.Return(imageData, err) | |
| 7789 | 7789 | return _c | |
| 7790 | 7790 | } | |
| 7791 | 7791 | ||
| 7792 | - | func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call { | |
| 7792 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call { | |
| 7793 | 7793 | _c.Call.Return(run) | |
| 7794 | 7794 | return _c | |
| 7795 | 7795 | } | |
| @@ -7822,7 +7822,7 @@ func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter { | |||
| 7822 | 7822 | } | |
| 7823 | 7823 | ||
| 7824 | 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 { | |
| 7825 | + | func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 7826 | 7826 | ret := _mock.Called(ctx, image, menuContext) | |
| 7827 | 7827 | ||
| 7828 | 7828 | if len(ret) == 0 { | |
| @@ -7830,7 +7830,7 @@ func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, imag | |||
| 7830 | 7830 | } | |
| 7831 | 7831 | ||
| 7832 | 7832 | var r0 error | |
| 7833 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok { | |
| 7833 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok { | |
| 7834 | 7834 | r0 = returnFunc(ctx, image, menuContext) | |
| 7835 | 7835 | } else { | |
| 7836 | 7836 | r0 = ret.Error(0) | |
| @@ -7845,21 +7845,21 @@ type MockResolvedImageSaver_SaveResolvedImage_Call struct { | |||
| 7845 | 7845 | ||
| 7846 | 7846 | // SaveResolvedImage is a helper method to define mock.On call | |
| 7847 | 7847 | // - ctx context.Context | |
| 7848 | - | // - image port.ImageData | |
| 7848 | + | // - image entity.ImageData | |
| 7849 | 7849 | // - menuContext port.MenuContext | |
| 7850 | 7850 | func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7851 | 7851 | return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)} | |
| 7852 | 7852 | } | |
| 7853 | 7853 | ||
| 7854 | - | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7854 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7855 | 7855 | _c.Call.Run(func(args mock.Arguments) { | |
| 7856 | 7856 | var arg0 context.Context | |
| 7857 | 7857 | if args[0] != nil { | |
| 7858 | 7858 | arg0 = args[0].(context.Context) | |
| 7859 | 7859 | } | |
| 7860 | - | var arg1 port.ImageData | |
| 7860 | + | var arg1 entity.ImageData | |
| 7861 | 7861 | if args[1] != nil { | |
| 7862 | - | arg1 = args[1].(port.ImageData) | |
| 7862 | + | arg1 = args[1].(entity.ImageData) | |
| 7863 | 7863 | } | |
| 7864 | 7864 | var arg2 port.MenuContext | |
| 7865 | 7865 | if args[2] != nil { | |
| @@ -7879,7 +7879,7 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *Mock | |||
| 7879 | 7879 | return _c | |
| 7880 | 7880 | } | |
| 7881 | 7881 | ||
| 7882 | - | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7882 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7883 | 7883 | _c.Call.Return(run) | |
| 7884 | 7884 | return _c | |
| 7885 | 7885 | } | |
| @@ -371,7 +371,7 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context | |||||
| 371 | } | 371 | } | ||
| 372 | 372 | ||||
| 373 | // WriteImage provides a mock function for the type MockClipboard | 373 | // WriteImage provides a mock function for the type MockClipboard | ||
| 374 | - | func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error { | 374 | + | func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { |
| 375 | ret := _mock.Called(ctx, image) | 375 | ret := _mock.Called(ctx, image) | ||
| 376 | 376 | ||||
| 377 | if len(ret) == 0 { | 377 | if len(ret) == 0 { | ||
| @@ -379,7 +379,7 @@ func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData | |||||
| 379 | } | 379 | } | ||
| 380 | 380 | ||||
| 381 | var r0 error | 381 | var r0 error | ||
| 382 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok { |
| 383 | r0 = returnFunc(ctx, image) | 383 | r0 = returnFunc(ctx, image) | ||
| 384 | } else { | 384 | } else { | ||
| 385 | r0 = ret.Error(0) | 385 | r0 = ret.Error(0) | ||
| @@ -394,20 +394,20 @@ type MockClipboard_WriteImage_Call struct { | |||||
| 394 | 394 | ||||
| 395 | // WriteImage is a helper method to define mock.On call | 395 | // WriteImage is a helper method to define mock.On call | ||
| 396 | // - ctx context.Context | 396 | // - ctx context.Context | ||
| 397 | - | // - image port.ImageData | 397 | + | // - image entity.ImageData |
| 398 | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | 398 | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | ||
| 399 | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | 399 | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | ||
| 400 | } | 400 | } | ||
| 401 | 401 | ||||
| 402 | - | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call { | 402 | + | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call { |
| 403 | _c.Call.Run(func(args mock.Arguments) { | 403 | _c.Call.Run(func(args mock.Arguments) { | ||
| 404 | var arg0 context.Context | 404 | var arg0 context.Context | ||
| 405 | if args[0] != nil { | 405 | if args[0] != nil { | ||
| 406 | arg0 = args[0].(context.Context) | 406 | arg0 = args[0].(context.Context) | ||
| 407 | } | 407 | } | ||
| 408 | - | var arg1 port.ImageData | 408 | + | var arg1 entity.ImageData |
| 409 | if args[1] != nil { | 409 | if args[1] != nil { | ||
| 410 | - | arg1 = args[1].(port.ImageData) | 410 | + | arg1 = args[1].(entity.ImageData) |
| 411 | } | 411 | } | ||
| 412 | run( | 412 | run( | ||
| 413 | arg0, | 413 | arg0, | ||
| @@ -422,7 +422,7 @@ func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteI | |||||
| 422 | return _c | 422 | return _c | ||
| 423 | } | 423 | } | ||
| 424 | 424 | ||||
| 425 | - | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call { | 425 | + | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call { |
| 426 | _c.Call.Return(run) | 426 | _c.Call.Return(run) | ||
| 427 | return _c | 427 | return _c | ||
| 428 | } | 428 | } | ||
| @@ -7729,22 +7729,22 @@ func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter { | |||||
| 7729 | } | 7729 | } | ||
| 7730 | 7730 | ||||
| 7731 | // ResolveImageData provides a mock function for the type MockImageDataResolver | 7731 | // ResolveImageData provides a mock function for the type MockImageDataResolver | ||
| 7732 | - | func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | 7732 | + | func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { |
| 7733 | ret := _mock.Called(ctx, uri) | 7733 | ret := _mock.Called(ctx, uri) | ||
| 7734 | 7734 | ||||
| 7735 | if len(ret) == 0 { | 7735 | if len(ret) == 0 { | ||
| 7736 | panic("no return value specified for ResolveImageData") | 7736 | panic("no return value specified for ResolveImageData") | ||
| 7737 | } | 7737 | } | ||
| 7738 | 7738 | ||||
| 7739 | - | var r0 port.ImageData | 7739 | + | var r0 entity.ImageData |
| 7740 | var r1 error | 7740 | var r1 error | ||
| 7741 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok { | 7741 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok { |
| 7742 | return returnFunc(ctx, uri) | 7742 | return returnFunc(ctx, uri) | ||
| 7743 | } | 7743 | } | ||
| 7744 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok { | 7744 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok { |
| 7745 | r0 = returnFunc(ctx, uri) | 7745 | r0 = returnFunc(ctx, uri) | ||
| 7746 | } else { | 7746 | } else { | ||
| 7747 | - | r0 = ret.Get(0).(port.ImageData) | 7747 | + | r0 = ret.Get(0).(entity.ImageData) |
| 7748 | } | 7748 | } | ||
| 7749 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | 7749 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | ||
| 7750 | r1 = returnFunc(ctx, uri) | 7750 | r1 = returnFunc(ctx, uri) | ||
| @@ -7784,12 +7784,12 @@ func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context. | |||||
| 7784 | return _c | 7784 | return _c | ||
| 7785 | } | 7785 | } | ||
| 7786 | 7786 | ||||
| 7787 | - | func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call { | 7787 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call { |
| 7788 | _c.Call.Return(imageData, err) | 7788 | _c.Call.Return(imageData, err) | ||
| 7789 | return _c | 7789 | return _c | ||
| 7790 | } | 7790 | } | ||
| 7791 | 7791 | ||||
| 7792 | - | func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call { | 7792 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call { |
| 7793 | _c.Call.Return(run) | 7793 | _c.Call.Return(run) | ||
| 7794 | return _c | 7794 | return _c | ||
| 7795 | } | 7795 | } | ||
| @@ -7822,7 +7822,7 @@ func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter { | |||||
| 7822 | } | 7822 | } | ||
| 7823 | 7823 | ||||
| 7824 | // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver | 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 { | 7825 | + | func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { |
| 7826 | ret := _mock.Called(ctx, image, menuContext) | 7826 | ret := _mock.Called(ctx, image, menuContext) | ||
| 7827 | 7827 | ||||
| 7828 | if len(ret) == 0 { | 7828 | if len(ret) == 0 { | ||
| @@ -7830,7 +7830,7 @@ func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, imag | |||||
| 7830 | } | 7830 | } | ||
| 7831 | 7831 | ||||
| 7832 | var r0 error | 7832 | var r0 error | ||
| 7833 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok { | 7833 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok { |
| 7834 | r0 = returnFunc(ctx, image, menuContext) | 7834 | r0 = returnFunc(ctx, image, menuContext) | ||
| 7835 | } else { | 7835 | } else { | ||
| 7836 | r0 = ret.Error(0) | 7836 | r0 = ret.Error(0) | ||
| @@ -7845,21 +7845,21 @@ type MockResolvedImageSaver_SaveResolvedImage_Call struct { | |||||
| 7845 | 7845 | ||||
| 7846 | // SaveResolvedImage is a helper method to define mock.On call | 7846 | // SaveResolvedImage is a helper method to define mock.On call | ||
| 7847 | // - ctx context.Context | 7847 | // - ctx context.Context | ||
| 7848 | - | // - image port.ImageData | 7848 | + | // - image entity.ImageData |
| 7849 | // - menuContext port.MenuContext | 7849 | // - menuContext port.MenuContext | ||
| 7850 | func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call { | 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)} | 7851 | return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)} | ||
| 7852 | } | 7852 | } | ||
| 7853 | 7853 | ||||
| 7854 | - | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call { | 7854 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call { |
| 7855 | _c.Call.Run(func(args mock.Arguments) { | 7855 | _c.Call.Run(func(args mock.Arguments) { | ||
| 7856 | var arg0 context.Context | 7856 | var arg0 context.Context | ||
| 7857 | if args[0] != nil { | 7857 | if args[0] != nil { | ||
| 7858 | arg0 = args[0].(context.Context) | 7858 | arg0 = args[0].(context.Context) | ||
| 7859 | } | 7859 | } | ||
| 7860 | - | var arg1 port.ImageData | 7860 | + | var arg1 entity.ImageData |
| 7861 | if args[1] != nil { | 7861 | if args[1] != nil { | ||
| 7862 | - | arg1 = args[1].(port.ImageData) | 7862 | + | arg1 = args[1].(entity.ImageData) |
| 7863 | } | 7863 | } | ||
| 7864 | var arg2 port.MenuContext | 7864 | var arg2 port.MenuContext | ||
| 7865 | if args[2] != nil { | 7865 | if args[2] != nil { | ||
| @@ -7879,7 +7879,7 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *Mock | |||||
| 7879 | return _c | 7879 | return _c | ||
| 7880 | } | 7880 | } | ||
| 7881 | 7881 | ||||
| 7882 | - | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | 7882 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call { |
| 7883 | _c.Call.Return(run) | 7883 | _c.Call.Return(run) | ||
| 7884 | return _c | 7884 | return _c | ||
| 7885 | } | 7885 | } | ||
M internal/application/usecase/execute_context_menu_action.go +12 -11
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // ExecuteContextMenuActionInput identifies the action and its context. | |
| @@ -81,25 +82,25 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||
| 81 | 82 | } | |
| 82 | 83 | return nil | |
| 83 | 84 | case port.MenuActionCopyImage: | |
| 85 | + | if uc.clipboard == nil { | |
| 86 | + | return fmt.Errorf("copy image: clipboard not available") | |
| 87 | + | } | |
| 84 | 88 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 85 | 89 | if err != nil { | |
| 86 | 90 | return fmt.Errorf("copy image: %w", err) | |
| 87 | 91 | } | |
| 88 | - | if uc.clipboard == nil { | |
| 89 | - | return fmt.Errorf("copy image: clipboard not available") | |
| 90 | - | } | |
| 91 | 92 | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | |
| 92 | 93 | return fmt.Errorf("copy image: %w", err) | |
| 93 | 94 | } | |
| 94 | 95 | return nil | |
| 95 | 96 | case port.MenuActionSaveImage: | |
| 97 | + | if uc.saver == nil { | |
| 98 | + | return fmt.Errorf("save image: image saver not available") | |
| 99 | + | } | |
| 96 | 100 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 97 | 101 | if err != nil { | |
| 98 | 102 | return fmt.Errorf("save image: %w", err) | |
| 99 | 103 | } | |
| 100 | - | if uc.saver == nil { | |
| 101 | - | return fmt.Errorf("save image: image saver not available") | |
| 102 | - | } | |
| 103 | 104 | if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil { | |
| 104 | 105 | return fmt.Errorf("save image: %w", err) | |
| 105 | 106 | } | |
| @@ -116,20 +117,20 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||
| 116 | 117 | } | |
| 117 | 118 | } | |
| 118 | 119 | ||
| 119 | - | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | |
| 120 | + | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | |
| 120 | 121 | if uri == "" { | |
| 121 | - | return port.ImageData{}, fmt.Errorf("image URI not available") | |
| 122 | + | return entity.ImageData{}, fmt.Errorf("image URI not available") | |
| 122 | 123 | } | |
| 123 | 124 | if uc.resolver == nil { | |
| 124 | - | return port.ImageData{}, fmt.Errorf("image data resolver not available") | |
| 125 | + | return entity.ImageData{}, fmt.Errorf("image data resolver not available") | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 127 | 128 | image, err := uc.resolver.ResolveImageData(ctx, uri) | |
| 128 | 129 | if err != nil { | |
| 129 | - | return port.ImageData{}, err | |
| 130 | + | return entity.ImageData{}, err | |
| 130 | 131 | } | |
| 131 | 132 | if len(image.Bytes) == 0 { | |
| 132 | - | return port.ImageData{}, fmt.Errorf("image data not available") | |
| 133 | + | return entity.ImageData{}, fmt.Errorf("image data not available") | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | 136 | return image, nil | |
| @@ -5,6 +5,7 @@ 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/domain/entity" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // ExecuteContextMenuActionInput identifies the action and its context. | 11 | // ExecuteContextMenuActionInput identifies the action and its context. | ||
| @@ -81,25 +82,25 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||||
| 81 | } | 82 | } | ||
| 82 | return nil | 83 | return nil | ||
| 83 | case port.MenuActionCopyImage: | 84 | case port.MenuActionCopyImage: | ||
| 85 | + | if uc.clipboard == nil { | |||
| 86 | + | return fmt.Errorf("copy image: clipboard not available") | |||
| 87 | + | } | |||
| 84 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | 88 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | ||
| 85 | if err != nil { | 89 | if err != nil { | ||
| 86 | return fmt.Errorf("copy image: %w", err) | 90 | return fmt.Errorf("copy image: %w", err) | ||
| 87 | } | 91 | } | ||
| 88 | - | if uc.clipboard == nil { | |||
| 89 | - | return fmt.Errorf("copy image: clipboard not available") | |||
| 90 | - | } | |||
| 91 | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | 92 | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | ||
| 92 | return fmt.Errorf("copy image: %w", err) | 93 | return fmt.Errorf("copy image: %w", err) | ||
| 93 | } | 94 | } | ||
| 94 | return nil | 95 | return nil | ||
| 95 | case port.MenuActionSaveImage: | 96 | case port.MenuActionSaveImage: | ||
| 97 | + | if uc.saver == nil { | |||
| 98 | + | return fmt.Errorf("save image: image saver not available") | |||
| 99 | + | } | |||
| 96 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | 100 | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | ||
| 97 | if err != nil { | 101 | if err != nil { | ||
| 98 | return fmt.Errorf("save image: %w", err) | 102 | return fmt.Errorf("save image: %w", err) | ||
| 99 | } | 103 | } | ||
| 100 | - | if uc.saver == nil { | |||
| 101 | - | return fmt.Errorf("save image: image saver not available") | |||
| 102 | - | } | |||
| 103 | if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil { | 104 | if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil { | ||
| 104 | return fmt.Errorf("save image: %w", err) | 105 | return fmt.Errorf("save image: %w", err) | ||
| 105 | } | 106 | } | ||
| @@ -116,20 +117,20 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||||
| 116 | } | 117 | } | ||
| 117 | } | 118 | } | ||
| 118 | 119 | ||||
| 119 | - | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | 120 | + | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { |
| 120 | if uri == "" { | 121 | if uri == "" { | ||
| 121 | - | return port.ImageData{}, fmt.Errorf("image URI not available") | 122 | + | return entity.ImageData{}, fmt.Errorf("image URI not available") |
| 122 | } | 123 | } | ||
| 123 | if uc.resolver == nil { | 124 | if uc.resolver == nil { | ||
| 124 | - | return port.ImageData{}, fmt.Errorf("image data resolver not available") | 125 | + | return entity.ImageData{}, fmt.Errorf("image data resolver not available") |
| 125 | } | 126 | } | ||
| 126 | 127 | ||||
| 127 | image, err := uc.resolver.ResolveImageData(ctx, uri) | 128 | image, err := uc.resolver.ResolveImageData(ctx, uri) | ||
| 128 | if err != nil { | 129 | if err != nil { | ||
| 129 | - | return port.ImageData{}, err | 130 | + | return entity.ImageData{}, err |
| 130 | } | 131 | } | ||
| 131 | if len(image.Bytes) == 0 { | 132 | if len(image.Bytes) == 0 { | ||
| 132 | - | return port.ImageData{}, fmt.Errorf("image data not available") | 133 | + | return entity.ImageData{}, fmt.Errorf("image data not available") |
| 133 | } | 134 | } | ||
| 134 | 135 | ||||
| 135 | return image, nil | 136 | return image, nil | ||
M internal/application/usecase/execute_context_menu_action_test.go +65 -8
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -13,7 +14,7 @@ type fakeClipboard struct { | |||
| 13 | 14 | writeTextCalls int | |
| 14 | 15 | writeImageCalls int | |
| 15 | 16 | text string | |
| 16 | - | image port.ImageData | |
| 17 | + | image entity.ImageData | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |
| @@ -22,7 +23,7 @@ func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |||
| 22 | 23 | return nil | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | - | func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 26 | + | func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error { | |
| 26 | 27 | f.writeImageCalls++ | |
| 27 | 28 | f.image = image | |
| 28 | 29 | return nil | |
| @@ -35,11 +36,11 @@ func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, | |||
| 35 | 36 | type fakeImageResolver struct { | |
| 36 | 37 | resolveCalls int | |
| 37 | 38 | uri string | |
| 38 | - | image port.ImageData | |
| 39 | + | image entity.ImageData | |
| 39 | 40 | err error | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | - | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) { | |
| 43 | + | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) { | |
| 43 | 44 | f.resolveCalls++ | |
| 44 | 45 | f.uri = uri | |
| 45 | 46 | return f.image, f.err | |
| @@ -47,12 +48,12 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por | |||
| 47 | 48 | ||
| 48 | 49 | type fakeResolvedImageSaver struct { | |
| 49 | 50 | saveCalls int | |
| 50 | - | image port.ImageData | |
| 51 | + | image entity.ImageData | |
| 51 | 52 | menuContext port.MenuContext | |
| 52 | 53 | err error | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | - | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error { | |
| 56 | + | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 56 | 57 | f.saveCalls++ | |
| 57 | 58 | f.image = image | |
| 58 | 59 | f.menuContext = menuContext | |
| @@ -93,9 +94,48 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te | |||
| 93 | 94 | require.Zero(t, delegator.delegateCalls) | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 97 | + | func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) { | |
| 98 | + | clipboard := &fakeClipboard{} | |
| 99 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |
| 100 | + | saver := &fakeResolvedImageSaver{} | |
| 101 | + | delegator := &fakeMenuActionDelegator{} | |
| 102 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 103 | + | ||
| 104 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 105 | + | Action: port.MenuActionCopyImage, | |
| 106 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 107 | + | }) | |
| 108 | + | ||
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.Equal(t, 1, resolver.resolveCalls) | |
| 111 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 112 | + | require.Equal(t, 1, clipboard.writeImageCalls) | |
| 113 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image) | |
| 114 | + | require.Zero(t, saver.saveCalls) | |
| 115 | + | require.Zero(t, delegator.delegateCalls) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) { | |
| 119 | + | resolver := &fakeImageResolver{} | |
| 120 | + | saver := &fakeResolvedImageSaver{} | |
| 121 | + | delegator := &fakeMenuActionDelegator{} | |
| 122 | + | uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator) | |
| 123 | + | ||
| 124 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 125 | + | Action: port.MenuActionCopyImage, | |
| 126 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | require.Error(t, err) | |
| 130 | + | require.Contains(t, err.Error(), "clipboard not available") | |
| 131 | + | require.Zero(t, resolver.resolveCalls) | |
| 132 | + | require.Zero(t, saver.saveCalls) | |
| 133 | + | require.Zero(t, delegator.delegateCalls) | |
| 134 | + | } | |
| 135 | + | ||
| 96 | 136 | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | |
| 97 | 137 | clipboard := &fakeClipboard{} | |
| 98 | - | resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |
| 138 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |
| 99 | 139 | saver := &fakeResolvedImageSaver{} | |
| 100 | 140 | delegator := &fakeMenuActionDelegator{} | |
| 101 | 141 | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| @@ -109,13 +149,30 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test | |||
| 109 | 149 | require.Equal(t, 1, resolver.resolveCalls) | |
| 110 | 150 | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 111 | 151 | require.Equal(t, 1, saver.saveCalls) | |
| 112 | - | require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image) | |
| 152 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image) | |
| 113 | 153 | require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext) | |
| 114 | 154 | require.Zero(t, clipboard.writeTextCalls) | |
| 115 | 155 | require.Zero(t, clipboard.writeImageCalls) | |
| 116 | 156 | require.Zero(t, delegator.delegateCalls) | |
| 117 | 157 | } | |
| 118 | 158 | ||
| 159 | + | func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) { | |
| 160 | + | clipboard := &fakeClipboard{} | |
| 161 | + | resolver := &fakeImageResolver{} | |
| 162 | + | delegator := &fakeMenuActionDelegator{} | |
| 163 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator) | |
| 164 | + | ||
| 165 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 166 | + | Action: port.MenuActionSaveImage, | |
| 167 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | require.Error(t, err) | |
| 171 | + | require.Contains(t, err.Error(), "image saver not available") | |
| 172 | + | require.Zero(t, resolver.resolveCalls) | |
| 173 | + | require.Zero(t, delegator.delegateCalls) | |
| 174 | + | } | |
| 175 | + | ||
| 119 | 176 | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | |
| 120 | 177 | clipboard := &fakeClipboard{} | |
| 121 | 178 | resolver := &fakeImageResolver{} | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -13,7 +14,7 @@ type fakeClipboard struct { | |||||
| 13 | writeTextCalls int | 14 | writeTextCalls int | ||
| 14 | writeImageCalls int | 15 | writeImageCalls int | ||
| 15 | text string | 16 | text string | ||
| 16 | - | image port.ImageData | 17 | + | image entity.ImageData |
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | 20 | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | ||
| @@ -22,7 +23,7 @@ func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |||||
| 22 | return nil | 23 | return nil | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | - | func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error { | 26 | + | func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error { |
| 26 | f.writeImageCalls++ | 27 | f.writeImageCalls++ | ||
| 27 | f.image = image | 28 | f.image = image | ||
| 28 | return nil | 29 | return nil | ||
| @@ -35,11 +36,11 @@ func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, | |||||
| 35 | type fakeImageResolver struct { | 36 | type fakeImageResolver struct { | ||
| 36 | resolveCalls int | 37 | resolveCalls int | ||
| 37 | uri string | 38 | uri string | ||
| 38 | - | image port.ImageData | 39 | + | image entity.ImageData |
| 39 | err error | 40 | err error | ||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | - | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) { | 43 | + | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) { |
| 43 | f.resolveCalls++ | 44 | f.resolveCalls++ | ||
| 44 | f.uri = uri | 45 | f.uri = uri | ||
| 45 | return f.image, f.err | 46 | return f.image, f.err | ||
| @@ -47,12 +48,12 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por | |||||
| 47 | 48 | ||||
| 48 | type fakeResolvedImageSaver struct { | 49 | type fakeResolvedImageSaver struct { | ||
| 49 | saveCalls int | 50 | saveCalls int | ||
| 50 | - | image port.ImageData | 51 | + | image entity.ImageData |
| 51 | menuContext port.MenuContext | 52 | menuContext port.MenuContext | ||
| 52 | err error | 53 | err error | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 55 | - | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error { | 56 | + | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error { |
| 56 | f.saveCalls++ | 57 | f.saveCalls++ | ||
| 57 | f.image = image | 58 | f.image = image | ||
| 58 | f.menuContext = menuContext | 59 | f.menuContext = menuContext | ||
| @@ -93,9 +94,48 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te | |||||
| 93 | require.Zero(t, delegator.delegateCalls) | 94 | require.Zero(t, delegator.delegateCalls) | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| 97 | + | func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) { | |||
| 98 | + | clipboard := &fakeClipboard{} | |||
| 99 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |||
| 100 | + | saver := &fakeResolvedImageSaver{} | |||
| 101 | + | delegator := &fakeMenuActionDelegator{} | |||
| 102 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 103 | + | ||||
| 104 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 105 | + | Action: port.MenuActionCopyImage, | |||
| 106 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 107 | + | }) | |||
| 108 | + | ||||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | require.Equal(t, 1, resolver.resolveCalls) | |||
| 111 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |||
| 112 | + | require.Equal(t, 1, clipboard.writeImageCalls) | |||
| 113 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image) | |||
| 114 | + | require.Zero(t, saver.saveCalls) | |||
| 115 | + | require.Zero(t, delegator.delegateCalls) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) { | |||
| 119 | + | resolver := &fakeImageResolver{} | |||
| 120 | + | saver := &fakeResolvedImageSaver{} | |||
| 121 | + | delegator := &fakeMenuActionDelegator{} | |||
| 122 | + | uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator) | |||
| 123 | + | ||||
| 124 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 125 | + | Action: port.MenuActionCopyImage, | |||
| 126 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | require.Error(t, err) | |||
| 130 | + | require.Contains(t, err.Error(), "clipboard not available") | |||
| 131 | + | require.Zero(t, resolver.resolveCalls) | |||
| 132 | + | require.Zero(t, saver.saveCalls) | |||
| 133 | + | require.Zero(t, delegator.delegateCalls) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 96 | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | 136 | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | ||
| 97 | clipboard := &fakeClipboard{} | 137 | clipboard := &fakeClipboard{} | ||
| 98 | - | resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | 138 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} |
| 99 | saver := &fakeResolvedImageSaver{} | 139 | saver := &fakeResolvedImageSaver{} | ||
| 100 | delegator := &fakeMenuActionDelegator{} | 140 | delegator := &fakeMenuActionDelegator{} | ||
| 101 | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | 141 | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | ||
| @@ -109,13 +149,30 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test | |||||
| 109 | require.Equal(t, 1, resolver.resolveCalls) | 149 | require.Equal(t, 1, resolver.resolveCalls) | ||
| 110 | require.Equal(t, "https://example.com/image.png", resolver.uri) | 150 | require.Equal(t, "https://example.com/image.png", resolver.uri) | ||
| 111 | require.Equal(t, 1, saver.saveCalls) | 151 | require.Equal(t, 1, saver.saveCalls) | ||
| 112 | - | require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image) | 152 | + | require.Equal(t, entity.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) | 153 | require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext) | ||
| 114 | require.Zero(t, clipboard.writeTextCalls) | 154 | require.Zero(t, clipboard.writeTextCalls) | ||
| 115 | require.Zero(t, clipboard.writeImageCalls) | 155 | require.Zero(t, clipboard.writeImageCalls) | ||
| 116 | require.Zero(t, delegator.delegateCalls) | 156 | require.Zero(t, delegator.delegateCalls) | ||
| 117 | } | 157 | } | ||
| 118 | 158 | ||||
| 159 | + | func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) { | |||
| 160 | + | clipboard := &fakeClipboard{} | |||
| 161 | + | resolver := &fakeImageResolver{} | |||
| 162 | + | delegator := &fakeMenuActionDelegator{} | |||
| 163 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator) | |||
| 164 | + | ||||
| 165 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 166 | + | Action: port.MenuActionSaveImage, | |||
| 167 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 168 | + | }) | |||
| 169 | + | ||||
| 170 | + | require.Error(t, err) | |||
| 171 | + | require.Contains(t, err.Error(), "image saver not available") | |||
| 172 | + | require.Zero(t, resolver.resolveCalls) | |||
| 173 | + | require.Zero(t, delegator.delegateCalls) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 119 | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | 176 | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | ||
| 120 | clipboard := &fakeClipboard{} | 177 | clipboard := &fakeClipboard{} | ||
| 121 | resolver := &fakeImageResolver{} | 178 | resolver := &fakeImageResolver{} | ||
A internal/domain/entity/image_data.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | // ImageData contains resolved image bytes and MIME type metadata. | |
| 4 | + | type ImageData struct { | |
| 5 | + | Bytes []byte | |
| 6 | + | MimeType string | |
| 7 | + | } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | // ImageData contains resolved image bytes and MIME type metadata. | |||
| 4 | + | type ImageData struct { | |||
| 5 | + | Bytes []byte | |||
| 6 | + | MimeType string | |||
| 7 | + | } | |||
M internal/infrastructure/cef/context_menu.go +2 -0
| @@ -25,9 +25,11 @@ func (h *handlerSet) RunContextMenu( | |||
| 25 | 25 | if callback != nil { | |
| 26 | 26 | callback.Cancel() | |
| 27 | 27 | } | |
| 28 | + | // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu. | |
| 28 | 29 | return 1 | |
| 29 | 30 | } | |
| 30 | 31 | if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil { | |
| 32 | + | // Returning 0 lets CEF show its native menu when no custom builder is wired. | |
| 31 | 33 | return 0 | |
| 32 | 34 | } | |
| 33 | 35 | ||
| @@ -25,9 +25,11 @@ func (h *handlerSet) RunContextMenu( | |||||
| 25 | if callback != nil { | 25 | if callback != nil { | ||
| 26 | callback.Cancel() | 26 | callback.Cancel() | ||
| 27 | } | 27 | } | ||
| 28 | + | // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu. | |||
| 28 | return 1 | 29 | return 1 | ||
| 29 | } | 30 | } | ||
| 30 | if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil { | 31 | if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil { | ||
| 32 | + | // Returning 0 lets CEF show its native menu when no custom builder is wired. | |||
| 31 | return 0 | 33 | return 0 | ||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
M internal/infrastructure/cef/context_menu_adapter_test.go +12 -0
| @@ -96,3 +96,15 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 96 | 96 | require.Zero(t, callback.contCalls) | |
| 97 | 97 | require.Equal(t, 1, callback.cancelCalls) | |
| 98 | 98 | } | |
| 99 | + | ||
| 100 | + | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 101 | + | callback := &stubRunContextMenuCallback{} | |
| 102 | + | ||
| 103 | + | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |
| 104 | + | port.MenuActionInspectElement: 204, | |
| 105 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |
| 106 | + | ||
| 107 | + | require.Equal(t, 1, callback.contCalls) | |
| 108 | + | require.Zero(t, callback.cancelCalls) | |
| 109 | + | require.Equal(t, int32(204), callback.commandID) | |
| 110 | + | } | |
| @@ -96,3 +96,15 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||||
| 96 | require.Zero(t, callback.contCalls) | 96 | require.Zero(t, callback.contCalls) | ||
| 97 | require.Equal(t, 1, callback.cancelCalls) | 97 | require.Equal(t, 1, callback.cancelCalls) | ||
| 98 | } | 98 | } | ||
| 99 | + | ||||
| 100 | + | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |||
| 101 | + | callback := &stubRunContextMenuCallback{} | |||
| 102 | + | ||||
| 103 | + | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |||
| 104 | + | port.MenuActionInspectElement: 204, | |||
| 105 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |||
| 106 | + | ||||
| 107 | + | require.Equal(t, 1, callback.contCalls) | |||
| 108 | + | require.Zero(t, callback.cancelCalls) | |||
| 109 | + | require.Equal(t, int32(204), callback.commandID) | |||
| 110 | + | } | |||
M internal/infrastructure/cef/renderer.go +15 -7
| @@ -71,6 +71,12 @@ func showMenu( | |||
| 71 | 71 | } | |
| 72 | 72 | return | |
| 73 | 73 | } | |
| 74 | + | if !hasRenderableItems(items) { | |
| 75 | + | if onClose != nil { | |
| 76 | + | onClose() | |
| 77 | + | } | |
| 78 | + | return | |
| 79 | + | } | |
| 74 | 80 | ||
| 75 | 81 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 76 | 82 | box.AddCssClass("context-menu") | |
| @@ -103,13 +109,6 @@ func showMenu( | |||
| 103 | 109 | box.Append(&btn.Widget) | |
| 104 | 110 | } | |
| 105 | 111 | ||
| 106 | - | if len(items) == 0 { | |
| 107 | - | if onClose != nil { | |
| 108 | - | onClose() | |
| 109 | - | } | |
| 110 | - | return | |
| 111 | - | } | |
| 112 | - | ||
| 113 | 112 | popover = gtk.NewPopover() | |
| 114 | 113 | popover.SetChild(&box.Widget) | |
| 115 | 114 | popover.SetParent(anchor) | |
| @@ -129,3 +128,12 @@ func showMenu( | |||
| 129 | 128 | ||
| 130 | 129 | popover.Popup() | |
| 131 | 130 | } | |
| 131 | + | ||
| 132 | + | func hasRenderableItems(items []renderItem) bool { | |
| 133 | + | for _, item := range items { | |
| 134 | + | if !item.separator { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| @@ -71,6 +71,12 @@ func showMenu( | |||||
| 71 | } | 71 | } | ||
| 72 | return | 72 | return | ||
| 73 | } | 73 | } | ||
| 74 | + | if !hasRenderableItems(items) { | |||
| 75 | + | if onClose != nil { | |||
| 76 | + | onClose() | |||
| 77 | + | } | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 74 | 80 | ||||
| 75 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | 81 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| 76 | box.AddCssClass("context-menu") | 82 | box.AddCssClass("context-menu") | ||
| @@ -103,13 +109,6 @@ func showMenu( | |||||
| 103 | box.Append(&btn.Widget) | 109 | box.Append(&btn.Widget) | ||
| 104 | } | 110 | } | ||
| 105 | 111 | ||||
| 106 | - | if len(items) == 0 { | |||
| 107 | - | if onClose != nil { | |||
| 108 | - | onClose() | |||
| 109 | - | } | |||
| 110 | - | return | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | popover = gtk.NewPopover() | 112 | popover = gtk.NewPopover() | ||
| 114 | popover.SetChild(&box.Widget) | 113 | popover.SetChild(&box.Widget) | ||
| 115 | popover.SetParent(anchor) | 114 | popover.SetParent(anchor) | ||
| @@ -129,3 +128,12 @@ func showMenu( | |||||
| 129 | 128 | ||||
| 130 | popover.Popup() | 129 | popover.Popup() | ||
| 131 | } | 130 | } | ||
| 131 | + | ||||
| 132 | + | func hasRenderableItems(items []renderItem) bool { | |||
| 133 | + | for _, item := range items { | |||
| 134 | + | if !item.separator { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return false | |||
| 139 | + | } | |||
M internal/infrastructure/cef/webview.go +3 -1
| @@ -473,7 +473,9 @@ func (wv *WebView) Destroy() { | |||
| 473 | 473 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 474 | 474 | return | |
| 475 | 475 | } | |
| 476 | - | wv.resizeReconciler.stop() | |
| 476 | + | if wv.resizeReconciler != nil { | |
| 477 | + | wv.resizeReconciler.stop() | |
| 478 | + | } | |
| 477 | 479 | wv.closeAudioStream() | |
| 478 | 480 | wv.scheduleStopBeginFrameLoop() | |
| 479 | 481 | wv.mu.RLock() | |
| @@ -473,7 +473,9 @@ func (wv *WebView) Destroy() { | |||||
| 473 | if !wv.destroyed.CompareAndSwap(false, true) { | 473 | if !wv.destroyed.CompareAndSwap(false, true) { | ||
| 474 | return | 474 | return | ||
| 475 | } | 475 | } | ||
| 476 | - | wv.resizeReconciler.stop() | 476 | + | if wv.resizeReconciler != nil { |
| 477 | + | wv.resizeReconciler.stop() | |||
| 478 | + | } | |||
| 477 | wv.closeAudioStream() | 479 | wv.closeAudioStream() | ||
| 478 | wv.scheduleStopBeginFrameLoop() | 480 | wv.scheduleStopBeginFrameLoop() | ||
| 479 | wv.mu.RLock() | 481 | wv.mu.RLock() | ||
M internal/infrastructure/cef/webview_handlers.go +9 -3
| @@ -215,7 +215,8 @@ func (h *handlerSet) OnPaint( | |||
| 215 | 215 | } | |
| 216 | 216 | log.Msg("cef: OnPaint begin") | |
| 217 | 217 | } | |
| 218 | - | if elementType != purecef.PaintElementTypePetPopup && !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) { | |
| 218 | + | if elementType != purecef.PaintElementTypePetPopup && | |
| 219 | + | !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) { | |
| 219 | 220 | return | |
| 220 | 221 | } | |
| 221 | 222 | rects := make([]rect, len(dirtyRects)) | |
| @@ -248,8 +249,13 @@ func (h *handlerSet) OnPaint( | |||
| 248 | 249 | } | |
| 249 | 250 | } | |
| 250 | 251 | ||
| 251 | - | func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool { | |
| 252 | - | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | |
| 252 | + | func (h *handlerSet) shouldAcceptMainViewPaint( | |
| 253 | + | width, height int32, | |
| 254 | + | dirtyRects []purecef.Rect, | |
| 255 | + | paintSeq uint64, | |
| 256 | + | resizeSeq uint64, | |
| 257 | + | resizeAgeMs int64, | |
| 258 | + | ) bool { | |
| 253 | 259 | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |
| 254 | 260 | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |
| 255 | 261 | ||
| @@ -215,7 +215,8 @@ func (h *handlerSet) OnPaint( | |||||
| 215 | } | 215 | } | ||
| 216 | log.Msg("cef: OnPaint begin") | 216 | log.Msg("cef: OnPaint begin") | ||
| 217 | } | 217 | } | ||
| 218 | - | if elementType != purecef.PaintElementTypePetPopup && !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) { | 218 | + | if elementType != purecef.PaintElementTypePetPopup && |
| 219 | + | !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) { | |||
| 219 | return | 220 | return | ||
| 220 | } | 221 | } | ||
| 221 | rects := make([]rect, len(dirtyRects)) | 222 | rects := make([]rect, len(dirtyRects)) | ||
| @@ -248,8 +249,13 @@ func (h *handlerSet) OnPaint( | |||||
| 248 | } | 249 | } | ||
| 249 | } | 250 | } | ||
| 250 | 251 | ||||
| 251 | - | func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool { | 252 | + | func (h *handlerSet) shouldAcceptMainViewPaint( |
| 252 | - | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | 253 | + | width, height int32, |
| 254 | + | dirtyRects []purecef.Rect, | |||
| 255 | + | paintSeq uint64, | |||
| 256 | + | resizeSeq uint64, | |||
| 257 | + | resizeAgeMs int64, | |||
| 258 | + | ) bool { | |||
| 253 | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | 259 | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | ||
| 254 | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | 260 | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | ||
| 255 | 261 | ||||
M internal/infrastructure/clipboard/clipboard.go +76 -27
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback. | |
| 1 | + | // Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback. | |
| 2 | 2 | package clipboard | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | "github.com/bnema/puregotk/v4/gdk" | |
| 15 | 16 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -18,11 +19,11 @@ import ( | |||
| 18 | 19 | ||
| 19 | 20 | type toolkitClipboard interface { | |
| 20 | 21 | WriteText(ctx context.Context, text string) error | |
| 21 | - | WriteImage(ctx context.Context, image port.ImageData) error | |
| 22 | + | WriteImage(ctx context.Context, image entity.ImageData) error | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | - | // Adapter implements port.Clipboard using the toolkit clipboard when available. | |
| 25 | - | // It falls back to wl-clipboard for Wayland, then xclip/xsel for X11. | |
| 25 | + | // Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11. | |
| 26 | + | // If no system clipboard tool is available, it falls back to the toolkit clipboard. | |
| 26 | 27 | type Adapter struct { | |
| 27 | 28 | toolkit toolkitClipboard | |
| 28 | 29 | copyCmd string | |
| @@ -61,29 +62,53 @@ type gdkToolkitClipboard struct { | |||
| 61 | 62 | clipboard *gdk.Clipboard | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | - | func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error { | |
| 65 | - | if c == nil || c.clipboard == nil { | |
| 66 | - | return fmt.Errorf("toolkit clipboard not available") | |
| 67 | - | } | |
| 68 | - | c.clipboard.SetText(text) | |
| 69 | - | return nil | |
| 65 | + | func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error { | |
| 66 | + | return withToolkitClipboard(ctx, func() error { | |
| 67 | + | if c == nil || c.clipboard == nil { | |
| 68 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 69 | + | } | |
| 70 | + | c.clipboard.SetText(text) | |
| 71 | + | return nil | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 76 | + | return withToolkitClipboard(ctx, func() error { | |
| 77 | + | if c == nil || c.clipboard == nil { | |
| 78 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |
| 82 | + | if err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | if texture == nil { | |
| 86 | + | return fmt.Errorf("toolkit clipboard returned nil texture") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | c.clipboard.SetTexture(texture) | |
| 90 | + | return nil | |
| 91 | + | }) | |
| 70 | 92 | } | |
| 71 | 93 | ||
| 72 | - | func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 73 | - | if c == nil || c.clipboard == nil { | |
| 74 | - | return fmt.Errorf("toolkit clipboard not available") | |
| 94 | + | func withToolkitClipboard(ctx context.Context, fn func() error) error { | |
| 95 | + | mainContext := glib.MainContextDefault() | |
| 96 | + | if mainContext == nil || mainContext.IsOwner() { | |
| 97 | + | return fn() | |
| 75 | 98 | } | |
| 76 | 99 | ||
| 77 | - | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |
| 78 | - | if err != nil { | |
| 100 | + | resultCh := make(chan error, 1) | |
| 101 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 102 | + | resultCh <- fn() | |
| 103 | + | return false | |
| 104 | + | }) | |
| 105 | + | glib.IdleAdd(&cb, 0) | |
| 106 | + | select { | |
| 107 | + | case err := <-resultCh: | |
| 79 | 108 | return err | |
| 109 | + | case <-ctx.Done(): | |
| 110 | + | return ctx.Err() | |
| 80 | 111 | } | |
| 81 | - | if texture == nil { | |
| 82 | - | return fmt.Errorf("toolkit clipboard returned nil texture") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | c.clipboard.SetTexture(texture) | |
| 86 | - | return nil | |
| 87 | 112 | } | |
| 88 | 113 | ||
| 89 | 114 | func selectSystemClipboard() (copyCmd, pasteCmd string) { | |
| @@ -114,6 +139,14 @@ func selectSystemClipboard() (copyCmd, pasteCmd string) { | |||
| 114 | 139 | // WriteText copies text to the clipboard. | |
| 115 | 140 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | |
| 116 | 141 | log := logging.FromContext(ctx) | |
| 142 | + | var commandErr error | |
| 143 | + | if a.copyCmd != "" { | |
| 144 | + | err := a.writeTextWithCommand(ctx, text, log) | |
| 145 | + | if err == nil { | |
| 146 | + | return nil | |
| 147 | + | } | |
| 148 | + | commandErr = err | |
| 149 | + | } | |
| 117 | 150 | if a.toolkit != nil { | |
| 118 | 151 | err := a.toolkit.WriteText(ctx, text) | |
| 119 | 152 | if err == nil { | |
| @@ -122,21 +155,33 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 122 | 155 | } | |
| 123 | 156 | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | |
| 124 | 157 | } | |
| 125 | - | ||
| 126 | - | return a.writeTextWithCommand(ctx, text, log) | |
| 158 | + | if commandErr != nil { | |
| 159 | + | return commandErr | |
| 160 | + | } | |
| 161 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 162 | + | log.Error().Err(err).Msg("clipboard write failed") | |
| 163 | + | return err | |
| 127 | 164 | } | |
| 128 | 165 | ||
| 129 | 166 | // WriteImage copies image bytes to the clipboard. | |
| 130 | 167 | // xsel is not supported because the adapter has no reliable binary image mode | |
| 131 | 168 | // for it in this codebase. | |
| 132 | - | func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 169 | + | func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 133 | 170 | log := logging.FromContext(ctx) | |
| 171 | + | var commandErr error | |
| 134 | 172 | ||
| 135 | 173 | if len(image.Bytes) == 0 { | |
| 136 | 174 | err := fmt.Errorf("empty image data") | |
| 137 | 175 | log.Error().Err(err).Msg("clipboard image write failed") | |
| 138 | 176 | return err | |
| 139 | 177 | } | |
| 178 | + | if a.copyCmd != "" { | |
| 179 | + | err := a.writeImageWithCommand(ctx, image, log) | |
| 180 | + | if err == nil { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | commandErr = err | |
| 184 | + | } | |
| 140 | 185 | if a.toolkit != nil { | |
| 141 | 186 | err := a.toolkit.WriteImage(ctx, image) | |
| 142 | 187 | if err == nil { | |
| @@ -145,8 +190,12 @@ func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |||
| 145 | 190 | } | |
| 146 | 191 | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |
| 147 | 192 | } | |
| 148 | - | ||
| 149 | - | return a.writeImageWithCommand(ctx, image, log) | |
| 193 | + | if commandErr != nil { | |
| 194 | + | return commandErr | |
| 195 | + | } | |
| 196 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 197 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 198 | + | return err | |
| 150 | 199 | } | |
| 151 | 200 | ||
| 152 | 201 | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | |
| @@ -180,7 +229,7 @@ func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *ze | |||
| 180 | 229 | return nil | |
| 181 | 230 | } | |
| 182 | 231 | ||
| 183 | - | func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error { | |
| 232 | + | func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error { | |
| 184 | 233 | if a.copyCmd == "" { | |
| 185 | 234 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 186 | 235 | log.Error().Err(err).Msg("clipboard image write failed") | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | // Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback. | 1 | + | // Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback. |
| 2 | package clipboard | 2 | package clipboard | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | "github.com/bnema/puregotk/v4/gdk" | 15 | "github.com/bnema/puregotk/v4/gdk" | ||
| 15 | "github.com/bnema/puregotk/v4/glib" | 16 | "github.com/bnema/puregotk/v4/glib" | ||
| @@ -18,11 +19,11 @@ import ( | |||||
| 18 | 19 | ||||
| 19 | type toolkitClipboard interface { | 20 | type toolkitClipboard interface { | ||
| 20 | WriteText(ctx context.Context, text string) error | 21 | WriteText(ctx context.Context, text string) error | ||
| 21 | - | WriteImage(ctx context.Context, image port.ImageData) error | 22 | + | WriteImage(ctx context.Context, image entity.ImageData) error |
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | - | // Adapter implements port.Clipboard using the toolkit clipboard when available. | 25 | + | // Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11. |
| 25 | - | // It falls back to wl-clipboard for Wayland, then xclip/xsel for X11. | 26 | + | // If no system clipboard tool is available, it falls back to the toolkit clipboard. |
| 26 | type Adapter struct { | 27 | type Adapter struct { | ||
| 27 | toolkit toolkitClipboard | 28 | toolkit toolkitClipboard | ||
| 28 | copyCmd string | 29 | copyCmd string | ||
| @@ -61,29 +62,53 @@ type gdkToolkitClipboard struct { | |||||
| 61 | clipboard *gdk.Clipboard | 62 | clipboard *gdk.Clipboard | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 64 | - | func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error { | 65 | + | func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error { |
| 65 | - | if c == nil || c.clipboard == nil { | 66 | + | return withToolkitClipboard(ctx, func() error { |
| 66 | - | return fmt.Errorf("toolkit clipboard not available") | 67 | + | if c == nil || c.clipboard == nil { |
| 67 | - | } | 68 | + | return fmt.Errorf("toolkit clipboard not available") |
| 68 | - | c.clipboard.SetText(text) | 69 | + | } |
| 69 | - | return nil | 70 | + | c.clipboard.SetText(text) |
| 71 | + | return nil | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { | |||
| 76 | + | return withToolkitClipboard(ctx, func() error { | |||
| 77 | + | if c == nil || c.clipboard == nil { | |||
| 78 | + | return fmt.Errorf("toolkit clipboard not available") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | if texture == nil { | |||
| 86 | + | return fmt.Errorf("toolkit clipboard returned nil texture") | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | c.clipboard.SetTexture(texture) | |||
| 90 | + | return nil | |||
| 91 | + | }) | |||
| 70 | } | 92 | } | ||
| 71 | 93 | ||||
| 72 | - | func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | 94 | + | func withToolkitClipboard(ctx context.Context, fn func() error) error { |
| 73 | - | if c == nil || c.clipboard == nil { | 95 | + | mainContext := glib.MainContextDefault() |
| 74 | - | return fmt.Errorf("toolkit clipboard not available") | 96 | + | if mainContext == nil || mainContext.IsOwner() { |
| 97 | + | return fn() | |||
| 75 | } | 98 | } | ||
| 76 | 99 | ||||
| 77 | - | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | 100 | + | resultCh := make(chan error, 1) |
| 78 | - | if err != nil { | 101 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 102 | + | resultCh <- fn() | |||
| 103 | + | return false | |||
| 104 | + | }) | |||
| 105 | + | glib.IdleAdd(&cb, 0) | |||
| 106 | + | select { | |||
| 107 | + | case err := <-resultCh: | |||
| 79 | return err | 108 | return err | ||
| 109 | + | case <-ctx.Done(): | |||
| 110 | + | return ctx.Err() | |||
| 80 | } | 111 | } | ||
| 81 | - | if texture == nil { | |||
| 82 | - | return fmt.Errorf("toolkit clipboard returned nil texture") | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | c.clipboard.SetTexture(texture) | |||
| 86 | - | return nil | |||
| 87 | } | 112 | } | ||
| 88 | 113 | ||||
| 89 | func selectSystemClipboard() (copyCmd, pasteCmd string) { | 114 | func selectSystemClipboard() (copyCmd, pasteCmd string) { | ||
| @@ -114,6 +139,14 @@ func selectSystemClipboard() (copyCmd, pasteCmd string) { | |||||
| 114 | // WriteText copies text to the clipboard. | 139 | // WriteText copies text to the clipboard. | ||
| 115 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | 140 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | ||
| 116 | log := logging.FromContext(ctx) | 141 | log := logging.FromContext(ctx) | ||
| 142 | + | var commandErr error | |||
| 143 | + | if a.copyCmd != "" { | |||
| 144 | + | err := a.writeTextWithCommand(ctx, text, log) | |||
| 145 | + | if err == nil { | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | commandErr = err | |||
| 149 | + | } | |||
| 117 | if a.toolkit != nil { | 150 | if a.toolkit != nil { | ||
| 118 | err := a.toolkit.WriteText(ctx, text) | 151 | err := a.toolkit.WriteText(ctx, text) | ||
| 119 | if err == nil { | 152 | if err == nil { | ||
| @@ -122,21 +155,33 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||||
| 122 | } | 155 | } | ||
| 123 | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | 156 | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | ||
| 124 | } | 157 | } | ||
| 125 | - | 158 | + | if commandErr != nil { | |
| 126 | - | return a.writeTextWithCommand(ctx, text, log) | 159 | + | return commandErr |
| 160 | + | } | |||
| 161 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |||
| 162 | + | log.Error().Err(err).Msg("clipboard write failed") | |||
| 163 | + | return err | |||
| 127 | } | 164 | } | ||
| 128 | 165 | ||||
| 129 | // WriteImage copies image bytes to the clipboard. | 166 | // WriteImage copies image bytes to the clipboard. | ||
| 130 | // xsel is not supported because the adapter has no reliable binary image mode | 167 | // xsel is not supported because the adapter has no reliable binary image mode | ||
| 131 | // for it in this codebase. | 168 | // for it in this codebase. | ||
| 132 | - | func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | 169 | + | func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error { |
| 133 | log := logging.FromContext(ctx) | 170 | log := logging.FromContext(ctx) | ||
| 171 | + | var commandErr error | |||
| 134 | 172 | ||||
| 135 | if len(image.Bytes) == 0 { | 173 | if len(image.Bytes) == 0 { | ||
| 136 | err := fmt.Errorf("empty image data") | 174 | err := fmt.Errorf("empty image data") | ||
| 137 | log.Error().Err(err).Msg("clipboard image write failed") | 175 | log.Error().Err(err).Msg("clipboard image write failed") | ||
| 138 | return err | 176 | return err | ||
| 139 | } | 177 | } | ||
| 178 | + | if a.copyCmd != "" { | |||
| 179 | + | err := a.writeImageWithCommand(ctx, image, log) | |||
| 180 | + | if err == nil { | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | commandErr = err | |||
| 184 | + | } | |||
| 140 | if a.toolkit != nil { | 185 | if a.toolkit != nil { | ||
| 141 | err := a.toolkit.WriteImage(ctx, image) | 186 | err := a.toolkit.WriteImage(ctx, image) | ||
| 142 | if err == nil { | 187 | if err == nil { | ||
| @@ -145,8 +190,12 @@ func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |||||
| 145 | } | 190 | } | ||
| 146 | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | 191 | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | ||
| 147 | } | 192 | } | ||
| 148 | - | 193 | + | if commandErr != nil { | |
| 149 | - | return a.writeImageWithCommand(ctx, image, log) | 194 | + | return commandErr |
| 195 | + | } | |||
| 196 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |||
| 197 | + | log.Error().Err(err).Msg("clipboard image write failed") | |||
| 198 | + | return err | |||
| 150 | } | 199 | } | ||
| 151 | 200 | ||||
| 152 | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | 201 | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | ||
| @@ -180,7 +229,7 @@ func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *ze | |||||
| 180 | return nil | 229 | return nil | ||
| 181 | } | 230 | } | ||
| 182 | 231 | ||||
| 183 | - | func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error { | 232 | + | func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error { |
| 184 | if a.copyCmd == "" { | 233 | if a.copyCmd == "" { | ||
| 185 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | 234 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | ||
| 186 | log.Error().Err(err).Msg("clipboard image write failed") | 235 | log.Error().Err(err).Msg("clipboard image write failed") | ||
M internal/infrastructure/clipboard/clipboard_test.go +30 -13
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "testing" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| @@ -16,7 +16,7 @@ type fakeToolkitClipboard struct { | |||
| 16 | 16 | writeTextCalls int | |
| 17 | 17 | writeImageCalls int | |
| 18 | 18 | text string | |
| 19 | - | image port.ImageData | |
| 19 | + | image entity.ImageData | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |
| @@ -25,13 +25,13 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |||
| 25 | 25 | return nil | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 28 | + | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error { | |
| 29 | 29 | f.writeImageCalls++ | |
| 30 | 30 | f.image = image | |
| 31 | 31 | return nil | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | func TestNew_PrefersToolkitClipboard(t *testing.T) { | |
| 34 | + | func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) { | |
| 35 | 35 | oldToolkitFactory := newToolkitClipboard | |
| 36 | 36 | oldLookPath := lookPath | |
| 37 | 37 | t.Cleanup(func() { | |
| @@ -39,26 +39,43 @@ func TestNew_PrefersToolkitClipboard(t *testing.T) { | |||
| 39 | 39 | lookPath = oldLookPath | |
| 40 | 40 | }) | |
| 41 | 41 | ||
| 42 | - | toolkitChecked := false | |
| 43 | 42 | fake := &fakeToolkitClipboard{} | |
| 44 | 43 | newToolkitClipboard = func() toolkitClipboard { | |
| 45 | - | toolkitChecked = true | |
| 46 | 44 | return fake | |
| 47 | 45 | } | |
| 48 | 46 | lookPath = func(_ string) (string, error) { | |
| 49 | - | require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools") | |
| 50 | - | return "", errors.New("unexpected call") | |
| 47 | + | return "", errors.New("missing") | |
| 51 | 48 | } | |
| 52 | 49 | ||
| 53 | 50 | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |
| 54 | 51 | t.Setenv("DISPLAY", ":0") | |
| 55 | 52 | ||
| 56 | 53 | adapter := New().(*Adapter) | |
| 57 | - | require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}})) | |
| 54 | + | require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}})) | |
| 58 | 55 | require.Equal(t, 1, fake.writeImageCalls) | |
| 59 | 56 | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | |
| 60 | 57 | } | |
| 61 | 58 | ||
| 59 | + | func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) { | |
| 60 | + | dir := t.TempDir() | |
| 61 | + | scriptPath := filepath.Join(dir, "wl-copy") | |
| 62 | + | argsPath := filepath.Join(dir, "args.txt") | |
| 63 | + | stdinPath := filepath.Join(dir, "stdin.bin") | |
| 64 | + | script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n" | |
| 65 | + | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |
| 66 | + | ||
| 67 | + | fake := &fakeToolkitClipboard{} | |
| 68 | + | adapter := &Adapter{toolkit: fake, copyCmd: scriptPath} | |
| 69 | + | image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"} | |
| 70 | + | ||
| 71 | + | require.NoError(t, adapter.WriteImage(context.Background(), image)) | |
| 72 | + | require.Zero(t, fake.writeImageCalls) | |
| 73 | + | ||
| 74 | + | stdin, err := os.ReadFile(stdinPath) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Equal(t, image.Bytes, stdin) | |
| 77 | + | } | |
| 78 | + | ||
| 62 | 79 | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | |
| 63 | 80 | oldToolkitFactory := newToolkitClipboard | |
| 64 | 81 | oldLookPath := lookPath | |
| @@ -102,7 +119,7 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||
| 102 | 119 | newToolkitClipboard = func() toolkitClipboard { return fake } | |
| 103 | 120 | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | |
| 104 | 121 | ||
| 105 | - | err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{}) | |
| 122 | + | err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{}) | |
| 106 | 123 | require.Error(t, err) | |
| 107 | 124 | require.Contains(t, err.Error(), "empty image data") | |
| 108 | 125 | require.Zero(t, fake.writeImageCalls) | |
| @@ -111,17 +128,17 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||
| 111 | 128 | func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) { | |
| 112 | 129 | testCases := []struct { | |
| 113 | 130 | name string | |
| 114 | - | image port.ImageData | |
| 131 | + | image entity.ImageData | |
| 115 | 132 | expected string | |
| 116 | 133 | }{ | |
| 117 | 134 | { | |
| 118 | 135 | name: "uses provided MIME type", | |
| 119 | - | image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"}, | |
| 136 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"}, | |
| 120 | 137 | expected: "image/jpeg", | |
| 121 | 138 | }, | |
| 122 | 139 | { | |
| 123 | 140 | name: "falls back to PNG", | |
| 124 | - | image: port.ImageData{Bytes: []byte{1, 2, 3}}, | |
| 141 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}}, | |
| 125 | 142 | expected: "image/png", | |
| 126 | 143 | }, | |
| 127 | 144 | } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "testing" | 9 | "testing" | ||
| 10 | 10 | ||||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | 11 | + | "github.com/bnema/dumber/internal/domain/entity" |
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| @@ -16,7 +16,7 @@ type fakeToolkitClipboard struct { | |||||
| 16 | writeTextCalls int | 16 | writeTextCalls int | ||
| 17 | writeImageCalls int | 17 | writeImageCalls int | ||
| 18 | text string | 18 | text string | ||
| 19 | - | image port.ImageData | 19 | + | image entity.ImageData |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | 22 | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | ||
| @@ -25,13 +25,13 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |||||
| 25 | return nil | 25 | return nil | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | 28 | + | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error { |
| 29 | f.writeImageCalls++ | 29 | f.writeImageCalls++ | ||
| 30 | f.image = image | 30 | f.image = image | ||
| 31 | return nil | 31 | return nil | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | func TestNew_PrefersToolkitClipboard(t *testing.T) { | 34 | + | func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) { |
| 35 | oldToolkitFactory := newToolkitClipboard | 35 | oldToolkitFactory := newToolkitClipboard | ||
| 36 | oldLookPath := lookPath | 36 | oldLookPath := lookPath | ||
| 37 | t.Cleanup(func() { | 37 | t.Cleanup(func() { | ||
| @@ -39,26 +39,43 @@ func TestNew_PrefersToolkitClipboard(t *testing.T) { | |||||
| 39 | lookPath = oldLookPath | 39 | lookPath = oldLookPath | ||
| 40 | }) | 40 | }) | ||
| 41 | 41 | ||||
| 42 | - | toolkitChecked := false | |||
| 43 | fake := &fakeToolkitClipboard{} | 42 | fake := &fakeToolkitClipboard{} | ||
| 44 | newToolkitClipboard = func() toolkitClipboard { | 43 | newToolkitClipboard = func() toolkitClipboard { | ||
| 45 | - | toolkitChecked = true | |||
| 46 | return fake | 44 | return fake | ||
| 47 | } | 45 | } | ||
| 48 | lookPath = func(_ string) (string, error) { | 46 | lookPath = func(_ string) (string, error) { | ||
| 49 | - | require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools") | 47 | + | return "", errors.New("missing") |
| 50 | - | return "", errors.New("unexpected call") | |||
| 51 | } | 48 | } | ||
| 52 | 49 | ||||
| 53 | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | 50 | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | ||
| 54 | t.Setenv("DISPLAY", ":0") | 51 | t.Setenv("DISPLAY", ":0") | ||
| 55 | 52 | ||||
| 56 | adapter := New().(*Adapter) | 53 | adapter := New().(*Adapter) | ||
| 57 | - | require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}})) | 54 | + | require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}})) |
| 58 | require.Equal(t, 1, fake.writeImageCalls) | 55 | require.Equal(t, 1, fake.writeImageCalls) | ||
| 59 | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | 56 | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | ||
| 60 | } | 57 | } | ||
| 61 | 58 | ||||
| 59 | + | func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) { | |||
| 60 | + | dir := t.TempDir() | |||
| 61 | + | scriptPath := filepath.Join(dir, "wl-copy") | |||
| 62 | + | argsPath := filepath.Join(dir, "args.txt") | |||
| 63 | + | stdinPath := filepath.Join(dir, "stdin.bin") | |||
| 64 | + | script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n" | |||
| 65 | + | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |||
| 66 | + | ||||
| 67 | + | fake := &fakeToolkitClipboard{} | |||
| 68 | + | adapter := &Adapter{toolkit: fake, copyCmd: scriptPath} | |||
| 69 | + | image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"} | |||
| 70 | + | ||||
| 71 | + | require.NoError(t, adapter.WriteImage(context.Background(), image)) | |||
| 72 | + | require.Zero(t, fake.writeImageCalls) | |||
| 73 | + | ||||
| 74 | + | stdin, err := os.ReadFile(stdinPath) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | require.Equal(t, image.Bytes, stdin) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 62 | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | 79 | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | ||
| 63 | oldToolkitFactory := newToolkitClipboard | 80 | oldToolkitFactory := newToolkitClipboard | ||
| 64 | oldLookPath := lookPath | 81 | oldLookPath := lookPath | ||
| @@ -102,7 +119,7 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||||
| 102 | newToolkitClipboard = func() toolkitClipboard { return fake } | 119 | newToolkitClipboard = func() toolkitClipboard { return fake } | ||
| 103 | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | 120 | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | ||
| 104 | 121 | ||||
| 105 | - | err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{}) | 122 | + | err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{}) |
| 106 | require.Error(t, err) | 123 | require.Error(t, err) | ||
| 107 | require.Contains(t, err.Error(), "empty image data") | 124 | require.Contains(t, err.Error(), "empty image data") | ||
| 108 | require.Zero(t, fake.writeImageCalls) | 125 | require.Zero(t, fake.writeImageCalls) | ||
| @@ -111,17 +128,17 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||||
| 111 | func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) { | 128 | func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) { | ||
| 112 | testCases := []struct { | 129 | testCases := []struct { | ||
| 113 | name string | 130 | name string | ||
| 114 | - | image port.ImageData | 131 | + | image entity.ImageData |
| 115 | expected string | 132 | expected string | ||
| 116 | }{ | 133 | }{ | ||
| 117 | { | 134 | { | ||
| 118 | name: "uses provided MIME type", | 135 | name: "uses provided MIME type", | ||
| 119 | - | image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"}, | 136 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"}, |
| 120 | expected: "image/jpeg", | 137 | expected: "image/jpeg", | ||
| 121 | }, | 138 | }, | ||
| 122 | { | 139 | { | ||
| 123 | name: "falls back to PNG", | 140 | name: "falls back to PNG", | ||
| 124 | - | image: port.ImageData{Bytes: []byte{1, 2, 3}}, | 141 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}}, |
| 125 | expected: "image/png", | 142 | expected: "image/png", | ||
| 126 | }, | 143 | }, | ||
| 127 | } | 144 | } | ||
M internal/infrastructure/config/transform_legacy_test.go +45 -0
| @@ -188,6 +188,51 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test | |||
| 188 | 188 | assert.False(t, exists) | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | + | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | |
| 192 | + | transformer := NewLegacyConfigTransformer() | |
| 193 | + | rawConfig := map[string]any{ | |
| 194 | + | "engine": map[string]any{ | |
| 195 | + | "webkit": map[string]any{"enabled": true}, | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 200 | + | ||
| 201 | + | engine, ok := rawConfig["engine"].(map[string]any) | |
| 202 | + | require.True(t, ok) | |
| 203 | + | assert.NotNil(t, engine) | |
| 204 | + | assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"]) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) { | |
| 208 | + | transformer := NewLegacyConfigTransformer() | |
| 209 | + | rawConfig := map[string]any{ | |
| 210 | + | "engine": map[string]any{ | |
| 211 | + | "cef": map[string]any{ | |
| 212 | + | "other_setting": true, | |
| 213 | + | }, | |
| 214 | + | }, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 218 | + | ||
| 219 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 220 | + | assert.True(t, cef["other_setting"].(bool)) | |
| 221 | + | _, exists := cef["enable_context_menu_handler"] | |
| 222 | + | assert.False(t, exists) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) { | |
| 226 | + | transformer := NewLegacyConfigTransformer() | |
| 227 | + | rawConfig := map[string]any{ | |
| 228 | + | "engine": "invalid", | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 232 | + | ||
| 233 | + | assert.Equal(t, "invalid", rawConfig["engine"]) | |
| 234 | + | } | |
| 235 | + | ||
| 191 | 236 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |
| 192 | 237 | transformer := NewLegacyConfigTransformer() | |
| 193 | 238 | ||
| @@ -188,6 +188,51 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test | |||||
| 188 | assert.False(t, exists) | 188 | assert.False(t, exists) | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | + | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | |||
| 192 | + | transformer := NewLegacyConfigTransformer() | |||
| 193 | + | rawConfig := map[string]any{ | |||
| 194 | + | "engine": map[string]any{ | |||
| 195 | + | "webkit": map[string]any{"enabled": true}, | |||
| 196 | + | }, | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 200 | + | ||||
| 201 | + | engine, ok := rawConfig["engine"].(map[string]any) | |||
| 202 | + | require.True(t, ok) | |||
| 203 | + | assert.NotNil(t, engine) | |||
| 204 | + | assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"]) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) { | |||
| 208 | + | transformer := NewLegacyConfigTransformer() | |||
| 209 | + | rawConfig := map[string]any{ | |||
| 210 | + | "engine": map[string]any{ | |||
| 211 | + | "cef": map[string]any{ | |||
| 212 | + | "other_setting": true, | |||
| 213 | + | }, | |||
| 214 | + | }, | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 218 | + | ||||
| 219 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |||
| 220 | + | assert.True(t, cef["other_setting"].(bool)) | |||
| 221 | + | _, exists := cef["enable_context_menu_handler"] | |||
| 222 | + | assert.False(t, exists) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) { | |||
| 226 | + | transformer := NewLegacyConfigTransformer() | |||
| 227 | + | rawConfig := map[string]any{ | |||
| 228 | + | "engine": "invalid", | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 232 | + | ||||
| 233 | + | assert.Equal(t, "invalid", rawConfig["engine"]) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 191 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | 236 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | ||
| 192 | transformer := NewLegacyConfigTransformer() | 237 | transformer := NewLegacyConfigTransformer() | ||
| 193 | 238 | ||||
M internal/infrastructure/webkit/context_menu.go +28 -16
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 16 | 17 | "github.com/bnema/puregotk-webkit/webkit" | |
| 17 | 18 | ) | |
| @@ -31,61 +32,72 @@ var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |||
| 31 | 32 | const maxImageFetchBytes = 50 * 1024 * 1024 | |
| 32 | 33 | ||
| 33 | 34 | // ResolveImageData fetches raw image bytes from the given URI. | |
| 34 | - | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) { | |
| 35 | + | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) { | |
| 35 | 36 | if imageURI == "" { | |
| 36 | - | return port.ImageData{}, fmt.Errorf("empty image URI") | |
| 37 | + | return entity.ImageData{}, fmt.Errorf("empty image URI") | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 39 | 40 | parsed, parseErr := url.Parse(imageURI) | |
| 40 | 41 | if parseErr != nil { | |
| 41 | - | return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) | |
| 42 | + | return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) | |
| 42 | 43 | } | |
| 43 | 44 | switch strings.ToLower(parsed.Scheme) { | |
| 44 | 45 | case "http", "https": | |
| 45 | 46 | default: | |
| 46 | - | return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme) | |
| 47 | + | return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme) | |
| 47 | 48 | } | |
| 48 | 49 | if err := r.validateImageURL(ctx, parsed); err != nil { | |
| 49 | - | return port.ImageData{}, err | |
| 50 | + | return entity.ImageData{}, err | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | client := r.imageFetchClient(ctx) | |
| 53 | 54 | ||
| 54 | 55 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody) | |
| 55 | 56 | if err != nil { | |
| 56 | - | return port.ImageData{}, fmt.Errorf("build request: %w", err) | |
| 57 | + | return entity.ImageData{}, fmt.Errorf("build request: %w", err) | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | resp, err := client.Do(req) | |
| 60 | 61 | if err != nil { | |
| 61 | - | return port.ImageData{}, fmt.Errorf("fetch image: %w", err) | |
| 62 | + | return entity.ImageData{}, fmt.Errorf("fetch image: %w", err) | |
| 62 | 63 | } | |
| 63 | 64 | defer func() { _ = resp.Body.Close() }() | |
| 64 | 65 | ||
| 65 | 66 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 66 | - | return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | |
| 67 | + | return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1)) | |
| 70 | 71 | if err != nil { | |
| 71 | - | return port.ImageData{}, fmt.Errorf("read image body: %w", err) | |
| 72 | + | return entity.ImageData{}, fmt.Errorf("read image body: %w", err) | |
| 72 | 73 | } | |
| 73 | 74 | if len(data) == 0 { | |
| 74 | - | return port.ImageData{}, fmt.Errorf("read image body: empty image data") | |
| 75 | + | return entity.ImageData{}, fmt.Errorf("read image body: empty image data") | |
| 75 | 76 | } | |
| 76 | 77 | if len(data) > maxImageFetchBytes { | |
| 77 | - | return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) | |
| 78 | + | return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) | |
| 78 | 79 | } | |
| 79 | - | detectedType := validatedImageMimeType(http.DetectContentType(data)) | |
| 80 | + | rawDetectedType := strings.ToLower(http.DetectContentType(data)) | |
| 81 | + | detectedType := validatedImageMimeType(rawDetectedType) | |
| 80 | 82 | mimeType := detectedType | |
| 81 | - | if mimeType == "" { | |
| 82 | - | mimeType = validatedImageMimeType(resp.Header.Get("Content-Type")) | |
| 83 | + | headerType := resp.Header.Get("Content-Type") | |
| 84 | + | if mimeType == "" && rawDetectedType == "application/octet-stream" { | |
| 85 | + | mimeType = validatedImageMimeType(headerType) | |
| 83 | 86 | } | |
| 84 | 87 | if mimeType == "" { | |
| 85 | - | return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType) | |
| 88 | + | responseURL := parsed.String() | |
| 89 | + | if resp.Request != nil && resp.Request.URL != nil { | |
| 90 | + | responseURL = resp.Request.URL.String() | |
| 91 | + | } | |
| 92 | + | return entity.ImageData{}, fmt.Errorf( | |
| 93 | + | "read image body: content is not an image (detected=%q, header=%q, url=%q)", | |
| 94 | + | rawDetectedType, | |
| 95 | + | headerType, | |
| 96 | + | responseURL, | |
| 97 | + | ) | |
| 86 | 98 | } | |
| 87 | 99 | ||
| 88 | - | return port.ImageData{Bytes: data, MimeType: mimeType}, nil | |
| 100 | + | return entity.ImageData{Bytes: data, MimeType: mimeType}, nil | |
| 89 | 101 | } | |
| 90 | 102 | ||
| 91 | 103 | func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client { | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 15 | "github.com/bnema/dumber/internal/logging" | 16 | "github.com/bnema/dumber/internal/logging" | ||
| 16 | "github.com/bnema/puregotk-webkit/webkit" | 17 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 17 | ) | 18 | ) | ||
| @@ -31,61 +32,72 @@ var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |||||
| 31 | const maxImageFetchBytes = 50 * 1024 * 1024 | 32 | const maxImageFetchBytes = 50 * 1024 * 1024 | ||
| 32 | 33 | ||||
| 33 | // ResolveImageData fetches raw image bytes from the given URI. | 34 | // ResolveImageData fetches raw image bytes from the given URI. | ||
| 34 | - | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) { | 35 | + | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) { |
| 35 | if imageURI == "" { | 36 | if imageURI == "" { | ||
| 36 | - | return port.ImageData{}, fmt.Errorf("empty image URI") | 37 | + | return entity.ImageData{}, fmt.Errorf("empty image URI") |
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 39 | parsed, parseErr := url.Parse(imageURI) | 40 | parsed, parseErr := url.Parse(imageURI) | ||
| 40 | if parseErr != nil { | 41 | if parseErr != nil { | ||
| 41 | - | return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) | 42 | + | return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) |
| 42 | } | 43 | } | ||
| 43 | switch strings.ToLower(parsed.Scheme) { | 44 | switch strings.ToLower(parsed.Scheme) { | ||
| 44 | case "http", "https": | 45 | case "http", "https": | ||
| 45 | default: | 46 | default: | ||
| 46 | - | return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme) | 47 | + | return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme) |
| 47 | } | 48 | } | ||
| 48 | if err := r.validateImageURL(ctx, parsed); err != nil { | 49 | if err := r.validateImageURL(ctx, parsed); err != nil { | ||
| 49 | - | return port.ImageData{}, err | 50 | + | return entity.ImageData{}, err |
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 52 | client := r.imageFetchClient(ctx) | 53 | client := r.imageFetchClient(ctx) | ||
| 53 | 54 | ||||
| 54 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody) | 55 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody) | ||
| 55 | if err != nil { | 56 | if err != nil { | ||
| 56 | - | return port.ImageData{}, fmt.Errorf("build request: %w", err) | 57 | + | return entity.ImageData{}, fmt.Errorf("build request: %w", err) |
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | resp, err := client.Do(req) | 60 | resp, err := client.Do(req) | ||
| 60 | if err != nil { | 61 | if err != nil { | ||
| 61 | - | return port.ImageData{}, fmt.Errorf("fetch image: %w", err) | 62 | + | return entity.ImageData{}, fmt.Errorf("fetch image: %w", err) |
| 62 | } | 63 | } | ||
| 63 | defer func() { _ = resp.Body.Close() }() | 64 | defer func() { _ = resp.Body.Close() }() | ||
| 64 | 65 | ||||
| 65 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | 66 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | ||
| 66 | - | return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | 67 | + | return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) |
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1)) | 70 | data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1)) | ||
| 70 | if err != nil { | 71 | if err != nil { | ||
| 71 | - | return port.ImageData{}, fmt.Errorf("read image body: %w", err) | 72 | + | return entity.ImageData{}, fmt.Errorf("read image body: %w", err) |
| 72 | } | 73 | } | ||
| 73 | if len(data) == 0 { | 74 | if len(data) == 0 { | ||
| 74 | - | return port.ImageData{}, fmt.Errorf("read image body: empty image data") | 75 | + | return entity.ImageData{}, fmt.Errorf("read image body: empty image data") |
| 75 | } | 76 | } | ||
| 76 | if len(data) > maxImageFetchBytes { | 77 | if len(data) > maxImageFetchBytes { | ||
| 77 | - | return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) | 78 | + | return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) |
| 78 | } | 79 | } | ||
| 79 | - | detectedType := validatedImageMimeType(http.DetectContentType(data)) | 80 | + | rawDetectedType := strings.ToLower(http.DetectContentType(data)) |
| 81 | + | detectedType := validatedImageMimeType(rawDetectedType) | |||
| 80 | mimeType := detectedType | 82 | mimeType := detectedType | ||
| 81 | - | if mimeType == "" { | 83 | + | headerType := resp.Header.Get("Content-Type") |
| 82 | - | mimeType = validatedImageMimeType(resp.Header.Get("Content-Type")) | 84 | + | if mimeType == "" && rawDetectedType == "application/octet-stream" { |
| 85 | + | mimeType = validatedImageMimeType(headerType) | |||
| 83 | } | 86 | } | ||
| 84 | if mimeType == "" { | 87 | if mimeType == "" { | ||
| 85 | - | return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType) | 88 | + | responseURL := parsed.String() |
| 89 | + | if resp.Request != nil && resp.Request.URL != nil { | |||
| 90 | + | responseURL = resp.Request.URL.String() | |||
| 91 | + | } | |||
| 92 | + | return entity.ImageData{}, fmt.Errorf( | |||
| 93 | + | "read image body: content is not an image (detected=%q, header=%q, url=%q)", | |||
| 94 | + | rawDetectedType, | |||
| 95 | + | headerType, | |||
| 96 | + | responseURL, | |||
| 97 | + | ) | |||
| 86 | } | 98 | } | ||
| 87 | 99 | ||||
| 88 | - | return port.ImageData{Bytes: data, MimeType: mimeType}, nil | 100 | + | return entity.ImageData{Bytes: data, MimeType: mimeType}, nil |
| 89 | } | 101 | } | ||
| 90 | 102 | ||||
| 91 | func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client { | 103 | func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client { | ||
M internal/infrastructure/webkit/context_menu_test.go +17 -0
| @@ -219,6 +219,23 @@ func TestResolveImageData(t *testing.T) { | |||
| 219 | 219 | assert.Contains(t, err.Error(), "not an image") | |
| 220 | 220 | }) | |
| 221 | 221 | ||
| 222 | + | t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) { | |
| 223 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 224 | + | w.Header().Set("Content-Type", "image/png") | |
| 225 | + | w.Write([]byte("<html><body>not really an image</body></html>")) | |
| 226 | + | _ = r | |
| 227 | + | })) | |
| 228 | + | defer srv.Close() | |
| 229 | + | ||
| 230 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 231 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png") | |
| 232 | + | require.Error(t, err) | |
| 233 | + | assert.Contains(t, err.Error(), "not an image") | |
| 234 | + | assert.Contains(t, err.Error(), "text/html") | |
| 235 | + | assert.Contains(t, err.Error(), "image/png") | |
| 236 | + | assert.Contains(t, err.Error(), "/spoofed.png") | |
| 237 | + | }) | |
| 238 | + | ||
| 222 | 239 | t.Run("returns error on HTTP failure", func(t *testing.T) { | |
| 223 | 240 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 224 | 241 | w.WriteHeader(http.StatusNotFound) | |
| @@ -219,6 +219,23 @@ func TestResolveImageData(t *testing.T) { | |||||
| 219 | assert.Contains(t, err.Error(), "not an image") | 219 | assert.Contains(t, err.Error(), "not an image") | ||
| 220 | }) | 220 | }) | ||
| 221 | 221 | ||||
| 222 | + | t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) { | |||
| 223 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 224 | + | w.Header().Set("Content-Type", "image/png") | |||
| 225 | + | w.Write([]byte("<html><body>not really an image</body></html>")) | |||
| 226 | + | _ = r | |||
| 227 | + | })) | |||
| 228 | + | defer srv.Close() | |||
| 229 | + | ||||
| 230 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |||
| 231 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png") | |||
| 232 | + | require.Error(t, err) | |||
| 233 | + | assert.Contains(t, err.Error(), "not an image") | |||
| 234 | + | assert.Contains(t, err.Error(), "text/html") | |||
| 235 | + | assert.Contains(t, err.Error(), "image/png") | |||
| 236 | + | assert.Contains(t, err.Error(), "/spoofed.png") | |||
| 237 | + | }) | |||
| 238 | + | ||||
| 222 | t.Run("returns error on HTTP failure", func(t *testing.T) { | 239 | t.Run("returns error on HTTP failure", func(t *testing.T) { | ||
| 223 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 240 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 224 | w.WriteHeader(http.StatusNotFound) | 241 | w.WriteHeader(http.StatusNotFound) | ||
M internal/infrastructure/webkit/factory.go +0 -2
| @@ -131,7 +131,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 131 | 131 | ||
| 132 | 132 | // Wire context menu if configured | |
| 133 | 133 | if f.ctxMenu != nil { | |
| 134 | - | wv.contextMenu = f.ctxMenu | |
| 135 | 134 | wv.connectContextMenuSignal(f.ctxMenu) | |
| 136 | 135 | } | |
| 137 | 136 | ||
| @@ -170,7 +169,6 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||
| 170 | 169 | ||
| 171 | 170 | // Wire context menu if configured | |
| 172 | 171 | if f.ctxMenu != nil { | |
| 173 | - | wv.contextMenu = f.ctxMenu | |
| 174 | 172 | wv.connectContextMenuSignal(f.ctxMenu) | |
| 175 | 173 | } | |
| 176 | 174 | ||
| @@ -131,7 +131,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 131 | 131 | ||||
| 132 | // Wire context menu if configured | 132 | // Wire context menu if configured | ||
| 133 | if f.ctxMenu != nil { | 133 | if f.ctxMenu != nil { | ||
| 134 | - | wv.contextMenu = f.ctxMenu | |||
| 135 | wv.connectContextMenuSignal(f.ctxMenu) | 134 | wv.connectContextMenuSignal(f.ctxMenu) | ||
| 136 | } | 135 | } | ||
| 137 | 136 | ||||
| @@ -170,7 +169,6 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||||
| 170 | 169 | ||||
| 171 | // Wire context menu if configured | 170 | // Wire context menu if configured | ||
| 172 | if f.ctxMenu != nil { | 171 | if f.ctxMenu != nil { | ||
| 173 | - | wv.contextMenu = f.ctxMenu | |||
| 174 | wv.connectContextMenuSignal(f.ctxMenu) | 172 | wv.connectContextMenuSignal(f.ctxMenu) | ||
| 175 | } | 173 | } | ||
| 176 | 174 | ||||
M internal/infrastructure/webkit/pool.go +14 -5
| @@ -65,6 +65,7 @@ type WebViewPool struct { | |||
| 65 | 65 | ||
| 66 | 66 | pool chan *WebView | |
| 67 | 67 | poolMu sync.Mutex | |
| 68 | + | ctxMenuMu sync.RWMutex | |
| 68 | 69 | injector *ContentInjector | |
| 69 | 70 | router *MessageRouter | |
| 70 | 71 | filterApplier FilterApplier // Optional content filter applier | |
| @@ -148,7 +149,9 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||
| 148 | 149 | ||
| 149 | 150 | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |
| 150 | 151 | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 152 | + | p.ctxMenuMu.Lock() | |
| 151 | 153 | p.ctxMenu = pipeline | |
| 154 | + | p.ctxMenuMu.Unlock() | |
| 152 | 155 | } | |
| 153 | 156 | ||
| 154 | 157 | // Acquire gets a WebView from the pool or creates a new one. | |
| @@ -178,8 +181,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 178 | 181 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 179 | 182 | } | |
| 180 | 183 | // Ensure context menu pipeline is set (may not have been available during prewarm) | |
| 181 | - | if p.ctxMenu != nil { | |
| 182 | - | wv.contextMenu = p.ctxMenu | |
| 184 | + | p.ctxMenuMu.RLock() | |
| 185 | + | ctxMenu := p.ctxMenu | |
| 186 | + | p.ctxMenuMu.RUnlock() | |
| 187 | + | if ctxMenu != nil { | |
| 188 | + | wv.contextMenu = ctxMenu | |
| 183 | 189 | } | |
| 184 | 190 | wv.ReconnectSignals() | |
| 185 | 191 | p.acquireCount.Add(1) | |
| @@ -248,9 +254,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 248 | 254 | } | |
| 249 | 255 | ||
| 250 | 256 | // Wire context menu if configured | |
| 251 | - | if p.ctxMenu != nil { | |
| 252 | - | wv.contextMenu = p.ctxMenu | |
| 253 | - | wv.connectContextMenuSignal(p.ctxMenu) | |
| 257 | + | p.ctxMenuMu.RLock() | |
| 258 | + | ctxMenu := p.ctxMenu | |
| 259 | + | p.ctxMenuMu.RUnlock() | |
| 260 | + | if ctxMenu != nil { | |
| 261 | + | wv.contextMenu = ctxMenu | |
| 262 | + | wv.connectContextMenuSignal(ctxMenu) | |
| 254 | 263 | } | |
| 255 | 264 | ||
| 256 | 265 | return wv, nil | |
| @@ -65,6 +65,7 @@ type WebViewPool struct { | |||||
| 65 | 65 | ||||
| 66 | pool chan *WebView | 66 | pool chan *WebView | ||
| 67 | poolMu sync.Mutex | 67 | poolMu sync.Mutex | ||
| 68 | + | ctxMenuMu sync.RWMutex | |||
| 68 | injector *ContentInjector | 69 | injector *ContentInjector | ||
| 69 | router *MessageRouter | 70 | router *MessageRouter | ||
| 70 | filterApplier FilterApplier // Optional content filter applier | 71 | filterApplier FilterApplier // Optional content filter applier | ||
| @@ -148,7 +149,9 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||||
| 148 | 149 | ||||
| 149 | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | 150 | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | ||
| 150 | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | 151 | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | ||
| 152 | + | p.ctxMenuMu.Lock() | |||
| 151 | p.ctxMenu = pipeline | 153 | p.ctxMenu = pipeline | ||
| 154 | + | p.ctxMenuMu.Unlock() | |||
| 152 | } | 155 | } | ||
| 153 | 156 | ||||
| 154 | // Acquire gets a WebView from the pool or creates a new one. | 157 | // Acquire gets a WebView from the pool or creates a new one. | ||
| @@ -178,8 +181,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 178 | p.filterApplier.ApplyTo(ctx, wv.ucm) | 181 | p.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 179 | } | 182 | } | ||
| 180 | // Ensure context menu pipeline is set (may not have been available during prewarm) | 183 | // Ensure context menu pipeline is set (may not have been available during prewarm) | ||
| 181 | - | if p.ctxMenu != nil { | 184 | + | p.ctxMenuMu.RLock() |
| 182 | - | wv.contextMenu = p.ctxMenu | 185 | + | ctxMenu := p.ctxMenu |
| 186 | + | p.ctxMenuMu.RUnlock() | |||
| 187 | + | if ctxMenu != nil { | |||
| 188 | + | wv.contextMenu = ctxMenu | |||
| 183 | } | 189 | } | ||
| 184 | wv.ReconnectSignals() | 190 | wv.ReconnectSignals() | ||
| 185 | p.acquireCount.Add(1) | 191 | p.acquireCount.Add(1) | ||
| @@ -248,9 +254,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||||
| 248 | } | 254 | } | ||
| 249 | 255 | ||||
| 250 | // Wire context menu if configured | 256 | // Wire context menu if configured | ||
| 251 | - | if p.ctxMenu != nil { | 257 | + | p.ctxMenuMu.RLock() |
| 252 | - | wv.contextMenu = p.ctxMenu | 258 | + | ctxMenu := p.ctxMenu |
| 253 | - | wv.connectContextMenuSignal(p.ctxMenu) | 259 | + | p.ctxMenuMu.RUnlock() |
| 260 | + | if ctxMenu != nil { | |||
| 261 | + | wv.contextMenu = ctxMenu | |||
| 262 | + | wv.connectContextMenuSignal(ctxMenu) | |||
| 254 | } | 263 | } | ||
| 255 | 264 | ||||
| 256 | return wv, nil | 265 | return wv, nil | ||
M internal/infrastructure/webkit/renderer.go +15 -7
| @@ -71,6 +71,12 @@ func showMenu( | |||
| 71 | 71 | } | |
| 72 | 72 | return | |
| 73 | 73 | } | |
| 74 | + | if !hasRenderableItems(items) { | |
| 75 | + | if onClose != nil { | |
| 76 | + | onClose() | |
| 77 | + | } | |
| 78 | + | return | |
| 79 | + | } | |
| 74 | 80 | ||
| 75 | 81 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 76 | 82 | box.AddCssClass("context-menu") | |
| @@ -103,13 +109,6 @@ func showMenu( | |||
| 103 | 109 | box.Append(&btn.Widget) | |
| 104 | 110 | } | |
| 105 | 111 | ||
| 106 | - | if len(items) == 0 { | |
| 107 | - | if onClose != nil { | |
| 108 | - | onClose() | |
| 109 | - | } | |
| 110 | - | return | |
| 111 | - | } | |
| 112 | - | ||
| 113 | 112 | popover = gtk.NewPopover() | |
| 114 | 113 | popover.SetChild(&box.Widget) | |
| 115 | 114 | popover.SetParent(anchor) | |
| @@ -129,3 +128,12 @@ func showMenu( | |||
| 129 | 128 | ||
| 130 | 129 | popover.Popup() | |
| 131 | 130 | } | |
| 131 | + | ||
| 132 | + | func hasRenderableItems(items []renderItem) bool { | |
| 133 | + | for _, item := range items { | |
| 134 | + | if !item.separator { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| @@ -71,6 +71,12 @@ func showMenu( | |||||
| 71 | } | 71 | } | ||
| 72 | return | 72 | return | ||
| 73 | } | 73 | } | ||
| 74 | + | if !hasRenderableItems(items) { | |||
| 75 | + | if onClose != nil { | |||
| 76 | + | onClose() | |||
| 77 | + | } | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 74 | 80 | ||||
| 75 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | 81 | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| 76 | box.AddCssClass("context-menu") | 82 | box.AddCssClass("context-menu") | ||
| @@ -103,13 +109,6 @@ func showMenu( | |||||
| 103 | box.Append(&btn.Widget) | 109 | box.Append(&btn.Widget) | ||
| 104 | } | 110 | } | ||
| 105 | 111 | ||||
| 106 | - | if len(items) == 0 { | |||
| 107 | - | if onClose != nil { | |||
| 108 | - | onClose() | |||
| 109 | - | } | |||
| 110 | - | return | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | popover = gtk.NewPopover() | 112 | popover = gtk.NewPopover() | ||
| 114 | popover.SetChild(&box.Widget) | 113 | popover.SetChild(&box.Widget) | ||
| 115 | popover.SetParent(anchor) | 114 | popover.SetParent(anchor) | ||
| @@ -129,3 +128,12 @@ func showMenu( | |||||
| 129 | 128 | ||||
| 130 | popover.Popup() | 129 | popover.Popup() | ||
| 131 | } | 130 | } | ||
| 131 | + | ||||
| 132 | + | func hasRenderableItems(items []renderItem) bool { | |||
| 133 | + | for _, item := range items { | |||
| 134 | + | if !item.separator { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return false | |||
| 139 | + | } | |||
M internal/infrastructure/webkit/resolved_image_saver.go +5 -4
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | const ( | |
| @@ -29,10 +30,13 @@ func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) * | |||
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // SaveResolvedImage resolves a destination path and writes the image bytes. | |
| 32 | - | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error { | |
| 33 | + | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 33 | 34 | if s == nil || s.preparer == nil { | |
| 34 | 35 | return fmt.Errorf("resolved image saver: download preparer not available") | |
| 35 | 36 | } | |
| 37 | + | if len(image.Bytes) == 0 { | |
| 38 | + | return fmt.Errorf("resolved image saver: empty image data") | |
| 39 | + | } | |
| 36 | 40 | ||
| 37 | 41 | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 38 | 42 | DownloadDir: s.downloadDir, | |
| @@ -46,9 +50,6 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I | |||
| 46 | 50 | if output == nil || output.DestinationPath == "" { | |
| 47 | 51 | return fmt.Errorf("resolved image saver: destination path not prepared") | |
| 48 | 52 | } | |
| 49 | - | if len(image.Bytes) == 0 { | |
| 50 | - | return fmt.Errorf("resolved image saver: empty image data") | |
| 51 | - | } | |
| 52 | 53 | ||
| 53 | 54 | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | |
| 54 | 55 | return err | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | const ( | 13 | const ( | ||
| @@ -29,10 +30,13 @@ func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) * | |||||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // SaveResolvedImage resolves a destination path and writes the image bytes. | 32 | // SaveResolvedImage resolves a destination path and writes the image bytes. | ||
| 32 | - | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error { | 33 | + | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { |
| 33 | if s == nil || s.preparer == nil { | 34 | if s == nil || s.preparer == nil { | ||
| 34 | return fmt.Errorf("resolved image saver: download preparer not available") | 35 | return fmt.Errorf("resolved image saver: download preparer not available") | ||
| 35 | } | 36 | } | ||
| 37 | + | if len(image.Bytes) == 0 { | |||
| 38 | + | return fmt.Errorf("resolved image saver: empty image data") | |||
| 39 | + | } | |||
| 36 | 40 | ||||
| 37 | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | 41 | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | ||
| 38 | DownloadDir: s.downloadDir, | 42 | DownloadDir: s.downloadDir, | ||
| @@ -46,9 +50,6 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I | |||||
| 46 | if output == nil || output.DestinationPath == "" { | 50 | if output == nil || output.DestinationPath == "" { | ||
| 47 | return fmt.Errorf("resolved image saver: destination path not prepared") | 51 | return fmt.Errorf("resolved image saver: destination path not prepared") | ||
| 48 | } | 52 | } | ||
| 49 | - | if len(image.Bytes) == 0 { | |||
| 50 | - | return fmt.Errorf("resolved image saver: empty image data") | |||
| 51 | - | } | |||
| 52 | 53 | ||||
| 53 | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | 54 | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | ||
| 54 | return err | 55 | return err | ||
M internal/infrastructure/webkit/resolved_image_saver_test.go +7 -3
| @@ -7,15 +7,18 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | type fakeDownloadPreparer struct { | |
| 15 | + | calls int | |
| 14 | 16 | input port.DownloadPrepareInput | |
| 15 | 17 | output *port.DownloadPrepareOutput | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | 20 | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |
| 21 | + | f.calls++ | |
| 19 | 22 | f.input = input | |
| 20 | 23 | return f.output | |
| 21 | 24 | } | |
| @@ -26,7 +29,7 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) { | |||
| 26 | 29 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | |
| 27 | 30 | saver := NewResolvedImageSaver(preparer, downloadDir) | |
| 28 | 31 | ||
| 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"}) | |
| 32 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"}) | |
| 30 | 33 | require.NoError(t, err) | |
| 31 | 34 | require.Equal(t, downloadDir, preparer.input.DownloadDir) | |
| 32 | 35 | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | |
| @@ -38,7 +41,7 @@ func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |||
| 38 | 41 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | |
| 39 | 42 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | |
| 40 | 43 | ||
| 41 | - | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{}) | |
| 44 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{}) | |
| 42 | 45 | require.Error(t, err) | |
| 43 | 46 | require.Contains(t, err.Error(), "destination path") | |
| 44 | 47 | } | |
| @@ -47,9 +50,10 @@ func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |||
| 47 | 50 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | |
| 48 | 51 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | |
| 49 | 52 | ||
| 50 | - | err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{}) | |
| 53 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{}) | |
| 51 | 54 | require.Error(t, err) | |
| 52 | 55 | require.Contains(t, err.Error(), "empty image data") | |
| 56 | + | require.Zero(t, preparer.calls) | |
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | 59 | func readFile(t *testing.T, path string) []byte { | |
| @@ -7,15 +7,18 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | type fakeDownloadPreparer struct { | 14 | type fakeDownloadPreparer struct { | ||
| 15 | + | calls int | |||
| 14 | input port.DownloadPrepareInput | 16 | input port.DownloadPrepareInput | ||
| 15 | output *port.DownloadPrepareOutput | 17 | output *port.DownloadPrepareOutput | ||
| 16 | } | 18 | } | ||
| 17 | 19 | ||||
| 18 | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | 20 | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | ||
| 21 | + | f.calls++ | |||
| 19 | f.input = input | 22 | f.input = input | ||
| 20 | return f.output | 23 | return f.output | ||
| 21 | } | 24 | } | ||
| @@ -26,7 +29,7 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) { | |||||
| 26 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | 29 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | ||
| 27 | saver := NewResolvedImageSaver(preparer, downloadDir) | 30 | saver := NewResolvedImageSaver(preparer, downloadDir) | ||
| 28 | 31 | ||||
| 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"}) | 32 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"}) |
| 30 | require.NoError(t, err) | 33 | require.NoError(t, err) | ||
| 31 | require.Equal(t, downloadDir, preparer.input.DownloadDir) | 34 | require.Equal(t, downloadDir, preparer.input.DownloadDir) | ||
| 32 | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | 35 | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | ||
| @@ -38,7 +41,7 @@ func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |||||
| 38 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | 41 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | ||
| 39 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | 42 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | ||
| 40 | 43 | ||||
| 41 | - | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{}) | 44 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{}) |
| 42 | require.Error(t, err) | 45 | require.Error(t, err) | ||
| 43 | require.Contains(t, err.Error(), "destination path") | 46 | require.Contains(t, err.Error(), "destination path") | ||
| 44 | } | 47 | } | ||
| @@ -47,9 +50,10 @@ func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |||||
| 47 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | 50 | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | ||
| 48 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | 51 | saver := NewResolvedImageSaver(preparer, t.TempDir()) | ||
| 49 | 52 | ||||
| 50 | - | err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{}) | 53 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{}) |
| 51 | require.Error(t, err) | 54 | require.Error(t, err) | ||
| 52 | require.Contains(t, err.Error(), "empty image data") | 55 | require.Contains(t, err.Error(), "empty image data") | ||
| 56 | + | require.Zero(t, preparer.calls) | |||
| 53 | } | 57 | } | ||
| 54 | 58 | ||||
| 55 | func readFile(t *testing.T, path string) []byte { | 59 | func readFile(t *testing.T, path string) []byte { | ||
M internal/ui/coordinator/workspace.go +9 -12
| @@ -682,12 +682,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 682 | 682 | Str("old_active_pane_id", string(oldActivePaneID)). | |
| 683 | 683 | Int("new_split_widget_alloc_width", newSplitWidth). | |
| 684 | 684 | Int("new_split_widget_alloc_height", newSplitHeight). | |
| 685 | - | Str("orientation", func() string { | |
| 686 | - | if orientation == layout.OrientationHorizontal { | |
| 687 | - | return "horizontal" | |
| 688 | - | } | |
| 689 | - | return "vertical" | |
| 690 | - | }()). | |
| 685 | + | Str("orientation", orientationString(orientation)). | |
| 691 | 686 | Msg("incremental split: replaced root with new split view") | |
| 692 | 687 | ||
| 693 | 688 | return nil | |
| @@ -760,17 +755,19 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 760 | 755 | Bool("was_start_child", isStartChild). | |
| 761 | 756 | Int("new_split_widget_alloc_width", newSplitWidth). | |
| 762 | 757 | Int("new_split_widget_alloc_height", newSplitHeight). | |
| 763 | - | Str("orientation", func() string { | |
| 764 | - | if orientation == layout.OrientationHorizontal { | |
| 765 | - | return "horizontal" | |
| 766 | - | } | |
| 767 | - | return "vertical" | |
| 768 | - | }()). | |
| 758 | + | Str("orientation", orientationString(orientation)). | |
| 769 | 759 | Msg("incremental split: replaced pane in grandparent with new split view") | |
| 770 | 760 | ||
| 771 | 761 | return nil | |
| 772 | 762 | } | |
| 773 | 763 | ||
| 764 | + | func orientationString(orientation layout.Orientation) string { | |
| 765 | + | if orientation == layout.OrientationHorizontal { | |
| 766 | + | return "horizontal" | |
| 767 | + | } | |
| 768 | + | return "vertical" | |
| 769 | + | } | |
| 770 | + | ||
| 774 | 771 | // ClosePane closes the active pane. | |
| 775 | 772 | func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |
| 776 | 773 | log := logging.FromContext(ctx) | |
| @@ -682,12 +682,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||||
| 682 | Str("old_active_pane_id", string(oldActivePaneID)). | 682 | Str("old_active_pane_id", string(oldActivePaneID)). | ||
| 683 | Int("new_split_widget_alloc_width", newSplitWidth). | 683 | Int("new_split_widget_alloc_width", newSplitWidth). | ||
| 684 | Int("new_split_widget_alloc_height", newSplitHeight). | 684 | Int("new_split_widget_alloc_height", newSplitHeight). | ||
| 685 | - | Str("orientation", func() string { | 685 | + | Str("orientation", orientationString(orientation)). |
| 686 | - | if orientation == layout.OrientationHorizontal { | |||
| 687 | - | return "horizontal" | |||
| 688 | - | } | |||
| 689 | - | return "vertical" | |||
| 690 | - | }()). | |||
| 691 | Msg("incremental split: replaced root with new split view") | 686 | Msg("incremental split: replaced root with new split view") | ||
| 692 | 687 | ||||
| 693 | return nil | 688 | return nil | ||
| @@ -760,17 +755,19 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||||
| 760 | Bool("was_start_child", isStartChild). | 755 | Bool("was_start_child", isStartChild). | ||
| 761 | Int("new_split_widget_alloc_width", newSplitWidth). | 756 | Int("new_split_widget_alloc_width", newSplitWidth). | ||
| 762 | Int("new_split_widget_alloc_height", newSplitHeight). | 757 | Int("new_split_widget_alloc_height", newSplitHeight). | ||
| 763 | - | Str("orientation", func() string { | 758 | + | Str("orientation", orientationString(orientation)). |
| 764 | - | if orientation == layout.OrientationHorizontal { | |||
| 765 | - | return "horizontal" | |||
| 766 | - | } | |||
| 767 | - | return "vertical" | |||
| 768 | - | }()). | |||
| 769 | Msg("incremental split: replaced pane in grandparent with new split view") | 759 | Msg("incremental split: replaced pane in grandparent with new split view") | ||
| 770 | 760 | ||||
| 771 | return nil | 761 | return nil | ||
| 772 | } | 762 | } | ||
| 773 | 763 | ||||
| 764 | + | func orientationString(orientation layout.Orientation) string { | |||
| 765 | + | if orientation == layout.OrientationHorizontal { | |||
| 766 | + | return "horizontal" | |||
| 767 | + | } | |||
| 768 | + | return "vertical" | |||
| 769 | + | } | |||
| 770 | + | ||||
| 774 | // ClosePane closes the active pane. | 771 | // ClosePane closes the active pane. | ||
| 775 | func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | 772 | func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | ||
| 776 | log := logging.FromContext(ctx) | 773 | log := logging.FromContext(ctx) | ||