detached fcafef6

fix: stabilize CEF engine startup and OSR zoom

commit
fcafef6a2911782f80ea467ceafb0776007af848
author
brice · · 2026-03-21 20:06 UTC
committer
brice · · 2026-03-28 04:37 UTC
parent
afef29b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +871 -52
M .gitignore +1 -0
@@ -42,6 +42,7 @@ Thumbs.db
4242 *.tmp
4343 *.temp
4444 *.log
45+__debug_bin*
4546
4647 # Database files
4748 *.db
@@ -42,6 +42,7 @@ Thumbs.db
42 *.tmp42 *.tmp
43 *.temp43 *.temp
44 *.log44 *.log
45+__debug_bin*
45 46
46 # Database files47 # Database files
47 *.db48 *.db
M internal/application/usecase/navigate.go +14 -4
@@ -249,18 +249,28 @@ func (uc *NavigateUseCase) historyWorker() {
249249
250250 log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251251
252- // Swap maps to avoid concurrent iteration/write issues
253- // (persist callbacks may re-enqueue records).
252+ // Swap maps first, then materialize immutable slices before invoking
253+ // persistence callbacks. This keeps any re-enqueued records isolated to
254+ // the fresh pending maps for the next flush cycle.
254255 visits := pendingVisits
255256 titles := pendingTitles
256257 pendingVisits = make(map[string]int)
257258 pendingTitles = make(map[string]string)
258259
260+ visitRecords := make([]historyRecord, 0, len(visits))
259261 for historyURL, v := range visits {
260- uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: v})
262+ visitRecords = append(visitRecords, historyRecord{url: historyURL, visits: v})
261263 }
264+ titleUpdates := make([]historyRecord, 0, len(titles))
262265 for historyURL, title := range titles {
263- uc.persistTitleUpdate(uc.ctx, historyURL, title)
266+ titleUpdates = append(titleUpdates, historyRecord{url: historyURL, title: title})
267+ }
268+
269+ for _, record := range visitRecords {
270+ uc.persistHistory(uc.ctx, record)
271+ }
272+ for _, record := range titleUpdates {
273+ uc.persistTitleUpdate(uc.ctx, record.url, record.title)
264274 }
265275 }
266276
@@ -249,18 +249,28 @@ func (uc *NavigateUseCase) historyWorker() {
249 249
250 log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")250 log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251 251
252- // Swap maps to avoid concurrent iteration/write issues252+ // Swap maps first, then materialize immutable slices before invoking
253- // (persist callbacks may re-enqueue records).253+ // persistence callbacks. This keeps any re-enqueued records isolated to
254+ // the fresh pending maps for the next flush cycle.
254 visits := pendingVisits255 visits := pendingVisits
255 titles := pendingTitles256 titles := pendingTitles
256 pendingVisits = make(map[string]int)257 pendingVisits = make(map[string]int)
257 pendingTitles = make(map[string]string)258 pendingTitles = make(map[string]string)
258 259
260+ visitRecords := make([]historyRecord, 0, len(visits))
259 for historyURL, v := range visits {261 for historyURL, v := range visits {
260- uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: v})262+ visitRecords = append(visitRecords, historyRecord{url: historyURL, visits: v})
261 }263 }
264+ titleUpdates := make([]historyRecord, 0, len(titles))
262 for historyURL, title := range titles {265 for historyURL, title := range titles {
263- uc.persistTitleUpdate(uc.ctx, historyURL, title)266+ titleUpdates = append(titleUpdates, historyRecord{url: historyURL, title: title})
267+ }
268+
269+ for _, record := range visitRecords {
270+ uc.persistHistory(uc.ctx, record)
271+ }
272+ for _, record := range titleUpdates {
273+ uc.persistTitleUpdate(uc.ctx, record.url, record.title)
264 }274 }
265 }275 }
266 276
M internal/application/usecase/navigate_test.go +135 -0
@@ -3,9 +3,12 @@ package usecase
33 import (
44 "context"
55 "path/filepath"
6+ "sync"
67 "testing"
8+ "time"
79
810 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
912 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1013 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1114 "github.com/stretchr/testify/mock"
@@ -88,3 +91,135 @@ func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testi
8891 require.Equal(t, "New", after.Title)
8992 require.Equal(t, before.VisitCount, after.VisitCount)
9093 }
94+
95+func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
96+ ctx := context.Background()
97+ repo := newHistoryWorkerRegressionRepo()
98+
99+ var uc *NavigateUseCase
100+ repo.onSave = func(entry *entity.HistoryEntry) {
101+ if entry.URL != "https://example.com/article" {
102+ return
103+ }
104+ uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
105+ }
106+
107+ uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
108+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
109+
110+ // Give the worker enough time to hit the periodic flush before shutdown.
111+ time.Sleep(historyWorkerFlushInterval * 2)
112+ uc.Close()
113+
114+ entry, err := repo.FindByURL(ctx, "https://example.com/article")
115+ require.NoError(t, err)
116+ require.NotNil(t, entry)
117+ require.Equal(t, int64(1), entry.VisitCount)
118+ require.Equal(t, "Queued title", entry.Title)
119+}
120+
121+type historyWorkerRegressionRepo struct {
122+ mu sync.Mutex
123+ byURL map[string]*entity.HistoryEntry
124+ saveMu sync.Once
125+ onSave func(*entity.HistoryEntry)
126+}
127+
128+func newHistoryWorkerRegressionRepo() *historyWorkerRegressionRepo {
129+ return &historyWorkerRegressionRepo{
130+ byURL: make(map[string]*entity.HistoryEntry),
131+ }
132+}
133+
134+func (r *historyWorkerRegressionRepo) Save(_ context.Context, entry *entity.HistoryEntry) error {
135+ clone := *entry
136+
137+ r.mu.Lock()
138+ r.byURL[entry.URL] = &clone
139+ r.mu.Unlock()
140+
141+ if r.onSave != nil {
142+ r.saveMu.Do(func() {
143+ r.onSave(&clone)
144+ })
145+ }
146+ return nil
147+}
148+
149+func (r *historyWorkerRegressionRepo) FindByURL(_ context.Context, url string) (*entity.HistoryEntry, error) {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+ entry := r.byURL[url]
153+ if entry == nil {
154+ return nil, nil
155+ }
156+ clone := *entry
157+ return &clone, nil
158+}
159+
160+func (*historyWorkerRegressionRepo) Search(context.Context, string, int) ([]entity.HistoryMatch, error) {
161+ return nil, nil
162+}
163+
164+func (*historyWorkerRegressionRepo) GetRecent(context.Context, int, int) ([]*entity.HistoryEntry, error) {
165+ return nil, nil
166+}
167+
168+func (*historyWorkerRegressionRepo) GetRecentSince(context.Context, int) ([]*entity.HistoryEntry, error) {
169+ return nil, nil
170+}
171+
172+func (*historyWorkerRegressionRepo) GetMostVisited(context.Context, int) ([]*entity.HistoryEntry, error) {
173+ return nil, nil
174+}
175+
176+func (*historyWorkerRegressionRepo) GetAllRecentHistory(context.Context) ([]*entity.HistoryEntry, error) {
177+ return nil, nil
178+}
179+
180+func (*historyWorkerRegressionRepo) GetAllMostVisited(context.Context) ([]*entity.HistoryEntry, error) {
181+ return nil, nil
182+}
183+
184+func (r *historyWorkerRegressionRepo) IncrementVisitCount(_ context.Context, url string) error {
185+ r.mu.Lock()
186+ defer r.mu.Unlock()
187+ if entry := r.byURL[url]; entry != nil {
188+ entry.VisitCount++
189+ }
190+ return nil
191+}
192+
193+func (*historyWorkerRegressionRepo) Delete(context.Context, int64) error {
194+ return nil
195+}
196+
197+func (*historyWorkerRegressionRepo) DeleteOlderThan(context.Context, time.Time) error {
198+ return nil
199+}
200+
201+func (*historyWorkerRegressionRepo) DeleteAll(context.Context) error {
202+ return nil
203+}
204+
205+func (*historyWorkerRegressionRepo) DeleteByDomain(context.Context, string) error {
206+ return nil
207+}
208+
209+func (*historyWorkerRegressionRepo) GetStats(context.Context) (*entity.HistoryStats, error) {
210+ return nil, nil
211+}
212+
213+func (*historyWorkerRegressionRepo) GetDomainStats(context.Context, int) ([]*entity.DomainStat, error) {
214+ return nil, nil
215+}
216+
217+func (*historyWorkerRegressionRepo) GetHourlyDistribution(context.Context) ([]*entity.HourlyDistribution, error) {
218+ return nil, nil
219+}
220+
221+func (*historyWorkerRegressionRepo) GetDailyVisitCount(context.Context, string) ([]*entity.DailyVisitCount, error) {
222+ return nil, nil
223+}
224+
225+var _ repository.HistoryRepository = (*historyWorkerRegressionRepo)(nil)
@@ -3,9 +3,12 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "path/filepath"5 "path/filepath"
6+ "sync"
6 "testing"7 "testing"
8+ "time"
7 9
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
9 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"13 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -88,3 +91,135 @@ func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testi
88 require.Equal(t, "New", after.Title)91 require.Equal(t, "New", after.Title)
89 require.Equal(t, before.VisitCount, after.VisitCount)92 require.Equal(t, before.VisitCount, after.VisitCount)
90 }93 }
94+
95+func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
96+ ctx := context.Background()
97+ repo := newHistoryWorkerRegressionRepo()
98+
99+ var uc *NavigateUseCase
100+ repo.onSave = func(entry *entity.HistoryEntry) {
101+ if entry.URL != "https://example.com/article" {
102+ return
103+ }
104+ uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
105+ }
106+
107+ uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
108+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
109+
110+ // Give the worker enough time to hit the periodic flush before shutdown.
111+ time.Sleep(historyWorkerFlushInterval * 2)
112+ uc.Close()
113+
114+ entry, err := repo.FindByURL(ctx, "https://example.com/article")
115+ require.NoError(t, err)
116+ require.NotNil(t, entry)
117+ require.Equal(t, int64(1), entry.VisitCount)
118+ require.Equal(t, "Queued title", entry.Title)
119+}
120+
121+type historyWorkerRegressionRepo struct {
122+ mu sync.Mutex
123+ byURL map[string]*entity.HistoryEntry
124+ saveMu sync.Once
125+ onSave func(*entity.HistoryEntry)
126+}
127+
128+func newHistoryWorkerRegressionRepo() *historyWorkerRegressionRepo {
129+ return &historyWorkerRegressionRepo{
130+ byURL: make(map[string]*entity.HistoryEntry),
131+ }
132+}
133+
134+func (r *historyWorkerRegressionRepo) Save(_ context.Context, entry *entity.HistoryEntry) error {
135+ clone := *entry
136+
137+ r.mu.Lock()
138+ r.byURL[entry.URL] = &clone
139+ r.mu.Unlock()
140+
141+ if r.onSave != nil {
142+ r.saveMu.Do(func() {
143+ r.onSave(&clone)
144+ })
145+ }
146+ return nil
147+}
148+
149+func (r *historyWorkerRegressionRepo) FindByURL(_ context.Context, url string) (*entity.HistoryEntry, error) {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+ entry := r.byURL[url]
153+ if entry == nil {
154+ return nil, nil
155+ }
156+ clone := *entry
157+ return &clone, nil
158+}
159+
160+func (*historyWorkerRegressionRepo) Search(context.Context, string, int) ([]entity.HistoryMatch, error) {
161+ return nil, nil
162+}
163+
164+func (*historyWorkerRegressionRepo) GetRecent(context.Context, int, int) ([]*entity.HistoryEntry, error) {
165+ return nil, nil
166+}
167+
168+func (*historyWorkerRegressionRepo) GetRecentSince(context.Context, int) ([]*entity.HistoryEntry, error) {
169+ return nil, nil
170+}
171+
172+func (*historyWorkerRegressionRepo) GetMostVisited(context.Context, int) ([]*entity.HistoryEntry, error) {
173+ return nil, nil
174+}
175+
176+func (*historyWorkerRegressionRepo) GetAllRecentHistory(context.Context) ([]*entity.HistoryEntry, error) {
177+ return nil, nil
178+}
179+
180+func (*historyWorkerRegressionRepo) GetAllMostVisited(context.Context) ([]*entity.HistoryEntry, error) {
181+ return nil, nil
182+}
183+
184+func (r *historyWorkerRegressionRepo) IncrementVisitCount(_ context.Context, url string) error {
185+ r.mu.Lock()
186+ defer r.mu.Unlock()
187+ if entry := r.byURL[url]; entry != nil {
188+ entry.VisitCount++
189+ }
190+ return nil
191+}
192+
193+func (*historyWorkerRegressionRepo) Delete(context.Context, int64) error {
194+ return nil
195+}
196+
197+func (*historyWorkerRegressionRepo) DeleteOlderThan(context.Context, time.Time) error {
198+ return nil
199+}
200+
201+func (*historyWorkerRegressionRepo) DeleteAll(context.Context) error {
202+ return nil
203+}
204+
205+func (*historyWorkerRegressionRepo) DeleteByDomain(context.Context, string) error {
206+ return nil
207+}
208+
209+func (*historyWorkerRegressionRepo) GetStats(context.Context) (*entity.HistoryStats, error) {
210+ return nil, nil
211+}
212+
213+func (*historyWorkerRegressionRepo) GetDomainStats(context.Context, int) ([]*entity.DomainStat, error) {
214+ return nil, nil
215+}
216+
217+func (*historyWorkerRegressionRepo) GetHourlyDistribution(context.Context) ([]*entity.HourlyDistribution, error) {
218+ return nil, nil
219+}
220+
221+func (*historyWorkerRegressionRepo) GetDailyVisitCount(context.Context, string) ([]*entity.DailyVisitCount, error) {
222+ return nil, nil
223+}
224+
225+var _ repository.HistoryRepository = (*historyWorkerRegressionRepo)(nil)
M internal/bootstrap/session.go +6 -2
@@ -156,7 +156,7 @@ func StartBrowserSession(
156156 }
157157
158158 // Background cleanup of old sessions.
159- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, log)
159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log)
160160 })
161161 return persistErr
162162 }
@@ -310,6 +310,7 @@ func runSessionCleanup(
310310 sessionUC *usecase.ManageSessionUseCase,
311311 cleanupUC *usecase.CleanupSessionsUseCase,
312312 cfg *config.Config,
313+ currentSessionID entity.SessionID,
313314 log *zerolog.Logger,
314315 ) {
315316 bgCtx := context.WithoutCancel(startupCtx)
@@ -323,7 +324,10 @@ func runSessionCleanup(
323324 } else {
324325 now := time.Now()
325326 for _, s := range recent {
326- if s != nil && s.Type == entity.SessionTypeBrowser && s.IsActive() {
327+ if s != nil &&
328+ s.ID != currentSessionID &&
329+ s.Type == entity.SessionTypeBrowser &&
330+ s.IsActive() {
327331 _ = sessionUC.EndSession(bgCtx, s.ID, now)
328332 }
329333 }
@@ -156,7 +156,7 @@ func StartBrowserSession(
156 }156 }
157 157
158 // Background cleanup of old sessions.158 // Background cleanup of old sessions.
159- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, log)159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log)
160 })160 })
161 return persistErr161 return persistErr
162 }162 }
@@ -310,6 +310,7 @@ func runSessionCleanup(
310 sessionUC *usecase.ManageSessionUseCase,310 sessionUC *usecase.ManageSessionUseCase,
311 cleanupUC *usecase.CleanupSessionsUseCase,311 cleanupUC *usecase.CleanupSessionsUseCase,
312 cfg *config.Config,312 cfg *config.Config,
313+ currentSessionID entity.SessionID,
313 log *zerolog.Logger,314 log *zerolog.Logger,
314 ) {315 ) {
315 bgCtx := context.WithoutCancel(startupCtx)316 bgCtx := context.WithoutCancel(startupCtx)
@@ -323,7 +324,10 @@ func runSessionCleanup(
323 } else {324 } else {
324 now := time.Now()325 now := time.Now()
325 for _, s := range recent {326 for _, s := range recent {
326- if s != nil && s.Type == entity.SessionTypeBrowser && s.IsActive() {327+ if s != nil &&
328+ s.ID != currentSessionID &&
329+ s.Type == entity.SessionTypeBrowser &&
330+ s.IsActive() {
327 _ = sessionUC.EndSession(bgCtx, s.ID, now)331 _ = sessionUC.EndSession(bgCtx, s.ID, now)
328 }332 }
329 }333 }
A internal/bootstrap/session_test.go +67 -0
@@ -0,0 +1,67 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
17+ t.Parallel()
18+
19+ ctx := context.Background()
20+ repo := repomocks.NewMockSessionRepository(t)
21+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
22+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
23+ cfg := config.DefaultConfig()
24+
25+ now := time.Now().UTC()
26+ current := &entity.Session{
27+ ID: entity.SessionID("current"),
28+ Type: entity.SessionTypeBrowser,
29+ StartedAt: now,
30+ }
31+ stale := &entity.Session{
32+ ID: entity.SessionID("stale"),
33+ Type: entity.SessionTypeBrowser,
34+ StartedAt: now.Add(-time.Hour),
35+ }
36+
37+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, stale}, nil).Once()
38+ repo.EXPECT().MarkEnded(mock.Anything, stale.ID, mock.MatchedBy(func(t time.Time) bool {
39+ diff := t.Sub(now)
40+ if diff < 0 {
41+ diff = -diff
42+ }
43+ return diff < 2*time.Second
44+ })).Return(nil).Once()
45+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
46+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
47+
48+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, current.ID, nil)
49+}
50+
51+func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
52+ t.Parallel()
53+
54+ ctx := context.Background()
55+ repo := repomocks.NewMockSessionRepository(t)
56+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
57+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
58+ cfg := config.DefaultConfig()
59+
60+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()
61+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
62+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
63+
64+ require.NotPanics(t, func() {
65+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, entity.SessionID("current"), nil)
66+ })
67+}
@@ -0,0 +1,67 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
17+ t.Parallel()
18+
19+ ctx := context.Background()
20+ repo := repomocks.NewMockSessionRepository(t)
21+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
22+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
23+ cfg := config.DefaultConfig()
24+
25+ now := time.Now().UTC()
26+ current := &entity.Session{
27+ ID: entity.SessionID("current"),
28+ Type: entity.SessionTypeBrowser,
29+ StartedAt: now,
30+ }
31+ stale := &entity.Session{
32+ ID: entity.SessionID("stale"),
33+ Type: entity.SessionTypeBrowser,
34+ StartedAt: now.Add(-time.Hour),
35+ }
36+
37+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, stale}, nil).Once()
38+ repo.EXPECT().MarkEnded(mock.Anything, stale.ID, mock.MatchedBy(func(t time.Time) bool {
39+ diff := t.Sub(now)
40+ if diff < 0 {
41+ diff = -diff
42+ }
43+ return diff < 2*time.Second
44+ })).Return(nil).Once()
45+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
46+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
47+
48+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, current.ID, nil)
49+}
50+
51+func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
52+ t.Parallel()
53+
54+ ctx := context.Background()
55+ repo := repomocks.NewMockSessionRepository(t)
56+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
57+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
58+ cfg := config.DefaultConfig()
59+
60+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()
61+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
62+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
63+
64+ require.NotPanics(t, func() {
65+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, entity.SessionID("current"), nil)
66+ })
67+}
M internal/infrastructure/cef/engine_init.go +106 -10
@@ -17,6 +17,8 @@ import (
1717 "github.com/bnema/dumber/internal/logging"
1818 )
1919
20+const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
21+
2022 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2123 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
2224 logger := logging.FromContext(ctx)
@@ -29,6 +31,9 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
2931 // Resolve pump mode from config (with defaults).
3032 multiThreaded := cfg.CEFMultiThreadedMessageLoop()
3133 manualPumpMs := cfg.CEFManualPumpIntervalMs()
34+ windowlessFrameRate := cfg.CEFWindowlessFrameRate()
35+
36+ purecef.SetHandlerTraceEnabled(cfg.TraceHandlers)
3237
3338 // 1. Initialize CEF.
3439 settings := purecef.DefaultSettings()
@@ -41,6 +46,20 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
4146 if cfg.CEFDir != "" {
4247 settings.CEFDir = cfg.CEFDir
4348 }
49+ if runtimeLogFile, err := prepareCEFLogFile(cfg); err != nil {
50+ logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
51+ } else {
52+ settings.LogFile = runtimeLogFile
53+ }
54+ if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg); err != nil {
55+ logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
56+ } else if bootstrapLogFile != "" {
57+ settings.InitTraceFile = bootstrapLogFile
58+ logger.Info().
59+ Str("bootstrap_log_file", bootstrapLogFile).
60+ Str("env_var", puregoCEFInitTraceEnvVar).
61+ Msg("cef: init bootstrap diagnostics enabled")
62+ }
4463 if cfg.LogSeverity != 0 {
4564 settings.LogSeverity = cfg.LogSeverity
4665 }
@@ -82,19 +101,37 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
82101 logger.Info().
83102 Bool("multi_threaded_message_loop", multiThreaded).
84103 Int64("manual_pump_interval_ms", manualPumpMs).
85- Msg("cef: configured message pump mode")
104+ Int32("windowless_frame_rate", windowlessFrameRate).
105+ Bool("trace_handlers", cfg.TraceHandlers).
106+ Bool("enable_audio_handler", cfg.EnableAudioHandler).
107+ Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
108+ Msg("cef: configured engine")
86109
87- // Create the CEF App with a BrowserProcessHandler that drives the
88- // adaptive message pump via OnScheduleMessagePumpWork.
89- app := newDumberApp(eng)
110+ if multiThreaded {
111+ // In multi-threaded message loop mode CEF owns the browser UI thread, so
112+ // Dumber does not rely on BrowserProcessHandler callbacks to drive the
113+ // message pump. Initializing without a CefApp avoids the current
114+ // purego-cef app bridge issue that causes cef_initialize() to fail early.
115+ logger.Debug().Msg("cef: calling Init")
116+ if err := purecef.Init(settings); err != nil {
117+ os.Args = savedArgs
118+ return nil, fmt.Errorf("cef.Init: %w", err)
119+ }
120+ os.Args = savedArgs
121+ logger.Debug().Msg("cef: Init returned OK")
122+ } else {
123+ // Create the CEF App with a BrowserProcessHandler that drives the
124+ // adaptive message pump via OnScheduleMessagePumpWork.
125+ app := newDumberApp(eng)
90126
91- logger.Debug().Msg("cef: calling InitWithApp")
92- if err := purecef.InitWithApp(settings, app); err != nil {
127+ logger.Debug().Msg("cef: calling InitWithApp")
128+ if err := purecef.InitWithApp(settings, app); err != nil {
129+ os.Args = savedArgs
130+ return nil, fmt.Errorf("cef.InitWithApp: %w", err)
131+ }
93132 os.Args = savedArgs
94- return nil, fmt.Errorf("cef.InitWithApp: %w", err)
133+ logger.Debug().Msg("cef: InitWithApp returned OK")
95134 }
96- os.Args = savedArgs
97- logger.Debug().Msg("cef: InitWithApp returned OK")
98135
99136 // 3. Load GL.
100137 gl, err := newGLLoader()
@@ -123,7 +160,12 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
123160 }
124161
125162 // 5. Create factory + pool and wire them into the engine.
126- factory := newWebViewFactory(eng, gl, scale)
163+ factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
164+ scale: scale,
165+ windowlessFrameRate: windowlessFrameRate,
166+ enableAudioHandler: cfg.EnableAudioHandler,
167+ enableContextMenuHandler: cfg.EnableContextMenuHandler,
168+ })
127169 pool := newWebViewPool(factory)
128170
129171 eng.gl = gl
@@ -200,3 +242,57 @@ func findHelperBinary() string {
200242 }
201243 return ""
202244 }
245+
246+func prepareCEFLogFile(cfg config.CEFEngineConfig) (string, error) {
247+ if cfg.LogFile != "" {
248+ runtimeLogFile := filepath.Clean(cfg.LogFile)
249+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), 0o755); err != nil {
250+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
251+ }
252+ return runtimeLogFile, nil
253+ }
254+
255+ cwd, cwdErr := os.Getwd()
256+ if cwdErr != nil {
257+ return "", fmt.Errorf("getwd: %w", cwdErr)
258+ }
259+ runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
260+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), 0o755); err != nil {
261+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
262+ }
263+ return runtimeLogFile, nil
264+}
265+
266+func prepareCEFInitTraceFile(cfg config.CEFEngineConfig) (string, error) {
267+ if !puregoCEFInitTraceEnabled() {
268+ return "", nil
269+ }
270+
271+ runtimeLogFile := cfg.LogFile
272+ if runtimeLogFile != "" {
273+ runtimeLogFile = filepath.Clean(runtimeLogFile)
274+ } else {
275+ cwd, cwdErr := os.Getwd()
276+ if cwdErr != nil {
277+ return "", fmt.Errorf("getwd: %w", cwdErr)
278+ }
279+ runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
280+ }
281+ bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
282+ if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), 0o755); err != nil {
283+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)
284+ }
285+ if err := os.WriteFile(bootstrapLogFile, nil, 0o644); err != nil {
286+ return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
287+ }
288+ return bootstrapLogFile, nil
289+}
290+
291+func puregoCEFInitTraceEnabled() bool {
292+ switch strings.ToLower(strings.TrimSpace(os.Getenv(puregoCEFInitTraceEnvVar))) {
293+ case "1", "true", "yes", "on":
294+ return true
295+ default:
296+ return false
297+ }
298+}
@@ -17,6 +17,8 @@ import (
17 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
18 )18 )
19 19
20+const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
21+
20 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.22 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
21 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {23 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
22 logger := logging.FromContext(ctx)24 logger := logging.FromContext(ctx)
@@ -29,6 +31,9 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
29 // Resolve pump mode from config (with defaults).31 // Resolve pump mode from config (with defaults).
30 multiThreaded := cfg.CEFMultiThreadedMessageLoop()32 multiThreaded := cfg.CEFMultiThreadedMessageLoop()
31 manualPumpMs := cfg.CEFManualPumpIntervalMs()33 manualPumpMs := cfg.CEFManualPumpIntervalMs()
34+ windowlessFrameRate := cfg.CEFWindowlessFrameRate()
35+
36+ purecef.SetHandlerTraceEnabled(cfg.TraceHandlers)
32 37
33 // 1. Initialize CEF.38 // 1. Initialize CEF.
34 settings := purecef.DefaultSettings()39 settings := purecef.DefaultSettings()
@@ -41,6 +46,20 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
41 if cfg.CEFDir != "" {46 if cfg.CEFDir != "" {
42 settings.CEFDir = cfg.CEFDir47 settings.CEFDir = cfg.CEFDir
43 }48 }
49+ if runtimeLogFile, err := prepareCEFLogFile(cfg); err != nil {
50+ logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
51+ } else {
52+ settings.LogFile = runtimeLogFile
53+ }
54+ if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg); err != nil {
55+ logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
56+ } else if bootstrapLogFile != "" {
57+ settings.InitTraceFile = bootstrapLogFile
58+ logger.Info().
59+ Str("bootstrap_log_file", bootstrapLogFile).
60+ Str("env_var", puregoCEFInitTraceEnvVar).
61+ Msg("cef: init bootstrap diagnostics enabled")
62+ }
44 if cfg.LogSeverity != 0 {63 if cfg.LogSeverity != 0 {
45 settings.LogSeverity = cfg.LogSeverity64 settings.LogSeverity = cfg.LogSeverity
46 }65 }
@@ -82,19 +101,37 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
82 logger.Info().101 logger.Info().
83 Bool("multi_threaded_message_loop", multiThreaded).102 Bool("multi_threaded_message_loop", multiThreaded).
84 Int64("manual_pump_interval_ms", manualPumpMs).103 Int64("manual_pump_interval_ms", manualPumpMs).
85- Msg("cef: configured message pump mode")104+ Int32("windowless_frame_rate", windowlessFrameRate).
105+ Bool("trace_handlers", cfg.TraceHandlers).
106+ Bool("enable_audio_handler", cfg.EnableAudioHandler).
107+ Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
108+ Msg("cef: configured engine")
86 109
87- // Create the CEF App with a BrowserProcessHandler that drives the110+ if multiThreaded {
88- // adaptive message pump via OnScheduleMessagePumpWork.111+ // In multi-threaded message loop mode CEF owns the browser UI thread, so
89- app := newDumberApp(eng)112+ // Dumber does not rely on BrowserProcessHandler callbacks to drive the
113+ // message pump. Initializing without a CefApp avoids the current
114+ // purego-cef app bridge issue that causes cef_initialize() to fail early.
115+ logger.Debug().Msg("cef: calling Init")
116+ if err := purecef.Init(settings); err != nil {
117+ os.Args = savedArgs
118+ return nil, fmt.Errorf("cef.Init: %w", err)
119+ }
120+ os.Args = savedArgs
121+ logger.Debug().Msg("cef: Init returned OK")
122+ } else {
123+ // Create the CEF App with a BrowserProcessHandler that drives the
124+ // adaptive message pump via OnScheduleMessagePumpWork.
125+ app := newDumberApp(eng)
90 126
91- logger.Debug().Msg("cef: calling InitWithApp")127+ logger.Debug().Msg("cef: calling InitWithApp")
92- if err := purecef.InitWithApp(settings, app); err != nil {128+ if err := purecef.InitWithApp(settings, app); err != nil {
129+ os.Args = savedArgs
130+ return nil, fmt.Errorf("cef.InitWithApp: %w", err)
131+ }
93 os.Args = savedArgs132 os.Args = savedArgs
94- return nil, fmt.Errorf("cef.InitWithApp: %w", err)133+ logger.Debug().Msg("cef: InitWithApp returned OK")
95 }134 }
96- os.Args = savedArgs
97- logger.Debug().Msg("cef: InitWithApp returned OK")
98 135
99 // 3. Load GL.136 // 3. Load GL.
100 gl, err := newGLLoader()137 gl, err := newGLLoader()
@@ -123,7 +160,12 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
123 }160 }
124 161
125 // 5. Create factory + pool and wire them into the engine.162 // 5. Create factory + pool and wire them into the engine.
126- factory := newWebViewFactory(eng, gl, scale)163+ factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
164+ scale: scale,
165+ windowlessFrameRate: windowlessFrameRate,
166+ enableAudioHandler: cfg.EnableAudioHandler,
167+ enableContextMenuHandler: cfg.EnableContextMenuHandler,
168+ })
127 pool := newWebViewPool(factory)169 pool := newWebViewPool(factory)
128 170
129 eng.gl = gl171 eng.gl = gl
@@ -200,3 +242,57 @@ func findHelperBinary() string {
200 }242 }
201 return ""243 return ""
202 }244 }
245+
246+func prepareCEFLogFile(cfg config.CEFEngineConfig) (string, error) {
247+ if cfg.LogFile != "" {
248+ runtimeLogFile := filepath.Clean(cfg.LogFile)
249+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), 0o755); err != nil {
250+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
251+ }
252+ return runtimeLogFile, nil
253+ }
254+
255+ cwd, cwdErr := os.Getwd()
256+ if cwdErr != nil {
257+ return "", fmt.Errorf("getwd: %w", cwdErr)
258+ }
259+ runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
260+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), 0o755); err != nil {
261+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
262+ }
263+ return runtimeLogFile, nil
264+}
265+
266+func prepareCEFInitTraceFile(cfg config.CEFEngineConfig) (string, error) {
267+ if !puregoCEFInitTraceEnabled() {
268+ return "", nil
269+ }
270+
271+ runtimeLogFile := cfg.LogFile
272+ if runtimeLogFile != "" {
273+ runtimeLogFile = filepath.Clean(runtimeLogFile)
274+ } else {
275+ cwd, cwdErr := os.Getwd()
276+ if cwdErr != nil {
277+ return "", fmt.Errorf("getwd: %w", cwdErr)
278+ }
279+ runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
280+ }
281+ bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
282+ if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), 0o755); err != nil {
283+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)
284+ }
285+ if err := os.WriteFile(bootstrapLogFile, nil, 0o644); err != nil {
286+ return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
287+ }
288+ return bootstrapLogFile, nil
289+}
290+
291+func puregoCEFInitTraceEnabled() bool {
292+ switch strings.ToLower(strings.TrimSpace(os.Getenv(puregoCEFInitTraceEnvVar))) {
293+ case "1", "true", "yes", "on":
294+ return true
295+ default:
296+ return false
297+ }
298+}
A internal/infrastructure/cef/engine_init_test.go +29 -0
@@ -0,0 +1,29 @@
1+package cef
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
12+ t.Setenv(puregoCEFInitTraceEnvVar, "")
13+
14+ path, err := prepareCEFInitTraceFile(config.CEFEngineConfig{})
15+ require.NoError(t, err)
16+ require.Empty(t, path)
17+}
18+
19+func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
20+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
21+ cfg := config.CEFEngineConfig{
22+ LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
23+ }
24+
25+ path, err := prepareCEFInitTraceFile(cfg)
26+ require.NoError(t, err)
27+ require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path)
28+ require.FileExists(t, path)
29+}
@@ -0,0 +1,29 @@
1+package cef
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
12+ t.Setenv(puregoCEFInitTraceEnvVar, "")
13+
14+ path, err := prepareCEFInitTraceFile(config.CEFEngineConfig{})
15+ require.NoError(t, err)
16+ require.Empty(t, path)
17+}
18+
19+func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
20+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
21+ cfg := config.CEFEngineConfig{
22+ LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
23+ }
24+
25+ path, err := prepareCEFInitTraceFile(cfg)
26+ require.NoError(t, err)
27+ require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path)
28+ require.FileExists(t, path)
29+}
M internal/infrastructure/cef/factory.go +35 -13
@@ -17,23 +17,39 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
1717 // gets a unique ID, its own renderPipeline and inputBridge, and an
1818 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
1919 type WebViewFactory struct {
20- engine *Engine
21- gl *glLoader
22- nextID atomic.Uint64
23- scale int32
24- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
20+ engine *Engine
21+ gl *glLoader
22+ nextID atomic.Uint64
23+ scale int32
24+ windowlessFrameRate int32
25+ enableAudioHandler bool
26+ enableContextMenuHandler bool
27+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
28+}
29+
30+type webViewFactoryOptions struct {
31+ scale int32
32+ windowlessFrameRate int32
33+ enableAudioHandler bool
34+ enableContextMenuHandler bool
2535 }
2636
2737 // newWebViewFactory returns a factory that will create WebViews using the
2838 // given GL loader and HiDPI scale factor.
29-func newWebViewFactory(engine *Engine, gl *glLoader, scale int32) *WebViewFactory {
30- if scale < 1 {
31- scale = 1
39+func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
40+ if opts.scale < 1 {
41+ opts.scale = 1
42+ }
43+ if opts.windowlessFrameRate < 1 {
44+ opts.windowlessFrameRate = 60
3245 }
3346 return &WebViewFactory{
34- engine: engine,
35- gl: gl,
36- scale: scale,
47+ engine: engine,
48+ gl: gl,
49+ scale: opts.scale,
50+ windowlessFrameRate: opts.windowlessFrameRate,
51+ enableAudioHandler: opts.enableAudioHandler,
52+ enableContextMenuHandler: opts.enableContextMenuHandler,
3753 }
3854 }
3955
@@ -59,7 +75,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
5975 pipeline: pipeline,
6076 }
6177
62- handlers := &handlerSet{wv: wv}
78+ handlers := &handlerSet{
79+ wv: wv,
80+ enableAudioHandler: f.enableAudioHandler,
81+ enableContextMenuHandler: f.enableContextMenuHandler,
82+ }
6383 wv.handlers = handlers
6484
6585 input := newInputBridge(f.scale)
@@ -89,10 +109,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
89109 // Configure WindowInfo for off-screen rendering (OSR).
90110 windowInfo := purecef.DefaultWindowInfo()
91111 windowInfo.WindowlessRenderingEnabled = 1
112+ windowInfo.ExternalBeginFrameEnabled = 1
92113
93114 // Configure BrowserSettings.
94115 settings := purecef.DefaultBrowserSettings()
95- settings.WindowlessFrameRate = 60
116+ settings.WindowlessFrameRate = f.windowlessFrameRate
96117 if bg := f.bgColor.Load(); bg != 0 {
97118 settings.BackgroundColor = bg
98119 }
@@ -126,6 +147,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
126147 log.Debug().
127148 Int32("result", result).
128149 Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
150+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
129151 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
130152 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
131153 Bool("client_nil", pc.client == nil).
@@ -17,23 +17,39 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
17 // gets a unique ID, its own renderPipeline and inputBridge, and an17 // gets a unique ID, its own renderPipeline and inputBridge, and an
18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
19 type WebViewFactory struct {19 type WebViewFactory struct {
20- engine *Engine20+ engine *Engine
21- gl *glLoader21+ gl *glLoader
22- nextID atomic.Uint6422+ nextID atomic.Uint64
23- scale int3223+ scale int32
24- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor24+ windowlessFrameRate int32
25+ enableAudioHandler bool
26+ enableContextMenuHandler bool
27+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
28+}
29+
30+type webViewFactoryOptions struct {
31+ scale int32
32+ windowlessFrameRate int32
33+ enableAudioHandler bool
34+ enableContextMenuHandler bool
25 }35 }
26 36
27 // newWebViewFactory returns a factory that will create WebViews using the37 // newWebViewFactory returns a factory that will create WebViews using the
28 // given GL loader and HiDPI scale factor.38 // given GL loader and HiDPI scale factor.
29-func newWebViewFactory(engine *Engine, gl *glLoader, scale int32) *WebViewFactory {39+func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
30- if scale < 1 {40+ if opts.scale < 1 {
31- scale = 141+ opts.scale = 1
42+ }
43+ if opts.windowlessFrameRate < 1 {
44+ opts.windowlessFrameRate = 60
32 }45 }
33 return &WebViewFactory{46 return &WebViewFactory{
34- engine: engine,47+ engine: engine,
35- gl: gl,48+ gl: gl,
36- scale: scale,49+ scale: opts.scale,
50+ windowlessFrameRate: opts.windowlessFrameRate,
51+ enableAudioHandler: opts.enableAudioHandler,
52+ enableContextMenuHandler: opts.enableContextMenuHandler,
37 }53 }
38 }54 }
39 55
@@ -59,7 +75,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
59 pipeline: pipeline,75 pipeline: pipeline,
60 }76 }
61 77
62- handlers := &handlerSet{wv: wv}78+ handlers := &handlerSet{
79+ wv: wv,
80+ enableAudioHandler: f.enableAudioHandler,
81+ enableContextMenuHandler: f.enableContextMenuHandler,
82+ }
63 wv.handlers = handlers83 wv.handlers = handlers
64 84
65 input := newInputBridge(f.scale)85 input := newInputBridge(f.scale)
@@ -89,10 +109,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
89 // Configure WindowInfo for off-screen rendering (OSR).109 // Configure WindowInfo for off-screen rendering (OSR).
90 windowInfo := purecef.DefaultWindowInfo()110 windowInfo := purecef.DefaultWindowInfo()
91 windowInfo.WindowlessRenderingEnabled = 1111 windowInfo.WindowlessRenderingEnabled = 1
112+ windowInfo.ExternalBeginFrameEnabled = 1
92 113
93 // Configure BrowserSettings.114 // Configure BrowserSettings.
94 settings := purecef.DefaultBrowserSettings()115 settings := purecef.DefaultBrowserSettings()
95- settings.WindowlessFrameRate = 60116+ settings.WindowlessFrameRate = f.windowlessFrameRate
96 if bg := f.bgColor.Load(); bg != 0 {117 if bg := f.bgColor.Load(); bg != 0 {
97 settings.BackgroundColor = bg118 settings.BackgroundColor = bg
98 }119 }
@@ -126,6 +147,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
126 log.Debug().147 log.Debug().
127 Int32("result", result).148 Int32("result", result).
128 Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).149 Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
150+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
129 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).151 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
130 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).152 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
131 Bool("client_nil", pc.client == nil).153 Bool("client_nil", pc.client == nil).
M internal/infrastructure/cef/webview.go +134 -0
@@ -11,6 +11,7 @@ import (
1111
1212 purecef "github.com/bnema/purego-cef/cef"
1313 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
1415
1516 "github.com/bnema/dumber/internal/application/port"
1617 "github.com/bnema/dumber/internal/logging"
@@ -42,6 +43,11 @@ type WebView struct {
4243 input *inputBridge
4344 handlers *handlerSet
4445
46+ // beginFrameTick drives CEF external BeginFrame requests while the GTK
47+ // widget is visible. Access is guarded by mu.
48+ beginFrameTick *gtk.TickCallback
49+ beginFrameTickID uint
50+
4551 // pendingCreate holds browser creation params until the GL area is realized.
4652 pendingCreate *pendingBrowserCreate
4753
@@ -307,6 +313,12 @@ func cefZoomFromFactor(factor float64) float64 {
307313 return math.Log(factor) / math.Log(chromiumZoomBase)
308314 }
309315
316+// factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
317+// linear zoom factor.
318+func factorFromCEFZoom(level float64) float64 {
319+ return math.Pow(chromiumZoomBase, level)
320+}
321+
310322 // GetZoomLevel returns the current linear zoom factor.
311323 func (wv *WebView) GetZoomLevel() float64 {
312324 if v := wv.zoomFactor.Load(); v != nil {
@@ -340,6 +352,11 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
340352 // SynchronizeVisualProperties cycle, which makes the renderer produce
341353 // a new compositor frame at the new zoom level.
342354 host.NotifyScreenInfoChanged()
355+ // Zoom is applied asynchronously in the renderer process. Request a couple
356+ // of follow-up refreshes on the CEF UI thread so OSR captures the updated
357+ // compositor frame after the zoom IPC has been processed.
358+ wv.scheduleZoomRefresh()
359+ wv.scheduleZoomReadback(factor, cefLevel)
343360 return nil
344361 }
345362
@@ -435,6 +452,7 @@ func (wv *WebView) Destroy() {
435452 if !wv.destroyed.CompareAndSwap(false, true) {
436453 return
437454 }
455+ wv.scheduleStopBeginFrameLoop()
438456 wv.mu.RLock()
439457 host := wv.host
440458 wv.mu.RUnlock()
@@ -527,6 +545,122 @@ func (wv *WebView) updateHoverURI(uri string) {
527545 wv.mu.Unlock()
528546 }
529547
548+type cefTaskFunc func()
549+
550+func (f cefTaskFunc) Execute() {
551+ if f != nil {
552+ f()
553+ }
554+}
555+
556+func (wv *WebView) scheduleZoomRefresh() {
557+ for _, delayMs := range [...]int64{16, 48} {
558+ delayMs := delayMs
559+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
560+ if wv.destroyed.Load() {
561+ return
562+ }
563+ wv.mu.RLock()
564+ host := wv.host
565+ wv.mu.RUnlock()
566+ if host == nil {
567+ return
568+ }
569+ host.Invalidate(purecef.PaintElementTypePetView)
570+ })), delayMs)
571+ }
572+}
573+
574+func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
575+ for _, delayMs := range [...]int64{0, 64} {
576+ delayMs := delayMs
577+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
578+ if wv.destroyed.Load() {
579+ return
580+ }
581+ wv.mu.RLock()
582+ host := wv.host
583+ wv.mu.RUnlock()
584+ if host == nil {
585+ return
586+ }
587+ actualLevel := host.GetZoomLevel()
588+ logging.FromContext(wv.ctx).Debug().
589+ Int64("delay_ms", delayMs).
590+ Float64("expected_factor", expectedFactor).
591+ Float64("expected_cef_level", expectedLevel).
592+ Float64("actual_factor", factorFromCEFZoom(actualLevel)).
593+ Float64("actual_cef_level", actualLevel).
594+ Msg("cef: zoom level readback")
595+ })), delayMs)
596+ }
597+}
598+
599+func (wv *WebView) scheduleStartBeginFrameLoop() {
600+ wv.runOnGTK(func() {
601+ wv.startBeginFrameLoop()
602+ })
603+}
604+
605+func (wv *WebView) scheduleStopBeginFrameLoop() {
606+ wv.runOnGTK(func() {
607+ wv.stopBeginFrameLoop()
608+ })
609+}
610+
611+func (wv *WebView) startBeginFrameLoop() {
612+ if wv.destroyed.Load() || wv.pipeline == nil || wv.pipeline.glArea == nil {
613+ return
614+ }
615+
616+ wv.mu.Lock()
617+ if wv.beginFrameTickID != 0 || wv.host == nil {
618+ wv.mu.Unlock()
619+ return
620+ }
621+
622+ glArea := wv.pipeline.glArea
623+ cb := new(gtk.TickCallback)
624+ *cb = func(_, _, _ uintptr) bool {
625+ if wv.destroyed.Load() {
626+ return false
627+ }
628+ wv.mu.RLock()
629+ host := wv.host
630+ wv.mu.RUnlock()
631+ if host == nil {
632+ return false
633+ }
634+ host.SendExternalBeginFrame()
635+ return true
636+ }
637+ wv.beginFrameTick = cb
638+ wv.beginFrameTickID = glArea.Widget.AddTickCallback(cb, 0, nil)
639+ host := wv.host
640+ wv.mu.Unlock()
641+
642+ if host != nil {
643+ host.SendExternalBeginFrame()
644+ }
645+}
646+
647+func (wv *WebView) stopBeginFrameLoop() {
648+ if wv.pipeline == nil || wv.pipeline.glArea == nil {
649+ return
650+ }
651+
652+ wv.mu.Lock()
653+ tickID := wv.beginFrameTickID
654+ wv.beginFrameTickID = 0
655+ wv.beginFrameTick = nil
656+ glArea := wv.pipeline.glArea
657+ wv.mu.Unlock()
658+
659+ if tickID != 0 {
660+ glArea.Widget.RemoveTickCallback(tickID)
661+ }
662+}
663+
530664 func (wv *WebView) runOnGTK(fn func()) {
531665 if fn == nil {
532666 return
@@ -11,6 +11,7 @@ import (
11 11
12 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/glib"13 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
14 15
15 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
16 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
@@ -42,6 +43,11 @@ type WebView struct {
42 input *inputBridge43 input *inputBridge
43 handlers *handlerSet44 handlers *handlerSet
44 45
46+ // beginFrameTick drives CEF external BeginFrame requests while the GTK
47+ // widget is visible. Access is guarded by mu.
48+ beginFrameTick *gtk.TickCallback
49+ beginFrameTickID uint
50+
45 // pendingCreate holds browser creation params until the GL area is realized.51 // pendingCreate holds browser creation params until the GL area is realized.
46 pendingCreate *pendingBrowserCreate52 pendingCreate *pendingBrowserCreate
47 53
@@ -307,6 +313,12 @@ func cefZoomFromFactor(factor float64) float64 {
307 return math.Log(factor) / math.Log(chromiumZoomBase)313 return math.Log(factor) / math.Log(chromiumZoomBase)
308 }314 }
309 315
316+// factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
317+// linear zoom factor.
318+func factorFromCEFZoom(level float64) float64 {
319+ return math.Pow(chromiumZoomBase, level)
320+}
321+
310 // GetZoomLevel returns the current linear zoom factor.322 // GetZoomLevel returns the current linear zoom factor.
311 func (wv *WebView) GetZoomLevel() float64 {323 func (wv *WebView) GetZoomLevel() float64 {
312 if v := wv.zoomFactor.Load(); v != nil {324 if v := wv.zoomFactor.Load(); v != nil {
@@ -340,6 +352,11 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
340 // SynchronizeVisualProperties cycle, which makes the renderer produce352 // SynchronizeVisualProperties cycle, which makes the renderer produce
341 // a new compositor frame at the new zoom level.353 // a new compositor frame at the new zoom level.
342 host.NotifyScreenInfoChanged()354 host.NotifyScreenInfoChanged()
355+ // Zoom is applied asynchronously in the renderer process. Request a couple
356+ // of follow-up refreshes on the CEF UI thread so OSR captures the updated
357+ // compositor frame after the zoom IPC has been processed.
358+ wv.scheduleZoomRefresh()
359+ wv.scheduleZoomReadback(factor, cefLevel)
343 return nil360 return nil
344 }361 }
345 362
@@ -435,6 +452,7 @@ func (wv *WebView) Destroy() {
435 if !wv.destroyed.CompareAndSwap(false, true) {452 if !wv.destroyed.CompareAndSwap(false, true) {
436 return453 return
437 }454 }
455+ wv.scheduleStopBeginFrameLoop()
438 wv.mu.RLock()456 wv.mu.RLock()
439 host := wv.host457 host := wv.host
440 wv.mu.RUnlock()458 wv.mu.RUnlock()
@@ -527,6 +545,122 @@ func (wv *WebView) updateHoverURI(uri string) {
527 wv.mu.Unlock()545 wv.mu.Unlock()
528 }546 }
529 547
548+type cefTaskFunc func()
549+
550+func (f cefTaskFunc) Execute() {
551+ if f != nil {
552+ f()
553+ }
554+}
555+
556+func (wv *WebView) scheduleZoomRefresh() {
557+ for _, delayMs := range [...]int64{16, 48} {
558+ delayMs := delayMs
559+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
560+ if wv.destroyed.Load() {
561+ return
562+ }
563+ wv.mu.RLock()
564+ host := wv.host
565+ wv.mu.RUnlock()
566+ if host == nil {
567+ return
568+ }
569+ host.Invalidate(purecef.PaintElementTypePetView)
570+ })), delayMs)
571+ }
572+}
573+
574+func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
575+ for _, delayMs := range [...]int64{0, 64} {
576+ delayMs := delayMs
577+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
578+ if wv.destroyed.Load() {
579+ return
580+ }
581+ wv.mu.RLock()
582+ host := wv.host
583+ wv.mu.RUnlock()
584+ if host == nil {
585+ return
586+ }
587+ actualLevel := host.GetZoomLevel()
588+ logging.FromContext(wv.ctx).Debug().
589+ Int64("delay_ms", delayMs).
590+ Float64("expected_factor", expectedFactor).
591+ Float64("expected_cef_level", expectedLevel).
592+ Float64("actual_factor", factorFromCEFZoom(actualLevel)).
593+ Float64("actual_cef_level", actualLevel).
594+ Msg("cef: zoom level readback")
595+ })), delayMs)
596+ }
597+}
598+
599+func (wv *WebView) scheduleStartBeginFrameLoop() {
600+ wv.runOnGTK(func() {
601+ wv.startBeginFrameLoop()
602+ })
603+}
604+
605+func (wv *WebView) scheduleStopBeginFrameLoop() {
606+ wv.runOnGTK(func() {
607+ wv.stopBeginFrameLoop()
608+ })
609+}
610+
611+func (wv *WebView) startBeginFrameLoop() {
612+ if wv.destroyed.Load() || wv.pipeline == nil || wv.pipeline.glArea == nil {
613+ return
614+ }
615+
616+ wv.mu.Lock()
617+ if wv.beginFrameTickID != 0 || wv.host == nil {
618+ wv.mu.Unlock()
619+ return
620+ }
621+
622+ glArea := wv.pipeline.glArea
623+ cb := new(gtk.TickCallback)
624+ *cb = func(_, _, _ uintptr) bool {
625+ if wv.destroyed.Load() {
626+ return false
627+ }
628+ wv.mu.RLock()
629+ host := wv.host
630+ wv.mu.RUnlock()
631+ if host == nil {
632+ return false
633+ }
634+ host.SendExternalBeginFrame()
635+ return true
636+ }
637+ wv.beginFrameTick = cb
638+ wv.beginFrameTickID = glArea.Widget.AddTickCallback(cb, 0, nil)
639+ host := wv.host
640+ wv.mu.Unlock()
641+
642+ if host != nil {
643+ host.SendExternalBeginFrame()
644+ }
645+}
646+
647+func (wv *WebView) stopBeginFrameLoop() {
648+ if wv.pipeline == nil || wv.pipeline.glArea == nil {
649+ return
650+ }
651+
652+ wv.mu.Lock()
653+ tickID := wv.beginFrameTickID
654+ wv.beginFrameTickID = 0
655+ wv.beginFrameTick = nil
656+ glArea := wv.pipeline.glArea
657+ wv.mu.Unlock()
658+
659+ if tickID != 0 {
660+ glArea.Widget.RemoveTickCallback(tickID)
661+ }
662+}
663+
530 func (wv *WebView) runOnGTK(fn func()) {664 func (wv *WebView) runOnGTK(fn func()) {
531 if fn == nil {665 if fn == nil {
532 return666 return
M internal/infrastructure/cef/webview_handlers.go +65 -21
@@ -11,7 +11,9 @@ import (
1111 // owning WebView. A single struct is used so that the Client's Get*Handler
1212 // methods can return the same receiver, avoiding extra allocations.
1313 type handlerSet struct {
14- wv *WebView
14+ wv *WebView
15+ enableAudioHandler bool
16+ enableContextMenuHandler bool
1517 }
1618
1719 // Compile-time interface checks.
@@ -30,24 +32,34 @@ var (
3032 // Client
3133 // ===========================================================================
3234
33-func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return nil }
34-func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
35-func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return nil }
36-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
37-func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
38-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
39-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
40-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }
41-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
42-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
43-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
44-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
45-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
46-func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
47-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
48-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
49-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
50-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
35+func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
36+ if h.enableAudioHandler {
37+ return h
38+ }
39+ return nil
40+}
41+func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
42+func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {
43+ if h.enableContextMenuHandler {
44+ return h
45+ }
46+ return nil
47+}
48+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
49+func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
50+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
51+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
52+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }
53+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
54+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
55+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
56+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
57+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
58+func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
59+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
60+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
61+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
62+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
5163
5264 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {
5365 return 0
@@ -69,10 +81,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
6981 h.wv.pipeline.mu.Lock()
7082 w := h.wv.pipeline.width
7183 ht := h.wv.pipeline.height
84+ s := h.wv.pipeline.scale
7285 h.wv.pipeline.mu.Unlock()
7386
74- // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area
75- // has not been realized yet.
87+ // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
88+ // in device pixels. The render pipeline tracks device pixels, so convert
89+ // back to DIP before answering GetViewRect/GetScreenInfo.
90+ if s <= 0 {
91+ s = 1
92+ }
93+ w /= s
94+ ht /= s
95+
96+ // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area has
97+ // not been realized yet.
7698 if w <= 0 {
7799 w = 1
78100 }
@@ -99,6 +121,17 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
99121 ht := h.wv.pipeline.height
100122 s := h.wv.pipeline.scale
101123 h.wv.pipeline.mu.Unlock()
124+ if s <= 0 {
125+ s = 1
126+ }
127+ w /= s
128+ ht /= s
129+ if w <= 0 {
130+ w = 1
131+ }
132+ if ht <= 0 {
133+ ht = 1
134+ }
102135
103136 const (
104137 screenDepth = 24
@@ -385,6 +418,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
385418 browserID := browser.GetIdentifier()
386419 log.Debug().
387420 Int32("browser_id", browserID).
421+ Bool("audio_handler_enabled", h.enableAudioHandler).
422+ Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
388423 Msg("cef: OnAfterCreated")
389424 if h.wv.engine != nil {
390425 h.wv.engine.recordBrowserAfterCreated(browser)
@@ -405,6 +440,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
405440 }
406441 }
407442 h.wv.mu.Unlock()
443+
444+ h.wv.scheduleStartBeginFrameLoop()
408445 }
409446
410447 // DoClose returns false to allow the default close behavior.
@@ -414,6 +451,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
414451
415452 // OnBeforeClose fires the OnClose callback.
416453 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
454+ h.wv.mu.Lock()
455+ h.wv.browser = nil
456+ h.wv.host = nil
457+ h.wv.input.setHost(nil)
458+ h.wv.mu.Unlock()
459+ h.wv.scheduleStopBeginFrameLoop()
460+
417461 h.wv.mu.RLock()
418462 cb := h.wv.callbacks
419463 h.wv.mu.RUnlock()
@@ -11,7 +11,9 @@ import (
11 // owning WebView. A single struct is used so that the Client's Get*Handler11 // owning WebView. A single struct is used so that the Client's Get*Handler
12 // methods can return the same receiver, avoiding extra allocations.12 // methods can return the same receiver, avoiding extra allocations.
13 type handlerSet struct {13 type handlerSet struct {
14- wv *WebView14+ wv *WebView
15+ enableAudioHandler bool
16+ enableContextMenuHandler bool
15 }17 }
16 18
17 // Compile-time interface checks.19 // Compile-time interface checks.
@@ -30,24 +32,34 @@ var (
30 // Client32 // Client
31 // ===========================================================================33 // ===========================================================================
32 34
33-func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return nil }35+func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
34-func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }36+ if h.enableAudioHandler {
35-func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return nil }37+ return h
36-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }38+ }
37-func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }39+ return nil
38-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }40+}
39-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }41+func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
40-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }42+func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {
41-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }43+ if h.enableContextMenuHandler {
42-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }44+ return h
43-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }45+ }
44-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }46+ return nil
45-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }47+}
46-func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }48+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
47-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }49+func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
48-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }50+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
49-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }51+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
50-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }52+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }
53+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
54+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
55+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
56+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
57+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
58+func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
59+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
60+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
61+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
62+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
51 63
52 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {64 func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {
53 return 065 return 0
@@ -69,10 +81,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
69 h.wv.pipeline.mu.Lock()81 h.wv.pipeline.mu.Lock()
70 w := h.wv.pipeline.width82 w := h.wv.pipeline.width
71 ht := h.wv.pipeline.height83 ht := h.wv.pipeline.height
84+ s := h.wv.pipeline.scale
72 h.wv.pipeline.mu.Unlock()85 h.wv.pipeline.mu.Unlock()
73 86
74- // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area87+ // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
75- // has not been realized yet.88+ // in device pixels. The render pipeline tracks device pixels, so convert
89+ // back to DIP before answering GetViewRect/GetScreenInfo.
90+ if s <= 0 {
91+ s = 1
92+ }
93+ w /= s
94+ ht /= s
95+
96+ // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area has
97+ // not been realized yet.
76 if w <= 0 {98 if w <= 0 {
77 w = 199 w = 1
78 }100 }
@@ -99,6 +121,17 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
99 ht := h.wv.pipeline.height121 ht := h.wv.pipeline.height
100 s := h.wv.pipeline.scale122 s := h.wv.pipeline.scale
101 h.wv.pipeline.mu.Unlock()123 h.wv.pipeline.mu.Unlock()
124+ if s <= 0 {
125+ s = 1
126+ }
127+ w /= s
128+ ht /= s
129+ if w <= 0 {
130+ w = 1
131+ }
132+ if ht <= 0 {
133+ ht = 1
134+ }
102 135
103 const (136 const (
104 screenDepth = 24137 screenDepth = 24
@@ -385,6 +418,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
385 browserID := browser.GetIdentifier()418 browserID := browser.GetIdentifier()
386 log.Debug().419 log.Debug().
387 Int32("browser_id", browserID).420 Int32("browser_id", browserID).
421+ Bool("audio_handler_enabled", h.enableAudioHandler).
422+ Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
388 Msg("cef: OnAfterCreated")423 Msg("cef: OnAfterCreated")
389 if h.wv.engine != nil {424 if h.wv.engine != nil {
390 h.wv.engine.recordBrowserAfterCreated(browser)425 h.wv.engine.recordBrowserAfterCreated(browser)
@@ -405,6 +440,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
405 }440 }
406 }441 }
407 h.wv.mu.Unlock()442 h.wv.mu.Unlock()
443+
444+ h.wv.scheduleStartBeginFrameLoop()
408 }445 }
409 446
410 // DoClose returns false to allow the default close behavior.447 // DoClose returns false to allow the default close behavior.
@@ -414,6 +451,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
414 451
415 // OnBeforeClose fires the OnClose callback.452 // OnBeforeClose fires the OnClose callback.
416 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {453 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
454+ h.wv.mu.Lock()
455+ h.wv.browser = nil
456+ h.wv.host = nil
457+ h.wv.input.setHost(nil)
458+ h.wv.mu.Unlock()
459+ h.wv.scheduleStopBeginFrameLoop()
460+
417 h.wv.mu.RLock()461 h.wv.mu.RLock()
418 cb := h.wv.callbacks462 cb := h.wv.callbacks
419 h.wv.mu.RUnlock()463 h.wv.mu.RUnlock()
M internal/infrastructure/cef/webview_handlers_test.go +55 -0
@@ -59,3 +59,58 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
5959 require.Equal(t, port.LoadCommitted, gotEvents[0])
6060 require.Equal(t, "https://google.com", wv.URI())
6161 }
62+
63+func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
64+ rect := &purecef.Rect{}
65+ h := &handlerSet{
66+ wv: &WebView{
67+ pipeline: &renderPipeline{
68+ width: 800,
69+ height: 600,
70+ scale: 2,
71+ },
72+ },
73+ }
74+
75+ h.GetViewRect(nil, rect)
76+
77+ require.Equal(t, int32(0), rect.X)
78+ require.Equal(t, int32(0), rect.Y)
79+ require.Equal(t, int32(400), rect.Width)
80+ require.Equal(t, int32(300), rect.Height)
81+}
82+
83+func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
84+ info := &purecef.ScreenInfo{}
85+ h := &handlerSet{
86+ wv: &WebView{
87+ pipeline: &renderPipeline{
88+ width: 1500,
89+ height: 900,
90+ scale: 3,
91+ },
92+ },
93+ }
94+
95+ ok := h.GetScreenInfo(nil, info)
96+
97+ require.Equal(t, int32(1), ok)
98+ require.Equal(t, float32(3), info.DeviceScaleFactor)
99+ require.Equal(t, int32(500), info.Rect.Width)
100+ require.Equal(t, int32(300), info.Rect.Height)
101+ require.Equal(t, int32(500), info.AvailableRect.Width)
102+ require.Equal(t, int32(300), info.AvailableRect.Height)
103+}
104+
105+func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
106+ h := &handlerSet{}
107+
108+ require.Nil(t, h.GetAudioHandler())
109+ require.Nil(t, h.GetContextMenuHandler())
110+
111+ h.enableAudioHandler = true
112+ h.enableContextMenuHandler = true
113+
114+ require.Same(t, h, h.GetAudioHandler())
115+ require.Same(t, h, h.GetContextMenuHandler())
116+}
@@ -59,3 +59,58 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
59 require.Equal(t, port.LoadCommitted, gotEvents[0])59 require.Equal(t, port.LoadCommitted, gotEvents[0])
60 require.Equal(t, "https://google.com", wv.URI())60 require.Equal(t, "https://google.com", wv.URI())
61 }61 }
62+
63+func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
64+ rect := &purecef.Rect{}
65+ h := &handlerSet{
66+ wv: &WebView{
67+ pipeline: &renderPipeline{
68+ width: 800,
69+ height: 600,
70+ scale: 2,
71+ },
72+ },
73+ }
74+
75+ h.GetViewRect(nil, rect)
76+
77+ require.Equal(t, int32(0), rect.X)
78+ require.Equal(t, int32(0), rect.Y)
79+ require.Equal(t, int32(400), rect.Width)
80+ require.Equal(t, int32(300), rect.Height)
81+}
82+
83+func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
84+ info := &purecef.ScreenInfo{}
85+ h := &handlerSet{
86+ wv: &WebView{
87+ pipeline: &renderPipeline{
88+ width: 1500,
89+ height: 900,
90+ scale: 3,
91+ },
92+ },
93+ }
94+
95+ ok := h.GetScreenInfo(nil, info)
96+
97+ require.Equal(t, int32(1), ok)
98+ require.Equal(t, float32(3), info.DeviceScaleFactor)
99+ require.Equal(t, int32(500), info.Rect.Width)
100+ require.Equal(t, int32(300), info.Rect.Height)
101+ require.Equal(t, int32(500), info.AvailableRect.Width)
102+ require.Equal(t, int32(300), info.AvailableRect.Height)
103+}
104+
105+func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
106+ h := &handlerSet{}
107+
108+ require.Nil(t, h.GetAudioHandler())
109+ require.Nil(t, h.GetContextMenuHandler())
110+
111+ h.enableAudioHandler = true
112+ h.enableContextMenuHandler = true
113+
114+ require.Same(t, h, h.GetAudioHandler())
115+ require.Same(t, h, h.GetContextMenuHandler())
116+}
A internal/infrastructure/cef/webview_zoom_test.go +19 -0
@@ -0,0 +1,19 @@
1+package cef
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestZoomConversionsRoundTrip(t *testing.T) {
9+ factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}
10+
11+ for _, factor := range factors {
12+ level := cefZoomFromFactor(factor)
13+ got := factorFromCEFZoom(level)
14+ if math.Abs(got-factor) > 1e-9 {
15+ t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
16+ }
17+ }
18+}
19+
@@ -0,0 +1,19 @@
1+package cef
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestZoomConversionsRoundTrip(t *testing.T) {
9+ factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}
10+
11+ for _, factor := range factors {
12+ level := cefZoomFromFactor(factor)
13+ got := factorFromCEFZoom(level)
14+ if math.Abs(got-factor) > 1e-9 {
15+ t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
16+ }
17+ }
18+}
19+
M internal/infrastructure/config/defaults.go +10 -0
@@ -67,6 +67,8 @@ const (
6767 // Performance defaults
6868 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
6969 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
70+ defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71+ defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval
7072
7173 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
7274 defaultSkiaCPUPaintingThreads = 0
@@ -84,6 +86,8 @@ func getDefaultLogDir() string {
8486
8587 // DefaultConfig returns the default configuration values for dumber.
8688 func DefaultConfig() *Config {
89+ defaultCEFMultiThreadedMessageLoop := true
90+
8791 return &Config{
8892 Database: DatabaseConfig{
8993 // Path is set dynamically in config.Load()
@@ -151,6 +155,12 @@ func DefaultConfig() *Config {
151155 // third-party cookie isolation more intelligently. Using Always + ITP
152156 // matches Epiphany's model and avoids a misleading setting.
153157 CookiePolicy: CookiePolicyAlways,
158+ CEF: CEFEngineConfig{
159+ WindowlessFrameRate: defaultCEFWindowlessFrameRate,
160+ MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop,
161+ ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs,
162+ LogFile: "",
163+ },
154164 WebKit: WebKitEngineConfig{
155165 ITPEnabled: true,
156166 SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
@@ -67,6 +67,8 @@ const (
67 // Performance defaults67 // Performance defaults
68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
70+ defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71+ defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval
70 72
71 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)73 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
72 defaultSkiaCPUPaintingThreads = 074 defaultSkiaCPUPaintingThreads = 0
@@ -84,6 +86,8 @@ func getDefaultLogDir() string {
84 86
85 // DefaultConfig returns the default configuration values for dumber.87 // DefaultConfig returns the default configuration values for dumber.
86 func DefaultConfig() *Config {88 func DefaultConfig() *Config {
89+ defaultCEFMultiThreadedMessageLoop := true
90+
87 return &Config{91 return &Config{
88 Database: DatabaseConfig{92 Database: DatabaseConfig{
89 // Path is set dynamically in config.Load()93 // Path is set dynamically in config.Load()
@@ -151,6 +155,12 @@ func DefaultConfig() *Config {
151 // third-party cookie isolation more intelligently. Using Always + ITP155 // third-party cookie isolation more intelligently. Using Always + ITP
152 // matches Epiphany's model and avoids a misleading setting.156 // matches Epiphany's model and avoids a misleading setting.
153 CookiePolicy: CookiePolicyAlways,157 CookiePolicy: CookiePolicyAlways,
158+ CEF: CEFEngineConfig{
159+ WindowlessFrameRate: defaultCEFWindowlessFrameRate,
160+ MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop,
161+ ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs,
162+ LogFile: "",
163+ },
154 WebKit: WebKitEngineConfig{164 WebKit: WebKitEngineConfig{
155 ITPEnabled: true,165 ITPEnabled: true,
156 SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,166 SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
M internal/infrastructure/config/defaults_test.go +7 -0
@@ -16,6 +16,13 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1616 assert.Equal(t, "webkit", cfg.Engine.Type)
1717 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
1818 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
19+ assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
20+ assert.True(t, cfg.Engine.CEF.CEFMultiThreadedMessageLoop())
21+ assert.Equal(t, int64(defaultCEFManualPumpIntervalMs), cfg.Engine.CEF.CEFManualPumpIntervalMs())
22+ assert.Empty(t, cfg.Engine.CEF.LogFile)
23+ assert.False(t, cfg.Engine.CEF.EnableAudioHandler)
24+ assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
25+ assert.False(t, cfg.Engine.CEF.TraceHandlers)
1926 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
2027
2128 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
@@ -16,6 +16,13 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
16 assert.Equal(t, "webkit", cfg.Engine.Type)16 assert.Equal(t, "webkit", cfg.Engine.Type)
17 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)17 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
18 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)18 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
19+ assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
20+ assert.True(t, cfg.Engine.CEF.CEFMultiThreadedMessageLoop())
21+ assert.Equal(t, int64(defaultCEFManualPumpIntervalMs), cfg.Engine.CEF.CEFManualPumpIntervalMs())
22+ assert.Empty(t, cfg.Engine.CEF.LogFile)
23+ assert.False(t, cfg.Engine.CEF.EnableAudioHandler)
24+ assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
25+ assert.False(t, cfg.Engine.CEF.TraceHandlers)
19 assert.True(t, cfg.Engine.WebKit.ITPEnabled)26 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
20 27
21 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.28 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
M internal/infrastructure/config/engine.go +19 -2
@@ -79,11 +79,14 @@ type CEFEngineConfig struct {
7979 // CEFDir is the path to the CEF framework directory containing
8080 // libcef.so and the Resources/locales subdirectories.
8181 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
82+ // LogFile overrides the CEF internal log destination. When empty,
83+ // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log.
84+ LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
8285 // LogSeverity controls CEF's internal log verbosity.
8386 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
8487 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
8588 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
86- // Default: 30. Higher values increase CPU usage.
89+ // Default: 60. Higher values increase CPU usage.
8790 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
8891 // MultiThreadedMessageLoop lets CEF run its own message loop thread.
8992 // Default: true. When false, the host drives the pump via a manual timer.
@@ -91,6 +94,12 @@ type CEFEngineConfig struct {
9194 // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
9295 // when MultiThreadedMessageLoop is false. Default: 10.
9396 ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
97+ // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
98+ EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
99+ // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
100+ EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll
101+ // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
102+ TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
94103 }
95104
96105 // CEFMultiThreadedMessageLoop returns the effective value with default true.
@@ -106,7 +115,15 @@ func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
106115 if c.ManualPumpIntervalMs > 0 {
107116 return c.ManualPumpIntervalMs
108117 }
109- return 10
118+ return defaultCEFManualPumpIntervalMs
119+}
120+
121+// CEFWindowlessFrameRate returns the effective OSR frame rate with default 60.
122+func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
123+ if c.WindowlessFrameRate > 0 {
124+ return c.WindowlessFrameRate
125+ }
126+ return defaultCEFWindowlessFrameRate
110127 }
111128
112129 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
@@ -79,11 +79,14 @@ type CEFEngineConfig struct {
79 // CEFDir is the path to the CEF framework directory containing79 // CEFDir is the path to the CEF framework directory containing
80 // libcef.so and the Resources/locales subdirectories.80 // libcef.so and the Resources/locales subdirectories.
81 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`81 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
82+ // LogFile overrides the CEF internal log destination. When empty,
83+ // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log.
84+ LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
82 // LogSeverity controls CEF's internal log verbosity.85 // LogSeverity controls CEF's internal log verbosity.
83 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.86 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
84 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`87 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
85 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.88 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
86- // Default: 30. Higher values increase CPU usage.89+ // Default: 60. Higher values increase CPU usage.
87 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`90 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
88 // MultiThreadedMessageLoop lets CEF run its own message loop thread.91 // MultiThreadedMessageLoop lets CEF run its own message loop thread.
89 // Default: true. When false, the host drives the pump via a manual timer.92 // Default: true. When false, the host drives the pump via a manual timer.
@@ -91,6 +94,12 @@ type CEFEngineConfig struct {
91 // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork94 // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
92 // when MultiThreadedMessageLoop is false. Default: 10.95 // when MultiThreadedMessageLoop is false. Default: 10.
93 ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`96 ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
97+ // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
98+ EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
99+ // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
100+ EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll
101+ // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
102+ TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
94 }103 }
95 104
96 // CEFMultiThreadedMessageLoop returns the effective value with default true.105 // CEFMultiThreadedMessageLoop returns the effective value with default true.
@@ -106,7 +115,15 @@ func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
106 if c.ManualPumpIntervalMs > 0 {115 if c.ManualPumpIntervalMs > 0 {
107 return c.ManualPumpIntervalMs116 return c.ManualPumpIntervalMs
108 }117 }
109- return 10118+ return defaultCEFManualPumpIntervalMs
119+}
120+
121+// CEFWindowlessFrameRate returns the effective OSR frame rate with default 60.
122+func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
123+ if c.WindowlessFrameRate > 0 {
124+ return c.WindowlessFrameRate
125+ }
126+ return defaultCEFWindowlessFrameRate
110 }127 }
111 128
112 // PerformanceConfigFromEngine constructs a PerformanceConfig from the129 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
M internal/infrastructure/config/loader.go +11 -0
@@ -568,6 +568,17 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
568568 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
569569 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
570570
571+ ce := e.CEF
572+ m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
573+ m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
574+ m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
575+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
576+ m.viper.SetDefault("engine.cef.multi_threaded_message_loop", ce.CEFMultiThreadedMessageLoop())
577+ m.viper.SetDefault("engine.cef.manual_pump_interval_ms", ce.CEFManualPumpIntervalMs())
578+ m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
579+ m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
580+ m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
581+
571582 wk := e.WebKit
572583 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
573584 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
@@ -568,6 +568,17 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
568 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)568 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
569 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))569 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
570 570
571+ ce := e.CEF
572+ m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
573+ m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
574+ m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
575+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
576+ m.viper.SetDefault("engine.cef.multi_threaded_message_loop", ce.CEFMultiThreadedMessageLoop())
577+ m.viper.SetDefault("engine.cef.manual_pump_interval_ms", ce.CEFManualPumpIntervalMs())
578+ m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
579+ m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
580+ m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
581+
571 wk := e.WebKit582 wk := e.WebKit
572 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)583 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
573 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)584 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
M internal/infrastructure/config/schema_provider.go +65 -0
@@ -847,6 +847,41 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
847847 Description: "Enable browser developer tools (F12)",
848848 Section: SectionDebug,
849849 },
850+ {
851+ Key: "engine.cef.log_file",
852+ Type: "string",
853+ Default: defaults.Engine.CEF.LogFile,
854+ Description: "Path to the CEF runtime log file written during startup and browser process execution",
855+ Section: SectionDebug,
856+ },
857+ {
858+ Key: "engine.cef.log_severity",
859+ Type: "int32",
860+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.LogSeverity),
861+ Description: "CEF internal log verbosity (0=default, 1=verbose, 2=info, 3=warning, 4=error, 99=disable)",
862+ Section: SectionDebug,
863+ },
864+ {
865+ Key: "engine.cef.trace_handlers",
866+ Type: "bool",
867+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.TraceHandlers),
868+ Description: "Trace purego-cef handler wrapping and refcount activity",
869+ Section: SectionDebug,
870+ },
871+ {
872+ Key: "engine.cef.enable_audio_handler",
873+ Type: "bool",
874+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableAudioHandler),
875+ Description: "Enable the experimental CEF audio handler bridge",
876+ Section: SectionDebug,
877+ },
878+ {
879+ Key: "engine.cef.enable_context_menu_handler",
880+ Type: "bool",
881+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler),
882+ Description: "Enable the experimental CEF context menu handler bridge",
883+ Section: SectionDebug,
884+ },
850885 }
851886 }
852887
@@ -876,6 +911,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
876911 Range: ">=0",
877912 Section: SectionPerformance,
878913 },
914+ {
915+ Key: "engine.cef.windowless_frame_rate",
916+ Type: "int32",
917+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
918+ Description: "Maximum off-screen frame rate for the CEF engine",
919+ Range: ">=1",
920+ Section: SectionPerformance,
921+ },
922+ {
923+ Key: "engine.cef.multi_threaded_message_loop",
924+ Type: "bool",
925+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.CEFMultiThreadedMessageLoop()),
926+ Description: "Let CEF drive its own UI/message loop thread",
927+ Section: SectionPerformance,
928+ },
929+ {
930+ Key: "engine.cef.manual_pump_interval_ms",
931+ Type: "int64",
932+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFManualPumpIntervalMs()),
933+ Description: "Manual CEF message pump interval when multi-threaded mode is disabled",
934+ Range: ">=1",
935+ Section: SectionPerformance,
936+ },
879937 {
880938 Key: "engine.webkit.skia_cpu_painting_threads",
881939 Type: "int",
@@ -968,6 +1026,13 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
9681026
9691027 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {
9701028 return []entity.ConfigKeyInfo{
1029+ {
1030+ Key: "engine.cef.cef_dir",
1031+ Type: "string",
1032+ Default: defaults.Engine.CEF.CEFDir,
1033+ Description: "Custom CEF runtime directory containing libcef and Resources",
1034+ Section: SectionRuntime,
1035+ },
9711036 {
9721037 Key: "engine.webkit.prefix",
9731038 Type: "string",
@@ -847,6 +847,41 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
847 Description: "Enable browser developer tools (F12)",847 Description: "Enable browser developer tools (F12)",
848 Section: SectionDebug,848 Section: SectionDebug,
849 },849 },
850+ {
851+ Key: "engine.cef.log_file",
852+ Type: "string",
853+ Default: defaults.Engine.CEF.LogFile,
854+ Description: "Path to the CEF runtime log file written during startup and browser process execution",
855+ Section: SectionDebug,
856+ },
857+ {
858+ Key: "engine.cef.log_severity",
859+ Type: "int32",
860+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.LogSeverity),
861+ Description: "CEF internal log verbosity (0=default, 1=verbose, 2=info, 3=warning, 4=error, 99=disable)",
862+ Section: SectionDebug,
863+ },
864+ {
865+ Key: "engine.cef.trace_handlers",
866+ Type: "bool",
867+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.TraceHandlers),
868+ Description: "Trace purego-cef handler wrapping and refcount activity",
869+ Section: SectionDebug,
870+ },
871+ {
872+ Key: "engine.cef.enable_audio_handler",
873+ Type: "bool",
874+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableAudioHandler),
875+ Description: "Enable the experimental CEF audio handler bridge",
876+ Section: SectionDebug,
877+ },
878+ {
879+ Key: "engine.cef.enable_context_menu_handler",
880+ Type: "bool",
881+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler),
882+ Description: "Enable the experimental CEF context menu handler bridge",
883+ Section: SectionDebug,
884+ },
850 }885 }
851 }886 }
852 887
@@ -876,6 +911,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
876 Range: ">=0",911 Range: ">=0",
877 Section: SectionPerformance,912 Section: SectionPerformance,
878 },913 },
914+ {
915+ Key: "engine.cef.windowless_frame_rate",
916+ Type: "int32",
917+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
918+ Description: "Maximum off-screen frame rate for the CEF engine",
919+ Range: ">=1",
920+ Section: SectionPerformance,
921+ },
922+ {
923+ Key: "engine.cef.multi_threaded_message_loop",
924+ Type: "bool",
925+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.CEFMultiThreadedMessageLoop()),
926+ Description: "Let CEF drive its own UI/message loop thread",
927+ Section: SectionPerformance,
928+ },
929+ {
930+ Key: "engine.cef.manual_pump_interval_ms",
931+ Type: "int64",
932+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFManualPumpIntervalMs()),
933+ Description: "Manual CEF message pump interval when multi-threaded mode is disabled",
934+ Range: ">=1",
935+ Section: SectionPerformance,
936+ },
879 {937 {
880 Key: "engine.webkit.skia_cpu_painting_threads",938 Key: "engine.webkit.skia_cpu_painting_threads",
881 Type: "int",939 Type: "int",
@@ -968,6 +1026,13 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
968 1026
969 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {1027 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {
970 return []entity.ConfigKeyInfo{1028 return []entity.ConfigKeyInfo{
1029+ {
1030+ Key: "engine.cef.cef_dir",
1031+ Type: "string",
1032+ Default: defaults.Engine.CEF.CEFDir,
1033+ Description: "Custom CEF runtime directory containing libcef and Resources",
1034+ Section: SectionRuntime,
1035+ },
971 {1036 {
972 Key: "engine.webkit.prefix",1037 Key: "engine.webkit.prefix",
973 Type: "string",1038 Type: "string",
M internal/infrastructure/config/validation.go +24 -0
@@ -33,6 +33,7 @@ func validateConfig(config *Config) error {
3333 validationErrors = append(validationErrors, validateColorScheme(config)...)
3434 validationErrors = append(validationErrors, validateSession(config)...)
3535 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
36+ validationErrors = append(validationErrors, validateCEF(config)...)
3637
3738 // If there are validation errors, return them
3839 if len(validationErrors) > 0 {
@@ -325,6 +326,29 @@ func validateLogging(config *Config) []string {
325326 return validationErrors
326327 }
327328
329+func validateCEF(config *Config) []string {
330+ var validationErrors []string
331+
332+ switch config.Engine.CEF.LogSeverity {
333+ case 0, 1, 2, 3, 4, 99:
334+ default:
335+ validationErrors = append(validationErrors, fmt.Sprintf(
336+ "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, 99 (got: %d)",
337+ config.Engine.CEF.LogSeverity,
338+ ))
339+ }
340+
341+ if config.Engine.CEF.WindowlessFrameRate < 0 {
342+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
343+ }
344+
345+ if config.Engine.CEF.ManualPumpIntervalMs < 0 {
346+ validationErrors = append(validationErrors, "engine.cef.manual_pump_interval_ms must be >= 0")
347+ }
348+
349+ return validationErrors
350+}
351+
328352 func validateWorkspaceNewPaneURL(config *Config) []string {
329353 var validationErrors []string
330354
@@ -33,6 +33,7 @@ func validateConfig(config *Config) error {
33 validationErrors = append(validationErrors, validateColorScheme(config)...)33 validationErrors = append(validationErrors, validateColorScheme(config)...)
34 validationErrors = append(validationErrors, validateSession(config)...)34 validationErrors = append(validationErrors, validateSession(config)...)
35 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)35 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
36+ validationErrors = append(validationErrors, validateCEF(config)...)
36 37
37 // If there are validation errors, return them38 // If there are validation errors, return them
38 if len(validationErrors) > 0 {39 if len(validationErrors) > 0 {
@@ -325,6 +326,29 @@ func validateLogging(config *Config) []string {
325 return validationErrors326 return validationErrors
326 }327 }
327 328
329+func validateCEF(config *Config) []string {
330+ var validationErrors []string
331+
332+ switch config.Engine.CEF.LogSeverity {
333+ case 0, 1, 2, 3, 4, 99:
334+ default:
335+ validationErrors = append(validationErrors, fmt.Sprintf(
336+ "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, 99 (got: %d)",
337+ config.Engine.CEF.LogSeverity,
338+ ))
339+ }
340+
341+ if config.Engine.CEF.WindowlessFrameRate < 0 {
342+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
343+ }
344+
345+ if config.Engine.CEF.ManualPumpIntervalMs < 0 {
346+ validationErrors = append(validationErrors, "engine.cef.manual_pump_interval_ms must be >= 0")
347+ }
348+
349+ return validationErrors
350+}
351+
328 func validateWorkspaceNewPaneURL(config *Config) []string {352 func validateWorkspaceNewPaneURL(config *Config) []string {
329 var validationErrors []string353 var validationErrors []string
330 354
M internal/infrastructure/config/validation_test.go +69 -0
@@ -34,3 +34,72 @@ func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
3434 })
3535 }
3636 }
37+
38+func TestValidateConfig_CEFConfig(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ mutate func(*Config)
42+ wantErr bool
43+ wantText string
44+ }{
45+ {
46+ name: "valid defaults",
47+ mutate: func(_ *Config) {
48+ },
49+ wantErr: false,
50+ },
51+ {
52+ name: "invalid log severity",
53+ mutate: func(cfg *Config) {
54+ cfg.Engine.CEF.LogSeverity = 7
55+ },
56+ wantErr: true,
57+ wantText: "engine.cef.log_severity",
58+ },
59+ {
60+ name: "zero frame rate uses default",
61+ mutate: func(cfg *Config) {
62+ cfg.Engine.CEF.WindowlessFrameRate = 0
63+ },
64+ wantErr: false,
65+ },
66+ {
67+ name: "zero manual pump interval uses default",
68+ mutate: func(cfg *Config) {
69+ cfg.Engine.CEF.ManualPumpIntervalMs = 0
70+ },
71+ wantErr: false,
72+ },
73+ {
74+ name: "negative frame rate",
75+ mutate: func(cfg *Config) {
76+ cfg.Engine.CEF.WindowlessFrameRate = -1
77+ },
78+ wantErr: true,
79+ wantText: "engine.cef.windowless_frame_rate",
80+ },
81+ {
82+ name: "negative manual pump interval",
83+ mutate: func(cfg *Config) {
84+ cfg.Engine.CEF.ManualPumpIntervalMs = -1
85+ },
86+ wantErr: true,
87+ wantText: "engine.cef.manual_pump_interval_ms",
88+ },
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ cfg := DefaultConfig()
94+ tt.mutate(cfg)
95+
96+ err := validateConfig(cfg)
97+ if tt.wantErr {
98+ require.Error(t, err)
99+ assert.Contains(t, err.Error(), tt.wantText)
100+ return
101+ }
102+ require.NoError(t, err)
103+ })
104+ }
105+}
@@ -34,3 +34,72 @@ func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
34 })34 })
35 }35 }
36 }36 }
37+
38+func TestValidateConfig_CEFConfig(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ mutate func(*Config)
42+ wantErr bool
43+ wantText string
44+ }{
45+ {
46+ name: "valid defaults",
47+ mutate: func(_ *Config) {
48+ },
49+ wantErr: false,
50+ },
51+ {
52+ name: "invalid log severity",
53+ mutate: func(cfg *Config) {
54+ cfg.Engine.CEF.LogSeverity = 7
55+ },
56+ wantErr: true,
57+ wantText: "engine.cef.log_severity",
58+ },
59+ {
60+ name: "zero frame rate uses default",
61+ mutate: func(cfg *Config) {
62+ cfg.Engine.CEF.WindowlessFrameRate = 0
63+ },
64+ wantErr: false,
65+ },
66+ {
67+ name: "zero manual pump interval uses default",
68+ mutate: func(cfg *Config) {
69+ cfg.Engine.CEF.ManualPumpIntervalMs = 0
70+ },
71+ wantErr: false,
72+ },
73+ {
74+ name: "negative frame rate",
75+ mutate: func(cfg *Config) {
76+ cfg.Engine.CEF.WindowlessFrameRate = -1
77+ },
78+ wantErr: true,
79+ wantText: "engine.cef.windowless_frame_rate",
80+ },
81+ {
82+ name: "negative manual pump interval",
83+ mutate: func(cfg *Config) {
84+ cfg.Engine.CEF.ManualPumpIntervalMs = -1
85+ },
86+ wantErr: true,
87+ wantText: "engine.cef.manual_pump_interval_ms",
88+ },
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ cfg := DefaultConfig()
94+ tt.mutate(cfg)
95+
96+ err := validateConfig(cfg)
97+ if tt.wantErr {
98+ require.Error(t, err)
99+ assert.Contains(t, err.Error(), tt.wantText)
100+ return
101+ }
102+ require.NoError(t, err)
103+ })
104+ }
105+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close