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// RunDeferredInit runs deferred initialization checks off the critical path.
200// This includes runtime requirements and media checks.
201func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
202 var (
203 runtimeErr error
204 mediaErr error
205 wg sync.WaitGroup
206 )
207
208 start := time.Now()
209 wg.Add(2)
210
211 go func() {
212 defer wg.Done()
213 runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
214 }()
215
216 go func() {
217 defer wg.Done()
218 mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
219 }()
220
221 wg.Wait()
222
223 return DeferredInitResult{
224 RuntimeErr: runtimeErr,
225 MediaErr: mediaErr,
226 Duration: time.Since(start),
227 }
228}
229
230// CheckRuntimeRequirements verifies GUI runtime dependencies currently covered by runtime checks.
231// Returns error if requirements are not met; caller should log details and exit.
232// Note: When running in a Flatpak sandbox, runtime checks are skipped because
233// the Flatpak runtime provides all required libraries.
234func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
235 // Skip pkg-config checks in Flatpak - the runtime provides all dependencies
236 if env.IsFlatpak() {
237 log := logging.FromContext(ctx)
238 log.Debug().Msg("running in Flatpak sandbox, skipping runtime dependency checks")
239 return nil
240 }
241
242 probe := deps.NewPkgConfigProbe()
243 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
244 runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
245 Prefix: cfg.Engine.WebKit.Prefix,
246 })
247 if err != nil {
248 return fmt.Errorf("runtime check execution failed: %w", err)
249 }
250 if runtimeOut.OK {
251 return nil
252 }
253 return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
254}
255
256// CheckMediaRequirements verifies GStreamer and media codec availability.
257// Returns error if media subsystem initialization fails.
258func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
259 mediaDiagAdapter := media.New()
260 checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
261 if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
262 ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
263 }); err != nil {
264 return fmt.Errorf("media check failed: %w", err)
265 }
266 return nil
267}
268
269// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
270func resolveWebKitDirs(profile runtimeprofile.Profile) error {
271 const dirPerm = 0o755
272 dataDir := profile.WebKitDataDir()
273 cacheDir := profile.WebKitCacheDir()
274 if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil {
275 return fmt.Errorf("create data directory %s: %w", dataDir, mkErr)
276 }
277 if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil {
278 return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
279 }
280 return nil
281}
282
283// OpenDatabase opens and initializes the SQLite database using XDG paths.
284// Returns error if database cannot be opened or migrations fail.
285func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
286 dbPath, err := config.GetDatabaseFile()
287 if err != nil {
288 return nil, fmt.Errorf("resolve database path: %w", err)
289 }
290 db, err := sqlite.NewConnection(ctx, dbPath)
291 if err != nil {
292 return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
293 }
294 return &DatabaseResult{
295 DB: db,
296 Cleanup: func() {
297 _ = sqlite.Close(db)
298 },
299 }, nil
300}
301
302// CreateLazyDatabase creates a lazy database provider that defers initialization.
303// The database is initialized on first access, allowing the UI to render faster.
304//
305// Currently unused: the application uses OpenDatabase with RunParallelDBEngine for
306// eager initialization. This function is kept for potential future use when lazy
307// initialization past first paint becomes beneficial.
308func CreateLazyDatabase() (*sqlite.LazyDB, error) {
309 dbPath, err := config.GetDatabaseFile()
310 if err != nil {
311 return nil, fmt.Errorf("resolve database path: %w", err)
312 }
313 return sqlite.NewLazyDB(dbPath), nil
314}
315
316// ParallelDBEngineResult holds results from parallel DB and engine initialization.
317type ParallelDBEngineResult struct {
318 DB *sql.DB
319 DBCleanup func()
320 Engine port.Engine
321}
322
323// ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
324type ParallelDBEngineInput struct {
325 Ctx context.Context
326 Config *config.Config
327 RuntimeProfile runtimeprofile.Profile
328 ThemeManager *theme.Manager
329 ExternalThemeSource port.ExternalThemeSource
330 ColorResolver port.ColorSchemeResolver
331}
332
333// Note: Database path is resolved via config.GetDatabaseFile() internally.
334
335// dbInitResult holds the result of database initialization for channel communication.
336type dbInitResult struct {
337 db *sql.DB
338 cleanup func()
339 err error
340}
341
342// RunParallelDBEngine initializes database in background while the engine runs on main thread.
343// Database init happens in goroutine (pure Go/WASM), engine must stay on main thread (GTK).
344// This saves ~150ms by overlapping DB migrations with engine context creation.
345func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, error) {
346 log := logging.FromContext(input.Ctx)
347
348 dbCh := make(chan dbInitResult, 1)
349
350 // Database initialization in background (WASM is thread-safe)
351 go func() {
352 dbResult, err := OpenDatabase(input.Ctx)
353 if err != nil {
354 dbCh <- dbInitResult{err: err}
355 return
356 }
357 dbCh <- dbInitResult{
358 db: dbResult.DB,
359 cleanup: dbResult.Cleanup,
360 }
361 }()
362
363 // Engine on main thread (GTK requirement)
364 engine, err := BuildEngine(EngineInput{
365 Ctx: input.Ctx,
366 Config: input.Config,
367 RuntimeProfile: input.RuntimeProfile,
368 ThemeManager: input.ThemeManager,
369 ExternalThemeSource: input.ExternalThemeSource,
370 ColorResolver: input.ColorResolver,
371 Logger: *log,
372 })
373 if err != nil {
374 dbRes := <-dbCh
375 if dbRes.err == nil && dbRes.cleanup != nil {
376 dbRes.cleanup()
377 }
378 return nil, fmt.Errorf("engine initialization: %w", err)
379 }
380
381 // Wait for database
382 dbRes := <-dbCh
383
384 if dbRes.err != nil {
385 return nil, fmt.Errorf("database initialization: %w", dbRes.err)
386 }
387
388 return &ParallelDBEngineResult{
389 DB: dbRes.db,
390 DBCleanup: dbRes.cleanup,
391 Engine: engine,
392 }, nil
393}