1// Package bootstrap provides initialization utilities for the browser.
2package bootstrap
3
4import (
5 "context"
6 "database/sql"
7 "fmt"
8 "os"
9 "sync"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
16 "github.com/bnema/dumber/internal/infrastructure/config"
17 "github.com/bnema/dumber/internal/infrastructure/deps"
18 "github.com/bnema/dumber/internal/infrastructure/env"
19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
20 "github.com/bnema/dumber/internal/infrastructure/media"
21 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
22 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
23 "github.com/bnema/dumber/internal/logging"
24 "github.com/bnema/dumber/internal/ui/theme"
25)
26
27// DatabaseResult holds database connection and cleanup function.
28type DatabaseResult struct {
29 DB *sql.DB
30 Cleanup func()
31}
32
33// ParallelInitResult holds the results of parallel initialization phase.
34type ParallelInitResult struct {
35 RuntimeProfile runtimeprofile.Profile
36 ThemeManager *theme.Manager
37 ResolvedTheme entity.ResolvedTheme
38 ResolveThemeUC *usecase.ResolveThemeUseCase
39 ExternalThemeSource port.ConfigurableExternalThemeSource
40 ExternalThemeWatcher port.ExternalThemeWatcher
41 ColorResolver port.ColorSchemeResolver
42 AdwaitaDetector *colorscheme.AdwaitaDetector
43 Duration time.Duration
44}
45
46// DeferredInitResult holds results from deferred initialization checks.
47type DeferredInitResult struct {
48 RuntimeErr error
49 MediaErr error
50 Duration time.Duration
51}
52
53// ParallelInitInput holds the input for parallel initialization.
54type ParallelInitInput struct {
55 RuntimeProfile runtimeprofile.Profile
56 Ctx context.Context
57 Config *config.Config
58}
59
60// DeferredInitInput holds the input for deferred initialization.
61type DeferredInitInput struct {
62 Ctx context.Context
63 Config *config.Config
64}
65
66// RuntimeRequirementsError contains details about missing runtime dependencies.
67type RuntimeRequirementsError struct {
68 Checks []usecase.RuntimeDependencyStatus
69}
70
71func (*RuntimeRequirementsError) Error() string {
72 return "runtime requirements not met"
73}
74
75// LogDetails logs detailed information about missing dependencies.
76func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
77 log := logging.FromContext(ctx)
78 for _, c := range e.Checks {
79 if c.Installed {
80 log.Error().
81 Str("dependency", c.PkgConfigName).
82 Str("have", c.Version).
83 Str("need", c.RequiredVersion).
84 Bool("ok", c.MeetsRequirement).
85 Msg("runtime dependency")
86 } else {
87 log.Error().
88 Str("dependency", c.PkgConfigName).
89 Str("need", c.RequiredVersion).
90 Msg("runtime dependency missing")
91 }
92 }
93}
94
95// RunParallelInit runs the essential parallel initialization phase.
96// This includes directory resolution, color scheme resolver creation, and theme creation.
97// Returns the first fatal error encountered, or nil with the results.
98func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
99 var (
100 dirsErr error
101 themeErr error
102 wg sync.WaitGroup
103 )
104
105 profile := input.RuntimeProfile
106 if profile.Engine == "" {
107 var err error
108 profile, err = ResolveRuntimeProfile(input.Config)
109 if err != nil {
110 return nil, fmt.Errorf("resolve runtime profile: %w", err)
111 }
112 }
113
114 start := time.Now()
115
116 // Create color scheme resolver with initial detectors.
117 // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()).
118 configAdapter := colorscheme.NewConfigAdapter(input.Config)
119 resolver := colorscheme.NewResolver(configAdapter)
120
121 // Register detectors that are available before GTK init
122 resolver.RegisterDetector(colorscheme.NewEnvDetector())
123 resolver.RegisterDetector(colorscheme.NewGsettingsDetector())
124
125 // Create adwaita detector (will be marked available after adw.Init())
126 adwaitaDetector := colorscheme.NewAdwaitaDetector()
127 resolver.RegisterDetector(adwaitaDetector)
128
129 wg.Add(2)
130
131 // Resolve directories
132 go func() {
133 defer wg.Done()
134 dirsErr = resolveWebKitDirs(profile)
135 }()
136
137 // Theme manager. External theme file reads, when enabled, are non-fatal
138 // and reported as theme warnings by the usecase.
139 externalThemeSource := noctalia.NewFileSourceFromConfig(input.Config.Appearance.ExternalTheme)
140 resolveThemeUC := usecase.NewResolveThemeUseCase(externalThemeSource)
141 var themeManager *theme.Manager
142 var resolvedTheme entity.ResolvedTheme
143 go func() {
144 defer wg.Done()
145 preference := resolver.Resolve()
146 resolved, err := resolveThemeUC.Execute(input.Ctx, usecase.ResolveThemeInputFromConfig(
147 &input.Config.Appearance,
148 input.Config.DefaultUIScale,
149 &input.Config.Workspace.Styling,
150 preference,
151 ))
152 if err != nil {
153 themeErr = err
154 return
155 }
156 LogResolvedTheme(input.Ctx, resolved.Theme)
157 resolvedTheme = resolved.Theme
158 themeManager = theme.NewManager(input.Ctx, resolved.Theme)
159 }()
160
161 wg.Wait()
162 duration := time.Since(start)
163
164 // Return first fatal error
165 if dirsErr != nil {
166 return nil, fmt.Errorf("resolve directories: %w", dirsErr)
167 }
168 if themeErr != nil {
169 return nil, fmt.Errorf("resolve theme: %w", themeErr)
170 }
171
172 return &ParallelInitResult{
173 RuntimeProfile: profile,
174 ThemeManager: themeManager,
175 ResolvedTheme: resolvedTheme,
176 ResolveThemeUC: resolveThemeUC,
177 ExternalThemeSource: externalThemeSource,
178 ExternalThemeWatcher: noctalia.NewFileWatcher(),
179 ColorResolver: resolver,
180 AdwaitaDetector: adwaitaDetector,
181 Duration: duration,
182 }, nil
183}
184
185// LogResolvedTheme writes a compact theme-source summary and non-fatal warnings.
186func LogResolvedTheme(ctx context.Context, resolved entity.ResolvedTheme) {
187 log := logging.FromContext(ctx)
188 for _, warning := range resolved.Warnings {
189 log.Warn().Str("field", warning.Field).Msg(warning.Message)
190 }
191 event := log.Info().
192 Str("theme_source", string(resolved.ThemeSource.Kind)).
193 Str("color_scheme_source", resolved.ColorSchemeSource).
194 Bool("prefers_dark", resolved.PrefersDark)
195 if resolved.ThemeSource.Provider != "" {
196 event = event.Str("provider", resolved.ThemeSource.Provider)
197 }
198 if resolved.ThemeSource.LastGood {
199 event = event.Bool("last_good", true)
200 }
201 event.Msg("theme resolved")
202}
203
204// deferredInitProbeForTest, when non-nil, observes each deferred check
205// before it runs. Tests use it to prove engine selection skips checks
206// without invoking them. It must remain nil in production.
207var deferredInitProbeForTest func(kind string)
208
209// RunDeferredInit runs deferred initialization checks off the critical path.
210// Applicable checks are selected by engine: WebKit runs the pkg-config
211// runtime and GStreamer media diagnostics, while CEF skips both (they cover
212// the WebKit stack only) and reports no error. Explicit diagnostic commands
213// (doctor, media checks) invoke the Check* functions directly and are
214// unaffected by this selection.
215func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
216 start := time.Now()
217 if input.Config != nil && input.Config.Engine.ResolveEngineType() == config.EngineTypeCEF {
218 logging.FromContext(input.Ctx).Debug().Msg("skipping WebKit runtime/media diagnostics for CEF engine")
219 return DeferredInitResult{Duration: time.Since(start)}
220 }
221
222 var (
223 runtimeErr error
224 mediaErr error
225 wg sync.WaitGroup
226 )
227
228 wg.Add(2)
229
230 go func() {
231 defer wg.Done()
232 if deferredInitProbeForTest != nil {
233 deferredInitProbeForTest("runtime")
234 }
235 runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
236 }()
237
238 go func() {
239 defer wg.Done()
240 if deferredInitProbeForTest != nil {
241 deferredInitProbeForTest("media")
242 }
243 mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
244 }()
245
246 wg.Wait()
247
248 return DeferredInitResult{
249 RuntimeErr: runtimeErr,
250 MediaErr: mediaErr,
251 Duration: time.Since(start),
252 }
253}
254
255// CheckRuntimeRequirements verifies GUI runtime dependencies currently covered by runtime checks.
256// Returns error if requirements are not met; caller should log details and exit.
257// Note: When running in a Flatpak sandbox, runtime checks are skipped because
258// the Flatpak runtime provides all required libraries.
259func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
260 // Skip pkg-config checks in Flatpak - the runtime provides all dependencies
261 if env.IsFlatpak() {
262 log := logging.FromContext(ctx)
263 log.Debug().Msg("running in Flatpak sandbox, skipping runtime dependency checks")
264 return nil
265 }
266
267 probe := deps.NewPkgConfigProbe()
268 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
269 runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
270 Prefix: cfg.Engine.WebKit.Prefix,
271 })
272 if err != nil {
273 return fmt.Errorf("runtime check execution failed: %w", err)
274 }
275 if runtimeOut.OK {
276 return nil
277 }
278 return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
279}
280
281// CheckMediaRequirements verifies GStreamer and media codec availability.
282// Returns error if media subsystem initialization fails.
283func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
284 mediaDiagAdapter := media.New()
285 checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
286 if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
287 ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
288 }); err != nil {
289 return fmt.Errorf("media check failed: %w", err)
290 }
291 return nil
292}
293
294// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
295func resolveWebKitDirs(profile runtimeprofile.Profile) error {
296 const dirPerm = 0o755
297 dataDir := profile.WebKitDataDir()
298 cacheDir := profile.WebKitCacheDir()
299 if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil {
300 return fmt.Errorf("create data directory %s: %w", dataDir, mkErr)
301 }
302 if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil {
303 return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
304 }
305 return nil
306}
307
308// OpenDatabase opens and initializes the SQLite database using XDG paths.
309// Returns error if database cannot be opened or migrations fail.
310func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
311 dbPath, err := config.GetDatabaseFile()
312 if err != nil {
313 return nil, fmt.Errorf("resolve database path: %w", err)
314 }
315 db, err := sqlite.NewConnection(ctx, dbPath)
316 if err != nil {
317 return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
318 }
319 return &DatabaseResult{
320 DB: db,
321 Cleanup: func() {
322 _ = sqlite.Close(db)
323 },
324 }, nil
325}
326
327// CreateLazyDatabase creates a lazy database provider that defers initialization.
328// The database is initialized on first access, allowing the UI to render faster.
329//
330// Currently unused: the application uses OpenDatabase with RunParallelDBEngine for
331// eager initialization. This function is kept for potential future use when lazy
332// initialization past first paint becomes beneficial.
333func CreateLazyDatabase() (*sqlite.LazyDB, error) {
334 dbPath, err := config.GetDatabaseFile()
335 if err != nil {
336 return nil, fmt.Errorf("resolve database path: %w", err)
337 }
338 return sqlite.NewLazyDB(dbPath), nil
339}
340
341// ParallelDBEngineResult holds results from parallel DB and engine initialization.
342type ParallelDBEngineResult struct {
343 DB *sql.DB
344 DBCleanup func()
345 Engine port.Engine
346}
347
348// ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
349type ParallelDBEngineInput struct {
350 Ctx context.Context
351 Config *config.Config
352 RuntimeProfile runtimeprofile.Profile
353 ThemeManager *theme.Manager
354 ExternalThemeSource port.ExternalThemeSource
355 ColorResolver port.ColorSchemeResolver
356}
357
358// Note: Database path is resolved via config.GetDatabaseFile() internally.
359
360// dbInitResult holds the result of database initialization for channel communication.
361type dbInitResult struct {
362 db *sql.DB
363 cleanup func()
364 err error
365}
366
367// RunParallelDBEngine initializes database in background while the engine runs on main thread.
368// Database init happens in goroutine (pure Go/WASM), engine must stay on main thread (GTK).
369// This saves ~150ms by overlapping DB migrations with engine context creation.
370func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, error) {
371 log := logging.FromContext(input.Ctx)
372
373 dbCh := make(chan dbInitResult, 1)
374
375 // Database initialization in background (WASM is thread-safe)
376 go func() {
377 dbResult, err := OpenDatabase(input.Ctx)
378 if err != nil {
379 dbCh <- dbInitResult{err: err}
380 return
381 }
382 dbCh <- dbInitResult{
383 db: dbResult.DB,
384 cleanup: dbResult.Cleanup,
385 }
386 }()
387
388 // Engine on main thread (GTK requirement)
389 engine, err := BuildEngine(EngineInput{
390 Ctx: input.Ctx,
391 Config: input.Config,
392 RuntimeProfile: input.RuntimeProfile,
393 ThemeManager: input.ThemeManager,
394 ExternalThemeSource: input.ExternalThemeSource,
395 ColorResolver: input.ColorResolver,
396 Logger: *log,
397 })
398 if err != nil {
399 dbRes := <-dbCh
400 if dbRes.err == nil && dbRes.cleanup != nil {
401 dbRes.cleanup()
402 }
403 return nil, fmt.Errorf("engine initialization: %w", err)
404 }
405
406 // Wait for database
407 dbRes := <-dbCh
408
409 if dbRes.err != nil {
410 return nil, fmt.Errorf("database initialization: %w", dbRes.err)
411 }
412
413 return &ParallelDBEngineResult{
414 DB: dbRes.db,
415 DBCleanup: dbRes.cleanup,
416 Engine: engine,
417 }, nil
418}