v0.32.0 0617278

gui.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close