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	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}

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