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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +871 -52
M .gitignore +1 -0
| @@ -42,6 +42,7 @@ Thumbs.db | |||
| 42 | 42 | *.tmp | |
| 43 | 43 | *.temp | |
| 44 | 44 | *.log | |
| 45 | + | __debug_bin* | |
| 45 | 46 | ||
| 46 | 47 | # Database files | |
| 47 | 48 | *.db | |
| @@ -42,6 +42,7 @@ Thumbs.db | |||||
| 42 | *.tmp | 42 | *.tmp | ||
| 43 | *.temp | 43 | *.temp | ||
| 44 | *.log | 44 | *.log | ||
| 45 | + | __debug_bin* | |||
| 45 | 46 | ||||
| 46 | # Database files | 47 | # Database files | ||
| 47 | *.db | 48 | *.db | ||
M internal/application/usecase/navigate.go +14 -4
| @@ -249,18 +249,28 @@ func (uc *NavigateUseCase) historyWorker() { | |||
| 249 | 249 | ||
| 250 | 250 | log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history") | |
| 251 | 251 | ||
| 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. | |
| 254 | 255 | visits := pendingVisits | |
| 255 | 256 | titles := pendingTitles | |
| 256 | 257 | pendingVisits = make(map[string]int) | |
| 257 | 258 | pendingTitles = make(map[string]string) | |
| 258 | 259 | ||
| 260 | + | visitRecords := make([]historyRecord, 0, len(visits)) | |
| 259 | 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 | 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 | ||
| @@ -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 issues | 252 | + | // 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 := pendingVisits | 255 | visits := pendingVisits | ||
| 255 | titles := pendingTitles | 256 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | 13 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 11 | 14 | "github.com/stretchr/testify/mock" | |
| @@ -88,3 +91,135 @@ func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testi | |||
| 88 | 91 | require.Equal(t, "New", after.Title) | |
| 89 | 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) | |
| @@ -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( | |||
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | 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 | 161 | return persistErr | |
| 162 | 162 | } | |
| @@ -310,6 +310,7 @@ func runSessionCleanup( | |||
| 310 | 310 | sessionUC *usecase.ManageSessionUseCase, | |
| 311 | 311 | cleanupUC *usecase.CleanupSessionsUseCase, | |
| 312 | 312 | cfg *config.Config, | |
| 313 | + | currentSessionID entity.SessionID, | |
| 313 | 314 | log *zerolog.Logger, | |
| 314 | 315 | ) { | |
| 315 | 316 | bgCtx := context.WithoutCancel(startupCtx) | |
| @@ -323,7 +324,10 @@ func runSessionCleanup( | |||
| 323 | 324 | } else { | |
| 324 | 325 | now := time.Now() | |
| 325 | 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 | 331 | _ = sessionUC.EndSession(bgCtx, s.ID, now) | |
| 328 | 332 | } | |
| 329 | 333 | } | |
| @@ -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 persistErr | 161 | 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 ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | + | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 21 | + | ||
| 20 | 22 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 21 | 23 | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | |
| 22 | 24 | logger := logging.FromContext(ctx) | |
| @@ -29,6 +31,9 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 29 | 31 | // Resolve pump mode from config (with defaults). | |
| 30 | 32 | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | |
| 31 | 33 | manualPumpMs := cfg.CEFManualPumpIntervalMs() | |
| 34 | + | windowlessFrameRate := cfg.CEFWindowlessFrameRate() | |
| 35 | + | ||
| 36 | + | purecef.SetHandlerTraceEnabled(cfg.TraceHandlers) | |
| 32 | 37 | ||
| 33 | 38 | // 1. Initialize CEF. | |
| 34 | 39 | settings := purecef.DefaultSettings() | |
| @@ -41,6 +46,20 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 41 | 46 | if cfg.CEFDir != "" { | |
| 42 | 47 | 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 | 63 | if cfg.LogSeverity != 0 { | |
| 45 | 64 | settings.LogSeverity = cfg.LogSeverity | |
| 46 | 65 | } | |
| @@ -82,19 +101,37 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 82 | 101 | logger.Info(). | |
| 83 | 102 | Bool("multi_threaded_message_loop", multiThreaded). | |
| 84 | 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 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) | |
| 90 | 126 | ||
| 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 | + | } | |
| 93 | 132 | 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 | 136 | // 3. Load GL. | |
| 100 | 137 | gl, err := newGLLoader() | |
| @@ -123,7 +160,12 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 123 | 160 | } | |
| 124 | 161 | ||
| 125 | 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 | 169 | pool := newWebViewPool(factory) | |
| 128 | 170 | ||
| 129 | 171 | eng.gl = gl | |
| @@ -200,3 +242,57 @@ func findHelperBinary() string { | |||
| 200 | 242 | } | |
| 201 | 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 | + | } | |
| @@ -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.CEFDir | 47 | 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.LogSeverity | 64 | 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 the | 110 | + | 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 = savedArgs | 132 | 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 = gl | 171 | 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) | |||
| 17 | 17 | // gets a unique ID, its own renderPipeline and inputBridge, and an | |
| 18 | 18 | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | |
| 19 | 19 | 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 | |
| 25 | 35 | } | |
| 26 | 36 | ||
| 27 | 37 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 28 | 38 | // 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 | |
| 32 | 45 | } | |
| 33 | 46 | 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, | |
| 37 | 53 | } | |
| 38 | 54 | } | |
| 39 | 55 | ||
| @@ -59,7 +75,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 59 | 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 | 83 | wv.handlers = handlers | |
| 64 | 84 | ||
| 65 | 85 | input := newInputBridge(f.scale) | |
| @@ -89,10 +109,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 89 | 109 | // Configure WindowInfo for off-screen rendering (OSR). | |
| 90 | 110 | windowInfo := purecef.DefaultWindowInfo() | |
| 91 | 111 | windowInfo.WindowlessRenderingEnabled = 1 | |
| 112 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 92 | 113 | ||
| 93 | 114 | // Configure BrowserSettings. | |
| 94 | 115 | settings := purecef.DefaultBrowserSettings() | |
| 95 | - | settings.WindowlessFrameRate = 60 | |
| 116 | + | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 96 | 117 | if bg := f.bgColor.Load(); bg != 0 { | |
| 97 | 118 | settings.BackgroundColor = bg | |
| 98 | 119 | } | |
| @@ -126,6 +147,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 126 | 147 | log.Debug(). | |
| 127 | 148 | Int32("result", result). | |
| 128 | 149 | Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled). | |
| 150 | + | Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate). | |
| 129 | 151 | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | |
| 130 | 152 | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | |
| 131 | 153 | 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 an | 17 | // 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 *Engine | 20 | + | engine *Engine |
| 21 | - | gl *glLoader | 21 | + | gl *glLoader |
| 22 | - | nextID atomic.Uint64 | 22 | + | nextID atomic.Uint64 |
| 23 | - | scale int32 | 23 | + | scale int32 |
| 24 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 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 | |||
| 25 | } | 35 | } | ||
| 26 | 36 | ||||
| 27 | // newWebViewFactory returns a factory that will create WebViews using the | 37 | // 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 = 1 | 41 | + | 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 = handlers | 83 | 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 = 1 | 111 | 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 = 60 | 116 | + | settings.WindowlessFrameRate = f.windowlessFrameRate |
| 96 | if bg := f.bgColor.Load(); bg != 0 { | 117 | if bg := f.bgColor.Load(); bg != 0 { | ||
| 97 | settings.BackgroundColor = bg | 118 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 17 | "github.com/bnema/dumber/internal/logging" | |
| @@ -42,6 +43,11 @@ type WebView struct { | |||
| 42 | 43 | input *inputBridge | |
| 43 | 44 | 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 | 51 | // pendingCreate holds browser creation params until the GL area is realized. | |
| 46 | 52 | pendingCreate *pendingBrowserCreate | |
| 47 | 53 | ||
| @@ -307,6 +313,12 @@ func cefZoomFromFactor(factor float64) float64 { | |||
| 307 | 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 | 322 | // GetZoomLevel returns the current linear zoom factor. | |
| 311 | 323 | func (wv *WebView) GetZoomLevel() float64 { | |
| 312 | 324 | if v := wv.zoomFactor.Load(); v != nil { | |
| @@ -340,6 +352,11 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||
| 340 | 352 | // SynchronizeVisualProperties cycle, which makes the renderer produce | |
| 341 | 353 | // a new compositor frame at the new zoom level. | |
| 342 | 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 | 360 | return nil | |
| 344 | 361 | } | |
| 345 | 362 | ||
| @@ -435,6 +452,7 @@ func (wv *WebView) Destroy() { | |||
| 435 | 452 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 436 | 453 | return | |
| 437 | 454 | } | |
| 455 | + | wv.scheduleStopBeginFrameLoop() | |
| 438 | 456 | wv.mu.RLock() | |
| 439 | 457 | host := wv.host | |
| 440 | 458 | wv.mu.RUnlock() | |
| @@ -527,6 +545,122 @@ func (wv *WebView) updateHoverURI(uri string) { | |||
| 527 | 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 | 664 | func (wv *WebView) runOnGTK(fn func()) { | |
| 531 | 665 | if fn == nil { | |
| 532 | 666 | 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 *inputBridge | 43 | input *inputBridge | ||
| 43 | handlers *handlerSet | 44 | 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 *pendingBrowserCreate | 52 | 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 produce | 352 | // 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 nil | 360 | 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 | return | 453 | return | ||
| 437 | } | 454 | } | ||
| 455 | + | wv.scheduleStopBeginFrameLoop() | |||
| 438 | wv.mu.RLock() | 456 | wv.mu.RLock() | ||
| 439 | host := wv.host | 457 | 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 | return | 666 | return | ||
M internal/infrastructure/cef/webview_handlers.go +65 -21
| @@ -11,7 +11,9 @@ import ( | |||
| 11 | 11 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 12 | 12 | // methods can return the same receiver, avoiding extra allocations. | |
| 13 | 13 | type handlerSet struct { | |
| 14 | - | wv *WebView | |
| 14 | + | wv *WebView | |
| 15 | + | enableAudioHandler bool | |
| 16 | + | enableContextMenuHandler bool | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | 19 | // Compile-time interface checks. | |
| @@ -30,24 +32,34 @@ var ( | |||
| 30 | 32 | // Client | |
| 31 | 33 | // =========================================================================== | |
| 32 | 34 | ||
| 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 } | |
| 51 | 63 | ||
| 52 | 64 | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | |
| 53 | 65 | return 0 | |
| @@ -69,10 +81,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 69 | 81 | h.wv.pipeline.mu.Lock() | |
| 70 | 82 | w := h.wv.pipeline.width | |
| 71 | 83 | ht := h.wv.pipeline.height | |
| 84 | + | s := h.wv.pipeline.scale | |
| 72 | 85 | h.wv.pipeline.mu.Unlock() | |
| 73 | 86 | ||
| 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. | |
| 76 | 98 | if w <= 0 { | |
| 77 | 99 | w = 1 | |
| 78 | 100 | } | |
| @@ -99,6 +121,17 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||
| 99 | 121 | ht := h.wv.pipeline.height | |
| 100 | 122 | s := h.wv.pipeline.scale | |
| 101 | 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 | 136 | const ( | |
| 104 | 137 | screenDepth = 24 | |
| @@ -385,6 +418,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 385 | 418 | browserID := browser.GetIdentifier() | |
| 386 | 419 | log.Debug(). | |
| 387 | 420 | Int32("browser_id", browserID). | |
| 421 | + | Bool("audio_handler_enabled", h.enableAudioHandler). | |
| 422 | + | Bool("context_menu_handler_enabled", h.enableContextMenuHandler). | |
| 388 | 423 | Msg("cef: OnAfterCreated") | |
| 389 | 424 | if h.wv.engine != nil { | |
| 390 | 425 | h.wv.engine.recordBrowserAfterCreated(browser) | |
| @@ -405,6 +440,8 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 405 | 440 | } | |
| 406 | 441 | } | |
| 407 | 442 | h.wv.mu.Unlock() | |
| 443 | + | ||
| 444 | + | h.wv.scheduleStartBeginFrameLoop() | |
| 408 | 445 | } | |
| 409 | 446 | ||
| 410 | 447 | // DoClose returns false to allow the default close behavior. | |
| @@ -414,6 +451,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||
| 414 | 451 | ||
| 415 | 452 | // OnBeforeClose fires the OnClose callback. | |
| 416 | 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 | 461 | h.wv.mu.RLock() | |
| 418 | 462 | cb := h.wv.callbacks | |
| 419 | 463 | h.wv.mu.RUnlock() | |
| @@ -11,7 +11,9 @@ import ( | |||||
| 11 | // owning WebView. A single struct is used so that the Client's Get*Handler | 11 | // 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 *WebView | 14 | + | 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 | // Client | 32 | // 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 0 | 65 | 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.width | 82 | w := h.wv.pipeline.width | ||
| 71 | ht := h.wv.pipeline.height | 83 | 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 area | 87 | + | // 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 = 1 | 99 | w = 1 | ||
| 78 | } | 100 | } | ||
| @@ -99,6 +121,17 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||||
| 99 | ht := h.wv.pipeline.height | 121 | ht := h.wv.pipeline.height | ||
| 100 | s := h.wv.pipeline.scale | 122 | 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 = 24 | 137 | 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.callbacks | 462 | 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) { | |||
| 59 | 59 | require.Equal(t, port.LoadCommitted, gotEvents[0]) | |
| 60 | 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 | + | } | |
| @@ -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 ( | |||
| 67 | 67 | // Performance defaults | |
| 68 | 68 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 69 | 69 | 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 | 73 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 72 | 74 | defaultSkiaCPUPaintingThreads = 0 | |
| @@ -84,6 +86,8 @@ func getDefaultLogDir() string { | |||
| 84 | 86 | ||
| 85 | 87 | // DefaultConfig returns the default configuration values for dumber. | |
| 86 | 88 | func DefaultConfig() *Config { | |
| 89 | + | defaultCEFMultiThreadedMessageLoop := true | |
| 90 | + | ||
| 87 | 91 | return &Config{ | |
| 88 | 92 | Database: DatabaseConfig{ | |
| 89 | 93 | // Path is set dynamically in config.Load() | |
| @@ -151,6 +155,12 @@ func DefaultConfig() *Config { | |||
| 151 | 155 | // third-party cookie isolation more intelligently. Using Always + ITP | |
| 152 | 156 | // matches Epiphany's model and avoids a misleading setting. | |
| 153 | 157 | CookiePolicy: CookiePolicyAlways, | |
| 158 | + | CEF: CEFEngineConfig{ | |
| 159 | + | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 160 | + | MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop, | |
| 161 | + | ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs, | |
| 162 | + | LogFile: "", | |
| 163 | + | }, | |
| 154 | 164 | WebKit: WebKitEngineConfig{ | |
| 155 | 165 | ITPEnabled: true, | |
| 156 | 166 | SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads, | |
| @@ -67,6 +67,8 @@ const ( | |||||
| 67 | // Performance defaults | 67 | // 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 startup | 69 | 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 = 0 | 74 | 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 + ITP | 155 | // 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) { | |||
| 16 | 16 | assert.Equal(t, "webkit", cfg.Engine.Type) | |
| 17 | 17 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | |
| 18 | 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 | 26 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
| 20 | 27 | ||
| 21 | 28 | // 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 { | |||
| 79 | 79 | // CEFDir is the path to the CEF framework directory containing | |
| 80 | 80 | // libcef.so and the Resources/locales subdirectories. | |
| 81 | 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 | 85 | // LogSeverity controls CEF's internal log verbosity. | |
| 83 | 86 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | |
| 84 | 87 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | |
| 85 | 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 | 90 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | |
| 88 | 91 | // MultiThreadedMessageLoop lets CEF run its own message loop thread. | |
| 89 | 92 | // Default: true. When false, the host drives the pump via a manual timer. | |
| @@ -91,6 +94,12 @@ type CEFEngineConfig struct { | |||
| 91 | 94 | // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork | |
| 92 | 95 | // when MultiThreadedMessageLoop is false. Default: 10. | |
| 93 | 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 | 105 | // CEFMultiThreadedMessageLoop returns the effective value with default true. | |
| @@ -106,7 +115,15 @@ func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 { | |||
| 106 | 115 | if c.ManualPumpIntervalMs > 0 { | |
| 107 | 116 | return c.ManualPumpIntervalMs | |
| 108 | 117 | } | |
| 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 | |
| 110 | 127 | } | |
| 111 | 128 | ||
| 112 | 129 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | |
| @@ -79,11 +79,14 @@ type CEFEngineConfig struct { | |||||
| 79 | // CEFDir is the path to the CEF framework directory containing | 79 | // 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 CefDoMessageLoopWork | 94 | // 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.ManualPumpIntervalMs | 116 | return c.ManualPumpIntervalMs | ||
| 108 | } | 117 | } | ||
| 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 | |||
| 110 | } | 127 | } | ||
| 111 | 128 | ||||
| 112 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | 129 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | ||
M internal/infrastructure/config/loader.go +11 -0
| @@ -568,6 +568,17 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 568 | 568 | m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize) | |
| 569 | 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 | 582 | wk := e.WebKit | |
| 572 | 583 | m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled) | |
| 573 | 584 | 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.WebKit | 582 | 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 { | |||
| 847 | 847 | Description: "Enable browser developer tools (F12)", | |
| 848 | 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 | 911 | Range: ">=0", | |
| 877 | 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 | 938 | Key: "engine.webkit.skia_cpu_painting_threads", | |
| 881 | 939 | Type: "int", | |
| @@ -968,6 +1026,13 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 968 | 1026 | ||
| 969 | 1027 | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 970 | 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 | 1037 | Key: "engine.webkit.prefix", | |
| 973 | 1038 | 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 { | |||
| 33 | 33 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 34 | 34 | validationErrors = append(validationErrors, validateSession(config)...) | |
| 35 | 35 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| 36 | + | validationErrors = append(validationErrors, validateCEF(config)...) | |
| 36 | 37 | ||
| 37 | 38 | // If there are validation errors, return them | |
| 38 | 39 | if len(validationErrors) > 0 { | |
| @@ -325,6 +326,29 @@ func validateLogging(config *Config) []string { | |||
| 325 | 326 | 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 | 352 | func validateWorkspaceNewPaneURL(config *Config) []string { | |
| 329 | 353 | var validationErrors []string | |
| 330 | 354 | ||
| @@ -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 them | 38 | // 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 validationErrors | 326 | 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 []string | 353 | var validationErrors []string | ||
| 330 | 354 | ||||
M internal/infrastructure/config/validation_test.go +69 -0
| @@ -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 | + | } | |
| @@ -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 | + | } | |||