perf(startup): defer non-critical init until after first navigation starts
- commit
- 180a0d03742b922453dd24c078054a805916193e
- author
- brice · · 2026-01-23 06:25 UTC
- parent
- 428bf73
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +213 -99
M cmd/dumber/main.go +79 -43
| @@ -29,6 +29,7 @@ import ( | |||
| 29 | 29 | "github.com/bnema/dumber/internal/logging" | |
| 30 | 30 | "github.com/bnema/dumber/internal/ui" | |
| 31 | 31 | "github.com/bnema/dumber/internal/ui/theme" | |
| 32 | + | "github.com/rs/zerolog" | |
| 32 | 33 | ) | |
| 33 | 34 | ||
| 34 | 35 | // Build-time variables (set via ldflags). | |
| @@ -75,30 +76,15 @@ func runGUI() int { | |||
| 75 | 76 | cfg := initConfig() | |
| 76 | 77 | timer.Mark("config") | |
| 77 | 78 | ||
| 78 | - | // Initialize startup trace for cold start tracking (debug/trace only) | |
| 79 | - | logging.InitStartupTrace(cfg.Logging.Level) | |
| 80 | - | logging.Trace().Mark("config_loaded") | |
| 81 | - | ||
| 82 | - | ctx := initStartupContext(cfg) | |
| 79 | + | ctx := initStartupContextWithTrace(cfg) | |
| 83 | 80 | timer.Mark("logger") | |
| 84 | 81 | bootstrapLog := logging.FromContext(ctx) | |
| 85 | 82 | ||
| 86 | - | // Set logger for startup trace and mark logger init | |
| 87 | - | logging.Trace().SetLogger(bootstrapLog) | |
| 88 | - | logging.Trace().Mark("logger_init") | |
| 89 | - | ||
| 90 | - | // Parallel phase: directories and theme setup | |
| 91 | - | logging.Trace().Mark("parallel_start") | |
| 92 | - | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |
| 93 | - | Ctx: ctx, | |
| 94 | - | Config: cfg, | |
| 95 | - | }) | |
| 83 | + | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 96 | 84 | if err != nil { | |
| 97 | - | handleParallelInitError(ctx, err) | |
| 98 | 85 | return 1 | |
| 99 | 86 | } | |
| 100 | 87 | timer.MarkDuration("parallel_phase", initResult.Duration) | |
| 101 | - | logging.Trace().Mark("parallel_done") | |
| 102 | 88 | ||
| 103 | 89 | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | |
| 104 | 90 | ||
| @@ -111,48 +97,27 @@ func runGUI() int { | |||
| 111 | 97 | } | |
| 112 | 98 | timer.Mark("db_webkit_parallel") | |
| 113 | 99 | ||
| 114 | - | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB) | |
| 115 | - | if err != nil { | |
| 116 | - | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 117 | - | } | |
| 118 | - | if browserSession.LogCleanup != nil { | |
| 119 | - | defer browserSession.LogCleanup() | |
| 120 | - | } | |
| 121 | - | defer func() { _ = browserSession.End(sessionCtx) }() | |
| 100 | + | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | |
| 101 | + | defer sessionCleanup() | |
| 122 | 102 | timer.Mark("session") | |
| 123 | 103 | ||
| 124 | - | ctx = sessionCtx | |
| 125 | 104 | log := logging.FromContext(ctx) | |
| 126 | - | ||
| 127 | - | // Update startup trace with session logger so milestones go to log file | |
| 128 | 105 | logging.Trace().UpdateLogger(log) | |
| 129 | 106 | ||
| 130 | 107 | if stack.MessageRouter != nil { | |
| 131 | 108 | stack.MessageRouter.SetBaseContext(ctx) | |
| 132 | 109 | } | |
| 133 | 110 | ||
| 134 | - | // Repositories and use cases | |
| 135 | 111 | useCases := createUseCases(repos, cfg) | |
| 136 | 112 | if needsEagerDB { | |
| 137 | 113 | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | |
| 138 | 114 | } | |
| 139 | 115 | ||
| 140 | 116 | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 141 | - | defer func() { | |
| 142 | - | if idleInhibitor != nil { | |
| 143 | - | _ = idleInhibitor.Close() | |
| 144 | - | } | |
| 145 | - | }() | |
| 117 | + | defer closeIdleInhibitor(idleInhibitor) | |
| 146 | 118 | timer.Mark("use_cases") | |
| 147 | 119 | ||
| 148 | - | // Build UI | |
| 149 | - | uiDeps := buildUIDependencies( | |
| 150 | - | ctx, cfg, initResult.ThemeManager, | |
| 151 | - | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 152 | - | &stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |
| 153 | - | ) | |
| 154 | - | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 155 | - | app, err := ui.New(uiDeps) | |
| 120 | + | app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession) | |
| 156 | 121 | if err != nil { | |
| 157 | 122 | log.Error().Err(err).Msg("failed to create application") | |
| 158 | 123 | return 1 | |
| @@ -160,12 +125,83 @@ func runGUI() int { | |||
| 160 | 125 | timer.Mark("ui_deps") | |
| 161 | 126 | timer.Log(ctx) | |
| 162 | 127 | ||
| 163 | - | // Signal handling | |
| 164 | 128 | setupSignalHandler(ctx, app) | |
| 165 | 129 | ||
| 166 | 130 | return app.Run(ctx, os.Args) | |
| 167 | 131 | } | |
| 168 | 132 | ||
| 133 | + | func initStartupContextWithTrace(cfg *config.Config) context.Context { | |
| 134 | + | logging.InitStartupTrace(cfg.Logging.Level) | |
| 135 | + | logging.Trace().Mark("config_loaded") | |
| 136 | + | ||
| 137 | + | ctx := initStartupContext(cfg) | |
| 138 | + | bootstrapLog := logging.FromContext(ctx) | |
| 139 | + | ||
| 140 | + | logging.Trace().SetLogger(bootstrapLog) | |
| 141 | + | logging.Trace().Mark("logger_init") | |
| 142 | + | ||
| 143 | + | return ctx | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | |
| 147 | + | logging.Trace().Mark("parallel_start") | |
| 148 | + | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |
| 149 | + | Ctx: ctx, | |
| 150 | + | Config: cfg, | |
| 151 | + | }) | |
| 152 | + | if err != nil { | |
| 153 | + | handleParallelInitError(ctx, err) | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | logging.Trace().Mark("parallel_done") | |
| 157 | + | return initResult, nil | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func initBrowserSession( | |
| 161 | + | ctx context.Context, | |
| 162 | + | cfg *config.Config, | |
| 163 | + | repos *repositories, | |
| 164 | + | bootstrapLog *zerolog.Logger, | |
| 165 | + | ) (context.Context, *bootstrap.BrowserSession, func()) { | |
| 166 | + | deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore | |
| 167 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist) | |
| 168 | + | if err != nil { | |
| 169 | + | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 170 | + | } | |
| 171 | + | cleanup := func() { | |
| 172 | + | if browserSession.LogCleanup != nil { | |
| 173 | + | browserSession.LogCleanup() | |
| 174 | + | } | |
| 175 | + | _ = browserSession.End(sessionCtx) | |
| 176 | + | } | |
| 177 | + | return sessionCtx, browserSession, cleanup | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func closeIdleInhibitor(inhibitor port.IdleInhibitor) { | |
| 181 | + | if inhibitor != nil { | |
| 182 | + | _ = inhibitor.Close() | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func buildAndConfigureApp( | |
| 187 | + | ctx context.Context, | |
| 188 | + | cfg *config.Config, | |
| 189 | + | initResult *bootstrap.ParallelInitResult, | |
| 190 | + | stack *bootstrap.WebKitStack, | |
| 191 | + | repos *repositories, | |
| 192 | + | useCases *useCases, | |
| 193 | + | idleInhibitor port.IdleInhibitor, | |
| 194 | + | browserSession *bootstrap.BrowserSession, | |
| 195 | + | ) (*ui.App, error) { | |
| 196 | + | uiDeps := buildUIDependencies( | |
| 197 | + | ctx, cfg, initResult.ThemeManager, | |
| 198 | + | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 199 | + | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |
| 200 | + | ) | |
| 201 | + | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 202 | + | return ui.New(uiDeps) | |
| 203 | + | } | |
| 204 | + | ||
| 169 | 205 | func initStartupContext(cfg *config.Config) context.Context { | |
| 170 | 206 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 171 | 207 | bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat( | |
| @@ -29,6 +29,7 @@ import ( | |||||
| 29 | "github.com/bnema/dumber/internal/logging" | 29 | "github.com/bnema/dumber/internal/logging" | ||
| 30 | "github.com/bnema/dumber/internal/ui" | 30 | "github.com/bnema/dumber/internal/ui" | ||
| 31 | "github.com/bnema/dumber/internal/ui/theme" | 31 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 32 | + | "github.com/rs/zerolog" | |||
| 32 | ) | 33 | ) | ||
| 33 | 34 | ||||
| 34 | // Build-time variables (set via ldflags). | 35 | // Build-time variables (set via ldflags). | ||
| @@ -75,30 +76,15 @@ func runGUI() int { | |||||
| 75 | cfg := initConfig() | 76 | cfg := initConfig() | ||
| 76 | timer.Mark("config") | 77 | timer.Mark("config") | ||
| 77 | 78 | ||||
| 78 | - | // Initialize startup trace for cold start tracking (debug/trace only) | 79 | + | ctx := initStartupContextWithTrace(cfg) |
| 79 | - | logging.InitStartupTrace(cfg.Logging.Level) | |||
| 80 | - | logging.Trace().Mark("config_loaded") | |||
| 81 | - | ||||
| 82 | - | ctx := initStartupContext(cfg) | |||
| 83 | timer.Mark("logger") | 80 | timer.Mark("logger") | ||
| 84 | bootstrapLog := logging.FromContext(ctx) | 81 | bootstrapLog := logging.FromContext(ctx) | ||
| 85 | 82 | ||||
| 86 | - | // Set logger for startup trace and mark logger init | 83 | + | initResult, err := runParallelInitPhase(ctx, cfg) |
| 87 | - | logging.Trace().SetLogger(bootstrapLog) | |||
| 88 | - | logging.Trace().Mark("logger_init") | |||
| 89 | - | ||||
| 90 | - | // Parallel phase: directories and theme setup | |||
| 91 | - | logging.Trace().Mark("parallel_start") | |||
| 92 | - | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |||
| 93 | - | Ctx: ctx, | |||
| 94 | - | Config: cfg, | |||
| 95 | - | }) | |||
| 96 | if err != nil { | 84 | if err != nil { | ||
| 97 | - | handleParallelInitError(ctx, err) | |||
| 98 | return 1 | 85 | return 1 | ||
| 99 | } | 86 | } | ||
| 100 | timer.MarkDuration("parallel_phase", initResult.Duration) | 87 | timer.MarkDuration("parallel_phase", initResult.Duration) | ||
| 101 | - | logging.Trace().Mark("parallel_done") | |||
| 102 | 88 | ||||
| 103 | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | 89 | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | ||
| 104 | 90 | ||||
| @@ -111,48 +97,27 @@ func runGUI() int { | |||||
| 111 | } | 97 | } | ||
| 112 | timer.Mark("db_webkit_parallel") | 98 | timer.Mark("db_webkit_parallel") | ||
| 113 | 99 | ||||
| 114 | - | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB) | 100 | + | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) |
| 115 | - | if err != nil { | 101 | + | defer sessionCleanup() |
| 116 | - | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |||
| 117 | - | } | |||
| 118 | - | if browserSession.LogCleanup != nil { | |||
| 119 | - | defer browserSession.LogCleanup() | |||
| 120 | - | } | |||
| 121 | - | defer func() { _ = browserSession.End(sessionCtx) }() | |||
| 122 | timer.Mark("session") | 102 | timer.Mark("session") | ||
| 123 | 103 | ||||
| 124 | - | ctx = sessionCtx | |||
| 125 | log := logging.FromContext(ctx) | 104 | log := logging.FromContext(ctx) | ||
| 126 | - | ||||
| 127 | - | // Update startup trace with session logger so milestones go to log file | |||
| 128 | logging.Trace().UpdateLogger(log) | 105 | logging.Trace().UpdateLogger(log) | ||
| 129 | 106 | ||||
| 130 | if stack.MessageRouter != nil { | 107 | if stack.MessageRouter != nil { | ||
| 131 | stack.MessageRouter.SetBaseContext(ctx) | 108 | stack.MessageRouter.SetBaseContext(ctx) | ||
| 132 | } | 109 | } | ||
| 133 | 110 | ||||
| 134 | - | // Repositories and use cases | |||
| 135 | useCases := createUseCases(repos, cfg) | 111 | useCases := createUseCases(repos, cfg) | ||
| 136 | if needsEagerDB { | 112 | if needsEagerDB { | ||
| 137 | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | 113 | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | ||
| 138 | } | 114 | } | ||
| 139 | 115 | ||||
| 140 | idleInhibitor := idle.NewPortalInhibitor(ctx) | 116 | idleInhibitor := idle.NewPortalInhibitor(ctx) | ||
| 141 | - | defer func() { | 117 | + | defer closeIdleInhibitor(idleInhibitor) |
| 142 | - | if idleInhibitor != nil { | |||
| 143 | - | _ = idleInhibitor.Close() | |||
| 144 | - | } | |||
| 145 | - | }() | |||
| 146 | timer.Mark("use_cases") | 118 | timer.Mark("use_cases") | ||
| 147 | 119 | ||||
| 148 | - | // Build UI | 120 | + | app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession) |
| 149 | - | uiDeps := buildUIDependencies( | |||
| 150 | - | ctx, cfg, initResult.ThemeManager, | |||
| 151 | - | initResult.ColorResolver, initResult.AdwaitaDetector, | |||
| 152 | - | &stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |||
| 153 | - | ) | |||
| 154 | - | configureDeferredInit(uiDeps, cfg, browserSession) | |||
| 155 | - | app, err := ui.New(uiDeps) | |||
| 156 | if err != nil { | 121 | if err != nil { | ||
| 157 | log.Error().Err(err).Msg("failed to create application") | 122 | log.Error().Err(err).Msg("failed to create application") | ||
| 158 | return 1 | 123 | return 1 | ||
| @@ -160,12 +125,83 @@ func runGUI() int { | |||||
| 160 | timer.Mark("ui_deps") | 125 | timer.Mark("ui_deps") | ||
| 161 | timer.Log(ctx) | 126 | timer.Log(ctx) | ||
| 162 | 127 | ||||
| 163 | - | // Signal handling | |||
| 164 | setupSignalHandler(ctx, app) | 128 | setupSignalHandler(ctx, app) | ||
| 165 | 129 | ||||
| 166 | return app.Run(ctx, os.Args) | 130 | return app.Run(ctx, os.Args) | ||
| 167 | } | 131 | } | ||
| 168 | 132 | ||||
| 133 | + | func initStartupContextWithTrace(cfg *config.Config) context.Context { | |||
| 134 | + | logging.InitStartupTrace(cfg.Logging.Level) | |||
| 135 | + | logging.Trace().Mark("config_loaded") | |||
| 136 | + | ||||
| 137 | + | ctx := initStartupContext(cfg) | |||
| 138 | + | bootstrapLog := logging.FromContext(ctx) | |||
| 139 | + | ||||
| 140 | + | logging.Trace().SetLogger(bootstrapLog) | |||
| 141 | + | logging.Trace().Mark("logger_init") | |||
| 142 | + | ||||
| 143 | + | return ctx | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | |||
| 147 | + | logging.Trace().Mark("parallel_start") | |||
| 148 | + | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |||
| 149 | + | Ctx: ctx, | |||
| 150 | + | Config: cfg, | |||
| 151 | + | }) | |||
| 152 | + | if err != nil { | |||
| 153 | + | handleParallelInitError(ctx, err) | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | logging.Trace().Mark("parallel_done") | |||
| 157 | + | return initResult, nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func initBrowserSession( | |||
| 161 | + | ctx context.Context, | |||
| 162 | + | cfg *config.Config, | |||
| 163 | + | repos *repositories, | |||
| 164 | + | bootstrapLog *zerolog.Logger, | |||
| 165 | + | ) (context.Context, *bootstrap.BrowserSession, func()) { | |||
| 166 | + | deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore | |||
| 167 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist) | |||
| 168 | + | if err != nil { | |||
| 169 | + | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |||
| 170 | + | } | |||
| 171 | + | cleanup := func() { | |||
| 172 | + | if browserSession.LogCleanup != nil { | |||
| 173 | + | browserSession.LogCleanup() | |||
| 174 | + | } | |||
| 175 | + | _ = browserSession.End(sessionCtx) | |||
| 176 | + | } | |||
| 177 | + | return sessionCtx, browserSession, cleanup | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func closeIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 181 | + | if inhibitor != nil { | |||
| 182 | + | _ = inhibitor.Close() | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func buildAndConfigureApp( | |||
| 187 | + | ctx context.Context, | |||
| 188 | + | cfg *config.Config, | |||
| 189 | + | initResult *bootstrap.ParallelInitResult, | |||
| 190 | + | stack *bootstrap.WebKitStack, | |||
| 191 | + | repos *repositories, | |||
| 192 | + | useCases *useCases, | |||
| 193 | + | idleInhibitor port.IdleInhibitor, | |||
| 194 | + | browserSession *bootstrap.BrowserSession, | |||
| 195 | + | ) (*ui.App, error) { | |||
| 196 | + | uiDeps := buildUIDependencies( | |||
| 197 | + | ctx, cfg, initResult.ThemeManager, | |||
| 198 | + | initResult.ColorResolver, initResult.AdwaitaDetector, | |||
| 199 | + | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |||
| 200 | + | ) | |||
| 201 | + | configureDeferredInit(uiDeps, cfg, browserSession) | |||
| 202 | + | return ui.New(uiDeps) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 169 | func initStartupContext(cfg *config.Config) context.Context { | 205 | func initStartupContext(cfg *config.Config) context.Context { | ||
| 170 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | 206 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | ||
| 171 | bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat( | 207 | bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat( | ||
M internal/bootstrap/webkit_stack.go +70 -41
| @@ -39,13 +39,41 @@ type WebKitStack struct { | |||
| 39 | 39 | func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |
| 40 | 40 | ctx := input.Ctx | |
| 41 | 41 | cfg := input.Config | |
| 42 | - | dataDir := input.DataDir | |
| 43 | - | cacheDir := input.CacheDir | |
| 44 | - | themeManager := input.ThemeManager | |
| 45 | - | colorResolver := input.ColorResolver | |
| 46 | 42 | logger := input.Logger | |
| 47 | 43 | ||
| 48 | - | // Detect hardware for performance profile scaling | |
| 44 | + | perfSettings := surveyHardwareAndResolveProfile(ctx, cfg, logger) | |
| 45 | + | ||
| 46 | + | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | |
| 47 | + | logging.Trace().Mark("render_env") | |
| 48 | + | ||
| 49 | + | wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings) | |
| 50 | + | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 51 | + | if err != nil { | |
| 52 | + | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 53 | + | } | |
| 54 | + | logging.Trace().Mark("webkit_context") | |
| 55 | + | ||
| 56 | + | filterManager := initFilterManager(ctx, cfg, input.DataDir, logger) | |
| 57 | + | ||
| 58 | + | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |
| 59 | + | schemeHandler.SetAssets(assets.WebUIAssets) | |
| 60 | + | schemeHandler.RegisterWithContext(wkCtx) | |
| 61 | + | ||
| 62 | + | settings, injector, messageRouter := initWebKitComponents(ctx, cfg, input.ThemeManager, input.ColorResolver, logger) | |
| 63 | + | ||
| 64 | + | pool := initWebViewPool(ctx, wkCtx, settings, injector, messageRouter, input.ThemeManager, filterManager, &perfSettings, logger) | |
| 65 | + | ||
| 66 | + | return WebKitStack{ | |
| 67 | + | Context: wkCtx, | |
| 68 | + | Settings: settings, | |
| 69 | + | Injector: injector, | |
| 70 | + | MessageRouter: messageRouter, | |
| 71 | + | Pool: pool, | |
| 72 | + | FilterManager: filterManager, | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings { | |
| 49 | 77 | hwSurveyor := env.NewHardwareSurveyor() | |
| 50 | 78 | hwInfo := hwSurveyor.Survey(ctx) | |
| 51 | 79 | logging.Trace().Mark("hardware_survey") | |
| @@ -57,7 +85,6 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 57 | 85 | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | |
| 58 | 86 | Msg("hardware survey completed") | |
| 59 | 87 | ||
| 60 | - | // Resolve performance profile to get actual settings | |
| 61 | 88 | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | |
| 62 | 89 | logging.Trace().Mark("performance_profile") | |
| 63 | 90 | logger.Info(). | |
| @@ -67,18 +94,16 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 67 | 94 | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | |
| 68 | 95 | Msg("resolved performance profile") | |
| 69 | 96 | ||
| 70 | - | // Configure rendering environment (must happen before WebKit/GTK init) | |
| 71 | - | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | |
| 72 | - | logging.Trace().Mark("render_env") | |
| 97 | + | return perfSettings | |
| 98 | + | } | |
| 73 | 99 | ||
| 74 | - | // Build WebKitContext options with memory pressure settings | |
| 100 | + | func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions { | |
| 75 | 101 | wkOpts := port.WebKitContextOptions{ | |
| 76 | 102 | DataDir: dataDir, | |
| 77 | 103 | CacheDir: cacheDir, | |
| 78 | 104 | } | |
| 79 | 105 | ||
| 80 | - | // Configure web process memory pressure if any setting is configured | |
| 81 | - | if hasWebProcessMemoryConfig(&perfSettings) { | |
| 106 | + | if hasWebProcessMemoryConfig(perfSettings) { | |
| 82 | 107 | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | |
| 83 | 108 | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | |
| 84 | 109 | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | |
| @@ -87,8 +112,7 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 87 | 112 | } | |
| 88 | 113 | } | |
| 89 | 114 | ||
| 90 | - | // Configure network process memory pressure if any setting is configured | |
| 91 | - | if hasNetworkProcessMemoryConfig(&perfSettings) { | |
| 115 | + | if hasNetworkProcessMemoryConfig(perfSettings) { | |
| 92 | 116 | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | |
| 93 | 117 | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | |
| 94 | 118 | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | |
| @@ -97,12 +121,10 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 97 | 121 | } | |
| 98 | 122 | } | |
| 99 | 123 | ||
| 100 | - | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 101 | - | if err != nil { | |
| 102 | - | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 103 | - | } | |
| 104 | - | logging.Trace().Mark("webkit_context") | |
| 124 | + | return wkOpts | |
| 125 | + | } | |
| 105 | 126 | ||
| 127 | + | func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager { | |
| 106 | 128 | filterStoreDir := filepath.Join(dataDir, "filters", "store") | |
| 107 | 129 | filterJSONDir := filepath.Join(dataDir, "filters", "json") | |
| 108 | 130 | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | |
| @@ -113,21 +135,25 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 113 | 135 | }) | |
| 114 | 136 | if err != nil { | |
| 115 | 137 | logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering") | |
| 116 | - | } else { | |
| 117 | - | if err := filterManager.Initialize(ctx); err != nil { | |
| 118 | - | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | |
| 119 | - | } | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | if err := filterManager.Initialize(ctx); err != nil { | |
| 141 | + | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | |
| 120 | 142 | } | |
| 121 | 143 | logging.Trace().Mark("filter_manager") | |
| 144 | + | return filterManager | |
| 145 | + | } | |
| 122 | 146 | ||
| 123 | - | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |
| 124 | - | schemeHandler.SetAssets(assets.WebUIAssets) | |
| 125 | - | schemeHandler.RegisterWithContext(wkCtx) | |
| 126 | - | ||
| 147 | + | func initWebKitComponents( | |
| 148 | + | ctx context.Context, | |
| 149 | + | cfg *config.Config, | |
| 150 | + | themeManager *theme.Manager, | |
| 151 | + | colorResolver port.ColorSchemeResolver, | |
| 152 | + | logger zerolog.Logger, | |
| 153 | + | ) (*webkit.SettingsManager, *webkit.ContentInjector, *webkit.MessageRouter) { | |
| 127 | 154 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 128 | 155 | injector := webkit.NewContentInjector(colorResolver) | |
| 129 | 156 | ||
| 130 | - | // Set up auto-copy on selection config getter | |
| 131 | 157 | injector.SetAutoCopyConfigGetter(func() bool { | |
| 132 | 158 | return config.Get().Clipboard.AutoCopyOnSelection | |
| 133 | 159 | }) | |
| @@ -141,13 +167,26 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 141 | 167 | messageRouter := webkit.NewMessageRouter(ctx) | |
| 142 | 168 | logging.Trace().Mark("settings_manager") | |
| 143 | 169 | ||
| 170 | + | return settings, injector, messageRouter | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func initWebViewPool( | |
| 174 | + | ctx context.Context, | |
| 175 | + | wkCtx *webkit.WebKitContext, | |
| 176 | + | settings *webkit.SettingsManager, | |
| 177 | + | injector *webkit.ContentInjector, | |
| 178 | + | messageRouter *webkit.MessageRouter, | |
| 179 | + | themeManager *theme.Manager, | |
| 180 | + | filterManager *filtering.Manager, | |
| 181 | + | perfSettings *config.ResolvedPerformanceSettings, | |
| 182 | + | logger zerolog.Logger, | |
| 183 | + | ) *webkit.WebViewPool { | |
| 144 | 184 | poolCfg := webkit.DefaultPoolConfig() | |
| 145 | - | // Override prewarm count from resolved profile settings | |
| 146 | 185 | if perfSettings.WebViewPoolPrewarmCount > 0 { | |
| 147 | 186 | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount | |
| 148 | 187 | } | |
| 149 | 188 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |
| 150 | - | // Ensure prewarmed WebViews pick up the theme background color. | |
| 189 | + | ||
| 151 | 190 | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | |
| 152 | 191 | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | |
| 153 | 192 | ||
| @@ -155,22 +194,12 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 155 | 194 | pool.SetFilterApplier(filterManager) | |
| 156 | 195 | } | |
| 157 | 196 | ||
| 158 | - | // Pre-create ONE WebView synchronously for instant first navigation. | |
| 159 | - | // This is the heaviest operation but happens before window is shown, | |
| 160 | - | // so users perceive it as part of the normal "loading" phase. | |
| 161 | 197 | if err := pool.PrewarmFirst(ctx); err != nil { | |
| 162 | 198 | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |
| 163 | 199 | } | |
| 164 | 200 | logging.Trace().Mark("pool_prewarm_first") | |
| 165 | 201 | ||
| 166 | - | return WebKitStack{ | |
| 167 | - | Context: wkCtx, | |
| 168 | - | Settings: settings, | |
| 169 | - | Injector: injector, | |
| 170 | - | MessageRouter: messageRouter, | |
| 171 | - | Pool: pool, | |
| 172 | - | FilterManager: filterManager, | |
| 173 | - | } | |
| 202 | + | return pool | |
| 174 | 203 | } | |
| 175 | 204 | ||
| 176 | 205 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | |
| @@ -39,13 +39,41 @@ type WebKitStack struct { | |||||
| 39 | func BuildWebKitStack(input WebKitStackInput) WebKitStack { | 39 | func BuildWebKitStack(input WebKitStackInput) WebKitStack { | ||
| 40 | ctx := input.Ctx | 40 | ctx := input.Ctx | ||
| 41 | cfg := input.Config | 41 | cfg := input.Config | ||
| 42 | - | dataDir := input.DataDir | |||
| 43 | - | cacheDir := input.CacheDir | |||
| 44 | - | themeManager := input.ThemeManager | |||
| 45 | - | colorResolver := input.ColorResolver | |||
| 46 | logger := input.Logger | 42 | logger := input.Logger | ||
| 47 | 43 | ||||
| 48 | - | // Detect hardware for performance profile scaling | 44 | + | perfSettings := surveyHardwareAndResolveProfile(ctx, cfg, logger) |
| 45 | + | ||||
| 46 | + | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | |||
| 47 | + | logging.Trace().Mark("render_env") | |||
| 48 | + | ||||
| 49 | + | wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings) | |||
| 50 | + | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |||
| 51 | + | if err != nil { | |||
| 52 | + | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |||
| 53 | + | } | |||
| 54 | + | logging.Trace().Mark("webkit_context") | |||
| 55 | + | ||||
| 56 | + | filterManager := initFilterManager(ctx, cfg, input.DataDir, logger) | |||
| 57 | + | ||||
| 58 | + | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |||
| 59 | + | schemeHandler.SetAssets(assets.WebUIAssets) | |||
| 60 | + | schemeHandler.RegisterWithContext(wkCtx) | |||
| 61 | + | ||||
| 62 | + | settings, injector, messageRouter := initWebKitComponents(ctx, cfg, input.ThemeManager, input.ColorResolver, logger) | |||
| 63 | + | ||||
| 64 | + | pool := initWebViewPool(ctx, wkCtx, settings, injector, messageRouter, input.ThemeManager, filterManager, &perfSettings, logger) | |||
| 65 | + | ||||
| 66 | + | return WebKitStack{ | |||
| 67 | + | Context: wkCtx, | |||
| 68 | + | Settings: settings, | |||
| 69 | + | Injector: injector, | |||
| 70 | + | MessageRouter: messageRouter, | |||
| 71 | + | Pool: pool, | |||
| 72 | + | FilterManager: filterManager, | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings { | |||
| 49 | hwSurveyor := env.NewHardwareSurveyor() | 77 | hwSurveyor := env.NewHardwareSurveyor() | ||
| 50 | hwInfo := hwSurveyor.Survey(ctx) | 78 | hwInfo := hwSurveyor.Survey(ctx) | ||
| 51 | logging.Trace().Mark("hardware_survey") | 79 | logging.Trace().Mark("hardware_survey") | ||
| @@ -57,7 +85,6 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 57 | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | 85 | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | ||
| 58 | Msg("hardware survey completed") | 86 | Msg("hardware survey completed") | ||
| 59 | 87 | ||||
| 60 | - | // Resolve performance profile to get actual settings | |||
| 61 | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | 88 | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | ||
| 62 | logging.Trace().Mark("performance_profile") | 89 | logging.Trace().Mark("performance_profile") | ||
| 63 | logger.Info(). | 90 | logger.Info(). | ||
| @@ -67,18 +94,16 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 67 | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | 94 | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | ||
| 68 | Msg("resolved performance profile") | 95 | Msg("resolved performance profile") | ||
| 69 | 96 | ||||
| 70 | - | // Configure rendering environment (must happen before WebKit/GTK init) | 97 | + | return perfSettings |
| 71 | - | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | 98 | + | } |
| 72 | - | logging.Trace().Mark("render_env") | |||
| 73 | 99 | ||||
| 74 | - | // Build WebKitContext options with memory pressure settings | 100 | + | func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions { |
| 75 | wkOpts := port.WebKitContextOptions{ | 101 | wkOpts := port.WebKitContextOptions{ | ||
| 76 | DataDir: dataDir, | 102 | DataDir: dataDir, | ||
| 77 | CacheDir: cacheDir, | 103 | CacheDir: cacheDir, | ||
| 78 | } | 104 | } | ||
| 79 | 105 | ||||
| 80 | - | // Configure web process memory pressure if any setting is configured | 106 | + | if hasWebProcessMemoryConfig(perfSettings) { |
| 81 | - | if hasWebProcessMemoryConfig(&perfSettings) { | |||
| 82 | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | 107 | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | ||
| 83 | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | 108 | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | ||
| 84 | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | 109 | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | ||
| @@ -87,8 +112,7 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 87 | } | 112 | } | ||
| 88 | } | 113 | } | ||
| 89 | 114 | ||||
| 90 | - | // Configure network process memory pressure if any setting is configured | 115 | + | if hasNetworkProcessMemoryConfig(perfSettings) { |
| 91 | - | if hasNetworkProcessMemoryConfig(&perfSettings) { | |||
| 92 | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | 116 | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | ||
| 93 | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | 117 | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | ||
| 94 | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | 118 | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | ||
| @@ -97,12 +121,10 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 97 | } | 121 | } | ||
| 98 | } | 122 | } | ||
| 99 | 123 | ||||
| 100 | - | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | 124 | + | return wkOpts |
| 101 | - | if err != nil { | 125 | + | } |
| 102 | - | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |||
| 103 | - | } | |||
| 104 | - | logging.Trace().Mark("webkit_context") | |||
| 105 | 126 | ||||
| 127 | + | func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager { | |||
| 106 | filterStoreDir := filepath.Join(dataDir, "filters", "store") | 128 | filterStoreDir := filepath.Join(dataDir, "filters", "store") | ||
| 107 | filterJSONDir := filepath.Join(dataDir, "filters", "json") | 129 | filterJSONDir := filepath.Join(dataDir, "filters", "json") | ||
| 108 | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | 130 | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | ||
| @@ -113,21 +135,25 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 113 | }) | 135 | }) | ||
| 114 | if err != nil { | 136 | if err != nil { | ||
| 115 | logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering") | 137 | logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering") | ||
| 116 | - | } else { | 138 | + | return nil |
| 117 | - | if err := filterManager.Initialize(ctx); err != nil { | 139 | + | } |
| 118 | - | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | 140 | + | if err := filterManager.Initialize(ctx); err != nil { |
| 119 | - | } | 141 | + | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") |
| 120 | } | 142 | } | ||
| 121 | logging.Trace().Mark("filter_manager") | 143 | logging.Trace().Mark("filter_manager") | ||
| 144 | + | return filterManager | |||
| 145 | + | } | |||
| 122 | 146 | ||||
| 123 | - | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | 147 | + | func initWebKitComponents( |
| 124 | - | schemeHandler.SetAssets(assets.WebUIAssets) | 148 | + | ctx context.Context, |
| 125 | - | schemeHandler.RegisterWithContext(wkCtx) | 149 | + | cfg *config.Config, |
| 126 | - | 150 | + | themeManager *theme.Manager, | |
| 151 | + | colorResolver port.ColorSchemeResolver, | |||
| 152 | + | logger zerolog.Logger, | |||
| 153 | + | ) (*webkit.SettingsManager, *webkit.ContentInjector, *webkit.MessageRouter) { | |||
| 127 | settings := webkit.NewSettingsManager(ctx, cfg) | 154 | settings := webkit.NewSettingsManager(ctx, cfg) | ||
| 128 | injector := webkit.NewContentInjector(colorResolver) | 155 | injector := webkit.NewContentInjector(colorResolver) | ||
| 129 | 156 | ||||
| 130 | - | // Set up auto-copy on selection config getter | |||
| 131 | injector.SetAutoCopyConfigGetter(func() bool { | 157 | injector.SetAutoCopyConfigGetter(func() bool { | ||
| 132 | return config.Get().Clipboard.AutoCopyOnSelection | 158 | return config.Get().Clipboard.AutoCopyOnSelection | ||
| 133 | }) | 159 | }) | ||
| @@ -141,13 +167,26 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 141 | messageRouter := webkit.NewMessageRouter(ctx) | 167 | messageRouter := webkit.NewMessageRouter(ctx) | ||
| 142 | logging.Trace().Mark("settings_manager") | 168 | logging.Trace().Mark("settings_manager") | ||
| 143 | 169 | ||||
| 170 | + | return settings, injector, messageRouter | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func initWebViewPool( | |||
| 174 | + | ctx context.Context, | |||
| 175 | + | wkCtx *webkit.WebKitContext, | |||
| 176 | + | settings *webkit.SettingsManager, | |||
| 177 | + | injector *webkit.ContentInjector, | |||
| 178 | + | messageRouter *webkit.MessageRouter, | |||
| 179 | + | themeManager *theme.Manager, | |||
| 180 | + | filterManager *filtering.Manager, | |||
| 181 | + | perfSettings *config.ResolvedPerformanceSettings, | |||
| 182 | + | logger zerolog.Logger, | |||
| 183 | + | ) *webkit.WebViewPool { | |||
| 144 | poolCfg := webkit.DefaultPoolConfig() | 184 | poolCfg := webkit.DefaultPoolConfig() | ||
| 145 | - | // Override prewarm count from resolved profile settings | |||
| 146 | if perfSettings.WebViewPoolPrewarmCount > 0 { | 185 | if perfSettings.WebViewPoolPrewarmCount > 0 { | ||
| 147 | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount | 186 | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount | ||
| 148 | } | 187 | } | ||
| 149 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | 188 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | ||
| 150 | - | // Ensure prewarmed WebViews pick up the theme background color. | 189 | + | |
| 151 | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | 190 | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | ||
| 152 | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | 191 | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | ||
| 153 | 192 | ||||
| @@ -155,22 +194,12 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 155 | pool.SetFilterApplier(filterManager) | 194 | pool.SetFilterApplier(filterManager) | ||
| 156 | } | 195 | } | ||
| 157 | 196 | ||||
| 158 | - | // Pre-create ONE WebView synchronously for instant first navigation. | |||
| 159 | - | // This is the heaviest operation but happens before window is shown, | |||
| 160 | - | // so users perceive it as part of the normal "loading" phase. | |||
| 161 | if err := pool.PrewarmFirst(ctx); err != nil { | 197 | if err := pool.PrewarmFirst(ctx); err != nil { | ||
| 162 | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | 198 | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | ||
| 163 | } | 199 | } | ||
| 164 | logging.Trace().Mark("pool_prewarm_first") | 200 | logging.Trace().Mark("pool_prewarm_first") | ||
| 165 | 201 | ||||
| 166 | - | return WebKitStack{ | 202 | + | return pool |
| 167 | - | Context: wkCtx, | |||
| 168 | - | Settings: settings, | |||
| 169 | - | Injector: injector, | |||
| 170 | - | MessageRouter: messageRouter, | |||
| 171 | - | Pool: pool, | |||
| 172 | - | FilterManager: filterManager, | |||
| 173 | - | } | |||
| 174 | } | 203 | } | ||
| 175 | 204 | ||||
| 176 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | 205 | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | ||
M internal/infrastructure/webkit/pool.go +2 -1
| @@ -312,7 +312,8 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |||
| 312 | 312 | schedule() | |
| 313 | 313 | return false | |
| 314 | 314 | }) | |
| 315 | - | glib.IdleAdd(&cb, 0) | |
| 315 | + | // Use LOW priority so initial navigation and other high-priority work runs first | |
| 316 | + | glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil) | |
| 316 | 317 | } | |
| 317 | 318 | ||
| 318 | 319 | schedule() | |
| @@ -312,7 +312,8 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |||||
| 312 | schedule() | 312 | schedule() | ||
| 313 | return false | 313 | return false | ||
| 314 | }) | 314 | }) | ||
| 315 | - | glib.IdleAdd(&cb, 0) | 315 | + | // Use LOW priority so initial navigation and other high-priority work runs first |
| 316 | + | glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil) | |||
| 316 | } | 317 | } | ||
| 317 | 318 | ||||
| 318 | schedule() | 319 | schedule() | ||
M internal/ui/app.go +43 -14
| @@ -115,6 +115,10 @@ type App struct { | |||
| 115 | 115 | insertAccentUC *usecase.InsertAccentUseCase | |
| 116 | 116 | accentFocusProvider *textinput.FocusProvider | |
| 117 | 117 | ||
| 118 | + | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| 119 | + | deferredInitOnce sync.Once | |
| 120 | + | deferredInitFn func() | |
| 121 | + | ||
| 118 | 122 | // lifecycle | |
| 119 | 123 | cancel context.CancelCauseFunc | |
| 120 | 124 | } | |
| @@ -256,8 +260,6 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 256 | 260 | a.initSnapshotService(ctx) | |
| 257 | 261 | a.initUpdateCoordinator(ctx) | |
| 258 | 262 | a.createInitialTab(ctx) | |
| 259 | - | // Prewarm the remaining WebViews after the initial tab starts loading. | |
| 260 | - | a.prewarmWebViewPoolAsync(ctx) | |
| 261 | 263 | a.finalizeActivation(ctx) | |
| 262 | 264 | } | |
| 263 | 265 | ||
| @@ -1158,26 +1160,48 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1158 | 1160 | func (a *App) finalizeActivation(ctx context.Context) { | |
| 1159 | 1161 | log := logging.FromContext(ctx) | |
| 1160 | 1162 | ||
| 1161 | - | // Show the window. | |
| 1163 | + | // Show the window | |
| 1162 | 1164 | if a.mainWindow != nil { | |
| 1163 | 1165 | a.mainWindow.Show() | |
| 1164 | 1166 | } | |
| 1165 | 1167 | log.Info().Msg("main window displayed") | |
| 1166 | 1168 | ||
| 1167 | - | // Start watching config for appearance changes. | |
| 1168 | - | a.initConfigWatcher(ctx) | |
| 1169 | + | // Defer non-critical initialization until after first navigation starts. | |
| 1170 | + | // This keeps pool prewarm, config watcher, and filter loading from | |
| 1171 | + | // competing with the initial page load. | |
| 1172 | + | a.runAfterFirstLoadStarted(func() { | |
| 1173 | + | a.prewarmWebViewPoolAsync(ctx) | |
| 1174 | + | a.initConfigWatcher(ctx) | |
| 1175 | + | a.initFilteringAsync(ctx) | |
| 1176 | + | a.checkConfigMigration(ctx) | |
| 1177 | + | }) | |
| 1178 | + | } | |
| 1169 | 1179 | ||
| 1170 | - | // Ensure the current appearance config is applied once at startup. | |
| 1171 | - | // This avoids the WebUI showing default tokens until the first config change event. | |
| 1172 | - | if a.deps != nil { | |
| 1173 | - | a.applyAppearanceConfig(ctx, a.deps.Config) | |
| 1174 | - | } | |
| 1180 | + | // runAfterFirstLoadStarted schedules work to run after the first navigation starts. | |
| 1181 | + | // This keeps the GTK main loop free to process the initial load_uri() quickly, | |
| 1182 | + | // reducing the gap between webview_attached and load_started. | |
| 1183 | + | func (a *App) runAfterFirstLoadStarted(fn func()) { | |
| 1184 | + | a.deferredInitFn = fn | |
| 1185 | + | } | |
| 1175 | 1186 | ||
| 1176 | - | // Start async filter loading after window is visible. | |
| 1177 | - | a.initFilteringAsync(ctx) | |
| 1187 | + | // triggerDeferredInit is called from ContentCoordinator on first load_started. | |
| 1188 | + | // It runs deferred initialization at LOW priority so navigation continues unblocked. | |
| 1189 | + | func (a *App) triggerDeferredInit(ctx context.Context) { | |
| 1190 | + | a.deferredInitOnce.Do(func() { | |
| 1191 | + | if a.deferredInitFn == nil { | |
| 1192 | + | return | |
| 1193 | + | } | |
| 1194 | + | log := logging.FromContext(ctx) | |
| 1195 | + | log.Debug().Msg("triggering deferred initialization") | |
| 1178 | 1196 | ||
| 1179 | - | // Check for config migration after window is visible. | |
| 1180 | - | a.checkConfigMigration(ctx) | |
| 1197 | + | fn := a.deferredInitFn | |
| 1198 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1199 | + | fn() | |
| 1200 | + | return false | |
| 1201 | + | }) | |
| 1202 | + | // Use LOW priority so navigation processing continues first | |
| 1203 | + | glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil) | |
| 1204 | + | }) | |
| 1181 | 1205 | } | |
| 1182 | 1206 | ||
| 1183 | 1207 | // onShutdown is called when the GTK application is shutting down. | |
| @@ -1251,6 +1275,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1251 | 1275 | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | |
| 1252 | 1276 | } | |
| 1253 | 1277 | ||
| 1278 | + | // Wire deferred init trigger - runs after first navigation starts | |
| 1279 | + | a.contentCoord.SetOnFirstLoadStarted(func() { | |
| 1280 | + | a.triggerDeferredInit(ctx) | |
| 1281 | + | }) | |
| 1282 | + | ||
| 1254 | 1283 | // 2. Tab Coordinator | |
| 1255 | 1284 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 1256 | 1285 | TabsUC: a.tabsUC, | |
| @@ -115,6 +115,10 @@ type App struct { | |||||
| 115 | insertAccentUC *usecase.InsertAccentUseCase | 115 | insertAccentUC *usecase.InsertAccentUseCase | ||
| 116 | accentFocusProvider *textinput.FocusProvider | 116 | accentFocusProvider *textinput.FocusProvider | ||
| 117 | 117 | ||||
| 118 | + | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |||
| 119 | + | deferredInitOnce sync.Once | |||
| 120 | + | deferredInitFn func() | |||
| 121 | + | ||||
| 118 | // lifecycle | 122 | // lifecycle | ||
| 119 | cancel context.CancelCauseFunc | 123 | cancel context.CancelCauseFunc | ||
| 120 | } | 124 | } | ||
| @@ -256,8 +260,6 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 256 | a.initSnapshotService(ctx) | 260 | a.initSnapshotService(ctx) | ||
| 257 | a.initUpdateCoordinator(ctx) | 261 | a.initUpdateCoordinator(ctx) | ||
| 258 | a.createInitialTab(ctx) | 262 | a.createInitialTab(ctx) | ||
| 259 | - | // Prewarm the remaining WebViews after the initial tab starts loading. | |||
| 260 | - | a.prewarmWebViewPoolAsync(ctx) | |||
| 261 | a.finalizeActivation(ctx) | 263 | a.finalizeActivation(ctx) | ||
| 262 | } | 264 | } | ||
| 263 | 265 | ||||
| @@ -1158,26 +1160,48 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||||
| 1158 | func (a *App) finalizeActivation(ctx context.Context) { | 1160 | func (a *App) finalizeActivation(ctx context.Context) { | ||
| 1159 | log := logging.FromContext(ctx) | 1161 | log := logging.FromContext(ctx) | ||
| 1160 | 1162 | ||||
| 1161 | - | // Show the window. | 1163 | + | // Show the window |
| 1162 | if a.mainWindow != nil { | 1164 | if a.mainWindow != nil { | ||
| 1163 | a.mainWindow.Show() | 1165 | a.mainWindow.Show() | ||
| 1164 | } | 1166 | } | ||
| 1165 | log.Info().Msg("main window displayed") | 1167 | log.Info().Msg("main window displayed") | ||
| 1166 | 1168 | ||||
| 1167 | - | // Start watching config for appearance changes. | 1169 | + | // Defer non-critical initialization until after first navigation starts. |
| 1168 | - | a.initConfigWatcher(ctx) | 1170 | + | // This keeps pool prewarm, config watcher, and filter loading from |
| 1171 | + | // competing with the initial page load. | |||
| 1172 | + | a.runAfterFirstLoadStarted(func() { | |||
| 1173 | + | a.prewarmWebViewPoolAsync(ctx) | |||
| 1174 | + | a.initConfigWatcher(ctx) | |||
| 1175 | + | a.initFilteringAsync(ctx) | |||
| 1176 | + | a.checkConfigMigration(ctx) | |||
| 1177 | + | }) | |||
| 1178 | + | } | |||
| 1169 | 1179 | ||||
| 1170 | - | // Ensure the current appearance config is applied once at startup. | 1180 | + | // runAfterFirstLoadStarted schedules work to run after the first navigation starts. |
| 1171 | - | // This avoids the WebUI showing default tokens until the first config change event. | 1181 | + | // This keeps the GTK main loop free to process the initial load_uri() quickly, |
| 1172 | - | if a.deps != nil { | 1182 | + | // reducing the gap between webview_attached and load_started. |
| 1173 | - | a.applyAppearanceConfig(ctx, a.deps.Config) | 1183 | + | func (a *App) runAfterFirstLoadStarted(fn func()) { |
| 1174 | - | } | 1184 | + | a.deferredInitFn = fn |
| 1185 | + | } | |||
| 1175 | 1186 | ||||
| 1176 | - | // Start async filter loading after window is visible. | 1187 | + | // triggerDeferredInit is called from ContentCoordinator on first load_started. |
| 1177 | - | a.initFilteringAsync(ctx) | 1188 | + | // It runs deferred initialization at LOW priority so navigation continues unblocked. |
| 1189 | + | func (a *App) triggerDeferredInit(ctx context.Context) { | |||
| 1190 | + | a.deferredInitOnce.Do(func() { | |||
| 1191 | + | if a.deferredInitFn == nil { | |||
| 1192 | + | return | |||
| 1193 | + | } | |||
| 1194 | + | log := logging.FromContext(ctx) | |||
| 1195 | + | log.Debug().Msg("triggering deferred initialization") | |||
| 1178 | 1196 | ||||
| 1179 | - | // Check for config migration after window is visible. | 1197 | + | fn := a.deferredInitFn |
| 1180 | - | a.checkConfigMigration(ctx) | 1198 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 1199 | + | fn() | |||
| 1200 | + | return false | |||
| 1201 | + | }) | |||
| 1202 | + | // Use LOW priority so navigation processing continues first | |||
| 1203 | + | glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil) | |||
| 1204 | + | }) | |||
| 1181 | } | 1205 | } | ||
| 1182 | 1206 | ||||
| 1183 | // onShutdown is called when the GTK application is shutting down. | 1207 | // onShutdown is called when the GTK application is shutting down. | ||
| @@ -1251,6 +1275,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1251 | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | 1275 | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | ||
| 1252 | } | 1276 | } | ||
| 1253 | 1277 | ||||
| 1278 | + | // Wire deferred init trigger - runs after first navigation starts | |||
| 1279 | + | a.contentCoord.SetOnFirstLoadStarted(func() { | |||
| 1280 | + | a.triggerDeferredInit(ctx) | |||
| 1281 | + | }) | |||
| 1282 | + | ||||
| 1254 | // 2. Tab Coordinator | 1283 | // 2. Tab Coordinator | ||
| 1255 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | 1284 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | ||
| 1256 | TabsUC: a.tabsUC, | 1285 | TabsUC: a.tabsUC, | ||
M internal/ui/coordinator/content.go +19 -0
| @@ -98,6 +98,10 @@ type ContentCoordinator struct { | |||
| 98 | 98 | ||
| 99 | 99 | // Callback when WebView gains focus (for accent picker text input targeting) | |
| 100 | 100 | onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView) | |
| 101 | + | ||
| 102 | + | // Callback for first load_started event (triggers deferred initialization) | |
| 103 | + | onFirstLoadStarted func() | |
| 104 | + | loadStartedOnce sync.Once | |
| 101 | 105 | } | |
| 102 | 106 | ||
| 103 | 107 | type pendingThemeUpdate struct { | |
| @@ -176,6 +180,13 @@ func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, w | |||
| 176 | 180 | c.onWebViewFocused = fn | |
| 177 | 181 | } | |
| 178 | 182 | ||
| 183 | + | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | |
| 184 | + | // This is used to trigger deferred initialization after the initial load_uri() | |
| 185 | + | // has been processed by the GTK main loop. | |
| 186 | + | func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) { | |
| 187 | + | c.onFirstLoadStarted = fn | |
| 188 | + | } | |
| 189 | + | ||
| 179 | 190 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 180 | 191 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 181 | 192 | log := logging.FromContext(ctx) | |
| @@ -1033,6 +1044,14 @@ func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) { | |||
| 1033 | 1044 | func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { | |
| 1034 | 1045 | logging.Trace().Mark("load_started") | |
| 1035 | 1046 | ||
| 1047 | + | // Trigger deferred initialization on first load_started. | |
| 1048 | + | // This ensures non-critical init runs after initial navigation starts. | |
| 1049 | + | c.loadStartedOnce.Do(func() { | |
| 1050 | + | if c.onFirstLoadStarted != nil { | |
| 1051 | + | c.onFirstLoadStarted() | |
| 1052 | + | } | |
| 1053 | + | }) | |
| 1054 | + | ||
| 1036 | 1055 | _, wsView := c.getActiveWS() | |
| 1037 | 1056 | if wsView == nil { | |
| 1038 | 1057 | return | |
| @@ -98,6 +98,10 @@ type ContentCoordinator struct { | |||||
| 98 | 98 | ||||
| 99 | // Callback when WebView gains focus (for accent picker text input targeting) | 99 | // Callback when WebView gains focus (for accent picker text input targeting) | ||
| 100 | onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView) | 100 | onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView) | ||
| 101 | + | ||||
| 102 | + | // Callback for first load_started event (triggers deferred initialization) | |||
| 103 | + | onFirstLoadStarted func() | |||
| 104 | + | loadStartedOnce sync.Once | |||
| 101 | } | 105 | } | ||
| 102 | 106 | ||||
| 103 | type pendingThemeUpdate struct { | 107 | type pendingThemeUpdate struct { | ||
| @@ -176,6 +180,13 @@ func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, w | |||||
| 176 | c.onWebViewFocused = fn | 180 | c.onWebViewFocused = fn | ||
| 177 | } | 181 | } | ||
| 178 | 182 | ||||
| 183 | + | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | |||
| 184 | + | // This is used to trigger deferred initialization after the initial load_uri() | |||
| 185 | + | // has been processed by the GTK main loop. | |||
| 186 | + | func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) { | |||
| 187 | + | c.onFirstLoadStarted = fn | |||
| 188 | + | } | |||
| 189 | + | ||||
| 179 | // EnsureWebView acquires or reuses a WebView for the given pane. | 190 | // EnsureWebView acquires or reuses a WebView for the given pane. | ||
| 180 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | 191 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | ||
| 181 | log := logging.FromContext(ctx) | 192 | log := logging.FromContext(ctx) | ||
| @@ -1033,6 +1044,14 @@ func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) { | |||||
| 1033 | func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { | 1044 | func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { | ||
| 1034 | logging.Trace().Mark("load_started") | 1045 | logging.Trace().Mark("load_started") | ||
| 1035 | 1046 | ||||
| 1047 | + | // Trigger deferred initialization on first load_started. | |||
| 1048 | + | // This ensures non-critical init runs after initial navigation starts. | |||
| 1049 | + | c.loadStartedOnce.Do(func() { | |||
| 1050 | + | if c.onFirstLoadStarted != nil { | |||
| 1051 | + | c.onFirstLoadStarted() | |||
| 1052 | + | } | |||
| 1053 | + | }) | |||
| 1054 | + | ||||
| 1036 | _, wsView := c.getActiveWS() | 1055 | _, wsView := c.getActiveWS() | ||
| 1037 | if wsView == nil { | 1056 | if wsView == nil { | ||
| 1038 | return | 1057 | return | ||