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