fix(systemviews): address follow-up review feedback
- commit
- 95983dd3ef183892c242f896dbe8b0fcb8e4bae2
- author
- brice · · 2026-04-26 06:40 UTC
- parent
- 1cb3bf3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
47 files · +601 -415
M assets/systemviews/systemviews.css +6 -0
| @@ -799,6 +799,12 @@ body { | |||
| 799 | 799 | min-width: 4.75rem; | |
| 800 | 800 | } | |
| 801 | 801 | ||
| 802 | + | .sv-history-row-actions .sv-button-icon-only { | |
| 803 | + | width: 2rem; | |
| 804 | + | min-width: 2rem; | |
| 805 | + | flex: 0 0 2rem; | |
| 806 | + | } | |
| 807 | + | ||
| 802 | 808 | .sv-button-active { | |
| 803 | 809 | border-color: var(--sv-accent, #8ab4ff); | |
| 804 | 810 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | |
| @@ -799,6 +799,12 @@ body { | |||||
| 799 | min-width: 4.75rem; | 799 | min-width: 4.75rem; | ||
| 800 | } | 800 | } | ||
| 801 | 801 | ||||
| 802 | + | .sv-history-row-actions .sv-button-icon-only { | |||
| 803 | + | width: 2rem; | |||
| 804 | + | min-width: 2rem; | |||
| 805 | + | flex: 0 0 2rem; | |||
| 806 | + | } | |||
| 807 | + | ||||
| 802 | .sv-button-active { | 808 | .sv-button-active { | ||
| 803 | border-color: var(--sv-accent, #8ab4ff); | 809 | border-color: var(--sv-accent, #8ab4ff); | ||
| 804 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | 810 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | ||
M cmd/systemviews/bootstrap_test.go +36 -4
| @@ -2,6 +2,7 @@ package main | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -45,15 +46,16 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||
| 45 | 46 | }, | |
| 46 | 47 | }, | |
| 47 | 48 | } | |
| 48 | - | dom := &fakeDOM{} | |
| 49 | + | dom := &fakeDOM{mounts: make(chan string, 4)} | |
| 49 | 50 | app := newBridgeApp(dom, "dumb://history", bridge) | |
| 50 | 51 | ||
| 51 | 52 | require.NoError(t, app.Run()) | |
| 52 | 53 | require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond) | |
| 53 | 54 | assert.True(t, bridge.calledConfig) | |
| 54 | 55 | assert.False(t, bridge.calledKeybindings) | |
| 55 | - | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | |
| 56 | - | assert.Contains(t, dom.html, `--sv-background: #111111;`) | |
| 56 | + | html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`) | |
| 57 | + | assert.Contains(t, html, `class="sv-app sv-dark"`) | |
| 58 | + | assert.Contains(t, html, `--sv-background: #111111;`) | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |
| @@ -90,14 +92,44 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |||
| 90 | 92 | } | |
| 91 | 93 | ||
| 92 | 94 | type fakeDOM struct { | |
| 93 | - | html string | |
| 95 | + | html string | |
| 96 | + | mounts chan string | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 96 | 99 | func (f *fakeDOM) Mount(html string) error { | |
| 97 | 100 | f.html = html | |
| 101 | + | if f.mounts != nil { | |
| 102 | + | select { | |
| 103 | + | case f.mounts <- html: | |
| 104 | + | default: | |
| 105 | + | } | |
| 106 | + | } | |
| 98 | 107 | return nil | |
| 99 | 108 | } | |
| 100 | 109 | ||
| 110 | + | func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string { | |
| 111 | + | t.Helper() | |
| 112 | + | deadline := time.After(time.Second) | |
| 113 | + | for { | |
| 114 | + | select { | |
| 115 | + | case html := <-mounts: | |
| 116 | + | matched := true | |
| 117 | + | for _, value := range values { | |
| 118 | + | if !strings.Contains(html, value) { | |
| 119 | + | matched = false | |
| 120 | + | break | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | if matched { | |
| 124 | + | return html | |
| 125 | + | } | |
| 126 | + | case <-deadline: | |
| 127 | + | t.Fatalf("timed out waiting for DOM mount containing %q", values) | |
| 128 | + | return "" | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 101 | 133 | // Handwritten fake intentionally tracks state across history, favorites, config, | |
| 102 | 134 | // and keybindings boundaries for the composite bootstrap assertions. | |
| 103 | 135 | type fakeBridgeService struct { | |
| @@ -2,6 +2,7 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -45,15 +46,16 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||||
| 45 | }, | 46 | }, | ||
| 46 | }, | 47 | }, | ||
| 47 | } | 48 | } | ||
| 48 | - | dom := &fakeDOM{} | 49 | + | dom := &fakeDOM{mounts: make(chan string, 4)} |
| 49 | app := newBridgeApp(dom, "dumb://history", bridge) | 50 | app := newBridgeApp(dom, "dumb://history", bridge) | ||
| 50 | 51 | ||||
| 51 | require.NoError(t, app.Run()) | 52 | require.NoError(t, app.Run()) | ||
| 52 | require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond) | 53 | require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond) | ||
| 53 | assert.True(t, bridge.calledConfig) | 54 | assert.True(t, bridge.calledConfig) | ||
| 54 | assert.False(t, bridge.calledKeybindings) | 55 | assert.False(t, bridge.calledKeybindings) | ||
| 55 | - | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | 56 | + | html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`) |
| 56 | - | assert.Contains(t, dom.html, `--sv-background: #111111;`) | 57 | + | assert.Contains(t, html, `class="sv-app sv-dark"`) |
| 58 | + | assert.Contains(t, html, `--sv-background: #111111;`) | |||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | 61 | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | ||
| @@ -90,14 +92,44 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |||||
| 90 | } | 92 | } | ||
| 91 | 93 | ||||
| 92 | type fakeDOM struct { | 94 | type fakeDOM struct { | ||
| 93 | - | html string | 95 | + | html string |
| 96 | + | mounts chan string | |||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | func (f *fakeDOM) Mount(html string) error { | 99 | func (f *fakeDOM) Mount(html string) error { | ||
| 97 | f.html = html | 100 | f.html = html | ||
| 101 | + | if f.mounts != nil { | |||
| 102 | + | select { | |||
| 103 | + | case f.mounts <- html: | |||
| 104 | + | default: | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 98 | return nil | 107 | return nil | ||
| 99 | } | 108 | } | ||
| 100 | 109 | ||||
| 110 | + | func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string { | |||
| 111 | + | t.Helper() | |||
| 112 | + | deadline := time.After(time.Second) | |||
| 113 | + | for { | |||
| 114 | + | select { | |||
| 115 | + | case html := <-mounts: | |||
| 116 | + | matched := true | |||
| 117 | + | for _, value := range values { | |||
| 118 | + | if !strings.Contains(html, value) { | |||
| 119 | + | matched = false | |||
| 120 | + | break | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if matched { | |||
| 124 | + | return html | |||
| 125 | + | } | |||
| 126 | + | case <-deadline: | |||
| 127 | + | t.Fatalf("timed out waiting for DOM mount containing %q", values) | |||
| 128 | + | return "" | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 101 | // Handwritten fake intentionally tracks state across history, favorites, config, | 133 | // Handwritten fake intentionally tracks state across history, favorites, config, | ||
| 102 | // and keybindings boundaries for the composite bootstrap assertions. | 134 | // and keybindings boundaries for the composite bootstrap assertions. | ||
| 103 | type fakeBridgeService struct { | 135 | type fakeBridgeService struct { | ||
A internal/application/dto/history.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | // HistorySearchInput holds search parameters for history search use cases. | |
| 6 | + | type HistorySearchInput struct { | |
| 7 | + | Query string | |
| 8 | + | Limit int | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // HistorySearchOutput holds search results for history search use cases. | |
| 12 | + | type HistorySearchOutput struct { | |
| 13 | + | Matches []entity.HistoryMatch | |
| 14 | + | } | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | // HistorySearchInput holds search parameters for history search use cases. | |||
| 6 | + | type HistorySearchInput struct { | |||
| 7 | + | Query string | |||
| 8 | + | Limit int | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // HistorySearchOutput holds search results for history search use cases. | |||
| 12 | + | type HistorySearchOutput struct { | |||
| 13 | + | Matches []entity.HistoryMatch | |||
| 14 | + | } | |||
M internal/application/port/homepage.go +1 -12
| @@ -8,23 +8,12 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // HistorySearchInput holds search parameters (moved from usecase.SearchInput). | |
| 12 | - | type HistorySearchInput struct { | |
| 13 | - | Query string | |
| 14 | - | Limit int | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | // HistorySearchOutput holds search results (moved from usecase.SearchOutput). | |
| 18 | - | type HistorySearchOutput struct { | |
| 19 | - | Matches []entity.HistoryMatch | |
| 20 | - | } | |
| 21 | - | ||
| 22 | 11 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | |
| 23 | 12 | type HomepageHistory interface { | |
| 24 | 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 25 | 14 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 26 | 15 | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |
| 27 | - | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | |
| 16 | + | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) | |
| 28 | 17 | Delete(ctx context.Context, id int64) error | |
| 29 | 18 | ClearRange(ctx context.Context, rangeID string) error | |
| 30 | 19 | ClearAll(ctx context.Context) error | |
| @@ -8,23 +8,12 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | // HistorySearchInput holds search parameters (moved from usecase.SearchInput). | |||
| 12 | - | type HistorySearchInput struct { | |||
| 13 | - | Query string | |||
| 14 | - | Limit int | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | // HistorySearchOutput holds search results (moved from usecase.SearchOutput). | |||
| 18 | - | type HistorySearchOutput struct { | |||
| 19 | - | Matches []entity.HistoryMatch | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | 11 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | ||
| 23 | type HomepageHistory interface { | 12 | type HomepageHistory interface { | ||
| 24 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 14 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 26 | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | 15 | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | ||
| 27 | - | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | 16 | + | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) |
| 28 | Delete(ctx context.Context, id int64) error | 17 | Delete(ctx context.Context, id int64) error | ||
| 29 | ClearRange(ctx context.Context, rangeID string) error | 18 | ClearRange(ctx context.Context, rangeID string) error | ||
| 30 | ClearAll(ctx context.Context) error | 19 | ClearAll(ctx context.Context) error | ||
M internal/application/port/mocks/mocks.go +12 -12
| @@ -7196,26 +7196,26 @@ func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.C | |||
| 7196 | 7196 | } | |
| 7197 | 7197 | ||
| 7198 | 7198 | // Search provides a mock function for the type MockHomepageHistory | |
| 7199 | - | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | |
| 7199 | + | func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) { | |
| 7200 | 7200 | ret := _mock.Called(ctx, input) | |
| 7201 | 7201 | ||
| 7202 | 7202 | if len(ret) == 0 { | |
| 7203 | 7203 | panic("no return value specified for Search") | |
| 7204 | 7204 | } | |
| 7205 | 7205 | ||
| 7206 | - | var r0 *port.HistorySearchOutput | |
| 7206 | + | var r0 *dto.HistorySearchOutput | |
| 7207 | 7207 | var r1 error | |
| 7208 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok { | |
| 7208 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok { | |
| 7209 | 7209 | return returnFunc(ctx, input) | |
| 7210 | 7210 | } | |
| 7211 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok { | |
| 7211 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok { | |
| 7212 | 7212 | r0 = returnFunc(ctx, input) | |
| 7213 | 7213 | } else { | |
| 7214 | 7214 | if ret.Get(0) != nil { | |
| 7215 | - | r0 = ret.Get(0).(*port.HistorySearchOutput) | |
| 7215 | + | r0 = ret.Get(0).(*dto.HistorySearchOutput) | |
| 7216 | 7216 | } | |
| 7217 | 7217 | } | |
| 7218 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok { | |
| 7218 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok { | |
| 7219 | 7219 | r1 = returnFunc(ctx, input) | |
| 7220 | 7220 | } else { | |
| 7221 | 7221 | r1 = ret.Error(1) | |
| @@ -7230,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct { | |||
| 7230 | 7230 | ||
| 7231 | 7231 | // Search is a helper method to define mock.On call | |
| 7232 | 7232 | // - ctx context.Context | |
| 7233 | - | // - input port.HistorySearchInput | |
| 7233 | + | // - input dto.HistorySearchInput | |
| 7234 | 7234 | func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call { | |
| 7235 | 7235 | return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)} | |
| 7236 | 7236 | } | |
| 7237 | 7237 | ||
| 7238 | - | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call { | |
| 7238 | + | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call { | |
| 7239 | 7239 | _c.Call.Run(func(args mock.Arguments) { | |
| 7240 | 7240 | var arg0 context.Context | |
| 7241 | 7241 | if args[0] != nil { | |
| 7242 | 7242 | arg0 = args[0].(context.Context) | |
| 7243 | 7243 | } | |
| 7244 | - | var arg1 port.HistorySearchInput | |
| 7244 | + | var arg1 dto.HistorySearchInput | |
| 7245 | 7245 | if args[1] != nil { | |
| 7246 | - | arg1 = args[1].(port.HistorySearchInput) | |
| 7246 | + | arg1 = args[1].(dto.HistorySearchInput) | |
| 7247 | 7247 | } | |
| 7248 | 7248 | run( | |
| 7249 | 7249 | arg0, | |
| @@ -7253,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp | |||
| 7253 | 7253 | return _c | |
| 7254 | 7254 | } | |
| 7255 | 7255 | ||
| 7256 | - | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { | |
| 7256 | + | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { | |
| 7257 | 7257 | _c.Call.Return(historySearchOutput, err) | |
| 7258 | 7258 | return _c | |
| 7259 | 7259 | } | |
| 7260 | 7260 | ||
| 7261 | - | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { | |
| 7261 | + | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { | |
| 7262 | 7262 | _c.Call.Return(run) | |
| 7263 | 7263 | return _c | |
| 7264 | 7264 | } | |
| @@ -7196,26 +7196,26 @@ func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.C | |||||
| 7196 | } | 7196 | } | ||
| 7197 | 7197 | ||||
| 7198 | // Search provides a mock function for the type MockHomepageHistory | 7198 | // Search provides a mock function for the type MockHomepageHistory | ||
| 7199 | - | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | 7199 | + | func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) { |
| 7200 | ret := _mock.Called(ctx, input) | 7200 | ret := _mock.Called(ctx, input) | ||
| 7201 | 7201 | ||||
| 7202 | if len(ret) == 0 { | 7202 | if len(ret) == 0 { | ||
| 7203 | panic("no return value specified for Search") | 7203 | panic("no return value specified for Search") | ||
| 7204 | } | 7204 | } | ||
| 7205 | 7205 | ||||
| 7206 | - | var r0 *port.HistorySearchOutput | 7206 | + | var r0 *dto.HistorySearchOutput |
| 7207 | var r1 error | 7207 | var r1 error | ||
| 7208 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok { | 7208 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok { |
| 7209 | return returnFunc(ctx, input) | 7209 | return returnFunc(ctx, input) | ||
| 7210 | } | 7210 | } | ||
| 7211 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok { | 7211 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok { |
| 7212 | r0 = returnFunc(ctx, input) | 7212 | r0 = returnFunc(ctx, input) | ||
| 7213 | } else { | 7213 | } else { | ||
| 7214 | if ret.Get(0) != nil { | 7214 | if ret.Get(0) != nil { | ||
| 7215 | - | r0 = ret.Get(0).(*port.HistorySearchOutput) | 7215 | + | r0 = ret.Get(0).(*dto.HistorySearchOutput) |
| 7216 | } | 7216 | } | ||
| 7217 | } | 7217 | } | ||
| 7218 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok { | 7218 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok { |
| 7219 | r1 = returnFunc(ctx, input) | 7219 | r1 = returnFunc(ctx, input) | ||
| 7220 | } else { | 7220 | } else { | ||
| 7221 | r1 = ret.Error(1) | 7221 | r1 = ret.Error(1) | ||
| @@ -7230,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct { | |||||
| 7230 | 7230 | ||||
| 7231 | // Search is a helper method to define mock.On call | 7231 | // Search is a helper method to define mock.On call | ||
| 7232 | // - ctx context.Context | 7232 | // - ctx context.Context | ||
| 7233 | - | // - input port.HistorySearchInput | 7233 | + | // - input dto.HistorySearchInput |
| 7234 | func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call { | 7234 | func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call { | ||
| 7235 | return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)} | 7235 | return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)} | ||
| 7236 | } | 7236 | } | ||
| 7237 | 7237 | ||||
| 7238 | - | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call { | 7238 | + | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call { |
| 7239 | _c.Call.Run(func(args mock.Arguments) { | 7239 | _c.Call.Run(func(args mock.Arguments) { | ||
| 7240 | var arg0 context.Context | 7240 | var arg0 context.Context | ||
| 7241 | if args[0] != nil { | 7241 | if args[0] != nil { | ||
| 7242 | arg0 = args[0].(context.Context) | 7242 | arg0 = args[0].(context.Context) | ||
| 7243 | } | 7243 | } | ||
| 7244 | - | var arg1 port.HistorySearchInput | 7244 | + | var arg1 dto.HistorySearchInput |
| 7245 | if args[1] != nil { | 7245 | if args[1] != nil { | ||
| 7246 | - | arg1 = args[1].(port.HistorySearchInput) | 7246 | + | arg1 = args[1].(dto.HistorySearchInput) |
| 7247 | } | 7247 | } | ||
| 7248 | run( | 7248 | run( | ||
| 7249 | arg0, | 7249 | arg0, | ||
| @@ -7253,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp | |||||
| 7253 | return _c | 7253 | return _c | ||
| 7254 | } | 7254 | } | ||
| 7255 | 7255 | ||||
| 7256 | - | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { | 7256 | + | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { |
| 7257 | _c.Call.Return(historySearchOutput, err) | 7257 | _c.Call.Return(historySearchOutput, err) | ||
| 7258 | return _c | 7258 | return _c | ||
| 7259 | } | 7259 | } | ||
| 7260 | 7260 | ||||
| 7261 | - | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { | 7261 | + | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { |
| 7262 | _c.Call.Return(run) | 7262 | _c.Call.Return(run) | ||
| 7263 | return _c | 7263 | return _c | ||
| 7264 | } | 7264 | } | ||
M internal/application/usecase/manage_favorites.go +20 -16
| @@ -102,6 +102,11 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||
| 102 | 102 | ||
| 103 | 103 | // AddFavorite creates a favorite from the UI-facing application port. | |
| 104 | 104 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 105 | + | for _, tagID := range input.Tags { | |
| 106 | + | if tagID <= 0 { | |
| 107 | + | return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID) | |
| 108 | + | } | |
| 109 | + | } | |
| 105 | 110 | favoriteURL, err := normalizeFavoriteURL(input.URL) | |
| 106 | 111 | if err != nil { | |
| 107 | 112 | return nil, err | |
| @@ -111,12 +116,7 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav | |||
| 111 | 116 | } else if existing != nil { | |
| 112 | 117 | return existing, nil | |
| 113 | 118 | } | |
| 114 | - | tags := make([]entity.TagID, 0, len(input.Tags)) | |
| 115 | - | for _, tagID := range input.Tags { | |
| 116 | - | if tagID > 0 { | |
| 117 | - | tags = append(tags, tagID) | |
| 118 | - | } | |
| 119 | - | } | |
| 119 | + | tags := append([]entity.TagID(nil), input.Tags...) | |
| 120 | 120 | title := strings.TrimSpace(input.Title) | |
| 121 | 121 | if title == "" { | |
| 122 | 122 | title = favoriteURL | |
| @@ -130,7 +130,11 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav | |||
| 130 | 130 | }) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | - | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) { | |
| 133 | + | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL( | |
| 134 | + | ctx context.Context, | |
| 135 | + | canonicalURL string, | |
| 136 | + | rawURL string, | |
| 137 | + | ) (*entity.Favorite, error) { | |
| 134 | 138 | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | |
| 135 | 139 | if err != nil { | |
| 136 | 140 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| @@ -229,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||
| 229 | 233 | } | |
| 230 | 234 | ||
| 231 | 235 | // RemoveByURL deletes a favorite by its URL. | |
| 232 | - | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error { | |
| 236 | + | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { | |
| 233 | 237 | log := logging.FromContext(ctx) | |
| 234 | - | log.Debug().Str("url", url).Msg("removing favorite by URL") | |
| 238 | + | log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL") | |
| 235 | 239 | ||
| 236 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 240 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) | |
| 237 | 241 | if err != nil { | |
| 238 | 242 | return fmt.Errorf("failed to find favorite: %w", err) | |
| 239 | 243 | } | |
| 240 | 244 | if fav == nil { | |
| 241 | - | log.Debug().Str("url", url).Msg("favorite not found") | |
| 245 | + | log.Debug().Str("url", favoriteURL).Msg("favorite not found") | |
| 242 | 246 | return nil | |
| 243 | 247 | } | |
| 244 | 248 | ||
| @@ -308,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (* | |||
| 308 | 312 | } | |
| 309 | 313 | ||
| 310 | 314 | // GetByURL finds a favorite by its URL. | |
| 311 | - | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) { | |
| 315 | + | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { | |
| 312 | 316 | log := logging.FromContext(ctx) | |
| 313 | - | log.Debug().Str("url", url).Msg("getting favorite by URL") | |
| 317 | + | log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL") | |
| 314 | 318 | ||
| 315 | - | return uc.favoriteRepo.FindByURL(ctx, url) | |
| 319 | + | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) | |
| 316 | 320 | } | |
| 317 | 321 | ||
| 318 | 322 | // GetAll retrieves all favorites. | |
| @@ -389,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st | |||
| 389 | 393 | } | |
| 390 | 394 | ||
| 391 | 395 | // IsFavorite checks if a URL is favorited. | |
| 392 | - | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | |
| 393 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 396 | + | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) { | |
| 397 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) | |
| 394 | 398 | if err != nil { | |
| 395 | 399 | return false, fmt.Errorf("failed to check favorite: %w", err) | |
| 396 | 400 | } | |
| @@ -102,6 +102,11 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||||
| 102 | 102 | ||||
| 103 | // AddFavorite creates a favorite from the UI-facing application port. | 103 | // AddFavorite creates a favorite from the UI-facing application port. | ||
| 104 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | 104 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | ||
| 105 | + | for _, tagID := range input.Tags { | |||
| 106 | + | if tagID <= 0 { | |||
| 107 | + | return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 105 | favoriteURL, err := normalizeFavoriteURL(input.URL) | 110 | favoriteURL, err := normalizeFavoriteURL(input.URL) | ||
| 106 | if err != nil { | 111 | if err != nil { | ||
| 107 | return nil, err | 112 | return nil, err | ||
| @@ -111,12 +116,7 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav | |||||
| 111 | } else if existing != nil { | 116 | } else if existing != nil { | ||
| 112 | return existing, nil | 117 | return existing, nil | ||
| 113 | } | 118 | } | ||
| 114 | - | tags := make([]entity.TagID, 0, len(input.Tags)) | 119 | + | tags := append([]entity.TagID(nil), input.Tags...) |
| 115 | - | for _, tagID := range input.Tags { | |||
| 116 | - | if tagID > 0 { | |||
| 117 | - | tags = append(tags, tagID) | |||
| 118 | - | } | |||
| 119 | - | } | |||
| 120 | title := strings.TrimSpace(input.Title) | 120 | title := strings.TrimSpace(input.Title) | ||
| 121 | if title == "" { | 121 | if title == "" { | ||
| 122 | title = favoriteURL | 122 | title = favoriteURL | ||
| @@ -130,7 +130,11 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav | |||||
| 130 | }) | 130 | }) | ||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | - | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) { | 133 | + | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL( |
| 134 | + | ctx context.Context, | |||
| 135 | + | canonicalURL string, | |||
| 136 | + | rawURL string, | |||
| 137 | + | ) (*entity.Favorite, error) { | |||
| 134 | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | 138 | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | ||
| 135 | if err != nil { | 139 | if err != nil { | ||
| 136 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | 140 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | ||
| @@ -229,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||||
| 229 | } | 233 | } | ||
| 230 | 234 | ||||
| 231 | // RemoveByURL deletes a favorite by its URL. | 235 | // RemoveByURL deletes a favorite by its URL. | ||
| 232 | - | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error { | 236 | + | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { |
| 233 | log := logging.FromContext(ctx) | 237 | log := logging.FromContext(ctx) | ||
| 234 | - | log.Debug().Str("url", url).Msg("removing favorite by URL") | 238 | + | log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL") |
| 235 | 239 | ||||
| 236 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | 240 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 237 | if err != nil { | 241 | if err != nil { | ||
| 238 | return fmt.Errorf("failed to find favorite: %w", err) | 242 | return fmt.Errorf("failed to find favorite: %w", err) | ||
| 239 | } | 243 | } | ||
| 240 | if fav == nil { | 244 | if fav == nil { | ||
| 241 | - | log.Debug().Str("url", url).Msg("favorite not found") | 245 | + | log.Debug().Str("url", favoriteURL).Msg("favorite not found") |
| 242 | return nil | 246 | return nil | ||
| 243 | } | 247 | } | ||
| 244 | 248 | ||||
| @@ -308,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (* | |||||
| 308 | } | 312 | } | ||
| 309 | 313 | ||||
| 310 | // GetByURL finds a favorite by its URL. | 314 | // GetByURL finds a favorite by its URL. | ||
| 311 | - | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) { | 315 | + | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { |
| 312 | log := logging.FromContext(ctx) | 316 | log := logging.FromContext(ctx) | ||
| 313 | - | log.Debug().Str("url", url).Msg("getting favorite by URL") | 317 | + | log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL") |
| 314 | 318 | ||||
| 315 | - | return uc.favoriteRepo.FindByURL(ctx, url) | 319 | + | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 316 | } | 320 | } | ||
| 317 | 321 | ||||
| 318 | // GetAll retrieves all favorites. | 322 | // GetAll retrieves all favorites. | ||
| @@ -389,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st | |||||
| 389 | } | 393 | } | ||
| 390 | 394 | ||||
| 391 | // IsFavorite checks if a URL is favorited. | 395 | // IsFavorite checks if a URL is favorited. | ||
| 392 | - | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | 396 | + | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) { |
| 393 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | 397 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 394 | if err != nil { | 398 | if err != nil { | ||
| 395 | return false, fmt.Errorf("failed to check favorite: %w", err) | 399 | return false, fmt.Errorf("failed to check favorite: %w", err) | ||
| 396 | } | 400 | } | ||
M internal/application/usecase/manage_favorites_test.go +13 -0
| @@ -389,6 +389,19 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |||
| 389 | 389 | assert.Equal(t, "Example", fav.Title) | |
| 390 | 390 | } | |
| 391 | 391 | ||
| 392 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) { | |
| 393 | + | ctx := testContext() | |
| 394 | + | ||
| 395 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 396 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 397 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 398 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 399 | + | ||
| 400 | + | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}}) | |
| 401 | + | require.Error(t, err) | |
| 402 | + | assert.Contains(t, err.Error(), "favorite tag id must be positive") | |
| 403 | + | } | |
| 404 | + | ||
| 392 | 405 | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | |
| 393 | 406 | ctx := testContext() | |
| 394 | 407 | ||
| @@ -389,6 +389,19 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |||||
| 389 | assert.Equal(t, "Example", fav.Title) | 389 | assert.Equal(t, "Example", fav.Title) | ||
| 390 | } | 390 | } | ||
| 391 | 391 | ||||
| 392 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) { | |||
| 393 | + | ctx := testContext() | |||
| 394 | + | ||||
| 395 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 396 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 397 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 398 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 399 | + | ||||
| 400 | + | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}}) | |||
| 401 | + | require.Error(t, err) | |||
| 402 | + | assert.Contains(t, err.Error(), "favorite tag id must be positive") | |||
| 403 | + | } | |||
| 404 | + | ||||
| 392 | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | 405 | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | ||
| 393 | ctx := testContext() | 406 | ctx := testContext() | ||
| 394 | 407 | ||||
M internal/application/usecase/save_webui_config.go +2 -2
| @@ -85,11 +85,11 @@ func validateWebUIConfig(cfg dto.WebUIConfig) error { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | switch cfg.Performance.Profile { | |
| 88 | - | case "", "default", "lite", "max", "custom": | |
| 88 | + | case "", "default", "lite", "balanced", "max", "custom": | |
| 89 | 89 | // ok | |
| 90 | 90 | default: | |
| 91 | 91 | errs = append(errs, fmt.Sprintf( | |
| 92 | - | "performance.profile must be one of: default, lite, max, custom (got: %s)", | |
| 92 | + | "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)", | |
| 93 | 93 | cfg.Performance.Profile, | |
| 94 | 94 | )) | |
| 95 | 95 | } | |
| @@ -85,11 +85,11 @@ func validateWebUIConfig(cfg dto.WebUIConfig) error { | |||||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | switch cfg.Performance.Profile { | 87 | switch cfg.Performance.Profile { | ||
| 88 | - | case "", "default", "lite", "max", "custom": | 88 | + | case "", "default", "lite", "balanced", "max", "custom": |
| 89 | // ok | 89 | // ok | ||
| 90 | default: | 90 | default: | ||
| 91 | errs = append(errs, fmt.Sprintf( | 91 | errs = append(errs, fmt.Sprintf( | ||
| 92 | - | "performance.profile must be one of: default, lite, max, custom (got: %s)", | 92 | + | "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)", |
| 93 | cfg.Performance.Profile, | 93 | cfg.Performance.Profile, | ||
| 94 | )) | 94 | )) | ||
| 95 | } | 95 | } | ||
M internal/application/usecase/search_history.go +5 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 12 | historydomain "github.com/bnema/dumber/internal/domain/history" | |
| @@ -31,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi | |||
| 31 | 32 | } | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | - | // SearchInput is an alias for port.HistorySearchInput. | |
| 35 | - | type SearchInput = port.HistorySearchInput | |
| 35 | + | // SearchInput is an alias for dto.HistorySearchInput. | |
| 36 | + | type SearchInput = dto.HistorySearchInput | |
| 36 | 37 | ||
| 37 | - | // SearchOutput is an alias for port.HistorySearchOutput. | |
| 38 | - | type SearchOutput = port.HistorySearchOutput | |
| 38 | + | // SearchOutput is an alias for dto.HistorySearchOutput. | |
| 39 | + | type SearchOutput = dto.HistorySearchOutput | |
| 39 | 40 | ||
| 40 | 41 | // Search performs a full-text search on history entries using SQLite FTS5. | |
| 41 | 42 | // Returns only entries that actually match the query terms. | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | historydomain "github.com/bnema/dumber/internal/domain/history" | 12 | historydomain "github.com/bnema/dumber/internal/domain/history" | ||
| @@ -31,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi | |||||
| 31 | } | 32 | } | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | - | // SearchInput is an alias for port.HistorySearchInput. | 35 | + | // SearchInput is an alias for dto.HistorySearchInput. |
| 35 | - | type SearchInput = port.HistorySearchInput | 36 | + | type SearchInput = dto.HistorySearchInput |
| 36 | 37 | ||||
| 37 | - | // SearchOutput is an alias for port.HistorySearchOutput. | 38 | + | // SearchOutput is an alias for dto.HistorySearchOutput. |
| 38 | - | type SearchOutput = port.HistorySearchOutput | 39 | + | type SearchOutput = dto.HistorySearchOutput |
| 39 | 40 | ||||
| 40 | // Search performs a full-text search on history entries using SQLite FTS5. | 41 | // Search performs a full-text search on history entries using SQLite FTS5. | ||
| 41 | // Returns only entries that actually match the query terms. | 42 | // Returns only entries that actually match the query terms. | ||
M internal/infrastructure/cef/scheme_handler.go +126 -29
| @@ -179,9 +179,14 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s | |||
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | 181 | // handleAPI routes API requests to the message router or built-in handlers. | |
| 182 | - | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 182 | + | func (h *dumbSchemeHandler) handleAPI( | |
| 183 | + | browser purecef.Browser, | |
| 184 | + | method string, | |
| 185 | + | requestPath string, | |
| 186 | + | request purecef.Request, | |
| 187 | + | ) purecef.ResourceHandler { | |
| 183 | 188 | if strings.EqualFold(method, http.MethodOptions) { | |
| 184 | - | if path == "/api/config" || path == "/api/config/default" { | |
| 189 | + | if requestPath == "/api/config" || requestPath == "/api/config/default" { | |
| 185 | 190 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |
| 186 | 191 | return denied | |
| 187 | 192 | } | |
| @@ -190,12 +195,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||
| 190 | 195 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |
| 191 | 196 | } | |
| 192 | 197 | if isAPIGetMethod(method) { | |
| 193 | - | if handler, ok := h.handleAPIGet(path, request); ok { | |
| 198 | + | if handler, ok := h.handleAPIGet(requestPath, request); ok { | |
| 194 | 199 | return handler | |
| 195 | 200 | } | |
| 196 | 201 | } | |
| 197 | 202 | if isAPIPostMethod(method) { | |
| 198 | - | if handler, ok := h.handleAPIPost(browser, path, request); ok { | |
| 203 | + | if handler, ok := h.handleAPIPost(browser, requestPath, request); ok { | |
| 199 | 204 | return handler | |
| 200 | 205 | } | |
| 201 | 206 | } | |
| @@ -210,8 +215,8 @@ func isAPIPostMethod(method string) bool { | |||
| 210 | 215 | return strings.EqualFold(method, http.MethodPost) | |
| 211 | 216 | } | |
| 212 | 217 | ||
| 213 | - | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 214 | - | switch path { | |
| 218 | + | func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 219 | + | switch requestPath { | |
| 215 | 220 | case "/api/config": | |
| 216 | 221 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |
| 217 | 222 | return denied, true | |
| @@ -231,8 +236,12 @@ func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) ( | |||
| 231 | 236 | } | |
| 232 | 237 | } | |
| 233 | 238 | ||
| 234 | - | func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 235 | - | switch path { | |
| 239 | + | func (h *dumbSchemeHandler) handleAPIPost( | |
| 240 | + | browser purecef.Browser, | |
| 241 | + | requestPath string, | |
| 242 | + | request purecef.Request, | |
| 243 | + | ) (purecef.ResourceHandler, bool) { | |
| 244 | + | switch requestPath { | |
| 236 | 245 | case "/api/message": | |
| 237 | 246 | return h.handleMessageAPI(request), true | |
| 238 | 247 | case "/api/clipboard-set": | |
| @@ -665,7 +674,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 665 | 674 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |
| 666 | 675 | } | |
| 667 | 676 | ||
| 668 | - | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 677 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 669 | 678 | if err != nil { | |
| 670 | 679 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 671 | 680 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |
| @@ -678,7 +687,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 678 | 687 | Int("size", len(data)). | |
| 679 | 688 | Msg("serving asset") | |
| 680 | 689 | ||
| 681 | - | return newStaticResourceHandler(http.StatusOK, contentType, data, headers) | |
| 690 | + | return newStaticResourceHandler(http.StatusOK, contentType, data, nil) | |
| 682 | 691 | } | |
| 683 | 692 | ||
| 684 | 693 | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |
| @@ -704,21 +713,21 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||
| 704 | 713 | return fullPath, cleanRelPath, true | |
| 705 | 714 | } | |
| 706 | 715 | ||
| 707 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | |
| 716 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | |
| 708 | 717 | if strings.HasSuffix(relPath, ".wasm") { | |
| 709 | 718 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 710 | 719 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |
| 711 | 720 | if err != nil { | |
| 712 | - | return nil, nil, err | |
| 721 | + | return nil, err | |
| 713 | 722 | } | |
| 714 | 723 | if len(data) > maxSystemviewsWASMBytes { | |
| 715 | - | return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |
| 724 | + | return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |
| 716 | 725 | } | |
| 717 | - | return data, nil, nil | |
| 726 | + | return data, nil | |
| 718 | 727 | } | |
| 719 | 728 | } | |
| 720 | 729 | data, err := fs.ReadFile(assets, fullPath) | |
| 721 | - | return data, nil, err | |
| 730 | + | return data, err | |
| 722 | 731 | } | |
| 723 | 732 | ||
| 724 | 733 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | |
| @@ -741,15 +750,15 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) | |||
| 741 | 750 | } | |
| 742 | 751 | ||
| 743 | 752 | if root, ok := pageRootFiles[u.Host]; ok { | |
| 744 | - | path := strings.TrimPrefix(u.Path, "/") | |
| 745 | - | if path == "" { | |
| 753 | + | assetPath := strings.TrimPrefix(u.Path, "/") | |
| 754 | + | if assetPath == "" { | |
| 746 | 755 | return assetDirForPageHost(u.Host), root, true | |
| 747 | 756 | } | |
| 748 | 757 | // Don't serve API paths as assets. | |
| 749 | - | if strings.HasPrefix(path, "api/") { | |
| 758 | + | if strings.HasPrefix(assetPath, "api/") { | |
| 750 | 759 | return "", "", false | |
| 751 | 760 | } | |
| 752 | - | return assetDirForPageHost(u.Host), path, true | |
| 761 | + | return assetDirForPageHost(u.Host), assetPath, true | |
| 753 | 762 | } | |
| 754 | 763 | ||
| 755 | 764 | // Handle opaque URLs like dumb:history. | |
| @@ -764,12 +773,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 764 | 773 | return "", "", false | |
| 765 | 774 | } | |
| 766 | 775 | ||
| 767 | - | path := strings.Trim(u.Path, "/") | |
| 768 | - | if path == "" { | |
| 776 | + | assetPath := strings.Trim(u.Path, "/") | |
| 777 | + | if assetPath == "" { | |
| 769 | 778 | return "", "", false | |
| 770 | 779 | } | |
| 771 | 780 | ||
| 772 | - | parts := strings.SplitN(path, "/", 2) | |
| 781 | + | parts := strings.SplitN(assetPath, "/", 2) | |
| 773 | 782 | page := parts[0] | |
| 774 | 783 | if root, ok := pageRootFiles[page]; ok { | |
| 775 | 784 | if len(parts) == 1 { | |
| @@ -781,12 +790,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 781 | 790 | return assetDirForPageHost(page), parts[1], true | |
| 782 | 791 | } | |
| 783 | 792 | ||
| 784 | - | if strings.HasPrefix(path, "api/") { | |
| 793 | + | if strings.HasPrefix(assetPath, "api/") { | |
| 785 | 794 | return "", "", false | |
| 786 | 795 | } | |
| 787 | 796 | ||
| 788 | - | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | |
| 789 | - | return systemviewsAssetDir, path, true | |
| 797 | + | if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") { | |
| 798 | + | return systemviewsAssetDir, assetPath, true | |
| 790 | 799 | } | |
| 791 | 800 | ||
| 792 | 801 | return "", "", false | |
| @@ -929,6 +938,79 @@ func newFaviconResourceHandler(service port.FaviconService, domain string, size | |||
| 929 | 938 | }) | |
| 930 | 939 | } | |
| 931 | 940 | ||
| 941 | + | const maxFaviconMemoryCacheEntries = 256 | |
| 942 | + | ||
| 943 | + | var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries) | |
| 944 | + | ||
| 945 | + | type faviconCacheEntry struct { | |
| 946 | + | statusCode int | |
| 947 | + | contentType string | |
| 948 | + | data []byte | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | type faviconMemoryCache struct { | |
| 952 | + | mu sync.Mutex | |
| 953 | + | max int | |
| 954 | + | entries map[string]faviconCacheEntry | |
| 955 | + | order []string | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache { | |
| 959 | + | return &faviconMemoryCache{ | |
| 960 | + | max: maxEntries, | |
| 961 | + | entries: make(map[string]faviconCacheEntry), | |
| 962 | + | order: make([]string, 0, maxEntries), | |
| 963 | + | } | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | func faviconCacheKey(domain string, size int) string { | |
| 967 | + | return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size) | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) { | |
| 971 | + | if c == nil || key == "" { | |
| 972 | + | return faviconCacheEntry{}, false | |
| 973 | + | } | |
| 974 | + | c.mu.Lock() | |
| 975 | + | defer c.mu.Unlock() | |
| 976 | + | entry, ok := c.entries[key] | |
| 977 | + | if !ok { | |
| 978 | + | return faviconCacheEntry{}, false | |
| 979 | + | } | |
| 980 | + | c.moveToBackLocked(key) | |
| 981 | + | return entry, true | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) { | |
| 985 | + | if c == nil || key == "" || c.max <= 0 { | |
| 986 | + | return | |
| 987 | + | } | |
| 988 | + | c.mu.Lock() | |
| 989 | + | defer c.mu.Unlock() | |
| 990 | + | if _, ok := c.entries[key]; !ok { | |
| 991 | + | c.order = append(c.order, key) | |
| 992 | + | } else { | |
| 993 | + | c.moveToBackLocked(key) | |
| 994 | + | } | |
| 995 | + | c.entries[key] = entry | |
| 996 | + | for len(c.order) > c.max { | |
| 997 | + | oldest := c.order[0] | |
| 998 | + | c.order = c.order[1:] | |
| 999 | + | delete(c.entries, oldest) | |
| 1000 | + | } | |
| 1001 | + | } | |
| 1002 | + | ||
| 1003 | + | func (c *faviconMemoryCache) moveToBackLocked(key string) { | |
| 1004 | + | for i, candidate := range c.order { | |
| 1005 | + | if candidate != key { | |
| 1006 | + | continue | |
| 1007 | + | } | |
| 1008 | + | copy(c.order[i:], c.order[i+1:]) | |
| 1009 | + | c.order[len(c.order)-1] = key | |
| 1010 | + | return | |
| 1011 | + | } | |
| 1012 | + | } | |
| 1013 | + | ||
| 932 | 1014 | type faviconResourceHandler struct { | |
| 933 | 1015 | service port.FaviconService | |
| 934 | 1016 | domain string | |
| @@ -947,20 +1029,35 @@ func (rh *faviconResourceHandler) load() { | |||
| 947 | 1029 | rh.statusCode = http.StatusNotFound | |
| 948 | 1030 | rh.contentType = "application/json" | |
| 949 | 1031 | rh.data = []byte(`{"error":"favicon not cached"}`) | |
| 950 | - | if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |
| 1032 | + | if rh.service == nil { | |
| 1033 | + | return | |
| 1034 | + | } | |
| 1035 | + | cacheKey := faviconCacheKey(rh.domain, rh.size) | |
| 1036 | + | if entry, ok := systemviewFaviconCache.get(cacheKey); ok { | |
| 1037 | + | rh.statusCode = entry.statusCode | |
| 1038 | + | rh.contentType = entry.contentType | |
| 1039 | + | rh.data = entry.data | |
| 1040 | + | return | |
| 1041 | + | } | |
| 1042 | + | if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |
| 951 | 1043 | return | |
| 952 | 1044 | } | |
| 953 | - | path := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |
| 954 | - | if path == "" { | |
| 1045 | + | diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |
| 1046 | + | if diskPath == "" { | |
| 955 | 1047 | return | |
| 956 | 1048 | } | |
| 957 | - | data, err := os.ReadFile(path) | |
| 1049 | + | data, err := os.ReadFile(diskPath) | |
| 958 | 1050 | if err != nil || len(data) == 0 { | |
| 959 | 1051 | return | |
| 960 | 1052 | } | |
| 961 | 1053 | rh.statusCode = http.StatusOK | |
| 962 | 1054 | rh.contentType = "image/png" | |
| 963 | 1055 | rh.data = data | |
| 1056 | + | systemviewFaviconCache.put(cacheKey, faviconCacheEntry{ | |
| 1057 | + | statusCode: rh.statusCode, | |
| 1058 | + | contentType: rh.contentType, | |
| 1059 | + | data: data, | |
| 1060 | + | }) | |
| 964 | 1061 | } | |
| 965 | 1062 | ||
| 966 | 1063 | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | |
| @@ -179,9 +179,14 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s | |||||
| 179 | } | 179 | } | ||
| 180 | 180 | ||||
| 181 | // handleAPI routes API requests to the message router or built-in handlers. | 181 | // handleAPI routes API requests to the message router or built-in handlers. | ||
| 182 | - | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | 182 | + | func (h *dumbSchemeHandler) handleAPI( |
| 183 | + | browser purecef.Browser, | |||
| 184 | + | method string, | |||
| 185 | + | requestPath string, | |||
| 186 | + | request purecef.Request, | |||
| 187 | + | ) purecef.ResourceHandler { | |||
| 183 | if strings.EqualFold(method, http.MethodOptions) { | 188 | if strings.EqualFold(method, http.MethodOptions) { | ||
| 184 | - | if path == "/api/config" || path == "/api/config/default" { | 189 | + | if requestPath == "/api/config" || requestPath == "/api/config/default" { |
| 185 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | 190 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | ||
| 186 | return denied | 191 | return denied | ||
| 187 | } | 192 | } | ||
| @@ -190,12 +195,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||||
| 190 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | 195 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | ||
| 191 | } | 196 | } | ||
| 192 | if isAPIGetMethod(method) { | 197 | if isAPIGetMethod(method) { | ||
| 193 | - | if handler, ok := h.handleAPIGet(path, request); ok { | 198 | + | if handler, ok := h.handleAPIGet(requestPath, request); ok { |
| 194 | return handler | 199 | return handler | ||
| 195 | } | 200 | } | ||
| 196 | } | 201 | } | ||
| 197 | if isAPIPostMethod(method) { | 202 | if isAPIPostMethod(method) { | ||
| 198 | - | if handler, ok := h.handleAPIPost(browser, path, request); ok { | 203 | + | if handler, ok := h.handleAPIPost(browser, requestPath, request); ok { |
| 199 | return handler | 204 | return handler | ||
| 200 | } | 205 | } | ||
| 201 | } | 206 | } | ||
| @@ -210,8 +215,8 @@ func isAPIPostMethod(method string) bool { | |||||
| 210 | return strings.EqualFold(method, http.MethodPost) | 215 | return strings.EqualFold(method, http.MethodPost) | ||
| 211 | } | 216 | } | ||
| 212 | 217 | ||||
| 213 | - | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 218 | + | func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) { |
| 214 | - | switch path { | 219 | + | switch requestPath { |
| 215 | case "/api/config": | 220 | case "/api/config": | ||
| 216 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | 221 | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | ||
| 217 | return denied, true | 222 | return denied, true | ||
| @@ -231,8 +236,12 @@ func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) ( | |||||
| 231 | } | 236 | } | ||
| 232 | } | 237 | } | ||
| 233 | 238 | ||||
| 234 | - | func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 239 | + | func (h *dumbSchemeHandler) handleAPIPost( |
| 235 | - | switch path { | 240 | + | browser purecef.Browser, |
| 241 | + | requestPath string, | |||
| 242 | + | request purecef.Request, | |||
| 243 | + | ) (purecef.ResourceHandler, bool) { | |||
| 244 | + | switch requestPath { | |||
| 236 | case "/api/message": | 245 | case "/api/message": | ||
| 237 | return h.handleMessageAPI(request), true | 246 | return h.handleMessageAPI(request), true | ||
| 238 | case "/api/clipboard-set": | 247 | case "/api/clipboard-set": | ||
| @@ -665,7 +674,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 665 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | 674 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | ||
| 666 | } | 675 | } | ||
| 667 | 676 | ||||
| 668 | - | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | 677 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) |
| 669 | if err != nil { | 678 | if err != nil { | ||
| 670 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 679 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| 671 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | 680 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | ||
| @@ -678,7 +687,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 678 | Int("size", len(data)). | 687 | Int("size", len(data)). | ||
| 679 | Msg("serving asset") | 688 | Msg("serving asset") | ||
| 680 | 689 | ||||
| 681 | - | return newStaticResourceHandler(http.StatusOK, contentType, data, headers) | 690 | + | return newStaticResourceHandler(http.StatusOK, contentType, data, nil) |
| 682 | } | 691 | } | ||
| 683 | 692 | ||||
| 684 | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | 693 | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | ||
| @@ -704,21 +713,21 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||||
| 704 | return fullPath, cleanRelPath, true | 713 | return fullPath, cleanRelPath, true | ||
| 705 | } | 714 | } | ||
| 706 | 715 | ||||
| 707 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | 716 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { |
| 708 | if strings.HasSuffix(relPath, ".wasm") { | 717 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 709 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 718 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | ||
| 710 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | 719 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | ||
| 711 | if err != nil { | 720 | if err != nil { | ||
| 712 | - | return nil, nil, err | 721 | + | return nil, err |
| 713 | } | 722 | } | ||
| 714 | if len(data) > maxSystemviewsWASMBytes { | 723 | if len(data) > maxSystemviewsWASMBytes { | ||
| 715 | - | return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | 724 | + | return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) |
| 716 | } | 725 | } | ||
| 717 | - | return data, nil, nil | 726 | + | return data, nil |
| 718 | } | 727 | } | ||
| 719 | } | 728 | } | ||
| 720 | data, err := fs.ReadFile(assets, fullPath) | 729 | data, err := fs.ReadFile(assets, fullPath) | ||
| 721 | - | return data, nil, err | 730 | + | return data, err |
| 722 | } | 731 | } | ||
| 723 | 732 | ||||
| 724 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | 733 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | ||
| @@ -741,15 +750,15 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) | |||||
| 741 | } | 750 | } | ||
| 742 | 751 | ||||
| 743 | if root, ok := pageRootFiles[u.Host]; ok { | 752 | if root, ok := pageRootFiles[u.Host]; ok { | ||
| 744 | - | path := strings.TrimPrefix(u.Path, "/") | 753 | + | assetPath := strings.TrimPrefix(u.Path, "/") |
| 745 | - | if path == "" { | 754 | + | if assetPath == "" { |
| 746 | return assetDirForPageHost(u.Host), root, true | 755 | return assetDirForPageHost(u.Host), root, true | ||
| 747 | } | 756 | } | ||
| 748 | // Don't serve API paths as assets. | 757 | // Don't serve API paths as assets. | ||
| 749 | - | if strings.HasPrefix(path, "api/") { | 758 | + | if strings.HasPrefix(assetPath, "api/") { |
| 750 | return "", "", false | 759 | return "", "", false | ||
| 751 | } | 760 | } | ||
| 752 | - | return assetDirForPageHost(u.Host), path, true | 761 | + | return assetDirForPageHost(u.Host), assetPath, true |
| 753 | } | 762 | } | ||
| 754 | 763 | ||||
| 755 | // Handle opaque URLs like dumb:history. | 764 | // Handle opaque URLs like dumb:history. | ||
| @@ -764,12 +773,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 764 | return "", "", false | 773 | return "", "", false | ||
| 765 | } | 774 | } | ||
| 766 | 775 | ||||
| 767 | - | path := strings.Trim(u.Path, "/") | 776 | + | assetPath := strings.Trim(u.Path, "/") |
| 768 | - | if path == "" { | 777 | + | if assetPath == "" { |
| 769 | return "", "", false | 778 | return "", "", false | ||
| 770 | } | 779 | } | ||
| 771 | 780 | ||||
| 772 | - | parts := strings.SplitN(path, "/", 2) | 781 | + | parts := strings.SplitN(assetPath, "/", 2) |
| 773 | page := parts[0] | 782 | page := parts[0] | ||
| 774 | if root, ok := pageRootFiles[page]; ok { | 783 | if root, ok := pageRootFiles[page]; ok { | ||
| 775 | if len(parts) == 1 { | 784 | if len(parts) == 1 { | ||
| @@ -781,12 +790,12 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 781 | return assetDirForPageHost(page), parts[1], true | 790 | return assetDirForPageHost(page), parts[1], true | ||
| 782 | } | 791 | } | ||
| 783 | 792 | ||||
| 784 | - | if strings.HasPrefix(path, "api/") { | 793 | + | if strings.HasPrefix(assetPath, "api/") { |
| 785 | return "", "", false | 794 | return "", "", false | ||
| 786 | } | 795 | } | ||
| 787 | 796 | ||||
| 788 | - | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | 797 | + | if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") { |
| 789 | - | return systemviewsAssetDir, path, true | 798 | + | return systemviewsAssetDir, assetPath, true |
| 790 | } | 799 | } | ||
| 791 | 800 | ||||
| 792 | return "", "", false | 801 | return "", "", false | ||
| @@ -929,6 +938,79 @@ func newFaviconResourceHandler(service port.FaviconService, domain string, size | |||||
| 929 | }) | 938 | }) | ||
| 930 | } | 939 | } | ||
| 931 | 940 | ||||
| 941 | + | const maxFaviconMemoryCacheEntries = 256 | |||
| 942 | + | ||||
| 943 | + | var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries) | |||
| 944 | + | ||||
| 945 | + | type faviconCacheEntry struct { | |||
| 946 | + | statusCode int | |||
| 947 | + | contentType string | |||
| 948 | + | data []byte | |||
| 949 | + | } | |||
| 950 | + | ||||
| 951 | + | type faviconMemoryCache struct { | |||
| 952 | + | mu sync.Mutex | |||
| 953 | + | max int | |||
| 954 | + | entries map[string]faviconCacheEntry | |||
| 955 | + | order []string | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache { | |||
| 959 | + | return &faviconMemoryCache{ | |||
| 960 | + | max: maxEntries, | |||
| 961 | + | entries: make(map[string]faviconCacheEntry), | |||
| 962 | + | order: make([]string, 0, maxEntries), | |||
| 963 | + | } | |||
| 964 | + | } | |||
| 965 | + | ||||
| 966 | + | func faviconCacheKey(domain string, size int) string { | |||
| 967 | + | return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size) | |||
| 968 | + | } | |||
| 969 | + | ||||
| 970 | + | func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) { | |||
| 971 | + | if c == nil || key == "" { | |||
| 972 | + | return faviconCacheEntry{}, false | |||
| 973 | + | } | |||
| 974 | + | c.mu.Lock() | |||
| 975 | + | defer c.mu.Unlock() | |||
| 976 | + | entry, ok := c.entries[key] | |||
| 977 | + | if !ok { | |||
| 978 | + | return faviconCacheEntry{}, false | |||
| 979 | + | } | |||
| 980 | + | c.moveToBackLocked(key) | |||
| 981 | + | return entry, true | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) { | |||
| 985 | + | if c == nil || key == "" || c.max <= 0 { | |||
| 986 | + | return | |||
| 987 | + | } | |||
| 988 | + | c.mu.Lock() | |||
| 989 | + | defer c.mu.Unlock() | |||
| 990 | + | if _, ok := c.entries[key]; !ok { | |||
| 991 | + | c.order = append(c.order, key) | |||
| 992 | + | } else { | |||
| 993 | + | c.moveToBackLocked(key) | |||
| 994 | + | } | |||
| 995 | + | c.entries[key] = entry | |||
| 996 | + | for len(c.order) > c.max { | |||
| 997 | + | oldest := c.order[0] | |||
| 998 | + | c.order = c.order[1:] | |||
| 999 | + | delete(c.entries, oldest) | |||
| 1000 | + | } | |||
| 1001 | + | } | |||
| 1002 | + | ||||
| 1003 | + | func (c *faviconMemoryCache) moveToBackLocked(key string) { | |||
| 1004 | + | for i, candidate := range c.order { | |||
| 1005 | + | if candidate != key { | |||
| 1006 | + | continue | |||
| 1007 | + | } | |||
| 1008 | + | copy(c.order[i:], c.order[i+1:]) | |||
| 1009 | + | c.order[len(c.order)-1] = key | |||
| 1010 | + | return | |||
| 1011 | + | } | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 932 | type faviconResourceHandler struct { | 1014 | type faviconResourceHandler struct { | ||
| 933 | service port.FaviconService | 1015 | service port.FaviconService | ||
| 934 | domain string | 1016 | domain string | ||
| @@ -947,20 +1029,35 @@ func (rh *faviconResourceHandler) load() { | |||||
| 947 | rh.statusCode = http.StatusNotFound | 1029 | rh.statusCode = http.StatusNotFound | ||
| 948 | rh.contentType = "application/json" | 1030 | rh.contentType = "application/json" | ||
| 949 | rh.data = []byte(`{"error":"favicon not cached"}`) | 1031 | rh.data = []byte(`{"error":"favicon not cached"}`) | ||
| 950 | - | if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | 1032 | + | if rh.service == nil { |
| 1033 | + | return | |||
| 1034 | + | } | |||
| 1035 | + | cacheKey := faviconCacheKey(rh.domain, rh.size) | |||
| 1036 | + | if entry, ok := systemviewFaviconCache.get(cacheKey); ok { | |||
| 1037 | + | rh.statusCode = entry.statusCode | |||
| 1038 | + | rh.contentType = entry.contentType | |||
| 1039 | + | rh.data = entry.data | |||
| 1040 | + | return | |||
| 1041 | + | } | |||
| 1042 | + | if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |||
| 951 | return | 1043 | return | ||
| 952 | } | 1044 | } | ||
| 953 | - | path := rh.service.DiskPathPNGSized(rh.domain, rh.size) | 1045 | + | diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size) |
| 954 | - | if path == "" { | 1046 | + | if diskPath == "" { |
| 955 | return | 1047 | return | ||
| 956 | } | 1048 | } | ||
| 957 | - | data, err := os.ReadFile(path) | 1049 | + | data, err := os.ReadFile(diskPath) |
| 958 | if err != nil || len(data) == 0 { | 1050 | if err != nil || len(data) == 0 { | ||
| 959 | return | 1051 | return | ||
| 960 | } | 1052 | } | ||
| 961 | rh.statusCode = http.StatusOK | 1053 | rh.statusCode = http.StatusOK | ||
| 962 | rh.contentType = "image/png" | 1054 | rh.contentType = "image/png" | ||
| 963 | rh.data = data | 1055 | rh.data = data | ||
| 1056 | + | systemviewFaviconCache.put(cacheKey, faviconCacheEntry{ | |||
| 1057 | + | statusCode: rh.statusCode, | |||
| 1058 | + | contentType: rh.contentType, | |||
| 1059 | + | data: data, | |||
| 1060 | + | }) | |||
| 964 | } | 1061 | } | ||
| 965 | 1062 | ||||
| 966 | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | 1063 | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | ||
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +1 -4
| @@ -45,16 +45,13 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 45 | 45 | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 46 | 46 | t.Parallel() | |
| 47 | 47 | ||
| 48 | - | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 48 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 49 | 49 | if err != nil { | |
| 50 | 50 | t.Fatalf("readAssetWithEncoding() error = %v", err) | |
| 51 | 51 | } | |
| 52 | 52 | if len(data) < 4 || string(data[:4]) != "\x00asm" { | |
| 53 | 53 | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |
| 54 | 54 | } | |
| 55 | - | if _, ok := headers["Content-Encoding"]; ok { | |
| 56 | - | t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"]) | |
| 57 | - | } | |
| 58 | 55 | } | |
| 59 | 56 | ||
| 60 | 57 | func mustParseURL(t *testing.T, raw string) *url.URL { | |
| @@ -45,16 +45,13 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||||
| 45 | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | 45 | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | ||
| 46 | t.Parallel() | 46 | t.Parallel() | ||
| 47 | 47 | ||||
| 48 | - | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | 48 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") |
| 49 | if err != nil { | 49 | if err != nil { | ||
| 50 | t.Fatalf("readAssetWithEncoding() error = %v", err) | 50 | t.Fatalf("readAssetWithEncoding() error = %v", err) | ||
| 51 | } | 51 | } | ||
| 52 | if len(data) < 4 || string(data[:4]) != "\x00asm" { | 52 | if len(data) < 4 || string(data[:4]) != "\x00asm" { | ||
| 53 | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | 53 | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | ||
| 54 | } | 54 | } | ||
| 55 | - | if _, ok := headers["Content-Encoding"]; ok { | |||
| 56 | - | t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"]) | |||
| 57 | - | } | |||
| 58 | } | 55 | } | ||
| 59 | 56 | ||||
| 60 | func mustParseURL(t *testing.T, raw string) *url.URL { | 57 | func mustParseURL(t *testing.T, raw string) *url.URL { | ||
M internal/infrastructure/cef/transcoding_handler.go +3 -3
| @@ -112,17 +112,17 @@ func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |||
| 112 | 112 | rh.startErrStatus = status | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | func (rh *transcodingResourceHandler) getStartErr() (error, int) { | |
| 115 | + | func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |
| 116 | 116 | rh.mu.Lock() | |
| 117 | 117 | defer rh.mu.Unlock() | |
| 118 | - | return rh.startErr, rh.startErrStatus | |
| 118 | + | return rh.startErrStatus, rh.startErr | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | // GetResponseHeaders sets the streaming response metadata. | |
| 122 | 122 | func (rh *transcodingResourceHandler) GetResponseHeaders( | |
| 123 | 123 | response purecef.Response, responseLength *int64, _ uintptr, | |
| 124 | 124 | ) { | |
| 125 | - | if startErr, status := rh.getStartErr(); startErr != nil { | |
| 125 | + | if status, startErr := rh.getStartErr(); startErr != nil { | |
| 126 | 126 | if status == 0 { | |
| 127 | 127 | status = httpStatusBadGateway | |
| 128 | 128 | } | |
| @@ -112,17 +112,17 @@ func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |||||
| 112 | rh.startErrStatus = status | 112 | rh.startErrStatus = status | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | - | func (rh *transcodingResourceHandler) getStartErr() (error, int) { | 115 | + | func (rh *transcodingResourceHandler) getStartErr() (int, error) { |
| 116 | rh.mu.Lock() | 116 | rh.mu.Lock() | ||
| 117 | defer rh.mu.Unlock() | 117 | defer rh.mu.Unlock() | ||
| 118 | - | return rh.startErr, rh.startErrStatus | 118 | + | return rh.startErrStatus, rh.startErr |
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | // GetResponseHeaders sets the streaming response metadata. | 121 | // GetResponseHeaders sets the streaming response metadata. | ||
| 122 | func (rh *transcodingResourceHandler) GetResponseHeaders( | 122 | func (rh *transcodingResourceHandler) GetResponseHeaders( | ||
| 123 | response purecef.Response, responseLength *int64, _ uintptr, | 123 | response purecef.Response, responseLength *int64, _ uintptr, | ||
| 124 | ) { | 124 | ) { | ||
| 125 | - | if startErr, status := rh.getStartErr(); startErr != nil { | 125 | + | if status, startErr := rh.getStartErr(); startErr != nil { |
| 126 | if status == 0 { | 126 | if status == 0 { | ||
| 127 | status = httpStatusBadGateway | 127 | status = httpStatusBadGateway | ||
| 128 | } | 128 | } | ||
M internal/infrastructure/config/loader.go +2 -0
| @@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) { | |||
| 254 | 254 | config.Engine.Profile = ProfileDefault | |
| 255 | 255 | case string(ProfileLite): | |
| 256 | 256 | config.Engine.Profile = ProfileLite | |
| 257 | + | case string(ProfileBalanced): | |
| 258 | + | config.Engine.Profile = ProfileBalanced | |
| 257 | 259 | case string(ProfileMax): | |
| 258 | 260 | config.Engine.Profile = ProfileMax | |
| 259 | 261 | case string(ProfileCustom): | |
| @@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) { | |||||
| 254 | config.Engine.Profile = ProfileDefault | 254 | config.Engine.Profile = ProfileDefault | ||
| 255 | case string(ProfileLite): | 255 | case string(ProfileLite): | ||
| 256 | config.Engine.Profile = ProfileLite | 256 | config.Engine.Profile = ProfileLite | ||
| 257 | + | case string(ProfileBalanced): | |||
| 258 | + | config.Engine.Profile = ProfileBalanced | |||
| 257 | case string(ProfileMax): | 259 | case string(ProfileMax): | ||
| 258 | config.Engine.Profile = ProfileMax | 260 | config.Engine.Profile = ProfileMax | ||
| 259 | case string(ProfileCustom): | 261 | case string(ProfileCustom): | ||
M internal/infrastructure/config/profile.go +9 -1
| @@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re | |||
| 85 | 85 | switch cfg.Profile { | |
| 86 | 86 | case ProfileLite: | |
| 87 | 87 | return resolveLiteProfile(hw) | |
| 88 | + | case ProfileBalanced: | |
| 89 | + | return resolveBalancedProfile() | |
| 88 | 90 | case ProfileMax: | |
| 89 | 91 | return resolveMaxProfile(hw) | |
| 90 | 92 | case ProfileCustom: | |
| @@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings { | |||
| 116 | 118 | } | |
| 117 | 119 | } | |
| 118 | 120 | ||
| 121 | + | // resolveBalancedProfile preserves the legacy balanced profile as WebKit's | |
| 122 | + | // default tuning with the normal WebView prewarm count. | |
| 123 | + | func resolveBalancedProfile() ResolvedPerformanceSettings { | |
| 124 | + | return resolveDefaultProfile() | |
| 125 | + | } | |
| 126 | + | ||
| 119 | 127 | // resolveLiteProfile returns settings optimized for low-RAM systems. | |
| 120 | 128 | // Uses hardware info to scale CPU threads if available. | |
| 121 | 129 | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| @@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |||
| 299 | 307 | // IsValidPerformanceProfile returns true if the profile name is recognized. | |
| 300 | 308 | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | |
| 301 | 309 | switch profile { | |
| 302 | - | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | |
| 310 | + | case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "": | |
| 303 | 311 | return true | |
| 304 | 312 | default: | |
| 305 | 313 | return false | |
| @@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re | |||||
| 85 | switch cfg.Profile { | 85 | switch cfg.Profile { | ||
| 86 | case ProfileLite: | 86 | case ProfileLite: | ||
| 87 | return resolveLiteProfile(hw) | 87 | return resolveLiteProfile(hw) | ||
| 88 | + | case ProfileBalanced: | |||
| 89 | + | return resolveBalancedProfile() | |||
| 88 | case ProfileMax: | 90 | case ProfileMax: | ||
| 89 | return resolveMaxProfile(hw) | 91 | return resolveMaxProfile(hw) | ||
| 90 | case ProfileCustom: | 92 | case ProfileCustom: | ||
| @@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings { | |||||
| 116 | } | 118 | } | ||
| 117 | } | 119 | } | ||
| 118 | 120 | ||||
| 121 | + | // resolveBalancedProfile preserves the legacy balanced profile as WebKit's | |||
| 122 | + | // default tuning with the normal WebView prewarm count. | |||
| 123 | + | func resolveBalancedProfile() ResolvedPerformanceSettings { | |||
| 124 | + | return resolveDefaultProfile() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 119 | // resolveLiteProfile returns settings optimized for low-RAM systems. | 127 | // resolveLiteProfile returns settings optimized for low-RAM systems. | ||
| 120 | // Uses hardware info to scale CPU threads if available. | 128 | // Uses hardware info to scale CPU threads if available. | ||
| 121 | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | 129 | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | ||
| @@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |||||
| 299 | // IsValidPerformanceProfile returns true if the profile name is recognized. | 307 | // IsValidPerformanceProfile returns true if the profile name is recognized. | ||
| 300 | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | 308 | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | ||
| 301 | switch profile { | 309 | switch profile { | ||
| 302 | - | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | 310 | + | case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "": |
| 303 | return true | 311 | return true | ||
| 304 | default: | 312 | default: | ||
| 305 | return false | 313 | return false | ||
M internal/infrastructure/config/profile_test.go +17 -0
| @@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func TestResolvePerformanceProfile_Balanced(t *testing.T) { | |
| 48 | + | cfg := &PerformanceConfig{Profile: ProfileBalanced} | |
| 49 | + | ||
| 50 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 51 | + | ||
| 52 | + | if result.SkiaCPUPaintingThreads != 0 { | |
| 53 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |
| 54 | + | } | |
| 55 | + | if result.SkiaGPUPaintingThreads != -1 { | |
| 56 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |
| 57 | + | } | |
| 58 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 59 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 47 | 63 | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | |
| 48 | 64 | cfg := &PerformanceConfig{ | |
| 49 | 65 | Profile: ProfileLite, | |
| @@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) { | |||
| 317 | 333 | }{ | |
| 318 | 334 | {ProfileDefault, true}, | |
| 319 | 335 | {ProfileLite, true}, | |
| 336 | + | {ProfileBalanced, true}, | |
| 320 | 337 | {ProfileMax, true}, | |
| 321 | 338 | {ProfileCustom, true}, | |
| 322 | 339 | {"", true}, // Empty is valid (treated as default) | |
| @@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func TestResolvePerformanceProfile_Balanced(t *testing.T) { | |||
| 48 | + | cfg := &PerformanceConfig{Profile: ProfileBalanced} | |||
| 49 | + | ||||
| 50 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 51 | + | ||||
| 52 | + | if result.SkiaCPUPaintingThreads != 0 { | |||
| 53 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |||
| 54 | + | } | |||
| 55 | + | if result.SkiaGPUPaintingThreads != -1 { | |||
| 56 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |||
| 57 | + | } | |||
| 58 | + | if result.WebViewPoolPrewarmCount != 4 { | |||
| 59 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 47 | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | 63 | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | ||
| 48 | cfg := &PerformanceConfig{ | 64 | cfg := &PerformanceConfig{ | ||
| 49 | Profile: ProfileLite, | 65 | Profile: ProfileLite, | ||
| @@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) { | |||||
| 317 | }{ | 333 | }{ | ||
| 318 | {ProfileDefault, true}, | 334 | {ProfileDefault, true}, | ||
| 319 | {ProfileLite, true}, | 335 | {ProfileLite, true}, | ||
| 336 | + | {ProfileBalanced, true}, | |||
| 320 | {ProfileMax, true}, | 337 | {ProfileMax, true}, | ||
| 321 | {ProfileCustom, true}, | 338 | {ProfileCustom, true}, | ||
| 322 | {"", true}, // Empty is valid (treated as default) | 339 | {"", true}, // Empty is valid (treated as default) | ||
M internal/infrastructure/config/schema.go +3 -1
| @@ -170,6 +170,8 @@ const ( | |||
| 170 | 170 | ProfileDefault PerformanceProfile = "default" | |
| 171 | 171 | // ProfileLite reduces resource usage for low-RAM systems. | |
| 172 | 172 | ProfileLite PerformanceProfile = "lite" | |
| 173 | + | // ProfileBalanced is a conservative compatibility preset between lite and max. | |
| 174 | + | ProfileBalanced PerformanceProfile = "balanced" | |
| 173 | 175 | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | |
| 174 | 176 | ProfileMax PerformanceProfile = "max" | |
| 175 | 177 | // ProfileCustom allows full control over individual performance settings. | |
| @@ -226,7 +228,7 @@ type HistoryConfig struct { | |||
| 226 | 228 | // These are not exposed in dumb://config UI but can be set in config file. | |
| 227 | 229 | type PerformanceConfig struct { | |
| 228 | 230 | // Profile selects a preset performance configuration. | |
| 229 | - | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | |
| 231 | + | // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control) | |
| 230 | 232 | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | |
| 231 | 233 | // Default: "default" | |
| 232 | 234 | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | |
| @@ -170,6 +170,8 @@ const ( | |||||
| 170 | ProfileDefault PerformanceProfile = "default" | 170 | ProfileDefault PerformanceProfile = "default" | ||
| 171 | // ProfileLite reduces resource usage for low-RAM systems. | 171 | // ProfileLite reduces resource usage for low-RAM systems. | ||
| 172 | ProfileLite PerformanceProfile = "lite" | 172 | ProfileLite PerformanceProfile = "lite" | ||
| 173 | + | // ProfileBalanced is a conservative compatibility preset between lite and max. | |||
| 174 | + | ProfileBalanced PerformanceProfile = "balanced" | |||
| 173 | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | 175 | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | ||
| 174 | ProfileMax PerformanceProfile = "max" | 176 | ProfileMax PerformanceProfile = "max" | ||
| 175 | // ProfileCustom allows full control over individual performance settings. | 177 | // ProfileCustom allows full control over individual performance settings. | ||
| @@ -226,7 +228,7 @@ type HistoryConfig struct { | |||||
| 226 | // These are not exposed in dumb://config UI but can be set in config file. | 228 | // These are not exposed in dumb://config UI but can be set in config file. | ||
| 227 | type PerformanceConfig struct { | 229 | type PerformanceConfig struct { | ||
| 228 | // Profile selects a preset performance configuration. | 230 | // Profile selects a preset performance configuration. | ||
| 229 | - | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | 231 | + | // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control) |
| 230 | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | 232 | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | ||
| 231 | // Default: "default" | 233 | // Default: "default" | ||
| 232 | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | 234 | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | ||
M internal/infrastructure/config/schema_provider.go +1 -1
| @@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 905 | 905 | Type: "string", | |
| 906 | 906 | Default: string(defaults.Engine.Profile), | |
| 907 | 907 | Description: "Performance profile preset (custom enables manual tuning)", | |
| 908 | - | Values: []string{"default", "lite", "max", "custom"}, | |
| 908 | + | Values: []string{"default", "lite", "balanced", "max", "custom"}, | |
| 909 | 909 | Section: SectionPerformance, | |
| 910 | 910 | }, | |
| 911 | 911 | { | |
| @@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 905 | Type: "string", | 905 | Type: "string", | ||
| 906 | Default: string(defaults.Engine.Profile), | 906 | Default: string(defaults.Engine.Profile), | ||
| 907 | Description: "Performance profile preset (custom enables manual tuning)", | 907 | Description: "Performance profile preset (custom enables manual tuning)", | ||
| 908 | - | Values: []string{"default", "lite", "max", "custom"}, | 908 | + | Values: []string{"default", "lite", "balanced", "max", "custom"}, |
| 909 | Section: SectionPerformance, | 909 | Section: SectionPerformance, | ||
| 910 | }, | 910 | }, | ||
| 911 | { | 911 | { | ||
M internal/infrastructure/config/validation.go +1 -1
| @@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string { | |||
| 483 | 483 | // Validate profile name | |
| 484 | 484 | if !IsValidPerformanceProfile(config.Engine.Profile) { | |
| 485 | 485 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 486 | - | "engine.profile must be one of: default, lite, max, custom (got: %s)", | |
| 486 | + | "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)", | |
| 487 | 487 | config.Engine.Profile, | |
| 488 | 488 | )) | |
| 489 | 489 | } | |
| @@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string { | |||||
| 483 | // Validate profile name | 483 | // Validate profile name | ||
| 484 | if !IsValidPerformanceProfile(config.Engine.Profile) { | 484 | if !IsValidPerformanceProfile(config.Engine.Profile) { | ||
| 485 | validationErrors = append(validationErrors, fmt.Sprintf( | 485 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 486 | - | "engine.profile must be one of: default, lite, max, custom (got: %s)", | 486 | + | "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)", |
| 487 | config.Engine.Profile, | 487 | config.Engine.Profile, | ||
| 488 | )) | 488 | )) | ||
| 489 | } | 489 | } | ||
M internal/infrastructure/handlers/homepage/favorites.go +4 -0
| @@ -278,6 +278,10 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||
| 278 | 278 | Int64("favorite_id", req.FavoriteID). | |
| 279 | 279 | Msg("handling favorite_set_folder") | |
| 280 | 280 | ||
| 281 | + | if req.FavoriteID <= 0 { | |
| 282 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil | |
| 283 | + | } | |
| 284 | + | ||
| 281 | 285 | folderID, err := folderIDFromInt64(req.FolderID) | |
| 282 | 286 | if err != nil { | |
| 283 | 287 | return NewErrorResponse(req.RequestID, err), nil | |
| @@ -278,6 +278,10 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||||
| 278 | Int64("favorite_id", req.FavoriteID). | 278 | Int64("favorite_id", req.FavoriteID). | ||
| 279 | Msg("handling favorite_set_folder") | 279 | Msg("handling favorite_set_folder") | ||
| 280 | 280 | ||||
| 281 | + | if req.FavoriteID <= 0 { | |||
| 282 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 281 | folderID, err := folderIDFromInt64(req.FolderID) | 285 | folderID, err := folderIDFromInt64(req.FolderID) | ||
| 282 | if err != nil { | 286 | if err != nil { | ||
| 283 | return NewErrorResponse(req.RequestID, err), nil | 287 | return NewErrorResponse(req.RequestID, err), nil | ||
M internal/infrastructure/handlers/homepage/history.go +10 -3
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| @@ -58,7 +59,10 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||
| 58 | 59 | Int("offset", req.Offset). | |
| 59 | 60 | Msg("handling history_timeline") | |
| 60 | 61 | if req.Limit <= 0 { | |
| 61 | - | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil | |
| 62 | + | return NewErrorResponse( | |
| 63 | + | req.RequestID, | |
| 64 | + | fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"), | |
| 65 | + | ), nil | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | |
| @@ -92,7 +96,10 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||
| 92 | 96 | Int("offset", req.Offset). | |
| 93 | 97 | Msg("handling history_timeline_by_domain") | |
| 94 | 98 | if req.Limit <= 0 { | |
| 95 | - | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil | |
| 99 | + | return NewErrorResponse( | |
| 100 | + | req.RequestID, | |
| 101 | + | fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"), | |
| 102 | + | ), nil | |
| 96 | 103 | } | |
| 97 | 104 | ||
| 98 | 105 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | |
| @@ -162,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler { | |||
| 162 | 169 | Str("query", req.Query). | |
| 163 | 170 | Msg("handling history_search_fts") | |
| 164 | 171 | ||
| 165 | - | output, err := h.historyUC.Search(ctx, port.HistorySearchInput{ | |
| 172 | + | output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{ | |
| 166 | 173 | Query: req.Query, | |
| 167 | 174 | Limit: req.Limit, | |
| 168 | 175 | }) | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -58,7 +59,10 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||||
| 58 | Int("offset", req.Offset). | 59 | Int("offset", req.Offset). | ||
| 59 | Msg("handling history_timeline") | 60 | Msg("handling history_timeline") | ||
| 60 | if req.Limit <= 0 { | 61 | if req.Limit <= 0 { | ||
| 61 | - | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil | 62 | + | return NewErrorResponse( |
| 63 | + | req.RequestID, | |||
| 64 | + | fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"), | |||
| 65 | + | ), nil | |||
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | 68 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | ||
| @@ -92,7 +96,10 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||||
| 92 | Int("offset", req.Offset). | 96 | Int("offset", req.Offset). | ||
| 93 | Msg("handling history_timeline_by_domain") | 97 | Msg("handling history_timeline_by_domain") | ||
| 94 | if req.Limit <= 0 { | 98 | if req.Limit <= 0 { | ||
| 95 | - | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil | 99 | + | return NewErrorResponse( |
| 100 | + | req.RequestID, | |||
| 101 | + | fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"), | |||
| 102 | + | ), nil | |||
| 96 | } | 103 | } | ||
| 97 | 104 | ||||
| 98 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | 105 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | ||
| @@ -162,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler { | |||||
| 162 | Str("query", req.Query). | 169 | Str("query", req.Query). | ||
| 163 | Msg("handling history_search_fts") | 170 | Msg("handling history_search_fts") | ||
| 164 | 171 | ||||
| 165 | - | output, err := h.historyUC.Search(ctx, port.HistorySearchInput{ | 172 | + | output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{ |
| 166 | Query: req.Query, | 173 | Query: req.Query, | ||
| 167 | Limit: req.Limit, | 174 | Limit: req.Limit, | ||
| 168 | }) | 175 | }) | ||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +6 -1
| @@ -163,7 +163,12 @@ func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, aft | |||
| 163 | 163 | return r.repo.GetRecentWindow(ctx, before, after) | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | - | func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 166 | + | func (r *LazyHistoryRepository) GetRecentWindowByDomain( | |
| 167 | + | ctx context.Context, | |
| 168 | + | domain string, | |
| 169 | + | before time.Time, | |
| 170 | + | after time.Time, | |
| 171 | + | ) ([]*entity.HistoryEntry, error) { | |
| 167 | 172 | if err := r.init(ctx); err != nil { | |
| 168 | 173 | return nil, err | |
| 169 | 174 | } | |
| @@ -163,7 +163,12 @@ func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, aft | |||||
| 163 | return r.repo.GetRecentWindow(ctx, before, after) | 163 | return r.repo.GetRecentWindow(ctx, before, after) | ||
| 164 | } | 164 | } | ||
| 165 | 165 | ||||
| 166 | - | func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | 166 | + | func (r *LazyHistoryRepository) GetRecentWindowByDomain( |
| 167 | + | ctx context.Context, | |||
| 168 | + | domain string, | |||
| 169 | + | before time.Time, | |||
| 170 | + | after time.Time, | |||
| 171 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 167 | if err := r.init(ctx); err != nil { | 172 | if err := r.init(ctx); err != nil { | ||
| 168 | return nil, err | 173 | return nil, err | ||
| 169 | } | 174 | } | ||
M internal/infrastructure/systemviewsbridge/client.go +4 -3
| @@ -416,10 +416,11 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er | |||
| 416 | 416 | if id <= 0 { | |
| 417 | 417 | return fmt.Errorf("update tag id must be positive, got %d", id) | |
| 418 | 418 | } | |
| 419 | - | var namePtr *string | |
| 420 | - | if trimmedName := strings.TrimSpace(name); trimmedName != "" { | |
| 421 | - | namePtr = &trimmedName | |
| 419 | + | trimmedName := strings.TrimSpace(name) | |
| 420 | + | if trimmedName == "" { | |
| 421 | + | return fmt.Errorf("tag name is required") | |
| 422 | 422 | } | |
| 423 | + | namePtr := &trimmedName | |
| 423 | 424 | var colorPtr *string | |
| 424 | 425 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | |
| 425 | 426 | colorPtr = &trimmedColor | |
| @@ -416,10 +416,11 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er | |||||
| 416 | if id <= 0 { | 416 | if id <= 0 { | ||
| 417 | return fmt.Errorf("update tag id must be positive, got %d", id) | 417 | return fmt.Errorf("update tag id must be positive, got %d", id) | ||
| 418 | } | 418 | } | ||
| 419 | - | var namePtr *string | 419 | + | trimmedName := strings.TrimSpace(name) |
| 420 | - | if trimmedName := strings.TrimSpace(name); trimmedName != "" { | 420 | + | if trimmedName == "" { |
| 421 | - | namePtr = &trimmedName | 421 | + | return fmt.Errorf("tag name is required") |
| 422 | } | 422 | } | ||
| 423 | + | namePtr := &trimmedName | |||
| 423 | var colorPtr *string | 424 | var colorPtr *string | ||
| 424 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | 425 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | ||
| 425 | colorPtr = &trimmedColor | 426 | colorPtr = &trimmedColor | ||
M internal/infrastructure/systemviewsbridge/client_test.go +4 -3
| @@ -316,7 +316,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 316 | 316 | }{ | |
| 317 | 317 | { | |
| 318 | 318 | name: "save config", | |
| 319 | - | call: func(t *testing.T, client *Client) error { | |
| 319 | + | call: func(_ *testing.T, client *Client) error { | |
| 320 | 320 | return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"}) | |
| 321 | 321 | }, | |
| 322 | 322 | want: "save_config", | |
| @@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 353 | 353 | }, | |
| 354 | 354 | { | |
| 355 | 355 | name: "reset keybinding", | |
| 356 | - | call: func(t *testing.T, client *Client) error { | |
| 356 | + | call: func(_ *testing.T, client *Client) error { | |
| 357 | 357 | return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"}) | |
| 358 | 358 | }, | |
| 359 | 359 | want: "reset_keybinding", | |
| @@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 361 | 361 | }, | |
| 362 | 362 | { | |
| 363 | 363 | name: "reset all keybindings", | |
| 364 | - | call: func(t *testing.T, client *Client) error { | |
| 364 | + | call: func(_ *testing.T, client *Client) error { | |
| 365 | 365 | return client.ResetAllKeybindings(context.Background()) | |
| 366 | 366 | }, | |
| 367 | 367 | want: "reset_all_keybindings", | |
| @@ -657,6 +657,7 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |||
| 657 | 657 | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | |
| 658 | 658 | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | |
| 659 | 659 | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | |
| 660 | + | {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }}, | |
| 660 | 661 | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | |
| 661 | 662 | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | |
| 662 | 663 | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | |
| @@ -316,7 +316,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 316 | }{ | 316 | }{ | ||
| 317 | { | 317 | { | ||
| 318 | name: "save config", | 318 | name: "save config", | ||
| 319 | - | call: func(t *testing.T, client *Client) error { | 319 | + | call: func(_ *testing.T, client *Client) error { |
| 320 | return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"}) | 320 | return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"}) | ||
| 321 | }, | 321 | }, | ||
| 322 | want: "save_config", | 322 | want: "save_config", | ||
| @@ -353,7 +353,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 353 | }, | 353 | }, | ||
| 354 | { | 354 | { | ||
| 355 | name: "reset keybinding", | 355 | name: "reset keybinding", | ||
| 356 | - | call: func(t *testing.T, client *Client) error { | 356 | + | call: func(_ *testing.T, client *Client) error { |
| 357 | return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"}) | 357 | return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"}) | ||
| 358 | }, | 358 | }, | ||
| 359 | want: "reset_keybinding", | 359 | want: "reset_keybinding", | ||
| @@ -361,7 +361,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 361 | }, | 361 | }, | ||
| 362 | { | 362 | { | ||
| 363 | name: "reset all keybindings", | 363 | name: "reset all keybindings", | ||
| 364 | - | call: func(t *testing.T, client *Client) error { | 364 | + | call: func(_ *testing.T, client *Client) error { |
| 365 | return client.ResetAllKeybindings(context.Background()) | 365 | return client.ResetAllKeybindings(context.Background()) | ||
| 366 | }, | 366 | }, | ||
| 367 | want: "reset_all_keybindings", | 367 | want: "reset_all_keybindings", | ||
| @@ -657,6 +657,7 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |||||
| 657 | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | 657 | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | ||
| 658 | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | 658 | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | ||
| 659 | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | 659 | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | ||
| 660 | + | {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }}, | |||
| 660 | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | 661 | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | ||
| 661 | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | 662 | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | ||
| 662 | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | 663 | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | ||
M internal/infrastructure/transcoder/audio.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/infrastructure/transcoder/ffmpeg_offsets.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | // Offset-based accessors for FFmpeg struct fields not yet exposed | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | // Offset-based accessors for FFmpeg struct fields not yet exposed | 5 | // Offset-based accessors for FFmpeg struct fields not yet exposed | ||
M internal/infrastructure/transcoder/http_client_test.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/infrastructure/transcoder/hwprobe.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/infrastructure/transcoder/hwprobe_test.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import "testing" | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import "testing" | 5 | import "testing" | ||
M internal/infrastructure/transcoder/pipeline.go +6 -107
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -8,7 +10,6 @@ import ( | |||
| 8 | 10 | "net" | |
| 9 | 11 | "net/http" | |
| 10 | 12 | "net/url" | |
| 11 | - | "sort" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "time" | |
| 14 | 15 | "unsafe" | |
| @@ -72,7 +73,7 @@ func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||
| 72 | 73 | } | |
| 73 | 74 | ||
| 74 | 75 | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |
| 75 | - | host, port, err := net.SplitHostPort(address) | |
| 76 | + | host, portValue, err := net.SplitHostPort(address) | |
| 76 | 77 | if err != nil { | |
| 77 | 78 | return nil, err | |
| 78 | 79 | } | |
| @@ -85,7 +86,7 @@ func safeTranscodeDialContext(ctx context.Context, network, address string) (net | |||
| 85 | 86 | dialer := &net.Dialer{Timeout: 10 * time.Second} | |
| 86 | 87 | var lastErr error | |
| 87 | 88 | for _, ip := range ips { | |
| 88 | - | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) | |
| 89 | + | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) | |
| 89 | 90 | if dialErr == nil { | |
| 90 | 91 | return conn, nil | |
| 91 | 92 | } | |
| @@ -581,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 581 | 582 | return inFmtCtx, cleanup, nil | |
| 582 | 583 | } | |
| 583 | 584 | ||
| 584 | - | func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |
| 585 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 586 | - | if inFmtCtx == nil { | |
| 587 | - | return nil, nil, errors.New("failed to allocate input format context") | |
| 588 | - | } | |
| 589 | - | ||
| 590 | - | var opts unsafe.Pointer | |
| 591 | - | defer freeInputOptions(&opts) | |
| 592 | - | ||
| 593 | - | if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil { | |
| 594 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 595 | - | return nil, nil, err | |
| 596 | - | } | |
| 597 | - | if userAgent := p.headers["User-Agent"]; userAgent != "" { | |
| 598 | - | if err := setInputOption(&opts, "user_agent", userAgent); err != nil { | |
| 599 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 600 | - | return nil, nil, err | |
| 601 | - | } | |
| 602 | - | } | |
| 603 | - | if referer := p.headers["Referer"]; referer != "" { | |
| 604 | - | if err := setInputOption(&opts, "referer", referer); err != nil { | |
| 605 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 606 | - | return nil, nil, err | |
| 607 | - | } | |
| 608 | - | } | |
| 609 | - | ||
| 610 | - | headerLines := formatHTTPHeaderOptions(p.headers) | |
| 611 | - | if headerLines != "" { | |
| 612 | - | if err := setInputOption(&opts, "headers", headerLines); err != nil { | |
| 613 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 614 | - | return nil, nil, err | |
| 615 | - | } | |
| 616 | - | } | |
| 617 | - | ||
| 618 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries. | |
| 619 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |
| 620 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 621 | - | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | return inFmtCtx, func() { | |
| 625 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |
| 626 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 627 | - | }, nil | |
| 628 | - | } | |
| 629 | - | ||
| 630 | - | func freeInputOptions(opts *unsafe.Pointer) { | |
| 631 | - | if opts == nil || *opts == nil { | |
| 632 | - | return | |
| 633 | - | } | |
| 634 | - | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |
| 635 | - | // itself corrupts memory and can crash inside libavutil during cleanup. | |
| 636 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup. | |
| 637 | - | ffmpeg.DictFree(unsafe.Pointer(opts)) | |
| 638 | - | *opts = nil | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | func setInputOption(opts *unsafe.Pointer, key, value string) error { | |
| 642 | - | if value == "" { | |
| 643 | - | return nil | |
| 644 | - | } | |
| 645 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates. | |
| 646 | - | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |
| 647 | - | return fmt.Errorf("set input option %q: %d", key, ret) | |
| 648 | - | } | |
| 649 | - | return nil | |
| 650 | - | } | |
| 651 | - | ||
| 652 | - | func formatHTTPHeaderOptions(headers map[string]string) string { | |
| 653 | - | if len(headers) == 0 { | |
| 654 | - | return "" | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | keys := make([]string, 0, len(headers)) | |
| 658 | - | for key, value := range headers { | |
| 659 | - | if strings.TrimSpace(value) == "" { | |
| 660 | - | continue | |
| 661 | - | } | |
| 662 | - | if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") { | |
| 663 | - | continue | |
| 664 | - | } | |
| 665 | - | keys = append(keys, key) | |
| 666 | - | } | |
| 667 | - | if len(keys) == 0 { | |
| 668 | - | return "" | |
| 669 | - | } | |
| 670 | - | ||
| 671 | - | sort.Strings(keys) | |
| 672 | - | ||
| 673 | - | var builder strings.Builder | |
| 674 | - | for _, key := range keys { | |
| 675 | - | builder.WriteString(key) | |
| 676 | - | builder.WriteString(": ") | |
| 677 | - | builder.WriteString(headers[key]) | |
| 678 | - | builder.WriteString("\r\n") | |
| 679 | - | } | |
| 680 | - | return builder.String() | |
| 681 | - | } | |
| 682 | - | ||
| 683 | - | // openVideoDecoder creates and opens a decoder context for the video stream. | |
| 684 | - | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | |
| 685 | - | // falling back to the generic software decoder. | |
| 686 | - | // | |
| 687 | - | //nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup. | |
| 688 | 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | |
| 689 | 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | |
| 690 | 587 | ||
| @@ -711,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er | |||
| 711 | 608 | } | |
| 712 | 609 | ||
| 713 | 610 | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | |
| 611 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 714 | 612 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 715 | 613 | return nil, fmt.Errorf("copy video codec params: %d", ret) | |
| 716 | 614 | } | |
| 717 | 615 | ||
| 718 | 616 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |
| 617 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 719 | 618 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 720 | 619 | return nil, fmt.Errorf("open video decoder: %d", ret) | |
| 721 | 620 | } | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
| @@ -8,7 +10,6 @@ import ( | |||||
| 8 | "net" | 10 | "net" | ||
| 9 | "net/http" | 11 | "net/http" | ||
| 10 | "net/url" | 12 | "net/url" | ||
| 11 | - | "sort" | |||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "time" | 14 | "time" | ||
| 14 | "unsafe" | 15 | "unsafe" | ||
| @@ -72,7 +73,7 @@ func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||||
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| 74 | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | 75 | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | ||
| 75 | - | host, port, err := net.SplitHostPort(address) | 76 | + | host, portValue, err := net.SplitHostPort(address) |
| 76 | if err != nil { | 77 | if err != nil { | ||
| 77 | return nil, err | 78 | return nil, err | ||
| 78 | } | 79 | } | ||
| @@ -85,7 +86,7 @@ func safeTranscodeDialContext(ctx context.Context, network, address string) (net | |||||
| 85 | dialer := &net.Dialer{Timeout: 10 * time.Second} | 86 | dialer := &net.Dialer{Timeout: 10 * time.Second} | ||
| 86 | var lastErr error | 87 | var lastErr error | ||
| 87 | for _, ip := range ips { | 88 | for _, ip := range ips { | ||
| 88 | - | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) | 89 | + | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) |
| 89 | if dialErr == nil { | 90 | if dialErr == nil { | ||
| 90 | return conn, nil | 91 | return conn, nil | ||
| 91 | } | 92 | } | ||
| @@ -581,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||||
| 581 | return inFmtCtx, cleanup, nil | 582 | return inFmtCtx, cleanup, nil | ||
| 582 | } | 583 | } | ||
| 583 | 584 | ||||
| 584 | - | func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |||
| 585 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 586 | - | if inFmtCtx == nil { | |||
| 587 | - | return nil, nil, errors.New("failed to allocate input format context") | |||
| 588 | - | } | |||
| 589 | - | ||||
| 590 | - | var opts unsafe.Pointer | |||
| 591 | - | defer freeInputOptions(&opts) | |||
| 592 | - | ||||
| 593 | - | if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil { | |||
| 594 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 595 | - | return nil, nil, err | |||
| 596 | - | } | |||
| 597 | - | if userAgent := p.headers["User-Agent"]; userAgent != "" { | |||
| 598 | - | if err := setInputOption(&opts, "user_agent", userAgent); err != nil { | |||
| 599 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 600 | - | return nil, nil, err | |||
| 601 | - | } | |||
| 602 | - | } | |||
| 603 | - | if referer := p.headers["Referer"]; referer != "" { | |||
| 604 | - | if err := setInputOption(&opts, "referer", referer); err != nil { | |||
| 605 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 606 | - | return nil, nil, err | |||
| 607 | - | } | |||
| 608 | - | } | |||
| 609 | - | ||||
| 610 | - | headerLines := formatHTTPHeaderOptions(p.headers) | |||
| 611 | - | if headerLines != "" { | |||
| 612 | - | if err := setInputOption(&opts, "headers", headerLines); err != nil { | |||
| 613 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 614 | - | return nil, nil, err | |||
| 615 | - | } | |||
| 616 | - | } | |||
| 617 | - | ||||
| 618 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries. | |||
| 619 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |||
| 620 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 621 | - | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |||
| 622 | - | } | |||
| 623 | - | ||||
| 624 | - | return inFmtCtx, func() { | |||
| 625 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 626 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 627 | - | }, nil | |||
| 628 | - | } | |||
| 629 | - | ||||
| 630 | - | func freeInputOptions(opts *unsafe.Pointer) { | |||
| 631 | - | if opts == nil || *opts == nil { | |||
| 632 | - | return | |||
| 633 | - | } | |||
| 634 | - | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |||
| 635 | - | // itself corrupts memory and can crash inside libavutil during cleanup. | |||
| 636 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup. | |||
| 637 | - | ffmpeg.DictFree(unsafe.Pointer(opts)) | |||
| 638 | - | *opts = nil | |||
| 639 | - | } | |||
| 640 | - | ||||
| 641 | - | func setInputOption(opts *unsafe.Pointer, key, value string) error { | |||
| 642 | - | if value == "" { | |||
| 643 | - | return nil | |||
| 644 | - | } | |||
| 645 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates. | |||
| 646 | - | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |||
| 647 | - | return fmt.Errorf("set input option %q: %d", key, ret) | |||
| 648 | - | } | |||
| 649 | - | return nil | |||
| 650 | - | } | |||
| 651 | - | ||||
| 652 | - | func formatHTTPHeaderOptions(headers map[string]string) string { | |||
| 653 | - | if len(headers) == 0 { | |||
| 654 | - | return "" | |||
| 655 | - | } | |||
| 656 | - | ||||
| 657 | - | keys := make([]string, 0, len(headers)) | |||
| 658 | - | for key, value := range headers { | |||
| 659 | - | if strings.TrimSpace(value) == "" { | |||
| 660 | - | continue | |||
| 661 | - | } | |||
| 662 | - | if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") { | |||
| 663 | - | continue | |||
| 664 | - | } | |||
| 665 | - | keys = append(keys, key) | |||
| 666 | - | } | |||
| 667 | - | if len(keys) == 0 { | |||
| 668 | - | return "" | |||
| 669 | - | } | |||
| 670 | - | ||||
| 671 | - | sort.Strings(keys) | |||
| 672 | - | ||||
| 673 | - | var builder strings.Builder | |||
| 674 | - | for _, key := range keys { | |||
| 675 | - | builder.WriteString(key) | |||
| 676 | - | builder.WriteString(": ") | |||
| 677 | - | builder.WriteString(headers[key]) | |||
| 678 | - | builder.WriteString("\r\n") | |||
| 679 | - | } | |||
| 680 | - | return builder.String() | |||
| 681 | - | } | |||
| 682 | - | ||||
| 683 | - | // openVideoDecoder creates and opens a decoder context for the video stream. | |||
| 684 | - | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | |||
| 685 | - | // falling back to the generic software decoder. | |||
| 686 | - | // | |||
| 687 | - | //nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup. | |||
| 688 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | ||
| 689 | codecID := ffmpeg.CodecParCodecID(codecPar) | 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | ||
| 690 | 587 | ||||
| @@ -711,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er | |||||
| 711 | } | 608 | } | ||
| 712 | 609 | ||||
| 713 | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | 610 | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | ||
| 611 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 714 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 612 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | ||
| 715 | return nil, fmt.Errorf("copy video codec params: %d", ret) | 613 | return nil, fmt.Errorf("copy video codec params: %d", ret) | ||
| 716 | } | 614 | } | ||
| 717 | 615 | ||||
| 718 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | 616 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | ||
| 617 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 719 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 618 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | ||
| 720 | return nil, fmt.Errorf("open video decoder: %d", ret) | 619 | return nil, fmt.Errorf("open video decoder: %d", ret) | ||
| 721 | } | 620 | } | ||
M internal/infrastructure/transcoder/transcoder.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 1 | 3 | package transcoder | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
A internal/infrastructure/transcoder/transcoder_wasm.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package transcoder | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | ||
| 9 | + | "github.com/rs/zerolog" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Compile-time check: Transcoder implements port.MediaTranscoder. | |
| 15 | + | var _ port.MediaTranscoder = (*Transcoder)(nil) | |
| 16 | + | ||
| 17 | + | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |
| 18 | + | type Transcoder struct{} | |
| 19 | + | ||
| 20 | + | // New creates an unavailable Transcoder for js/wasm builds. | |
| 21 | + | func New(_ any, _ *zerolog.Logger) *Transcoder { | |
| 22 | + | return &Transcoder{} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Available reports whether transcoding is available. | |
| 26 | + | func (*Transcoder) Available() bool { | |
| 27 | + | return false | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Capabilities returns empty hardware capabilities for js/wasm builds. | |
| 31 | + | func (*Transcoder) Capabilities() port.HWCapabilities { | |
| 32 | + | return port.HWCapabilities{} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Start always fails for js/wasm builds. | |
| 36 | + | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |
| 37 | + | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Close is a no-op for js/wasm builds. | |
| 41 | + | func (*Transcoder) Close() {} | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package transcoder | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | ||||
| 9 | + | "github.com/rs/zerolog" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 15 | + | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 16 | + | ||||
| 17 | + | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |||
| 18 | + | type Transcoder struct{} | |||
| 19 | + | ||||
| 20 | + | // New creates an unavailable Transcoder for js/wasm builds. | |||
| 21 | + | func New(_ any, _ *zerolog.Logger) *Transcoder { | |||
| 22 | + | return &Transcoder{} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Available reports whether transcoding is available. | |||
| 26 | + | func (*Transcoder) Available() bool { | |||
| 27 | + | return false | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Capabilities returns empty hardware capabilities for js/wasm builds. | |||
| 31 | + | func (*Transcoder) Capabilities() port.HWCapabilities { | |||
| 32 | + | return port.HWCapabilities{} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Start always fails for js/wasm builds. | |||
| 36 | + | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |||
| 37 | + | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Close is a no-op for js/wasm builds. | |||
| 41 | + | func (*Transcoder) Close() {} | |||
M internal/infrastructure/webkit/scheme_handler.go +21 -21
| @@ -238,11 +238,11 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||
| 238 | 238 | if !service.HasPNGSizedOnDisk(domain, size) { | |
| 239 | 239 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 240 | 240 | } | |
| 241 | - | path := service.DiskPathPNGSized(domain, size) | |
| 242 | - | if path == "" { | |
| 241 | + | diskPath := service.DiskPathPNGSized(domain, size) | |
| 242 | + | if diskPath == "" { | |
| 243 | 243 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 244 | 244 | } | |
| 245 | - | data, err := os.ReadFile(path) | |
| 245 | + | data, err := os.ReadFile(diskPath) | |
| 246 | 246 | if err != nil || len(data) == 0 { | |
| 247 | 247 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 248 | 248 | } | |
| @@ -330,11 +330,11 @@ func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |||
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | 332 | // RegisterPage registers a handler for a specific path. | |
| 333 | - | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | |
| 333 | + | func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) { | |
| 334 | 334 | h.mu.Lock() | |
| 335 | 335 | defer h.mu.Unlock() | |
| 336 | - | h.handlers[path] = handler | |
| 337 | - | h.logger.Debug().Str("path", path).Msg("registered page handler") | |
| 336 | + | h.handlers[pagePath] = handler | |
| 337 | + | h.logger.Debug().Str("path", pagePath).Msg("registered page handler") | |
| 338 | 338 | } | |
| 339 | 339 | ||
| 340 | 340 | // HandleRequest processes a scheme request and sends the response. | |
| @@ -443,7 +443,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 443 | 443 | return nil | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | - | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 446 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 447 | 447 | if err != nil { | |
| 448 | 448 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 449 | 449 | return nil | |
| @@ -460,7 +460,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 460 | 460 | Data: data, | |
| 461 | 461 | ContentType: contentType, | |
| 462 | 462 | StatusCode: http.StatusOK, | |
| 463 | - | Headers: headers, | |
| 463 | + | Headers: nil, | |
| 464 | 464 | } | |
| 465 | 465 | } | |
| 466 | 466 | ||
| @@ -487,7 +487,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||
| 487 | 487 | return fullPath, cleanRelPath, true | |
| 488 | 488 | } | |
| 489 | 489 | ||
| 490 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | |
| 490 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | |
| 491 | 491 | var compressedErr error | |
| 492 | 492 | if strings.HasSuffix(relPath, ".wasm") { | |
| 493 | 493 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| @@ -497,18 +497,18 @@ func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, m | |||
| 497 | 497 | } else if len(data) > maxSystemviewsWASMBytes { | |
| 498 | 498 | compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |
| 499 | 499 | } else { | |
| 500 | - | return data, nil, nil | |
| 500 | + | return data, nil | |
| 501 | 501 | } | |
| 502 | 502 | } | |
| 503 | 503 | } | |
| 504 | 504 | data, err := fs.ReadFile(assets, fullPath) | |
| 505 | 505 | if err == nil { | |
| 506 | - | return data, nil, nil | |
| 506 | + | return data, nil | |
| 507 | 507 | } | |
| 508 | 508 | if compressedErr != nil { | |
| 509 | - | return nil, nil, compressedErr | |
| 509 | + | return nil, compressedErr | |
| 510 | 510 | } | |
| 511 | - | return nil, nil, err | |
| 511 | + | return nil, err | |
| 512 | 512 | } | |
| 513 | 513 | ||
| 514 | 514 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| @@ -528,11 +528,11 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 528 | 528 | } | |
| 529 | 529 | ||
| 530 | 530 | if root, ok := rootByHost[u.Host]; ok { | |
| 531 | - | path := strings.TrimPrefix(u.Path, "/") | |
| 532 | - | if path == "" { | |
| 531 | + | assetPath := strings.TrimPrefix(u.Path, "/") | |
| 532 | + | if assetPath == "" { | |
| 533 | 533 | return root.assetDir, root.file, true | |
| 534 | 534 | } | |
| 535 | - | return root.assetDir, path, true | |
| 535 | + | return root.assetDir, assetPath, true | |
| 536 | 536 | } | |
| 537 | 537 | ||
| 538 | 538 | switch u.Opaque { | |
| @@ -543,13 +543,13 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 543 | 543 | } | |
| 544 | 544 | } | |
| 545 | 545 | ||
| 546 | - | func shouldAddCORSHeaders(path string) bool { | |
| 547 | - | path = strings.TrimSpace(path) | |
| 548 | - | return strings.HasSuffix(path, ".wasm") | |
| 546 | + | func shouldAddCORSHeaders(requestPath string) bool { | |
| 547 | + | requestPath = strings.TrimSpace(requestPath) | |
| 548 | + | return strings.HasSuffix(requestPath, ".wasm") | |
| 549 | 549 | } | |
| 550 | 550 | ||
| 551 | - | func responseHeadersForPath(path, contentType string) map[string]string { | |
| 552 | - | if !shouldAddCORSHeaders(path) { | |
| 551 | + | func responseHeadersForPath(requestPath, contentType string) map[string]string { | |
| 552 | + | if !shouldAddCORSHeaders(requestPath) { | |
| 553 | 553 | return nil | |
| 554 | 554 | } | |
| 555 | 555 | ||
| @@ -238,11 +238,11 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||||
| 238 | if !service.HasPNGSizedOnDisk(domain, size) { | 238 | if !service.HasPNGSizedOnDisk(domain, size) { | ||
| 239 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | 239 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | ||
| 240 | } | 240 | } | ||
| 241 | - | path := service.DiskPathPNGSized(domain, size) | 241 | + | diskPath := service.DiskPathPNGSized(domain, size) |
| 242 | - | if path == "" { | 242 | + | if diskPath == "" { |
| 243 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | 243 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | ||
| 244 | } | 244 | } | ||
| 245 | - | data, err := os.ReadFile(path) | 245 | + | data, err := os.ReadFile(diskPath) |
| 246 | if err != nil || len(data) == 0 { | 246 | if err != nil || len(data) == 0 { | ||
| 247 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | 247 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | ||
| 248 | } | 248 | } | ||
| @@ -330,11 +330,11 @@ func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |||||
| 330 | } | 330 | } | ||
| 331 | 331 | ||||
| 332 | // RegisterPage registers a handler for a specific path. | 332 | // RegisterPage registers a handler for a specific path. | ||
| 333 | - | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | 333 | + | func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) { |
| 334 | h.mu.Lock() | 334 | h.mu.Lock() | ||
| 335 | defer h.mu.Unlock() | 335 | defer h.mu.Unlock() | ||
| 336 | - | h.handlers[path] = handler | 336 | + | h.handlers[pagePath] = handler |
| 337 | - | h.logger.Debug().Str("path", path).Msg("registered page handler") | 337 | + | h.logger.Debug().Str("path", pagePath).Msg("registered page handler") |
| 338 | } | 338 | } | ||
| 339 | 339 | ||||
| 340 | // HandleRequest processes a scheme request and sends the response. | 340 | // HandleRequest processes a scheme request and sends the response. | ||
| @@ -443,7 +443,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 443 | return nil | 443 | return nil | ||
| 444 | } | 444 | } | ||
| 445 | 445 | ||||
| 446 | - | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | 446 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) |
| 447 | if err != nil { | 447 | if err != nil { | ||
| 448 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 448 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| 449 | return nil | 449 | return nil | ||
| @@ -460,7 +460,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 460 | Data: data, | 460 | Data: data, | ||
| 461 | ContentType: contentType, | 461 | ContentType: contentType, | ||
| 462 | StatusCode: http.StatusOK, | 462 | StatusCode: http.StatusOK, | ||
| 463 | - | Headers: headers, | 463 | + | Headers: nil, |
| 464 | } | 464 | } | ||
| 465 | } | 465 | } | ||
| 466 | 466 | ||||
| @@ -487,7 +487,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||||
| 487 | return fullPath, cleanRelPath, true | 487 | return fullPath, cleanRelPath, true | ||
| 488 | } | 488 | } | ||
| 489 | 489 | ||||
| 490 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | 490 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { |
| 491 | var compressedErr error | 491 | var compressedErr error | ||
| 492 | if strings.HasSuffix(relPath, ".wasm") { | 492 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 493 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 493 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | ||
| @@ -497,18 +497,18 @@ func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, m | |||||
| 497 | } else if len(data) > maxSystemviewsWASMBytes { | 497 | } else if len(data) > maxSystemviewsWASMBytes { | ||
| 498 | compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | 498 | compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | ||
| 499 | } else { | 499 | } else { | ||
| 500 | - | return data, nil, nil | 500 | + | return data, nil |
| 501 | } | 501 | } | ||
| 502 | } | 502 | } | ||
| 503 | } | 503 | } | ||
| 504 | data, err := fs.ReadFile(assets, fullPath) | 504 | data, err := fs.ReadFile(assets, fullPath) | ||
| 505 | if err == nil { | 505 | if err == nil { | ||
| 506 | - | return data, nil, nil | 506 | + | return data, nil |
| 507 | } | 507 | } | ||
| 508 | if compressedErr != nil { | 508 | if compressedErr != nil { | ||
| 509 | - | return nil, nil, compressedErr | 509 | + | return nil, compressedErr |
| 510 | } | 510 | } | ||
| 511 | - | return nil, nil, err | 511 | + | return nil, err |
| 512 | } | 512 | } | ||
| 513 | 513 | ||||
| 514 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | 514 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | ||
| @@ -528,11 +528,11 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 528 | } | 528 | } | ||
| 529 | 529 | ||||
| 530 | if root, ok := rootByHost[u.Host]; ok { | 530 | if root, ok := rootByHost[u.Host]; ok { | ||
| 531 | - | path := strings.TrimPrefix(u.Path, "/") | 531 | + | assetPath := strings.TrimPrefix(u.Path, "/") |
| 532 | - | if path == "" { | 532 | + | if assetPath == "" { |
| 533 | return root.assetDir, root.file, true | 533 | return root.assetDir, root.file, true | ||
| 534 | } | 534 | } | ||
| 535 | - | return root.assetDir, path, true | 535 | + | return root.assetDir, assetPath, true |
| 536 | } | 536 | } | ||
| 537 | 537 | ||||
| 538 | switch u.Opaque { | 538 | switch u.Opaque { | ||
| @@ -543,13 +543,13 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 543 | } | 543 | } | ||
| 544 | } | 544 | } | ||
| 545 | 545 | ||||
| 546 | - | func shouldAddCORSHeaders(path string) bool { | 546 | + | func shouldAddCORSHeaders(requestPath string) bool { |
| 547 | - | path = strings.TrimSpace(path) | 547 | + | requestPath = strings.TrimSpace(requestPath) |
| 548 | - | return strings.HasSuffix(path, ".wasm") | 548 | + | return strings.HasSuffix(requestPath, ".wasm") |
| 549 | } | 549 | } | ||
| 550 | 550 | ||||
| 551 | - | func responseHeadersForPath(path, contentType string) map[string]string { | 551 | + | func responseHeadersForPath(requestPath, contentType string) map[string]string { |
| 552 | - | if !shouldAddCORSHeaders(path) { | 552 | + | if !shouldAddCORSHeaders(requestPath) { |
| 553 | return nil | 553 | return nil | ||
| 554 | } | 554 | } | ||
| 555 | 555 | ||||
M internal/infrastructure/webkit/scheme_handler_test.go +1 -2
| @@ -276,11 +276,10 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||
| 276 | 276 | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 277 | 277 | t.Parallel() | |
| 278 | 278 | ||
| 279 | - | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 279 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 280 | 280 | require.NoError(t, err) | |
| 281 | 281 | require.GreaterOrEqual(t, len(data), 4) | |
| 282 | 282 | assert.Equal(t, "\x00asm", string(data[:4])) | |
| 283 | - | assert.NotContains(t, headers, "Content-Encoding") | |
| 284 | 283 | } | |
| 285 | 284 | ||
| 286 | 285 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | |
| @@ -276,11 +276,10 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||||
| 276 | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | 276 | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | ||
| 277 | t.Parallel() | 277 | t.Parallel() | ||
| 278 | 278 | ||||
| 279 | - | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | 279 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") |
| 280 | require.NoError(t, err) | 280 | require.NoError(t, err) | ||
| 281 | require.GreaterOrEqual(t, len(data), 4) | 281 | require.GreaterOrEqual(t, len(data), 4) | ||
| 282 | assert.Equal(t, "\x00asm", string(data[:4])) | 282 | assert.Equal(t, "\x00asm", string(data[:4])) | ||
| 283 | - | assert.NotContains(t, headers, "Content-Encoding") | |||
| 284 | } | 283 | } | ||
| 285 | 284 | ||||
| 286 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | 285 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | ||
M internal/ui/systemviews/app.go +7 -20
| @@ -549,6 +549,7 @@ type historyRouteSnapshot struct { | |||
| 549 | 549 | domainFilter string | |
| 550 | 550 | offset int | |
| 551 | 551 | windowBefore time.Time | |
| 552 | + | windowAfter time.Time | |
| 552 | 553 | notice string | |
| 553 | 554 | error string | |
| 554 | 555 | } | |
| @@ -572,6 +573,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot { | |||
| 572 | 573 | domainFilter: a.historyDomainFilter, | |
| 573 | 574 | offset: a.historyOffset, | |
| 574 | 575 | windowBefore: a.historyWindowBefore, | |
| 576 | + | windowAfter: a.historyWindowAfter, | |
| 575 | 577 | notice: a.historyNotice, | |
| 576 | 578 | error: a.historyError, | |
| 577 | 579 | } | |
| @@ -602,9 +604,11 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||
| 602 | 604 | domains = nil | |
| 603 | 605 | } | |
| 604 | 606 | result := historyRouteResult{ | |
| 605 | - | entries: entries, | |
| 606 | - | stats: stats, | |
| 607 | - | domains: domains, | |
| 607 | + | entries: entries, | |
| 608 | + | stats: stats, | |
| 609 | + | domains: domains, | |
| 610 | + | windowBefore: snapshot.windowBefore, | |
| 611 | + | windowAfter: snapshot.windowAfter, | |
| 608 | 612 | } | |
| 609 | 613 | if window != nil { | |
| 610 | 614 | result.windowBefore = window.Before | |
| @@ -666,23 +670,6 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | |||
| 666 | 670 | a.renderedHTML = result.html | |
| 667 | 671 | } | |
| 668 | 672 | ||
| 669 | - | func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | |
| 670 | - | query := strings.TrimSpace(a.historyQuery) | |
| 671 | - | domain := strings.TrimSpace(a.historyDomainFilter) | |
| 672 | - | if query != "" { | |
| 673 | - | entries, err := a.deps.History.Search(ctx, query, historySearchLimit) | |
| 674 | - | return nil, entries, err | |
| 675 | - | } | |
| 676 | - | window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain) | |
| 677 | - | if err != nil { | |
| 678 | - | return nil, nil, err | |
| 679 | - | } | |
| 680 | - | if window == nil { | |
| 681 | - | return nil, nil, nil | |
| 682 | - | } | |
| 683 | - | return window, window.Entries, nil | |
| 684 | - | } | |
| 685 | - | ||
| 686 | 673 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | |
| 687 | 674 | if a.deps.Favorites == nil { | |
| 688 | 675 | a.resetRouteState() | |
| @@ -549,6 +549,7 @@ type historyRouteSnapshot struct { | |||||
| 549 | domainFilter string | 549 | domainFilter string | ||
| 550 | offset int | 550 | offset int | ||
| 551 | windowBefore time.Time | 551 | windowBefore time.Time | ||
| 552 | + | windowAfter time.Time | |||
| 552 | notice string | 553 | notice string | ||
| 553 | error string | 554 | error string | ||
| 554 | } | 555 | } | ||
| @@ -572,6 +573,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot { | |||||
| 572 | domainFilter: a.historyDomainFilter, | 573 | domainFilter: a.historyDomainFilter, | ||
| 573 | offset: a.historyOffset, | 574 | offset: a.historyOffset, | ||
| 574 | windowBefore: a.historyWindowBefore, | 575 | windowBefore: a.historyWindowBefore, | ||
| 576 | + | windowAfter: a.historyWindowAfter, | |||
| 575 | notice: a.historyNotice, | 577 | notice: a.historyNotice, | ||
| 576 | error: a.historyError, | 578 | error: a.historyError, | ||
| 577 | } | 579 | } | ||
| @@ -602,9 +604,11 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||||
| 602 | domains = nil | 604 | domains = nil | ||
| 603 | } | 605 | } | ||
| 604 | result := historyRouteResult{ | 606 | result := historyRouteResult{ | ||
| 605 | - | entries: entries, | 607 | + | entries: entries, |
| 606 | - | stats: stats, | 608 | + | stats: stats, |
| 607 | - | domains: domains, | 609 | + | domains: domains, |
| 610 | + | windowBefore: snapshot.windowBefore, | |||
| 611 | + | windowAfter: snapshot.windowAfter, | |||
| 608 | } | 612 | } | ||
| 609 | if window != nil { | 613 | if window != nil { | ||
| 610 | result.windowBefore = window.Before | 614 | result.windowBefore = window.Before | ||
| @@ -666,23 +670,6 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | |||||
| 666 | a.renderedHTML = result.html | 670 | a.renderedHTML = result.html | ||
| 667 | } | 671 | } | ||
| 668 | 672 | ||||
| 669 | - | func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | |||
| 670 | - | query := strings.TrimSpace(a.historyQuery) | |||
| 671 | - | domain := strings.TrimSpace(a.historyDomainFilter) | |||
| 672 | - | if query != "" { | |||
| 673 | - | entries, err := a.deps.History.Search(ctx, query, historySearchLimit) | |||
| 674 | - | return nil, entries, err | |||
| 675 | - | } | |||
| 676 | - | window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain) | |||
| 677 | - | if err != nil { | |||
| 678 | - | return nil, nil, err | |||
| 679 | - | } | |||
| 680 | - | if window == nil { | |||
| 681 | - | return nil, nil, nil | |||
| 682 | - | } | |||
| 683 | - | return window, window.Entries, nil | |||
| 684 | - | } | |||
| 685 | - | ||||
| 686 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | 673 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | ||
| 687 | if a.deps.Favorites == nil { | 674 | if a.deps.Favorites == nil { | ||
| 688 | a.resetRouteState() | 675 | a.resetRouteState() | ||
M internal/ui/systemviews/app_test.go +25 -3
| @@ -198,6 +198,16 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi | |||
| 198 | 198 | assert.NotContains(t, dom.html, "Stale hydrated entry") | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | + | func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) { | |
| 202 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 203 | + | app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"}) | |
| 204 | + | app.currentRoute = RouteHistory | |
| 205 | + | app.historyWindowAfter = cursor | |
| 206 | + | ||
| 207 | + | snapshot := app.currentHistoryRouteSnapshotLocked() | |
| 208 | + | assert.Equal(t, cursor, snapshot.windowAfter) | |
| 209 | + | } | |
| 210 | + | ||
| 201 | 211 | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |
| 202 | 212 | t.Parallel() | |
| 203 | 213 | ||
| @@ -792,7 +802,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 792 | 802 | })) | |
| 793 | 803 | assert.True(t, service.calledSave) | |
| 794 | 804 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |
| 795 | - | assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale) | |
| 805 | + | assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001) | |
| 796 | 806 | assert.Contains(t, dom.html, "Saved appearance settings") | |
| 797 | 807 | assert.Contains(t, dom.html, `class="sv-app sv-light"`) | |
| 798 | 808 | ||
| @@ -1287,7 +1297,7 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||
| 1287 | 1297 | Action: favoriteActionCreate, | |
| 1288 | 1298 | Data: map[string]string{"url": "dumb:history", "title": "History"}, | |
| 1289 | 1299 | })) | |
| 1290 | - | assert.Equal(t, "dumb://history", favorites.createdFavorite.URL) | |
| 1300 | + | assert.Equal(t, "dumb:history", favorites.createdFavorite.URL) | |
| 1291 | 1301 | ||
| 1292 | 1302 | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1293 | 1303 | Action: favoriteActionCreate, | |
| @@ -1296,6 +1306,17 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||
| 1296 | 1306 | assert.Equal(t, "dumb://config", favorites.createdFavorite.URL) | |
| 1297 | 1307 | } | |
| 1298 | 1308 | ||
| 1309 | + | func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) { | |
| 1310 | + | _, err := parsePositiveInt64("abc", "tag id") | |
| 1311 | + | require.Error(t, err) | |
| 1312 | + | assert.Contains(t, err.Error(), "invalid tag id") | |
| 1313 | + | assert.Contains(t, err.Error(), "invalid syntax") | |
| 1314 | + | ||
| 1315 | + | _, err = parsePositiveInt64("0", "tag id") | |
| 1316 | + | require.Error(t, err) | |
| 1317 | + | assert.Contains(t, err.Error(), "tag id must be a positive integer") | |
| 1318 | + | } | |
| 1319 | + | ||
| 1299 | 1320 | func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) { | |
| 1300 | 1321 | favorites := &fakeFavoritesService{} | |
| 1301 | 1322 | app := NewApp(Dependencies{Favorites: favorites}) | |
| @@ -1335,7 +1356,8 @@ func TestHandleHistorySearchClearsDomainFilter(t *testing.T) { | |||
| 1335 | 1356 | func TestAppRunWithContextRejectsNilContext(t *testing.T) { | |
| 1336 | 1357 | app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"}) | |
| 1337 | 1358 | ||
| 1338 | - | err := app.RunWithContext(nil) | |
| 1359 | + | var ctx context.Context | |
| 1360 | + | err := app.RunWithContext(ctx) | |
| 1339 | 1361 | ||
| 1340 | 1362 | require.ErrorContains(t, err, "context is nil") | |
| 1341 | 1363 | } | |
| @@ -198,6 +198,16 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi | |||||
| 198 | assert.NotContains(t, dom.html, "Stale hydrated entry") | 198 | assert.NotContains(t, dom.html, "Stale hydrated entry") | ||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | + | func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) { | |||
| 202 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 203 | + | app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"}) | |||
| 204 | + | app.currentRoute = RouteHistory | |||
| 205 | + | app.historyWindowAfter = cursor | |||
| 206 | + | ||||
| 207 | + | snapshot := app.currentHistoryRouteSnapshotLocked() | |||
| 208 | + | assert.Equal(t, cursor, snapshot.windowAfter) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 201 | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | 211 | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | ||
| 202 | t.Parallel() | 212 | t.Parallel() | ||
| 203 | 213 | ||||
| @@ -792,7 +802,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||||
| 792 | })) | 802 | })) | ||
| 793 | assert.True(t, service.calledSave) | 803 | assert.True(t, service.calledSave) | ||
| 794 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | 804 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | ||
| 795 | - | assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale) | 805 | + | assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001) |
| 796 | assert.Contains(t, dom.html, "Saved appearance settings") | 806 | assert.Contains(t, dom.html, "Saved appearance settings") | ||
| 797 | assert.Contains(t, dom.html, `class="sv-app sv-light"`) | 807 | assert.Contains(t, dom.html, `class="sv-app sv-light"`) | ||
| 798 | 808 | ||||
| @@ -1287,7 +1297,7 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||||
| 1287 | Action: favoriteActionCreate, | 1297 | Action: favoriteActionCreate, | ||
| 1288 | Data: map[string]string{"url": "dumb:history", "title": "History"}, | 1298 | Data: map[string]string{"url": "dumb:history", "title": "History"}, | ||
| 1289 | })) | 1299 | })) | ||
| 1290 | - | assert.Equal(t, "dumb://history", favorites.createdFavorite.URL) | 1300 | + | assert.Equal(t, "dumb:history", favorites.createdFavorite.URL) |
| 1291 | 1301 | ||||
| 1292 | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | 1302 | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | ||
| 1293 | Action: favoriteActionCreate, | 1303 | Action: favoriteActionCreate, | ||
| @@ -1296,6 +1306,17 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||||
| 1296 | assert.Equal(t, "dumb://config", favorites.createdFavorite.URL) | 1306 | assert.Equal(t, "dumb://config", favorites.createdFavorite.URL) | ||
| 1297 | } | 1307 | } | ||
| 1298 | 1308 | ||||
| 1309 | + | func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) { | |||
| 1310 | + | _, err := parsePositiveInt64("abc", "tag id") | |||
| 1311 | + | require.Error(t, err) | |||
| 1312 | + | assert.Contains(t, err.Error(), "invalid tag id") | |||
| 1313 | + | assert.Contains(t, err.Error(), "invalid syntax") | |||
| 1314 | + | ||||
| 1315 | + | _, err = parsePositiveInt64("0", "tag id") | |||
| 1316 | + | require.Error(t, err) | |||
| 1317 | + | assert.Contains(t, err.Error(), "tag id must be a positive integer") | |||
| 1318 | + | } | |||
| 1319 | + | ||||
| 1299 | func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) { | 1320 | func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) { | ||
| 1300 | favorites := &fakeFavoritesService{} | 1321 | favorites := &fakeFavoritesService{} | ||
| 1301 | app := NewApp(Dependencies{Favorites: favorites}) | 1322 | app := NewApp(Dependencies{Favorites: favorites}) | ||
| @@ -1335,7 +1356,8 @@ func TestHandleHistorySearchClearsDomainFilter(t *testing.T) { | |||||
| 1335 | func TestAppRunWithContextRejectsNilContext(t *testing.T) { | 1356 | func TestAppRunWithContextRejectsNilContext(t *testing.T) { | ||
| 1336 | app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"}) | 1357 | app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"}) | ||
| 1337 | 1358 | ||||
| 1338 | - | err := app.RunWithContext(nil) | 1359 | + | var ctx context.Context |
| 1360 | + | err := app.RunWithContext(ctx) | |||
| 1339 | 1361 | ||||
| 1340 | require.ErrorContains(t, err, "context is nil") | 1362 | require.ErrorContains(t, err, "context is nil") | ||
| 1341 | } | 1363 | } | ||
M internal/ui/systemviews/config.templ +1 -0
| @@ -105,6 +105,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) { | |||
| 105 | 105 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form> | |
| 106 | 106 | <label><span>Profile</span><select name="profile" data-sv-performance-profile> | |
| 107 | 107 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | |
| 108 | + | <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option> | |
| 108 | 109 | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | |
| 109 | 110 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | |
| 110 | 111 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | |
| @@ -105,6 +105,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) { | |||||
| 105 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form> | 105 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form> | ||
| 106 | <label><span>Profile</span><select name="profile" data-sv-performance-profile> | 106 | <label><span>Profile</span><select name="profile" data-sv-performance-profile> | ||
| 107 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | 107 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | ||
| 108 | + | <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option> | |||
| 108 | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | 109 | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | ||
| 109 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | 110 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | ||
| 110 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | 111 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | ||
M internal/ui/systemviews/config_actions.go +8 -5
| @@ -25,6 +25,9 @@ const ( | |||
| 25 | 25 | configActionResetKeybinding = "config.keybinding.reset" | |
| 26 | 26 | configActionResetAllKeybindings = "config.keybinding.resetAll" | |
| 27 | 27 | configRequestIDPrefix = "systemviews-config" | |
| 28 | + | balancedPerformanceProfile = "balanced" | |
| 29 | + | customPerformanceProfile = "custom" | |
| 30 | + | httpScheme = "http" | |
| 28 | 31 | maxCustomPerformanceThreads = 8 | |
| 29 | 32 | maxCustomPerformanceWebMemoryMB = 16384 | |
| 30 | 33 | maxCustomPerformanceNetworkMemoryMB = 4096 | |
| @@ -241,9 +244,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||
| 241 | 244 | return fmt.Errorf("unknown performance profile: %q", profile) | |
| 242 | 245 | } | |
| 243 | 246 | cfg.Performance.Profile = profile | |
| 244 | - | if profile != "custom" { | |
| 245 | - | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 246 | - | return err | |
| 247 | + | if profile != customPerformanceProfile { | |
| 248 | + | if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil { | |
| 249 | + | return saveErr | |
| 247 | 250 | } | |
| 248 | 251 | a.configNotice = "Saved performance settings. Restart may be required." | |
| 249 | 252 | return nil | |
| @@ -479,7 +482,7 @@ func requireSearchURLTemplate(raw, label string) (string, error) { | |||
| 479 | 482 | return "", fmt.Errorf("%s must include %%s placeholder", label) | |
| 480 | 483 | } | |
| 481 | 484 | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | |
| 482 | - | if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { | |
| 485 | + | if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") { | |
| 483 | 486 | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | |
| 484 | 487 | } | |
| 485 | 488 | return value, nil | |
| @@ -503,7 +506,7 @@ func parseConfigFloat(raw, label string) (float64, error) { | |||
| 503 | 506 | ||
| 504 | 507 | func isAllowedPerformanceProfile(profile string) bool { | |
| 505 | 508 | switch profile { | |
| 506 | - | case "default", "lite", "max", "custom": | |
| 509 | + | case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile: | |
| 507 | 510 | return true | |
| 508 | 511 | default: | |
| 509 | 512 | return false | |
| @@ -25,6 +25,9 @@ const ( | |||||
| 25 | configActionResetKeybinding = "config.keybinding.reset" | 25 | configActionResetKeybinding = "config.keybinding.reset" | ||
| 26 | configActionResetAllKeybindings = "config.keybinding.resetAll" | 26 | configActionResetAllKeybindings = "config.keybinding.resetAll" | ||
| 27 | configRequestIDPrefix = "systemviews-config" | 27 | configRequestIDPrefix = "systemviews-config" | ||
| 28 | + | balancedPerformanceProfile = "balanced" | |||
| 29 | + | customPerformanceProfile = "custom" | |||
| 30 | + | httpScheme = "http" | |||
| 28 | maxCustomPerformanceThreads = 8 | 31 | maxCustomPerformanceThreads = 8 | ||
| 29 | maxCustomPerformanceWebMemoryMB = 16384 | 32 | maxCustomPerformanceWebMemoryMB = 16384 | ||
| 30 | maxCustomPerformanceNetworkMemoryMB = 4096 | 33 | maxCustomPerformanceNetworkMemoryMB = 4096 | ||
| @@ -241,9 +244,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||||
| 241 | return fmt.Errorf("unknown performance profile: %q", profile) | 244 | return fmt.Errorf("unknown performance profile: %q", profile) | ||
| 242 | } | 245 | } | ||
| 243 | cfg.Performance.Profile = profile | 246 | cfg.Performance.Profile = profile | ||
| 244 | - | if profile != "custom" { | 247 | + | if profile != customPerformanceProfile { |
| 245 | - | if err := a.saveEditableConfig(ctx, cfg); err != nil { | 248 | + | if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil { |
| 246 | - | return err | 249 | + | return saveErr |
| 247 | } | 250 | } | ||
| 248 | a.configNotice = "Saved performance settings. Restart may be required." | 251 | a.configNotice = "Saved performance settings. Restart may be required." | ||
| 249 | return nil | 252 | return nil | ||
| @@ -479,7 +482,7 @@ func requireSearchURLTemplate(raw, label string) (string, error) { | |||||
| 479 | return "", fmt.Errorf("%s must include %%s placeholder", label) | 482 | return "", fmt.Errorf("%s must include %%s placeholder", label) | ||
| 480 | } | 483 | } | ||
| 481 | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | 484 | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | ||
| 482 | - | if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { | 485 | + | if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") { |
| 483 | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | 486 | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | ||
| 484 | } | 487 | } | ||
| 485 | return value, nil | 488 | return value, nil | ||
| @@ -503,7 +506,7 @@ func parseConfigFloat(raw, label string) (float64, error) { | |||||
| 503 | 506 | ||||
| 504 | func isAllowedPerformanceProfile(profile string) bool { | 507 | func isAllowedPerformanceProfile(profile string) bool { | ||
| 505 | switch profile { | 508 | switch profile { | ||
| 506 | - | case "default", "lite", "max", "custom": | 509 | + | case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile: |
| 507 | return true | 510 | return true | ||
| 508 | default: | 511 | default: | ||
| 509 | return false | 512 | return false | ||
M internal/ui/systemviews/config_templ.go +82 -72 generated file: collapsed
M internal/ui/systemviews/config_view.go +55 -44
| @@ -176,57 +176,68 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 176 | 176 | ||
| 177 | 177 | switch value.Kind() { | |
| 178 | 178 | case reflect.Struct: | |
| 179 | - | typeOfValue := value.Type() | |
| 180 | - | for i := 0; i < value.NumField(); i++ { | |
| 181 | - | field := typeOfValue.Field(i) | |
| 182 | - | if !field.IsExported() { | |
| 183 | - | continue | |
| 184 | - | } | |
| 185 | - | name := jsonFieldName(field) | |
| 186 | - | if name == "-" { | |
| 187 | - | continue | |
| 188 | - | } | |
| 189 | - | nextPath := name | |
| 190 | - | if path != "" { | |
| 191 | - | nextPath = path + "." + name | |
| 192 | - | } | |
| 193 | - | flattenValue(rows, nextPath, value.Field(i), depth+1) | |
| 194 | - | } | |
| 179 | + | flattenStructValue(rows, path, value, depth) | |
| 195 | 180 | case reflect.Map: | |
| 196 | - | if value.Len() == 0 { | |
| 197 | - | appendFlatValue(rows, path, "{}") | |
| 198 | - | return | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | keys := value.MapKeys() | |
| 202 | - | sort.Slice(keys, func(i, j int) bool { | |
| 203 | - | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |
| 204 | - | }) | |
| 181 | + | flattenMapValue(rows, path, value, depth) | |
| 182 | + | case reflect.Slice, reflect.Array: | |
| 183 | + | flattenListValue(rows, path, value, depth) | |
| 184 | + | default: | |
| 185 | + | appendFlatValue(rows, path, formatFlatValue(value)) | |
| 186 | + | } | |
| 187 | + | } | |
| 205 | 188 | ||
| 206 | - | for _, key := range keys { | |
| 207 | - | name := mapKeyString(key) | |
| 208 | - | nextPath := name | |
| 209 | - | if path != "" { | |
| 210 | - | nextPath = path + "." + name | |
| 211 | - | } | |
| 212 | - | flattenValue(rows, nextPath, value.MapIndex(key), depth+1) | |
| 189 | + | func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |
| 190 | + | typeOfValue := value.Type() | |
| 191 | + | for i := 0; i < value.NumField(); i++ { | |
| 192 | + | field := typeOfValue.Field(i) | |
| 193 | + | if !field.IsExported() { | |
| 194 | + | continue | |
| 213 | 195 | } | |
| 214 | - | case reflect.Slice, reflect.Array: | |
| 215 | - | if value.Len() == 0 { | |
| 216 | - | appendFlatValue(rows, path, "[]") | |
| 217 | - | return | |
| 196 | + | name := jsonFieldName(field) | |
| 197 | + | if name == "-" { | |
| 198 | + | continue | |
| 218 | 199 | } | |
| 200 | + | flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |
| 205 | + | if value.Len() == 0 { | |
| 206 | + | appendFlatValue(rows, path, "{}") | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | keys := value.MapKeys() | |
| 211 | + | sort.Slice(keys, func(i, j int) bool { | |
| 212 | + | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | for _, key := range keys { | |
| 216 | + | name := mapKeyString(key) | |
| 217 | + | flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1) | |
| 218 | + | } | |
| 219 | + | } | |
| 219 | 220 | ||
| 220 | - | for i := 0; i < value.Len(); i++ { | |
| 221 | - | nextPath := fmt.Sprintf("%s[%d]", path, i) | |
| 222 | - | if path == "" { | |
| 223 | - | nextPath = fmt.Sprintf("[%d]", i) | |
| 224 | - | } | |
| 225 | - | flattenValue(rows, nextPath, value.Index(i), depth+1) | |
| 221 | + | func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |
| 222 | + | if value.Len() == 0 { | |
| 223 | + | appendFlatValue(rows, path, "[]") | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | for i := 0; i < value.Len(); i++ { | |
| 228 | + | nextPath := fmt.Sprintf("%s[%d]", path, i) | |
| 229 | + | if path == "" { | |
| 230 | + | nextPath = fmt.Sprintf("[%d]", i) | |
| 226 | 231 | } | |
| 227 | - | default: | |
| 228 | - | appendFlatValue(rows, path, formatFlatValue(value)) | |
| 232 | + | flattenValue(rows, nextPath, value.Index(i), depth+1) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func childFlatPath(path, name string) string { | |
| 237 | + | if path == "" { | |
| 238 | + | return name | |
| 229 | 239 | } | |
| 240 | + | return path + "." + name | |
| 230 | 241 | } | |
| 231 | 242 | ||
| 232 | 243 | func appendFlatValue(rows *[]kvPair, path, value string) { | |
| @@ -176,57 +176,68 @@ func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||||
| 176 | 176 | ||||
| 177 | switch value.Kind() { | 177 | switch value.Kind() { | ||
| 178 | case reflect.Struct: | 178 | case reflect.Struct: | ||
| 179 | - | typeOfValue := value.Type() | 179 | + | flattenStructValue(rows, path, value, depth) |
| 180 | - | for i := 0; i < value.NumField(); i++ { | |||
| 181 | - | field := typeOfValue.Field(i) | |||
| 182 | - | if !field.IsExported() { | |||
| 183 | - | continue | |||
| 184 | - | } | |||
| 185 | - | name := jsonFieldName(field) | |||
| 186 | - | if name == "-" { | |||
| 187 | - | continue | |||
| 188 | - | } | |||
| 189 | - | nextPath := name | |||
| 190 | - | if path != "" { | |||
| 191 | - | nextPath = path + "." + name | |||
| 192 | - | } | |||
| 193 | - | flattenValue(rows, nextPath, value.Field(i), depth+1) | |||
| 194 | - | } | |||
| 195 | case reflect.Map: | 180 | case reflect.Map: | ||
| 196 | - | if value.Len() == 0 { | 181 | + | flattenMapValue(rows, path, value, depth) |
| 197 | - | appendFlatValue(rows, path, "{}") | 182 | + | case reflect.Slice, reflect.Array: |
| 198 | - | return | 183 | + | flattenListValue(rows, path, value, depth) |
| 199 | - | } | 184 | + | default: |
| 200 | - | 185 | + | appendFlatValue(rows, path, formatFlatValue(value)) | |
| 201 | - | keys := value.MapKeys() | 186 | + | } |
| 202 | - | sort.Slice(keys, func(i, j int) bool { | 187 | + | } |
| 203 | - | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |||
| 204 | - | }) | |||
| 205 | 188 | ||||
| 206 | - | for _, key := range keys { | 189 | + | func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) { |
| 207 | - | name := mapKeyString(key) | 190 | + | typeOfValue := value.Type() |
| 208 | - | nextPath := name | 191 | + | for i := 0; i < value.NumField(); i++ { |
| 209 | - | if path != "" { | 192 | + | field := typeOfValue.Field(i) |
| 210 | - | nextPath = path + "." + name | 193 | + | if !field.IsExported() { |
| 211 | - | } | 194 | + | continue |
| 212 | - | flattenValue(rows, nextPath, value.MapIndex(key), depth+1) | |||
| 213 | } | 195 | } | ||
| 214 | - | case reflect.Slice, reflect.Array: | 196 | + | name := jsonFieldName(field) |
| 215 | - | if value.Len() == 0 { | 197 | + | if name == "-" { |
| 216 | - | appendFlatValue(rows, path, "[]") | 198 | + | continue |
| 217 | - | return | |||
| 218 | } | 199 | } | ||
| 200 | + | flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 205 | + | if value.Len() == 0 { | |||
| 206 | + | appendFlatValue(rows, path, "{}") | |||
| 207 | + | return | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | keys := value.MapKeys() | |||
| 211 | + | sort.Slice(keys, func(i, j int) bool { | |||
| 212 | + | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |||
| 213 | + | }) | |||
| 214 | + | ||||
| 215 | + | for _, key := range keys { | |||
| 216 | + | name := mapKeyString(key) | |||
| 217 | + | flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 219 | 220 | ||||
| 220 | - | for i := 0; i < value.Len(); i++ { | 221 | + | func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) { |
| 221 | - | nextPath := fmt.Sprintf("%s[%d]", path, i) | 222 | + | if value.Len() == 0 { |
| 222 | - | if path == "" { | 223 | + | appendFlatValue(rows, path, "[]") |
| 223 | - | nextPath = fmt.Sprintf("[%d]", i) | 224 | + | return |
| 224 | - | } | 225 | + | } |
| 225 | - | flattenValue(rows, nextPath, value.Index(i), depth+1) | 226 | + | |
| 227 | + | for i := 0; i < value.Len(); i++ { | |||
| 228 | + | nextPath := fmt.Sprintf("%s[%d]", path, i) | |||
| 229 | + | if path == "" { | |||
| 230 | + | nextPath = fmt.Sprintf("[%d]", i) | |||
| 226 | } | 231 | } | ||
| 227 | - | default: | 232 | + | flattenValue(rows, nextPath, value.Index(i), depth+1) |
| 228 | - | appendFlatValue(rows, path, formatFlatValue(value)) | 233 | + | } |
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func childFlatPath(path, name string) string { | |||
| 237 | + | if path == "" { | |||
| 238 | + | return name | |||
| 229 | } | 239 | } | ||
| 240 | + | return path + "." + name | |||
| 230 | } | 241 | } | ||
| 231 | 242 | ||||
| 232 | func appendFlatValue(rows *[]kvPair, path, value string) { | 243 | func appendFlatValue(rows *[]kvPair, path, value string) { | ||
M internal/ui/systemviews/dom_js.go +16 -0
| @@ -42,6 +42,7 @@ func (d *browserDOM) Mount(markup string) error { | |||
| 42 | 42 | d.target.Set("innerHTML", markup) | |
| 43 | 43 | d.updateDocumentTitle() | |
| 44 | 44 | d.scheduleAlertDismissal() | |
| 45 | + | d.syncPerformanceCustomInputStates() | |
| 45 | 46 | d.setupHistoryInfiniteScroll() | |
| 46 | 47 | return nil | |
| 47 | 48 | } | |
| @@ -286,6 +287,11 @@ func (d *browserDOM) setupHistoryInfiniteScroll() { | |||
| 286 | 287 | if d == nil || !d.target.Truthy() { | |
| 287 | 288 | return | |
| 288 | 289 | } | |
| 290 | + | // setupHistoryInfiniteScroll callbacks close over the current load-more | |
| 291 | + | // button. disconnectHistoryObserver owns the cleanup chain: it calls | |
| 292 | + | // d.historyObserver.disconnect(), releases d.historyObserverCallback, and | |
| 293 | + | // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new | |
| 294 | + | // observer so old button closures are not retained. | |
| 289 | 295 | d.disconnectHistoryObserver() | |
| 290 | 296 | observerCtor := js.Global().Get("IntersectionObserver") | |
| 291 | 297 | if !observerCtor.Truthy() { | |
| @@ -390,6 +396,16 @@ func collectActionData(element js.Value) map[string]string { | |||
| 390 | 396 | return data | |
| 391 | 397 | } | |
| 392 | 398 | ||
| 399 | + | func (d *browserDOM) syncPerformanceCustomInputStates() { | |
| 400 | + | if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() { | |
| 401 | + | return | |
| 402 | + | } | |
| 403 | + | forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]") | |
| 404 | + | for i := 0; i < forms.Get("length").Int(); i++ { | |
| 405 | + | updatePerformanceCustomInputs(forms.Index(i)) | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 393 | 409 | func updatePerformanceCustomInputs(form js.Value) { | |
| 394 | 410 | if !form.Truthy() || !form.Get("querySelector").Truthy() { | |
| 395 | 411 | return | |
| @@ -42,6 +42,7 @@ func (d *browserDOM) Mount(markup string) error { | |||||
| 42 | d.target.Set("innerHTML", markup) | 42 | d.target.Set("innerHTML", markup) | ||
| 43 | d.updateDocumentTitle() | 43 | d.updateDocumentTitle() | ||
| 44 | d.scheduleAlertDismissal() | 44 | d.scheduleAlertDismissal() | ||
| 45 | + | d.syncPerformanceCustomInputStates() | |||
| 45 | d.setupHistoryInfiniteScroll() | 46 | d.setupHistoryInfiniteScroll() | ||
| 46 | return nil | 47 | return nil | ||
| 47 | } | 48 | } | ||
| @@ -286,6 +287,11 @@ func (d *browserDOM) setupHistoryInfiniteScroll() { | |||||
| 286 | if d == nil || !d.target.Truthy() { | 287 | if d == nil || !d.target.Truthy() { | ||
| 287 | return | 288 | return | ||
| 288 | } | 289 | } | ||
| 290 | + | // setupHistoryInfiniteScroll callbacks close over the current load-more | |||
| 291 | + | // button. disconnectHistoryObserver owns the cleanup chain: it calls | |||
| 292 | + | // d.historyObserver.disconnect(), releases d.historyObserverCallback, and | |||
| 293 | + | // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new | |||
| 294 | + | // observer so old button closures are not retained. | |||
| 289 | d.disconnectHistoryObserver() | 295 | d.disconnectHistoryObserver() | ||
| 290 | observerCtor := js.Global().Get("IntersectionObserver") | 296 | observerCtor := js.Global().Get("IntersectionObserver") | ||
| 291 | if !observerCtor.Truthy() { | 297 | if !observerCtor.Truthy() { | ||
| @@ -390,6 +396,16 @@ func collectActionData(element js.Value) map[string]string { | |||||
| 390 | return data | 396 | return data | ||
| 391 | } | 397 | } | ||
| 392 | 398 | ||||
| 399 | + | func (d *browserDOM) syncPerformanceCustomInputStates() { | |||
| 400 | + | if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() { | |||
| 401 | + | return | |||
| 402 | + | } | |||
| 403 | + | forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]") | |||
| 404 | + | for i := 0; i < forms.Get("length").Int(); i++ { | |||
| 405 | + | updatePerformanceCustomInputs(forms.Index(i)) | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | ||||
| 393 | func updatePerformanceCustomInputs(form js.Value) { | 409 | func updatePerformanceCustomInputs(form js.Value) { | ||
| 394 | if !form.Truthy() || !form.Get("querySelector").Truthy() { | 410 | if !form.Truthy() || !form.Get("querySelector").Truthy() { | ||
| 395 | return | 411 | return | ||
M internal/ui/systemviews/favorite_actions.go +7 -3
| @@ -28,6 +28,7 @@ const ( | |||
| 28 | 28 | tagActionRemove = "tag.remove" | |
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |
| 31 | 32 | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |
| 32 | 33 | if a.deps.Favorites == nil { | |
| 33 | 34 | return fmt.Errorf("favorites service not configured") | |
| @@ -222,7 +223,7 @@ func validateFavoriteURL(raw string) (string, error) { | |||
| 222 | 223 | return favoriteURL, nil | |
| 223 | 224 | } | |
| 224 | 225 | if parsed.Opaque != "" { | |
| 225 | - | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | |
| 226 | + | return favoriteURL, nil | |
| 226 | 227 | } | |
| 227 | 228 | return "", fmt.Errorf("favorite URL host is required") | |
| 228 | 229 | default: | |
| @@ -242,8 +243,11 @@ func firstActionValue(data map[string]string, keys ...string) string { | |||
| 242 | 243 | ||
| 243 | 244 | func parsePositiveInt64(raw, label string) (int64, error) { | |
| 244 | 245 | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | |
| 245 | - | if err != nil || id <= 0 { | |
| 246 | - | return 0, fmt.Errorf("invalid %s", label) | |
| 246 | + | if err != nil { | |
| 247 | + | return 0, fmt.Errorf("invalid %s: %w", label, err) | |
| 248 | + | } | |
| 249 | + | if id <= 0 { | |
| 250 | + | return 0, fmt.Errorf("%s must be a positive integer", label) | |
| 247 | 251 | } | |
| 248 | 252 | return id, nil | |
| 249 | 253 | } | |
| @@ -28,6 +28,7 @@ const ( | |||||
| 28 | tagActionRemove = "tag.remove" | 28 | tagActionRemove = "tag.remove" | ||
| 29 | ) | 29 | ) | ||
| 30 | 30 | ||||
| 31 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |||
| 31 | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | 32 | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | ||
| 32 | if a.deps.Favorites == nil { | 33 | if a.deps.Favorites == nil { | ||
| 33 | return fmt.Errorf("favorites service not configured") | 34 | return fmt.Errorf("favorites service not configured") | ||
| @@ -222,7 +223,7 @@ func validateFavoriteURL(raw string) (string, error) { | |||||
| 222 | return favoriteURL, nil | 223 | return favoriteURL, nil | ||
| 223 | } | 224 | } | ||
| 224 | if parsed.Opaque != "" { | 225 | if parsed.Opaque != "" { | ||
| 225 | - | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | 226 | + | return favoriteURL, nil |
| 226 | } | 227 | } | ||
| 227 | return "", fmt.Errorf("favorite URL host is required") | 228 | return "", fmt.Errorf("favorite URL host is required") | ||
| 228 | default: | 229 | default: | ||
| @@ -242,8 +243,11 @@ func firstActionValue(data map[string]string, keys ...string) string { | |||||
| 242 | 243 | ||||
| 243 | func parsePositiveInt64(raw, label string) (int64, error) { | 244 | func parsePositiveInt64(raw, label string) (int64, error) { | ||
| 244 | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | 245 | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | ||
| 245 | - | if err != nil || id <= 0 { | 246 | + | if err != nil { |
| 246 | - | return 0, fmt.Errorf("invalid %s", label) | 247 | + | return 0, fmt.Errorf("invalid %s: %w", label, err) |
| 248 | + | } | |||
| 249 | + | if id <= 0 { | |||
| 250 | + | return 0, fmt.Errorf("%s must be a positive integer", label) | |||
| 247 | } | 251 | } | ||
| 248 | return id, nil | 252 | return id, nil | ||
| 249 | } | 253 | } | ||
M internal/ui/systemviews/favorites_view.go +5 -3
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | const defaultTagColor = "#808080" | |
| 11 | + | ||
| 10 | 12 | type favoritesRenderData struct { | |
| 11 | 13 | Favorites []*entity.Favorite | |
| 12 | 14 | Folders []*entity.Folder | |
| @@ -120,16 +122,16 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 120 | 122 | func safeTagColor(raw string) string { | |
| 121 | 123 | value := strings.TrimSpace(raw) | |
| 122 | 124 | if len(value) != 4 && len(value) != 7 { | |
| 123 | - | return "#808080" | |
| 125 | + | return defaultTagColor | |
| 124 | 126 | } | |
| 125 | 127 | if value[0] != '#' { | |
| 126 | - | return "#808080" | |
| 128 | + | return defaultTagColor | |
| 127 | 129 | } | |
| 128 | 130 | for _, ch := range value[1:] { | |
| 129 | 131 | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | |
| 130 | 132 | continue | |
| 131 | 133 | } | |
| 132 | - | return "#808080" | |
| 134 | + | return defaultTagColor | |
| 133 | 135 | } | |
| 134 | 136 | return value | |
| 135 | 137 | } | |
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | const defaultTagColor = "#808080" | |||
| 11 | + | ||||
| 10 | type favoritesRenderData struct { | 12 | type favoritesRenderData struct { | ||
| 11 | Favorites []*entity.Favorite | 13 | Favorites []*entity.Favorite | ||
| 12 | Folders []*entity.Folder | 14 | Folders []*entity.Folder | ||
| @@ -120,16 +122,16 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |||||
| 120 | func safeTagColor(raw string) string { | 122 | func safeTagColor(raw string) string { | ||
| 121 | value := strings.TrimSpace(raw) | 123 | value := strings.TrimSpace(raw) | ||
| 122 | if len(value) != 4 && len(value) != 7 { | 124 | if len(value) != 4 && len(value) != 7 { | ||
| 123 | - | return "#808080" | 125 | + | return defaultTagColor |
| 124 | } | 126 | } | ||
| 125 | if value[0] != '#' { | 127 | if value[0] != '#' { | ||
| 126 | - | return "#808080" | 128 | + | return defaultTagColor |
| 127 | } | 129 | } | ||
| 128 | for _, ch := range value[1:] { | 130 | for _, ch := range value[1:] { | ||
| 129 | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | 131 | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | ||
| 130 | continue | 132 | continue | ||
| 131 | } | 133 | } | ||
| 132 | - | return "#808080" | 134 | + | return defaultTagColor |
| 133 | } | 135 | } | ||
| 134 | return value | 136 | return value | ||
| 135 | } | 137 | } | ||
M internal/ui/systemviews/history_actions.go +1 -0
| @@ -86,6 +86,7 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |
| 89 | 90 | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |
| 90 | 91 | if a.deps.History == nil { | |
| 91 | 92 | return fmt.Errorf("history service not configured") | |
| @@ -86,6 +86,7 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 86 | } | 86 | } | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |||
| 89 | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | 90 | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | ||
| 90 | if a.deps.History == nil { | 91 | if a.deps.History == nil { | ||
| 91 | return fmt.Errorf("history service not configured") | 92 | return fmt.Errorf("history service not configured") | ||
M internal/ui/systemviews/history_view.go +3 -23
| @@ -12,6 +12,8 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | const historyURLDisplayMaxRunes = 180 | |
| 14 | 14 | ||
| 15 | + | const historySearchTitleMaxRunes = 48 | |
| 16 | + | ||
| 15 | 17 | type historyRenderData struct { | |
| 16 | 18 | Entries []*entity.HistoryEntry | |
| 17 | 19 | Stats *entity.HistoryStats | |
| @@ -56,7 +58,7 @@ func historyDocumentTitle(data historyRenderData) string { | |||
| 56 | 58 | return "History — Loading" | |
| 57 | 59 | } | |
| 58 | 60 | if query := strings.TrimSpace(data.Query); query != "" { | |
| 59 | - | return "History — search: " + truncateTitle(query, 48) | |
| 61 | + | return "History — search: " + truncateTitle(query, historySearchTitleMaxRunes) | |
| 60 | 62 | } | |
| 61 | 63 | if domain := strings.TrimSpace(data.DomainFilter); domain != "" { | |
| 62 | 64 | label := browserurl.DisplayDomain(domain) | |
| @@ -109,28 +111,6 @@ func historyIsPaginated(data historyRenderData) bool { | |||
| 109 | 111 | return historyLimit(data) > 0 | |
| 110 | 112 | } | |
| 111 | 113 | ||
| 112 | - | func previousHistoryOffset(data historyRenderData) int { | |
| 113 | - | if !historyIsPaginated(data) { | |
| 114 | - | return 0 | |
| 115 | - | } | |
| 116 | - | offset := data.Offset - historyLimit(data) | |
| 117 | - | if offset < 0 { | |
| 118 | - | return 0 | |
| 119 | - | } | |
| 120 | - | return offset | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | func nextHistoryOffset(data historyRenderData) int { | |
| 124 | - | if !historyIsPaginated(data) { | |
| 125 | - | return 0 | |
| 126 | - | } | |
| 127 | - | return max(data.Offset, 0) + historyLimit(data) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func disableHistoryPrev(data historyRenderData) bool { | |
| 131 | - | return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != "" | |
| 132 | - | } | |
| 133 | - | ||
| 134 | 114 | func disableHistoryNext(data historyRenderData) bool { | |
| 135 | 115 | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | |
| 136 | 116 | } | |
| @@ -12,6 +12,8 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | const historyURLDisplayMaxRunes = 180 | 13 | const historyURLDisplayMaxRunes = 180 | ||
| 14 | 14 | ||||
| 15 | + | const historySearchTitleMaxRunes = 48 | |||
| 16 | + | ||||
| 15 | type historyRenderData struct { | 17 | type historyRenderData struct { | ||
| 16 | Entries []*entity.HistoryEntry | 18 | Entries []*entity.HistoryEntry | ||
| 17 | Stats *entity.HistoryStats | 19 | Stats *entity.HistoryStats | ||
| @@ -56,7 +58,7 @@ func historyDocumentTitle(data historyRenderData) string { | |||||
| 56 | return "History — Loading" | 58 | return "History — Loading" | ||
| 57 | } | 59 | } | ||
| 58 | if query := strings.TrimSpace(data.Query); query != "" { | 60 | if query := strings.TrimSpace(data.Query); query != "" { | ||
| 59 | - | return "History — search: " + truncateTitle(query, 48) | 61 | + | return "History — search: " + truncateTitle(query, historySearchTitleMaxRunes) |
| 60 | } | 62 | } | ||
| 61 | if domain := strings.TrimSpace(data.DomainFilter); domain != "" { | 63 | if domain := strings.TrimSpace(data.DomainFilter); domain != "" { | ||
| 62 | label := browserurl.DisplayDomain(domain) | 64 | label := browserurl.DisplayDomain(domain) | ||
| @@ -109,28 +111,6 @@ func historyIsPaginated(data historyRenderData) bool { | |||||
| 109 | return historyLimit(data) > 0 | 111 | return historyLimit(data) > 0 | ||
| 110 | } | 112 | } | ||
| 111 | 113 | ||||
| 112 | - | func previousHistoryOffset(data historyRenderData) int { | |||
| 113 | - | if !historyIsPaginated(data) { | |||
| 114 | - | return 0 | |||
| 115 | - | } | |||
| 116 | - | offset := data.Offset - historyLimit(data) | |||
| 117 | - | if offset < 0 { | |||
| 118 | - | return 0 | |||
| 119 | - | } | |||
| 120 | - | return offset | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | func nextHistoryOffset(data historyRenderData) int { | |||
| 124 | - | if !historyIsPaginated(data) { | |||
| 125 | - | return 0 | |||
| 126 | - | } | |||
| 127 | - | return max(data.Offset, 0) + historyLimit(data) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | func disableHistoryPrev(data historyRenderData) bool { | |||
| 131 | - | return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != "" | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | func disableHistoryNext(data historyRenderData) bool { | 114 | func disableHistoryNext(data historyRenderData) bool { | ||
| 135 | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | 115 | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | ||
| 136 | } | 116 | } | ||
M internal/ui/systemviews/href.go +3 -1
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | const httpsScheme = "https" | |
| 9 | + | ||
| 8 | 10 | func sanitizeHref(raw string) string { | |
| 9 | 11 | trimmed := strings.TrimSpace(raw) | |
| 10 | 12 | if trimmed == "" || strings.HasPrefix(trimmed, "//") { | |
| @@ -24,7 +26,7 @@ func sanitizeHref(raw string) string { | |||
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | switch strings.ToLower(parsed.Scheme) { | |
| 27 | - | case "http", "https": | |
| 29 | + | case httpScheme, httpsScheme: | |
| 28 | 30 | if parsed.Host == "" { | |
| 29 | 31 | return "#" | |
| 30 | 32 | } | |
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "strings" | 5 | "strings" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | + | const httpsScheme = "https" | |||
| 9 | + | ||||
| 8 | func sanitizeHref(raw string) string { | 10 | func sanitizeHref(raw string) string { | ||
| 9 | trimmed := strings.TrimSpace(raw) | 11 | trimmed := strings.TrimSpace(raw) | ||
| 10 | if trimmed == "" || strings.HasPrefix(trimmed, "//") { | 12 | if trimmed == "" || strings.HasPrefix(trimmed, "//") { | ||
| @@ -24,7 +26,7 @@ func sanitizeHref(raw string) string { | |||||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | switch strings.ToLower(parsed.Scheme) { | 28 | switch strings.ToLower(parsed.Scheme) { | ||
| 27 | - | case "http", "https": | 29 | + | case httpScheme, httpsScheme: |
| 28 | if parsed.Host == "" { | 30 | if parsed.Host == "" { | ||
| 29 | return "#" | 31 | return "#" | ||
| 30 | } | 32 | } | ||
M internal/ui/systemviews/shell.go +4 -9
| @@ -91,18 +91,10 @@ func listHTML(rows, emptyMessage string) string { | |||
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | |
| 94 | - | func listRowHTML(inner string) string { | |
| 95 | - | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | 94 | func linkHTML(href, label string) string { | |
| 99 | 95 | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | |
| 100 | 96 | } | |
| 101 | 97 | ||
| 102 | - | func metaHTML(text string) string { | |
| 103 | - | return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text)) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | 98 | func kvHTML(rows []kvPair) string { | |
| 107 | 99 | var items strings.Builder | |
| 108 | 100 | for _, row := range rows { | |
| @@ -138,5 +130,8 @@ func emptyStateHTML(message string) string { | |||
| 138 | 130 | } | |
| 139 | 131 | ||
| 140 | 132 | func errorStateHTML(message string) string { | |
| 141 | - | return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message)) | |
| 133 | + | return fmt.Sprintf( | |
| 134 | + | `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, | |
| 135 | + | html.EscapeString(message), | |
| 136 | + | ) | |
| 142 | 137 | } | |
| @@ -91,18 +91,10 @@ func listHTML(rows, emptyMessage string) string { | |||||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | 93 | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | ||
| 94 | - | func listRowHTML(inner string) string { | |||
| 95 | - | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | func linkHTML(href, label string) string { | 94 | func linkHTML(href, label string) string { | ||
| 99 | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | 95 | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | ||
| 100 | } | 96 | } | ||
| 101 | 97 | ||||
| 102 | - | func metaHTML(text string) string { | |||
| 103 | - | return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text)) | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | func kvHTML(rows []kvPair) string { | 98 | func kvHTML(rows []kvPair) string { | ||
| 107 | var items strings.Builder | 99 | var items strings.Builder | ||
| 108 | for _, row := range rows { | 100 | for _, row := range rows { | ||
| @@ -138,5 +130,8 @@ func emptyStateHTML(message string) string { | |||||
| 138 | } | 130 | } | ||
| 139 | 131 | ||||
| 140 | func errorStateHTML(message string) string { | 132 | func errorStateHTML(message string) string { | ||
| 141 | - | return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message)) | 133 | + | return fmt.Sprintf( |
| 134 | + | `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, | |||
| 135 | + | html.EscapeString(message), | |||
| 136 | + | ) | |||
| 142 | } | 137 | } | ||
M internal/ui/systemviews/theme.go +3 -2
| @@ -19,6 +19,7 @@ const ( | |||
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | var cssNamedColors = func() map[string]struct{} { | |
| 22 | + | //nolint:lll // CSS named color list is clearer as a single literal. | |
| 22 | 23 | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | |
| 23 | 24 | colors := make(map[string]struct{}, len(names)) | |
| 24 | 25 | for _, name := range names { | |
| @@ -143,7 +144,7 @@ func isHexColor(value string) bool { | |||
| 143 | 144 | return false | |
| 144 | 145 | } | |
| 145 | 146 | for _, r := range value[1:] { | |
| 146 | - | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { | |
| 147 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { | |
| 147 | 148 | return false | |
| 148 | 149 | } | |
| 149 | 150 | } | |
| @@ -163,7 +164,7 @@ func isFunctionalColor(value string) bool { | |||
| 163 | 164 | return false | |
| 164 | 165 | } | |
| 165 | 166 | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | |
| 166 | - | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) { | |
| 167 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) { | |
| 167 | 168 | return false | |
| 168 | 169 | } | |
| 169 | 170 | } | |
| @@ -19,6 +19,7 @@ const ( | |||||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | var cssNamedColors = func() map[string]struct{} { | 21 | var cssNamedColors = func() map[string]struct{} { | ||
| 22 | + | //nolint:lll // CSS named color list is clearer as a single literal. | |||
| 22 | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | 23 | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | ||
| 23 | colors := make(map[string]struct{}, len(names)) | 24 | colors := make(map[string]struct{}, len(names)) | ||
| 24 | for _, name := range names { | 25 | for _, name := range names { | ||
| @@ -143,7 +144,7 @@ func isHexColor(value string) bool { | |||||
| 143 | return false | 144 | return false | ||
| 144 | } | 145 | } | ||
| 145 | for _, r := range value[1:] { | 146 | for _, r := range value[1:] { | ||
| 146 | - | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { | 147 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { |
| 147 | return false | 148 | return false | ||
| 148 | } | 149 | } | ||
| 149 | } | 150 | } | ||
| @@ -163,7 +164,7 @@ func isFunctionalColor(value string) bool { | |||||
| 163 | return false | 164 | return false | ||
| 164 | } | 165 | } | ||
| 165 | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | 166 | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | ||
| 166 | - | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) { | 167 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) { |
| 167 | return false | 168 | return false | ||
| 168 | } | 169 | } | ||
| 169 | } | 170 | } | ||