detached 29ea63e

feat(config): add MigrateToEngineConfig for [engine]/[engine.webkit] restructure

Implements a pre-migration step that reads old config sections
([performance], [privacy], [rendering], [runtime] and media GStreamer
fields) and restructures them into the new [engine]/[engine.webkit]
format, with engine.type defaulting to "webkit".
commit
29ea63e474b21a0563b79028f56e4f7d7d62827f
author
brice · · 2026-03-15 16:09 UTC
parent
a49a8b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +467 -0
A internal/infrastructure/config/migrate_engine.go +192 -0
@@ -0,0 +1,192 @@
1+package config
2+
3+import (
4+ "os"
5+
6+ "github.com/pelletier/go-toml/v2"
7+)
8+
9+// engineMigrationMapping defines old key -> new key mappings for the engine migration.
10+// Keys with empty new key ("") are dropped (not migrated).
11+// The source section is the first part of the old key.
12+type engineFieldMapping struct {
13+ oldSection string
14+ oldField string
15+ newSection string // "engine" or "engine.webkit"
16+ newField string // empty means drop
17+}
18+
19+// engineUniversalMappings are fields that go into [engine] (not [engine.webkit]).
20+var engineUniversalMappings = []engineFieldMapping{
21+ // [performance] -> [engine]
22+ {oldSection: "performance", oldField: "profile", newSection: "engine", newField: "profile"},
23+ {oldSection: "performance", oldField: "zoom_cache_size", newSection: "engine", newField: "zoom_cache_size"},
24+ {oldSection: "performance", oldField: "webview_pool_prewarm_count", newSection: "engine", newField: "pool_prewarm_count"},
25+ // [privacy] -> [engine]
26+ {oldSection: "privacy", oldField: "cookie_policy", newSection: "engine", newField: "cookie_policy"},
27+}
28+
29+// engineWebkitMappings are fields that go into [engine.webkit].
30+var engineWebkitMappings = []engineFieldMapping{
31+ // [privacy] -> [engine.webkit]
32+ {oldSection: "privacy", oldField: "itp_enabled", newSection: "engine.webkit", newField: "itp_enabled"},
33+
34+ // [rendering] -> [engine.webkit] (rendering.mode is dropped)
35+ {oldSection: "rendering", oldField: "disable_dmabuf_renderer", newSection: "engine.webkit", newField: "disable_dmabuf_renderer"},
36+ {oldSection: "rendering", oldField: "force_compositing_mode", newSection: "engine.webkit", newField: "force_compositing_mode"},
37+ {oldSection: "rendering", oldField: "disable_compositing_mode", newSection: "engine.webkit", newField: "disable_compositing_mode"},
38+ {oldSection: "rendering", oldField: "gsk_renderer", newSection: "engine.webkit", newField: "gsk_renderer"},
39+ {oldSection: "rendering", oldField: "disable_mipmaps", newSection: "engine.webkit", newField: "disable_mipmaps"},
40+ {oldSection: "rendering", oldField: "prefer_gl", newSection: "engine.webkit", newField: "prefer_gl"},
41+ {oldSection: "rendering", oldField: "draw_compositing_indicators", newSection: "engine.webkit", newField: "draw_compositing_indicators"},
42+ {oldSection: "rendering", oldField: "show_fps", newSection: "engine.webkit", newField: "show_fps"},
43+ {oldSection: "rendering", oldField: "sample_memory", newSection: "engine.webkit", newField: "sample_memory"},
44+ {oldSection: "rendering", oldField: "debug_frames", newSection: "engine.webkit", newField: "debug_frames"},
45+
46+ // [performance] -> [engine.webkit] (webkit-specific)
47+ {oldSection: "performance", oldField: "skia_cpu_painting_threads", newSection: "engine.webkit", newField: "skia_cpu_painting_threads"},
48+ {oldSection: "performance", oldField: "skia_gpu_painting_threads", newSection: "engine.webkit", newField: "skia_gpu_painting_threads"},
49+ {oldSection: "performance", oldField: "skia_enable_cpu_rendering", newSection: "engine.webkit", newField: "skia_enable_cpu_rendering"},
50+ {oldSection: "performance", oldField: "web_process_memory_limit_mb", newSection: "engine.webkit", newField: "web_process_memory_limit_mb"},
51+ {oldSection: "performance", oldField: "web_process_memory_poll_interval_sec", newSection: "engine.webkit", newField: "web_process_memory_poll_interval_sec"},
52+ {oldSection: "performance", oldField: "web_process_memory_conservative_threshold", newSection: "engine.webkit", newField: "web_process_memory_conservative_threshold"},
53+ {oldSection: "performance", oldField: "web_process_memory_strict_threshold", newSection: "engine.webkit", newField: "web_process_memory_strict_threshold"},
54+ {oldSection: "performance", oldField: "network_process_memory_limit_mb", newSection: "engine.webkit", newField: "network_process_memory_limit_mb"},
55+ {oldSection: "performance", oldField: "network_process_memory_poll_interval_sec", newSection: "engine.webkit", newField: "network_process_memory_poll_interval_sec"},
56+ {oldSection: "performance", oldField: "network_process_memory_conservative_threshold", newSection: "engine.webkit", newField: "network_process_memory_conservative_threshold"},
57+ {oldSection: "performance", oldField: "network_process_memory_strict_threshold", newSection: "engine.webkit", newField: "network_process_memory_strict_threshold"},
58+
59+ // [media] GStreamer fields -> [engine.webkit]
60+ {oldSection: "media", oldField: "force_vsync", newSection: "engine.webkit", newField: "force_vsync"},
61+ {oldSection: "media", oldField: "gl_rendering_mode", newSection: "engine.webkit", newField: "gl_rendering_mode"},
62+ {oldSection: "media", oldField: "gstreamer_debug_level", newSection: "engine.webkit", newField: "gstreamer_debug_level"},
63+
64+ // [runtime] -> [engine.webkit]
65+ {oldSection: "runtime", oldField: "prefix", newSection: "engine.webkit", newField: "prefix"},
66+}
67+
68+// oldSectionsToRemove are the sections that are fully removed after migration
69+// (media is not here because it has fields that stay).
70+var oldSectionsToRemove = []string{"rendering", "performance", "privacy", "runtime"}
71+
72+// MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].
73+// Returns true if migration was performed, false if already migrated or no old sections found.
74+func MigrateToEngineConfig(configFile string) (bool, error) {
75+ data, err := os.ReadFile(configFile)
76+ if err != nil {
77+ return false, err
78+ }
79+
80+ var raw map[string]any
81+ if err := toml.Unmarshal(data, &raw); err != nil {
82+ return false, err
83+ }
84+
85+ // If "engine" key already exists, already migrated.
86+ if _, exists := raw["engine"]; exists {
87+ return false, nil
88+ }
89+
90+ // Check if any old sections exist.
91+ hasOldSections := false
92+ for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
93+ if _, exists := raw[section]; exists {
94+ hasOldSections = true
95+ break
96+ }
97+ }
98+ // Also check media for GStreamer fields that migrate.
99+ if !hasOldSections {
100+ if mediaAny, exists := raw["media"]; exists {
101+ if media, ok := mediaAny.(map[string]any); ok {
102+ for _, f := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
103+ if _, has := media[f]; has {
104+ hasOldSections = true
105+ break
106+ }
107+ }
108+ }
109+ }
110+ }
111+
112+ if !hasOldSections {
113+ return false, nil
114+ }
115+
116+ // Build engine and engine.webkit maps.
117+ engineMap := make(map[string]any)
118+ webkitMap := make(map[string]any)
119+
120+ // Apply universal mappings -> engine.
121+ for _, m := range engineUniversalMappings {
122+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
123+ engineMap[m.newField] = val
124+ }
125+ }
126+
127+ // Apply webkit mappings -> engine.webkit.
128+ for _, m := range engineWebkitMappings {
129+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
130+ webkitMap[m.newField] = val
131+ }
132+ }
133+
134+ // Set engine.type default.
135+ engineMap["type"] = "webkit"
136+
137+ // Attach webkit sub-map.
138+ if len(webkitMap) > 0 {
139+ engineMap["webkit"] = webkitMap
140+ }
141+
142+ // Set engine in raw map.
143+ raw["engine"] = engineMap
144+
145+ // Remove old sections entirely.
146+ for _, section := range oldSectionsToRemove {
147+ delete(raw, section)
148+ }
149+
150+ // Remove migrated fields from media (but keep non-migrated ones).
151+ if mediaAny, exists := raw["media"]; exists {
152+ if media, ok := mediaAny.(map[string]any); ok {
153+ delete(media, "force_vsync")
154+ delete(media, "gl_rendering_mode")
155+ delete(media, "gstreamer_debug_level")
156+ if len(media) == 0 {
157+ delete(raw, "media")
158+ } else {
159+ raw["media"] = media
160+ }
161+ }
162+ }
163+
164+ // Write back.
165+ out, err := toml.Marshal(raw)
166+ if err != nil {
167+ return false, err
168+ }
169+
170+ if err := os.WriteFile(configFile, out, 0o644); err != nil {
171+ return false, err
172+ }
173+
174+ return true, nil
175+}
176+
177+// getSectionField retrieves a field value from a named section in the raw config map.
178+func getSectionField(raw map[string]any, section, field string) any {
179+ sectionAny, exists := raw[section]
180+ if !exists {
181+ return nil
182+ }
183+ sectionMap, ok := sectionAny.(map[string]any)
184+ if !ok {
185+ return nil
186+ }
187+ val, exists := sectionMap[field]
188+ if !exists {
189+ return nil
190+ }
191+ return val
192+}
@@ -0,0 +1,192 @@
1+package config
2+
3+import (
4+ "os"
5+
6+ "github.com/pelletier/go-toml/v2"
7+)
8+
9+// engineMigrationMapping defines old key -> new key mappings for the engine migration.
10+// Keys with empty new key ("") are dropped (not migrated).
11+// The source section is the first part of the old key.
12+type engineFieldMapping struct {
13+ oldSection string
14+ oldField string
15+ newSection string // "engine" or "engine.webkit"
16+ newField string // empty means drop
17+}
18+
19+// engineUniversalMappings are fields that go into [engine] (not [engine.webkit]).
20+var engineUniversalMappings = []engineFieldMapping{
21+ // [performance] -> [engine]
22+ {oldSection: "performance", oldField: "profile", newSection: "engine", newField: "profile"},
23+ {oldSection: "performance", oldField: "zoom_cache_size", newSection: "engine", newField: "zoom_cache_size"},
24+ {oldSection: "performance", oldField: "webview_pool_prewarm_count", newSection: "engine", newField: "pool_prewarm_count"},
25+ // [privacy] -> [engine]
26+ {oldSection: "privacy", oldField: "cookie_policy", newSection: "engine", newField: "cookie_policy"},
27+}
28+
29+// engineWebkitMappings are fields that go into [engine.webkit].
30+var engineWebkitMappings = []engineFieldMapping{
31+ // [privacy] -> [engine.webkit]
32+ {oldSection: "privacy", oldField: "itp_enabled", newSection: "engine.webkit", newField: "itp_enabled"},
33+
34+ // [rendering] -> [engine.webkit] (rendering.mode is dropped)
35+ {oldSection: "rendering", oldField: "disable_dmabuf_renderer", newSection: "engine.webkit", newField: "disable_dmabuf_renderer"},
36+ {oldSection: "rendering", oldField: "force_compositing_mode", newSection: "engine.webkit", newField: "force_compositing_mode"},
37+ {oldSection: "rendering", oldField: "disable_compositing_mode", newSection: "engine.webkit", newField: "disable_compositing_mode"},
38+ {oldSection: "rendering", oldField: "gsk_renderer", newSection: "engine.webkit", newField: "gsk_renderer"},
39+ {oldSection: "rendering", oldField: "disable_mipmaps", newSection: "engine.webkit", newField: "disable_mipmaps"},
40+ {oldSection: "rendering", oldField: "prefer_gl", newSection: "engine.webkit", newField: "prefer_gl"},
41+ {oldSection: "rendering", oldField: "draw_compositing_indicators", newSection: "engine.webkit", newField: "draw_compositing_indicators"},
42+ {oldSection: "rendering", oldField: "show_fps", newSection: "engine.webkit", newField: "show_fps"},
43+ {oldSection: "rendering", oldField: "sample_memory", newSection: "engine.webkit", newField: "sample_memory"},
44+ {oldSection: "rendering", oldField: "debug_frames", newSection: "engine.webkit", newField: "debug_frames"},
45+
46+ // [performance] -> [engine.webkit] (webkit-specific)
47+ {oldSection: "performance", oldField: "skia_cpu_painting_threads", newSection: "engine.webkit", newField: "skia_cpu_painting_threads"},
48+ {oldSection: "performance", oldField: "skia_gpu_painting_threads", newSection: "engine.webkit", newField: "skia_gpu_painting_threads"},
49+ {oldSection: "performance", oldField: "skia_enable_cpu_rendering", newSection: "engine.webkit", newField: "skia_enable_cpu_rendering"},
50+ {oldSection: "performance", oldField: "web_process_memory_limit_mb", newSection: "engine.webkit", newField: "web_process_memory_limit_mb"},
51+ {oldSection: "performance", oldField: "web_process_memory_poll_interval_sec", newSection: "engine.webkit", newField: "web_process_memory_poll_interval_sec"},
52+ {oldSection: "performance", oldField: "web_process_memory_conservative_threshold", newSection: "engine.webkit", newField: "web_process_memory_conservative_threshold"},
53+ {oldSection: "performance", oldField: "web_process_memory_strict_threshold", newSection: "engine.webkit", newField: "web_process_memory_strict_threshold"},
54+ {oldSection: "performance", oldField: "network_process_memory_limit_mb", newSection: "engine.webkit", newField: "network_process_memory_limit_mb"},
55+ {oldSection: "performance", oldField: "network_process_memory_poll_interval_sec", newSection: "engine.webkit", newField: "network_process_memory_poll_interval_sec"},
56+ {oldSection: "performance", oldField: "network_process_memory_conservative_threshold", newSection: "engine.webkit", newField: "network_process_memory_conservative_threshold"},
57+ {oldSection: "performance", oldField: "network_process_memory_strict_threshold", newSection: "engine.webkit", newField: "network_process_memory_strict_threshold"},
58+
59+ // [media] GStreamer fields -> [engine.webkit]
60+ {oldSection: "media", oldField: "force_vsync", newSection: "engine.webkit", newField: "force_vsync"},
61+ {oldSection: "media", oldField: "gl_rendering_mode", newSection: "engine.webkit", newField: "gl_rendering_mode"},
62+ {oldSection: "media", oldField: "gstreamer_debug_level", newSection: "engine.webkit", newField: "gstreamer_debug_level"},
63+
64+ // [runtime] -> [engine.webkit]
65+ {oldSection: "runtime", oldField: "prefix", newSection: "engine.webkit", newField: "prefix"},
66+}
67+
68+// oldSectionsToRemove are the sections that are fully removed after migration
69+// (media is not here because it has fields that stay).
70+var oldSectionsToRemove = []string{"rendering", "performance", "privacy", "runtime"}
71+
72+// MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].
73+// Returns true if migration was performed, false if already migrated or no old sections found.
74+func MigrateToEngineConfig(configFile string) (bool, error) {
75+ data, err := os.ReadFile(configFile)
76+ if err != nil {
77+ return false, err
78+ }
79+
80+ var raw map[string]any
81+ if err := toml.Unmarshal(data, &raw); err != nil {
82+ return false, err
83+ }
84+
85+ // If "engine" key already exists, already migrated.
86+ if _, exists := raw["engine"]; exists {
87+ return false, nil
88+ }
89+
90+ // Check if any old sections exist.
91+ hasOldSections := false
92+ for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
93+ if _, exists := raw[section]; exists {
94+ hasOldSections = true
95+ break
96+ }
97+ }
98+ // Also check media for GStreamer fields that migrate.
99+ if !hasOldSections {
100+ if mediaAny, exists := raw["media"]; exists {
101+ if media, ok := mediaAny.(map[string]any); ok {
102+ for _, f := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
103+ if _, has := media[f]; has {
104+ hasOldSections = true
105+ break
106+ }
107+ }
108+ }
109+ }
110+ }
111+
112+ if !hasOldSections {
113+ return false, nil
114+ }
115+
116+ // Build engine and engine.webkit maps.
117+ engineMap := make(map[string]any)
118+ webkitMap := make(map[string]any)
119+
120+ // Apply universal mappings -> engine.
121+ for _, m := range engineUniversalMappings {
122+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
123+ engineMap[m.newField] = val
124+ }
125+ }
126+
127+ // Apply webkit mappings -> engine.webkit.
128+ for _, m := range engineWebkitMappings {
129+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
130+ webkitMap[m.newField] = val
131+ }
132+ }
133+
134+ // Set engine.type default.
135+ engineMap["type"] = "webkit"
136+
137+ // Attach webkit sub-map.
138+ if len(webkitMap) > 0 {
139+ engineMap["webkit"] = webkitMap
140+ }
141+
142+ // Set engine in raw map.
143+ raw["engine"] = engineMap
144+
145+ // Remove old sections entirely.
146+ for _, section := range oldSectionsToRemove {
147+ delete(raw, section)
148+ }
149+
150+ // Remove migrated fields from media (but keep non-migrated ones).
151+ if mediaAny, exists := raw["media"]; exists {
152+ if media, ok := mediaAny.(map[string]any); ok {
153+ delete(media, "force_vsync")
154+ delete(media, "gl_rendering_mode")
155+ delete(media, "gstreamer_debug_level")
156+ if len(media) == 0 {
157+ delete(raw, "media")
158+ } else {
159+ raw["media"] = media
160+ }
161+ }
162+ }
163+
164+ // Write back.
165+ out, err := toml.Marshal(raw)
166+ if err != nil {
167+ return false, err
168+ }
169+
170+ if err := os.WriteFile(configFile, out, 0o644); err != nil {
171+ return false, err
172+ }
173+
174+ return true, nil
175+}
176+
177+// getSectionField retrieves a field value from a named section in the raw config map.
178+func getSectionField(raw map[string]any, section, field string) any {
179+ sectionAny, exists := raw[section]
180+ if !exists {
181+ return nil
182+ }
183+ sectionMap, ok := sectionAny.(map[string]any)
184+ if !ok {
185+ return nil
186+ }
187+ val, exists := sectionMap[field]
188+ if !exists {
189+ return nil
190+ }
191+ return val
192+}
A internal/infrastructure/config/migrate_engine_test.go +275 -0
@@ -0,0 +1,275 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/pelletier/go-toml/v2"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+const oldFormatConfig = `
14+[performance]
15+profile = "balanced"
16+zoom_cache_size = 50
17+webview_pool_prewarm_count = 2
18+skia_cpu_painting_threads = 4
19+skia_gpu_painting_threads = 2
20+skia_enable_cpu_rendering = false
21+web_process_memory_limit_mb = 512
22+web_process_memory_poll_interval_sec = 5.0
23+web_process_memory_conservative_threshold = 0.7
24+web_process_memory_strict_threshold = 0.9
25+network_process_memory_limit_mb = 256
26+network_process_memory_poll_interval_sec = 10.0
27+network_process_memory_conservative_threshold = 0.6
28+network_process_memory_strict_threshold = 0.85
29+
30+[privacy]
31+cookie_policy = "accept_all"
32+itp_enabled = true
33+
34+[rendering]
35+mode = "auto"
36+disable_dmabuf_renderer = false
37+force_compositing_mode = true
38+disable_compositing_mode = false
39+gsk_renderer = "gl"
40+disable_mipmaps = false
41+prefer_gl = true
42+draw_compositing_indicators = false
43+show_fps = false
44+sample_memory = false
45+debug_frames = false
46+
47+[media]
48+hardware_decoding = true
49+prefer_av1 = false
50+show_diagnostics = false
51+force_vsync = true
52+gl_rendering_mode = "auto"
53+gstreamer_debug_level = 2
54+
55+[runtime]
56+prefix = "/usr/local"
57+`
58+
59+func TestMigrateToEngineConfig_OldFormat(t *testing.T) {
60+ tmpDir := t.TempDir()
61+ configFile := filepath.Join(tmpDir, "config.toml")
62+
63+ err := os.WriteFile(configFile, []byte(oldFormatConfig), 0o644)
64+ require.NoError(t, err)
65+
66+ migrated, err := MigrateToEngineConfig(configFile)
67+ require.NoError(t, err)
68+ assert.True(t, migrated, "should report migration performed")
69+
70+ // Read the migrated config
71+ data, err := os.ReadFile(configFile)
72+ require.NoError(t, err)
73+
74+ var raw map[string]any
75+ err = toml.Unmarshal(data, &raw)
76+ require.NoError(t, err)
77+
78+ // [engine] section must exist
79+ engineAny, ok := raw["engine"]
80+ require.True(t, ok, "engine section must exist")
81+ engine, ok := engineAny.(map[string]any)
82+ require.True(t, ok, "engine must be a map")
83+
84+ // engine.type defaults to "webkit"
85+ assert.Equal(t, "webkit", engine["type"])
86+
87+ // [performance] -> [engine] universal fields
88+ assert.Equal(t, "balanced", engine["profile"])
89+ assert.Equal(t, int64(50), engine["zoom_cache_size"])
90+ assert.Equal(t, int64(2), engine["pool_prewarm_count"])
91+
92+ // [privacy] -> [engine] universal field
93+ assert.Equal(t, "accept_all", engine["cookie_policy"])
94+
95+ // [engine.webkit] section
96+ webkitAny, ok := engine["webkit"]
97+ require.True(t, ok, "engine.webkit section must exist")
98+ webkit, ok := webkitAny.(map[string]any)
99+ require.True(t, ok, "engine.webkit must be a map")
100+
101+ // [privacy] -> [engine.webkit]
102+ assert.Equal(t, true, webkit["itp_enabled"])
103+
104+ // [rendering] -> [engine.webkit] (excluding mode which is dropped)
105+ assert.Equal(t, false, webkit["disable_dmabuf_renderer"])
106+ assert.Equal(t, true, webkit["force_compositing_mode"])
107+ assert.Equal(t, false, webkit["disable_compositing_mode"])
108+ assert.Equal(t, "gl", webkit["gsk_renderer"])
109+ assert.Equal(t, false, webkit["disable_mipmaps"])
110+ assert.Equal(t, true, webkit["prefer_gl"])
111+ assert.Equal(t, false, webkit["draw_compositing_indicators"])
112+ assert.Equal(t, false, webkit["show_fps"])
113+ assert.Equal(t, false, webkit["sample_memory"])
114+ assert.Equal(t, false, webkit["debug_frames"])
115+ // rendering.mode must be dropped (not present in webkit)
116+ assert.NotContains(t, webkit, "mode")
117+
118+ // [performance] -> [engine.webkit] (webkit-specific)
119+ assert.Equal(t, int64(4), webkit["skia_cpu_painting_threads"])
120+ assert.Equal(t, int64(2), webkit["skia_gpu_painting_threads"])
121+ assert.Equal(t, false, webkit["skia_enable_cpu_rendering"])
122+ assert.Equal(t, int64(512), webkit["web_process_memory_limit_mb"])
123+ assert.InDelta(t, 5.0, webkit["web_process_memory_poll_interval_sec"], 0.001)
124+ assert.InDelta(t, 0.7, webkit["web_process_memory_conservative_threshold"], 0.001)
125+ assert.InDelta(t, 0.9, webkit["web_process_memory_strict_threshold"], 0.001)
126+ assert.Equal(t, int64(256), webkit["network_process_memory_limit_mb"])
127+ assert.InDelta(t, 10.0, webkit["network_process_memory_poll_interval_sec"], 0.001)
128+ assert.InDelta(t, 0.6, webkit["network_process_memory_conservative_threshold"], 0.001)
129+ assert.InDelta(t, 0.85, webkit["network_process_memory_strict_threshold"], 0.001)
130+
131+ // [media] GStreamer fields -> [engine.webkit]
132+ assert.Equal(t, true, webkit["force_vsync"])
133+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
134+ assert.Equal(t, int64(2), webkit["gstreamer_debug_level"])
135+
136+ // [runtime] -> [engine.webkit]
137+ assert.Equal(t, "/usr/local", webkit["prefix"])
138+
139+ // Old top-level sections must be removed
140+ assert.NotContains(t, raw, "performance")
141+ assert.NotContains(t, raw, "rendering")
142+ assert.NotContains(t, raw, "runtime")
143+
144+ // [privacy] should be removed (all fields migrated)
145+ assert.NotContains(t, raw, "privacy")
146+}
147+
148+func TestMigrateToEngineConfig_AlreadyMigrated(t *testing.T) {
149+ tmpDir := t.TempDir()
150+ configFile := filepath.Join(tmpDir, "config.toml")
151+
152+ alreadyMigrated := `
153+[engine]
154+type = "webkit"
155+profile = "balanced"
156+
157+[engine.webkit]
158+itp_enabled = true
159+`
160+ err := os.WriteFile(configFile, []byte(alreadyMigrated), 0o644)
161+ require.NoError(t, err)
162+
163+ migrated, err := MigrateToEngineConfig(configFile)
164+ require.NoError(t, err)
165+ assert.False(t, migrated, "should not migrate when already migrated")
166+
167+ // Content should be unchanged
168+ data, err := os.ReadFile(configFile)
169+ require.NoError(t, err)
170+ assert.Equal(t, alreadyMigrated, string(data))
171+}
172+
173+func TestMigrateToEngineConfig_NoOldSections(t *testing.T) {
174+ tmpDir := t.TempDir()
175+ configFile := filepath.Join(tmpDir, "config.toml")
176+
177+ noOldSections := `
178+[history]
179+max_entries = 1000
180+
181+[logging]
182+level = "info"
183+`
184+ err := os.WriteFile(configFile, []byte(noOldSections), 0o644)
185+ require.NoError(t, err)
186+
187+ migrated, err := MigrateToEngineConfig(configFile)
188+ require.NoError(t, err)
189+ assert.False(t, migrated, "should not migrate when no old sections exist")
190+
191+ // Content should be unchanged
192+ data, err := os.ReadFile(configFile)
193+ require.NoError(t, err)
194+ assert.Equal(t, noOldSections, string(data))
195+}
196+
197+func TestMigrateToEngineConfig_PreservesNonMigratedMediaFields(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ configFile := filepath.Join(tmpDir, "config.toml")
200+
201+ content := `
202+[media]
203+hardware_decoding = true
204+prefer_av1 = false
205+show_diagnostics = true
206+force_vsync = true
207+gl_rendering_mode = "auto"
208+gstreamer_debug_level = 3
209+
210+[rendering]
211+disable_dmabuf_renderer = false
212+`
213+ err := os.WriteFile(configFile, []byte(content), 0o644)
214+ require.NoError(t, err)
215+
216+ migrated, err := MigrateToEngineConfig(configFile)
217+ require.NoError(t, err)
218+ assert.True(t, migrated)
219+
220+ data, err := os.ReadFile(configFile)
221+ require.NoError(t, err)
222+
223+ var raw map[string]any
224+ err = toml.Unmarshal(data, &raw)
225+ require.NoError(t, err)
226+
227+ // media section must still exist with non-migrated fields
228+ mediaAny, ok := raw["media"]
229+ require.True(t, ok, "media section must still exist")
230+ media, ok := mediaAny.(map[string]any)
231+ require.True(t, ok)
232+
233+ assert.Equal(t, true, media["hardware_decoding"])
234+ assert.Equal(t, false, media["prefer_av1"])
235+ assert.Equal(t, true, media["show_diagnostics"])
236+
237+ // Migrated media fields should be removed from media
238+ assert.NotContains(t, media, "force_vsync")
239+ assert.NotContains(t, media, "gl_rendering_mode")
240+ assert.NotContains(t, media, "gstreamer_debug_level")
241+
242+ // Migrated fields should be in engine.webkit
243+ engine := raw["engine"].(map[string]any)
244+ webkit := engine["webkit"].(map[string]any)
245+ assert.Equal(t, true, webkit["force_vsync"])
246+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
247+ assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
248+}
249+
250+func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ configFile := filepath.Join(tmpDir, "config.toml")
253+
254+ // Minimal old-format config to trigger migration
255+ content := `
256+[rendering]
257+show_fps = true
258+`
259+ err := os.WriteFile(configFile, []byte(content), 0o644)
260+ require.NoError(t, err)
261+
262+ migrated, err := MigrateToEngineConfig(configFile)
263+ require.NoError(t, err)
264+ assert.True(t, migrated)
265+
266+ data, err := os.ReadFile(configFile)
267+ require.NoError(t, err)
268+
269+ var raw map[string]any
270+ err = toml.Unmarshal(data, &raw)
271+ require.NoError(t, err)
272+
273+ engine := raw["engine"].(map[string]any)
274+ assert.Equal(t, "webkit", engine["type"], "engine.type should default to webkit")
275+}
@@ -0,0 +1,275 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/pelletier/go-toml/v2"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+const oldFormatConfig = `
14+[performance]
15+profile = "balanced"
16+zoom_cache_size = 50
17+webview_pool_prewarm_count = 2
18+skia_cpu_painting_threads = 4
19+skia_gpu_painting_threads = 2
20+skia_enable_cpu_rendering = false
21+web_process_memory_limit_mb = 512
22+web_process_memory_poll_interval_sec = 5.0
23+web_process_memory_conservative_threshold = 0.7
24+web_process_memory_strict_threshold = 0.9
25+network_process_memory_limit_mb = 256
26+network_process_memory_poll_interval_sec = 10.0
27+network_process_memory_conservative_threshold = 0.6
28+network_process_memory_strict_threshold = 0.85
29+
30+[privacy]
31+cookie_policy = "accept_all"
32+itp_enabled = true
33+
34+[rendering]
35+mode = "auto"
36+disable_dmabuf_renderer = false
37+force_compositing_mode = true
38+disable_compositing_mode = false
39+gsk_renderer = "gl"
40+disable_mipmaps = false
41+prefer_gl = true
42+draw_compositing_indicators = false
43+show_fps = false
44+sample_memory = false
45+debug_frames = false
46+
47+[media]
48+hardware_decoding = true
49+prefer_av1 = false
50+show_diagnostics = false
51+force_vsync = true
52+gl_rendering_mode = "auto"
53+gstreamer_debug_level = 2
54+
55+[runtime]
56+prefix = "/usr/local"
57+`
58+
59+func TestMigrateToEngineConfig_OldFormat(t *testing.T) {
60+ tmpDir := t.TempDir()
61+ configFile := filepath.Join(tmpDir, "config.toml")
62+
63+ err := os.WriteFile(configFile, []byte(oldFormatConfig), 0o644)
64+ require.NoError(t, err)
65+
66+ migrated, err := MigrateToEngineConfig(configFile)
67+ require.NoError(t, err)
68+ assert.True(t, migrated, "should report migration performed")
69+
70+ // Read the migrated config
71+ data, err := os.ReadFile(configFile)
72+ require.NoError(t, err)
73+
74+ var raw map[string]any
75+ err = toml.Unmarshal(data, &raw)
76+ require.NoError(t, err)
77+
78+ // [engine] section must exist
79+ engineAny, ok := raw["engine"]
80+ require.True(t, ok, "engine section must exist")
81+ engine, ok := engineAny.(map[string]any)
82+ require.True(t, ok, "engine must be a map")
83+
84+ // engine.type defaults to "webkit"
85+ assert.Equal(t, "webkit", engine["type"])
86+
87+ // [performance] -> [engine] universal fields
88+ assert.Equal(t, "balanced", engine["profile"])
89+ assert.Equal(t, int64(50), engine["zoom_cache_size"])
90+ assert.Equal(t, int64(2), engine["pool_prewarm_count"])
91+
92+ // [privacy] -> [engine] universal field
93+ assert.Equal(t, "accept_all", engine["cookie_policy"])
94+
95+ // [engine.webkit] section
96+ webkitAny, ok := engine["webkit"]
97+ require.True(t, ok, "engine.webkit section must exist")
98+ webkit, ok := webkitAny.(map[string]any)
99+ require.True(t, ok, "engine.webkit must be a map")
100+
101+ // [privacy] -> [engine.webkit]
102+ assert.Equal(t, true, webkit["itp_enabled"])
103+
104+ // [rendering] -> [engine.webkit] (excluding mode which is dropped)
105+ assert.Equal(t, false, webkit["disable_dmabuf_renderer"])
106+ assert.Equal(t, true, webkit["force_compositing_mode"])
107+ assert.Equal(t, false, webkit["disable_compositing_mode"])
108+ assert.Equal(t, "gl", webkit["gsk_renderer"])
109+ assert.Equal(t, false, webkit["disable_mipmaps"])
110+ assert.Equal(t, true, webkit["prefer_gl"])
111+ assert.Equal(t, false, webkit["draw_compositing_indicators"])
112+ assert.Equal(t, false, webkit["show_fps"])
113+ assert.Equal(t, false, webkit["sample_memory"])
114+ assert.Equal(t, false, webkit["debug_frames"])
115+ // rendering.mode must be dropped (not present in webkit)
116+ assert.NotContains(t, webkit, "mode")
117+
118+ // [performance] -> [engine.webkit] (webkit-specific)
119+ assert.Equal(t, int64(4), webkit["skia_cpu_painting_threads"])
120+ assert.Equal(t, int64(2), webkit["skia_gpu_painting_threads"])
121+ assert.Equal(t, false, webkit["skia_enable_cpu_rendering"])
122+ assert.Equal(t, int64(512), webkit["web_process_memory_limit_mb"])
123+ assert.InDelta(t, 5.0, webkit["web_process_memory_poll_interval_sec"], 0.001)
124+ assert.InDelta(t, 0.7, webkit["web_process_memory_conservative_threshold"], 0.001)
125+ assert.InDelta(t, 0.9, webkit["web_process_memory_strict_threshold"], 0.001)
126+ assert.Equal(t, int64(256), webkit["network_process_memory_limit_mb"])
127+ assert.InDelta(t, 10.0, webkit["network_process_memory_poll_interval_sec"], 0.001)
128+ assert.InDelta(t, 0.6, webkit["network_process_memory_conservative_threshold"], 0.001)
129+ assert.InDelta(t, 0.85, webkit["network_process_memory_strict_threshold"], 0.001)
130+
131+ // [media] GStreamer fields -> [engine.webkit]
132+ assert.Equal(t, true, webkit["force_vsync"])
133+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
134+ assert.Equal(t, int64(2), webkit["gstreamer_debug_level"])
135+
136+ // [runtime] -> [engine.webkit]
137+ assert.Equal(t, "/usr/local", webkit["prefix"])
138+
139+ // Old top-level sections must be removed
140+ assert.NotContains(t, raw, "performance")
141+ assert.NotContains(t, raw, "rendering")
142+ assert.NotContains(t, raw, "runtime")
143+
144+ // [privacy] should be removed (all fields migrated)
145+ assert.NotContains(t, raw, "privacy")
146+}
147+
148+func TestMigrateToEngineConfig_AlreadyMigrated(t *testing.T) {
149+ tmpDir := t.TempDir()
150+ configFile := filepath.Join(tmpDir, "config.toml")
151+
152+ alreadyMigrated := `
153+[engine]
154+type = "webkit"
155+profile = "balanced"
156+
157+[engine.webkit]
158+itp_enabled = true
159+`
160+ err := os.WriteFile(configFile, []byte(alreadyMigrated), 0o644)
161+ require.NoError(t, err)
162+
163+ migrated, err := MigrateToEngineConfig(configFile)
164+ require.NoError(t, err)
165+ assert.False(t, migrated, "should not migrate when already migrated")
166+
167+ // Content should be unchanged
168+ data, err := os.ReadFile(configFile)
169+ require.NoError(t, err)
170+ assert.Equal(t, alreadyMigrated, string(data))
171+}
172+
173+func TestMigrateToEngineConfig_NoOldSections(t *testing.T) {
174+ tmpDir := t.TempDir()
175+ configFile := filepath.Join(tmpDir, "config.toml")
176+
177+ noOldSections := `
178+[history]
179+max_entries = 1000
180+
181+[logging]
182+level = "info"
183+`
184+ err := os.WriteFile(configFile, []byte(noOldSections), 0o644)
185+ require.NoError(t, err)
186+
187+ migrated, err := MigrateToEngineConfig(configFile)
188+ require.NoError(t, err)
189+ assert.False(t, migrated, "should not migrate when no old sections exist")
190+
191+ // Content should be unchanged
192+ data, err := os.ReadFile(configFile)
193+ require.NoError(t, err)
194+ assert.Equal(t, noOldSections, string(data))
195+}
196+
197+func TestMigrateToEngineConfig_PreservesNonMigratedMediaFields(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ configFile := filepath.Join(tmpDir, "config.toml")
200+
201+ content := `
202+[media]
203+hardware_decoding = true
204+prefer_av1 = false
205+show_diagnostics = true
206+force_vsync = true
207+gl_rendering_mode = "auto"
208+gstreamer_debug_level = 3
209+
210+[rendering]
211+disable_dmabuf_renderer = false
212+`
213+ err := os.WriteFile(configFile, []byte(content), 0o644)
214+ require.NoError(t, err)
215+
216+ migrated, err := MigrateToEngineConfig(configFile)
217+ require.NoError(t, err)
218+ assert.True(t, migrated)
219+
220+ data, err := os.ReadFile(configFile)
221+ require.NoError(t, err)
222+
223+ var raw map[string]any
224+ err = toml.Unmarshal(data, &raw)
225+ require.NoError(t, err)
226+
227+ // media section must still exist with non-migrated fields
228+ mediaAny, ok := raw["media"]
229+ require.True(t, ok, "media section must still exist")
230+ media, ok := mediaAny.(map[string]any)
231+ require.True(t, ok)
232+
233+ assert.Equal(t, true, media["hardware_decoding"])
234+ assert.Equal(t, false, media["prefer_av1"])
235+ assert.Equal(t, true, media["show_diagnostics"])
236+
237+ // Migrated media fields should be removed from media
238+ assert.NotContains(t, media, "force_vsync")
239+ assert.NotContains(t, media, "gl_rendering_mode")
240+ assert.NotContains(t, media, "gstreamer_debug_level")
241+
242+ // Migrated fields should be in engine.webkit
243+ engine := raw["engine"].(map[string]any)
244+ webkit := engine["webkit"].(map[string]any)
245+ assert.Equal(t, true, webkit["force_vsync"])
246+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
247+ assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
248+}
249+
250+func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ configFile := filepath.Join(tmpDir, "config.toml")
253+
254+ // Minimal old-format config to trigger migration
255+ content := `
256+[rendering]
257+show_fps = true
258+`
259+ err := os.WriteFile(configFile, []byte(content), 0o644)
260+ require.NoError(t, err)
261+
262+ migrated, err := MigrateToEngineConfig(configFile)
263+ require.NoError(t, err)
264+ assert.True(t, migrated)
265+
266+ data, err := os.ReadFile(configFile)
267+ require.NoError(t, err)
268+
269+ var raw map[string]any
270+ err = toml.Unmarshal(data, &raw)
271+ require.NoError(t, err)
272+
273+ engine := raw["engine"].(map[string]any)
274+ assert.Equal(t, "webkit", engine["type"], "engine.type should default to webkit")
275+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close