detached d3cff9d

Merge pull request #47 from bnema/refactor/cold-start-optimization

perf: cold start optimization with parallel init and WebView prewarming
commit
d3cff9dd27da2160804c4faf865caeb156fab406
author
bnema · · 2025-12-27 22:35 UTC
committer
GitHub · · 2025-12-27 22:35 UTC
merge of
c13e49e + 9df21b1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1930 -201
M .mockery.yaml +1 -0
@@ -9,6 +9,7 @@ packages:
99 pkgname: mocks
1010 interfaces:
1111 ConfigMigrator: {}
12+ DatabaseProvider: {}
1213 SessionLogger: {}
1314 SessionSpawner: {}
1415 TabListProvider: {}
@@ -9,6 +9,7 @@ packages:
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11 ConfigMigrator: {}11 ConfigMigrator: {}
12+ DatabaseProvider: {}
12 SessionLogger: {}13 SessionLogger: {}
13 SessionSpawner: {}14 SessionSpawner: {}
14 TabListProvider: {}15 TabListProvider: {}
M CHANGELOG.md +15 -0
@@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file.
1919 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
2020 - New config: `workspace.switch_to_tab_on_move` (default: true).
2121
22+### Changed
23+- **Cold start optimization**: Reduced startup time by ~150ms (29% faster) through parallel initialization.
24+ - New `internal/bootstrap` package encapsulates all startup orchestration.
25+ - `RunParallelInit()` runs 5 goroutines concurrently: directory resolution, pkg-config check, GStreamer check, theme manager creation, and SQLite WASM pre-compilation.
26+ - WebView pool prewarming creates one WebView synchronously so first `Acquire()` is instant.
27+ - Async session cleanup: stale session marking runs in background, doesn't block startup.
28+ - `StartupTimer` logs timing for each bootstrap phase (parallel init, DB/WebKit, app creation).
29+ - Database initialization runs in background goroutine while WebKit stack initializes on main thread.
30+ - Database path now resolved via `config.GetDatabaseFile()` following clean architecture.
31+ - Added connection pool settings optimized for SQLite (`SetMaxOpenConns(1)`, keep-alive).
32+ - Reduced `mmap_size` from 30GB to 256MB (reasonable for browser history DB).
33+ - Added `PRAGMA foreign_keys = ON` for referential integrity.
34+- **Startup deferrals**: Runtime/media checks and SQLite WASM precompile now run after first paint, and DB initialization uses lazy repositories when auto-restore is off to cut cold-start latency further.
35+- **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
36+
2237 ### Fixed
2338 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
2439 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
@@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file.
19 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).19 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
20 - New config: `workspace.switch_to_tab_on_move` (default: true).20 - New config: `workspace.switch_to_tab_on_move` (default: true).
21 21
22+### Changed
23+- **Cold start optimization**: Reduced startup time by ~150ms (29% faster) through parallel initialization.
24+ - New `internal/bootstrap` package encapsulates all startup orchestration.
25+ - `RunParallelInit()` runs 5 goroutines concurrently: directory resolution, pkg-config check, GStreamer check, theme manager creation, and SQLite WASM pre-compilation.
26+ - WebView pool prewarming creates one WebView synchronously so first `Acquire()` is instant.
27+ - Async session cleanup: stale session marking runs in background, doesn't block startup.
28+ - `StartupTimer` logs timing for each bootstrap phase (parallel init, DB/WebKit, app creation).
29+ - Database initialization runs in background goroutine while WebKit stack initializes on main thread.
30+ - Database path now resolved via `config.GetDatabaseFile()` following clean architecture.
31+ - Added connection pool settings optimized for SQLite (`SetMaxOpenConns(1)`, keep-alive).
32+ - Reduced `mmap_size` from 30GB to 256MB (reasonable for browser history DB).
33+ - Added `PRAGMA foreign_keys = ON` for referential integrity.
34+- **Startup deferrals**: Runtime/media checks and SQLite WASM precompile now run after first paint, and DB initialization uses lazy repositories when auto-restore is off to cut cold-start latency further.
35+- **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
36+
22 ### Fixed37 ### Fixed
23 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.38 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
24 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.39 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
M cmd/dumber/main.go +193 -133
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "os/signal"
9- "path/filepath"
109 "runtime"
1110 "syscall"
1211
@@ -23,16 +22,14 @@ import (
2322 "github.com/bnema/dumber/internal/infrastructure/deps"
2423 "github.com/bnema/dumber/internal/infrastructure/favicon"
2524 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
2725 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2826 "github.com/bnema/dumber/internal/infrastructure/updater"
2927 "github.com/bnema/dumber/internal/logging"
3028 "github.com/bnema/dumber/internal/ui"
3129 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
3330 )
3431
35-// Build-time variables (set via ldflags)
32+// Build-time variables (set via ldflags).
3633 var (
3734 version = "dev"
3835 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
4845 func main() {
4946 // Run GUI mode for browse command
5047 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
5248 if len(os.Args) > 2 {
5349 initialURL = os.Args[2]
5450 }
55- // Check for session restoration via environment variable
5651 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
5852 os.Args = os.Args[:1]
5953 os.Exit(runGUI())
6054 return
@@ -73,75 +67,58 @@ func main() {
7367 }
7468
7569 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
7770 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
7872
7973 cfg := initConfig()
74+ timer.Mark("config")
8075
81- // Apply optional /opt-style runtime prefix overrides (if configured)
82- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
83-
84- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
85- bootstrapLogger.Info().
86- Str("version", version).
87- Str("commit", commit).
88- Str("build_date", buildDate).
89- Msg("starting dumber")
76+ ctx := initStartupContext(cfg)
77+ timer.Mark("logger")
78+ bootstrapLog := logging.FromContext(ctx)
9079
91- ctx := logging.WithContext(context.Background(), bootstrapLogger)
80+ // Parallel phase: directories and theme setup
81+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
82+ Ctx: ctx,
83+ Config: cfg,
84+ })
85+ if err != nil {
86+ handleParallelInitError(ctx, err)
87+ return 1
88+ }
89+ timer.MarkDuration("parallel_phase", initResult.Duration)
9290
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
9492
95- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)
93+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
94+ if err != nil {
95+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
96+ }
9697 if dbCleanup != nil {
9798 defer dbCleanup()
9899 }
100+ timer.Mark("db_webkit_parallel")
99101
100- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
101103 if err != nil {
102- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
103105 }
104106 if browserSession.LogCleanup != nil {
105107 defer browserSession.LogCleanup()
106108 }
107- defer func() {
108- _ = browserSession.End(sessionCtx)
109- }()
109+ defer func() { _ = browserSession.End(sessionCtx) }()
110+ timer.Mark("session")
110111
111112 ctx = sessionCtx
112113 log := logging.FromContext(ctx)
113-
114- checkRuntimeRequirements(ctx, cfg, *log)
115- checkMediaRequirements(ctx, cfg, *log)
116-
117- themeManager := theme.NewManager(ctx, cfg)
118- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
119- repos := createRepositories(db)
120-
121- stateDir, _ := config.GetStateDir()
122- buildInfo := build.Info{
123- Version: version,
124- Commit: commit,
125- BuildDate: buildDate,
126- GoVersion: runtime.Version(),
114+ if stack.MessageRouter != nil {
115+ stack.MessageRouter.SetBaseContext(ctx)
127116 }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129117
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested
131- if cfg.Session.AutoRestore && restoreSessionID == "" {
132- var out *usecase.GetLastRestorableSessionOutput
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
134- ExcludeSessionID: browserSession.Session.ID,
135- })
136- if err != nil {
137- log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
138- } else if out.SessionID != "" {
139- restoreSessionID = string(out.SessionID)
140- log.Info().
141- Str("session_id", restoreSessionID).
142- Int("tabs", len(out.State.Tabs)).
143- Msg("auto-restore: found last session")
144- }
118+ // Repositories and use cases
119+ useCases := createUseCases(repos, cfg)
120+ if needsEagerDB {
121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
145122 }
146123
147124 idleInhibitor := idle.NewPortalInhibitor(ctx)
@@ -150,29 +127,99 @@ func runGUI() int {
150127 _ = idleInhibitor.Close()
151128 }
152129 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
130+ timer.Mark("use_cases")
154131
132+ // Build UI
133+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
155135 app, err := ui.New(uiDeps)
156136 if err != nil {
157137 log.Error().Err(err).Msg("failed to create application")
158138 return 1
159139 }
140+ timer.Mark("ui_deps")
141+ timer.Log(ctx)
160142
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.
162- // Without this, the process may exit immediately and skip defers,
163- // leaving the DB session stuck in "active" state.
164- sigCh := make(chan os.Signal, 1)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
166- defer signal.Stop(sigCh)
167- go func() {
168- <-sigCh
169- log.Info().Msg("received interrupt, quitting")
170- app.Quit()
171- }()
143+ // Signal handling
144+ setupSignalHandler(ctx, app)
145+
146+ return app.Run(ctx, os.Args)
147+}
148+
149+func initStartupContext(cfg *config.Config) context.Context {
150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
152+ cfg.Logging.Level,
153+ cfg.Logging.Format,
154+ logging.ConsoleTimeFormat,
155+ )
156+ bootstrapLogger.Info().
157+ Str("version", version).
158+ Str("commit", commit).
159+ Str("build_date", buildDate).
160+ Msg("starting dumber")
161+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
162+ return ctx
163+}
164+
165+func initStackAndRepos(
166+ ctx context.Context,
167+ cfg *config.Config,
168+ initResult *bootstrap.ParallelInitResult,
169+ needsEagerDB bool,
170+) (bootstrap.WebKitStack, *repositories, func(), error) {
171+ if needsEagerDB {
172+ // Parallel phase 2: Database + WebKit stack initialize concurrently
173+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174+ Ctx: ctx,
175+ Config: cfg,
176+ DataDir: initResult.DataDir,
177+ CacheDir: initResult.CacheDir,
178+ ThemeManager: initResult.ThemeManager,
179+ })
180+ if err != nil {
181+ return bootstrap.WebKitStack{}, nil, nil, err
182+ }
183+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
184+ }
172185
173- // Run the application
174- exitCode := app.Run(ctx, os.Args)
175- return exitCode
186+ log := logging.FromContext(ctx)
187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
188+
189+ lazyDB, err := bootstrap.CreateLazyDatabase()
190+ if err != nil {
191+ return stack, nil, nil, err
192+ }
193+ dbCleanup := func() { _ = lazyDB.Close() }
194+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
195+}
196+
197+func configureDeferredInit(
198+ uiDeps *ui.Dependencies,
199+ cfg *config.Config,
200+ session *bootstrap.BrowserSession,
201+) {
202+ if uiDeps == nil {
203+ return
204+ }
205+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
206+ logger := logging.FromContext(cbCtx)
207+ bgCtx := logging.WithContext(context.Background(), *logger)
208+ go func() {
209+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
210+ Ctx: bgCtx,
211+ Config: cfg,
212+ })
213+ logDeferredInitResults(bgCtx, result)
214+ }()
215+ if session != nil && session.Persist != nil {
216+ go func() {
217+ if persistErr := session.Persist(bgCtx); persistErr != nil {
218+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
219+ }
220+ }()
221+ }
222+ }
176223 }
177224
178225 func initConfig() *config.Config {
@@ -183,77 +230,74 @@ func initConfig() *config.Config {
183230 return config.Get()
184231 }
185232
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
187- probe := deps.NewPkgConfigProbe()
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
190- Prefix: cfg.Runtime.Prefix,
191- })
192- if err != nil {
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
233+func handleParallelInitError(ctx context.Context, err error) {
234+ log := logging.FromContext(ctx)
235+ if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
236+ runtimeErr.LogDetails(ctx)
237+ log.Fatal().Err(runtimeErr).
238+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
239+ Msg("runtime requirements not met")
194240 }
195- if runtimeOut.OK {
196- return
241+ log.Fatal().Err(err).Msg("initialization failed")
242+}
243+
244+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
245+ log := logging.FromContext(ctx)
246+ if result.SQLiteErr != nil {
247+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
197248 }
198- for _, c := range runtimeOut.Checks {
199- if c.Installed {
200- logger.Error().
201- Str("dependency", c.PkgConfigName).
202- Str("have", c.Version).
203- Str("need", c.RequiredVersion).
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
249+ if result.RuntimeErr != nil {
250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
251+ runtimeErr.LogDetails(ctx)
252+ log.Warn().Err(runtimeErr).
253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
254+ Msg("runtime requirements not met")
206255 } else {
207- logger.Error().
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
211257 }
212258 }
213- logger.Fatal().
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
215- Msg("runtime requirements not met")
216-}
217-
218-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
219- mediaDiagAdapter := media.New()
220- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
221- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
222- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
223- }); mediaErr != nil {
224- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
259+ if result.MediaErr != nil {
260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
225261 }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
226263 }
227264
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {
229- const cacheDirPerm = 0o755
230- dataDir, err := config.GetDataDir()
231- if err != nil {
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")
265+func handleAutoRestore(
266+ ctx context.Context,
267+ cfg *config.Config,
268+ uc *useCases,
269+ currentSessionID entity.SessionID,
270+) {
271+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
272+ return
233273 }
234- stateDir, err := config.GetStateDir()
274+ log := logging.FromContext(ctx)
275+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
276+ ExcludeSessionID: currentSessionID,
277+ })
235278 if err != nil {
236- logger.Fatal().Err(err).Msg("failed to resolve state directory")
279+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
280+ return
237281 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
282+ if out.SessionID != "" {
283+ restoreSessionID = string(out.SessionID)
284+ log.Info().
285+ Str("session_id", restoreSessionID).
286+ Int("tabs", len(out.State.Tabs)).
287+ Msg("auto-restore: found last session")
241288 }
242- return dataDir, cacheDir
243289 }
244290
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {
246- dbPath := filepath.Join(dataDir, "dumber.db")
247- db, err := sqlite.NewConnection(ctx, dbPath)
248- if err != nil {
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
250- }
251- cleanup := func() {
252- if err := sqlite.Close(db); err != nil {
253- logger.Error().Err(err).Msg("failed to close database")
254- }
255- }
256- return db, cleanup
291+func setupSignalHandler(ctx context.Context, app *ui.App) {
292+ log := logging.FromContext(ctx)
293+ sigCh := make(chan os.Signal, 1)
294+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
295+ go func() {
296+ sig := <-sigCh
297+ signal.Stop(sigCh)
298+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
299+ app.Quit()
300+ }()
257301 }
258302
259303 // repositories groups infrastructure layer repository implementations.
@@ -279,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
279323 }
280324 }
281325
326+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
327+ return &repositories{
328+ history: sqlite.NewLazyHistoryRepository(provider),
329+ favorite: sqlite.NewLazyFavoriteRepository(provider),
330+ folder: sqlite.NewLazyFolderRepository(provider),
331+ tag: sqlite.NewLazyTagRepository(provider),
332+ zoom: sqlite.NewLazyZoomRepository(provider),
333+ session: sqlite.NewLazySessionRepository(provider),
334+ sessionState: sqlite.NewLazySessionStateRepository(provider),
335+ }
336+}
337+
282338 // useCases groups application layer use case implementations.
283339 type useCases struct {
284340 tabs *usecase.ManageTabsUseCase
@@ -296,7 +352,7 @@ type useCases struct {
296352 favicon *favicon.Service
297353 }
298354
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {
355+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300356 const idAlphabetSize = 26
301357 idCounter := uint64(0)
302358 idGenerator := func() string {
@@ -306,13 +362,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306362
307363 clipboardAdapter := clipboard.New()
308364 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()
365+ xdgDirs, _ := config.GetXDGDirs()
366+ stateDir, _ := config.GetStateDir()
310367 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313368 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314369
315- // Create update infrastructure
370+ buildInfo := build.Info{
371+ Version: version,
372+ Commit: commit,
373+ BuildDate: buildDate,
374+ GoVersion: runtime.Version(),
375+ }
316376 updateChecker := updater.NewGitHubChecker()
317377 updateDownloader := updater.NewGitHubDownloader()
318378 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +388,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328388 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329389 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330390 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),
391+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332392 clipboard: clipboardAdapter,
333393 favicon: favicon.NewService(faviconCacheDir),
334394 }
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9- "path/filepath"
10 "runtime"9 "runtime"
11 "syscall"10 "syscall"
12 11
@@ -23,16 +22,14 @@ import (
23 "github.com/bnema/dumber/internal/infrastructure/deps"22 "github.com/bnema/dumber/internal/infrastructure/deps"
24 "github.com/bnema/dumber/internal/infrastructure/favicon"23 "github.com/bnema/dumber/internal/infrastructure/favicon"
25 "github.com/bnema/dumber/internal/infrastructure/idle"24 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
27 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"25 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
28 "github.com/bnema/dumber/internal/infrastructure/updater"26 "github.com/bnema/dumber/internal/infrastructure/updater"
29 "github.com/bnema/dumber/internal/logging"27 "github.com/bnema/dumber/internal/logging"
30 "github.com/bnema/dumber/internal/ui"28 "github.com/bnema/dumber/internal/ui"
31 "github.com/bnema/dumber/internal/ui/theme"29 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
33 )30 )
34 31
35-// Build-time variables (set via ldflags)32+// Build-time variables (set via ldflags).
36 var (33 var (
37 version = "dev"34 version = "dev"
38 commit = "unknown"35 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
48 func main() {45 func main() {
49 // Run GUI mode for browse command46 // Run GUI mode for browse command
50 if len(os.Args) > 1 && os.Args[1] == "browse" {47 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
52 if len(os.Args) > 2 {48 if len(os.Args) > 2 {
53 initialURL = os.Args[2]49 initialURL = os.Args[2]
54 }50 }
55- // Check for session restoration via environment variable
56 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")51 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
58 os.Args = os.Args[:1]52 os.Args = os.Args[:1]
59 os.Exit(runGUI())53 os.Exit(runGUI())
60 return54 return
@@ -73,75 +67,58 @@ func main() {
73 }67 }
74 68
75 func runGUI() int {69 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
77 runtime.LockOSThread()70 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
78 72
79 cfg := initConfig()73 cfg := initConfig()
74+ timer.Mark("config")
80 75
81- // Apply optional /opt-style runtime prefix overrides (if configured)76+ ctx := initStartupContext(cfg)
82- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)77+ timer.Mark("logger")
83-78+ bootstrapLog := logging.FromContext(ctx)
84- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
85- bootstrapLogger.Info().
86- Str("version", version).
87- Str("commit", commit).
88- Str("build_date", buildDate).
89- Msg("starting dumber")
90 79
91- ctx := logging.WithContext(context.Background(), bootstrapLogger)80+ // Parallel phase: directories and theme setup
81+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
82+ Ctx: ctx,
83+ Config: cfg,
84+ })
85+ if err != nil {
86+ handleParallelInitError(ctx, err)
87+ return 1
88+ }
89+ timer.MarkDuration("parallel_phase", initResult.Duration)
92 90
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
94 92
95- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)93+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
94+ if err != nil {
95+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
96+ }
96 if dbCleanup != nil {97 if dbCleanup != nil {
97 defer dbCleanup()98 defer dbCleanup()
98 }99 }
100+ timer.Mark("db_webkit_parallel")
99 101
100- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
101 if err != nil {103 if err != nil {
102- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
103 }105 }
104 if browserSession.LogCleanup != nil {106 if browserSession.LogCleanup != nil {
105 defer browserSession.LogCleanup()107 defer browserSession.LogCleanup()
106 }108 }
107- defer func() {109+ defer func() { _ = browserSession.End(sessionCtx) }()
108- _ = browserSession.End(sessionCtx)110+ timer.Mark("session")
109- }()
110 111
111 ctx = sessionCtx112 ctx = sessionCtx
112 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
113-114+ if stack.MessageRouter != nil {
114- checkRuntimeRequirements(ctx, cfg, *log)115+ stack.MessageRouter.SetBaseContext(ctx)
115- checkMediaRequirements(ctx, cfg, *log)
116-
117- themeManager := theme.NewManager(ctx, cfg)
118- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
119- repos := createRepositories(db)
120-
121- stateDir, _ := config.GetStateDir()
122- buildInfo := build.Info{
123- Version: version,
124- Commit: commit,
125- BuildDate: buildDate,
126- GoVersion: runtime.Version(),
127 }116 }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129 117
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested118+ // Repositories and use cases
131- if cfg.Session.AutoRestore && restoreSessionID == "" {119+ useCases := createUseCases(repos, cfg)
132- var out *usecase.GetLastRestorableSessionOutput120+ if needsEagerDB {
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
134- ExcludeSessionID: browserSession.Session.ID,
135- })
136- if err != nil {
137- log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
138- } else if out.SessionID != "" {
139- restoreSessionID = string(out.SessionID)
140- log.Info().
141- Str("session_id", restoreSessionID).
142- Int("tabs", len(out.State.Tabs)).
143- Msg("auto-restore: found last session")
144- }
145 }122 }
146 123
147 idleInhibitor := idle.NewPortalInhibitor(ctx)124 idleInhibitor := idle.NewPortalInhibitor(ctx)
@@ -150,29 +127,99 @@ func runGUI() int {
150 _ = idleInhibitor.Close()127 _ = idleInhibitor.Close()
151 }128 }
152 }()129 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)130+ timer.Mark("use_cases")
154 131
132+ // Build UI
133+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
155 app, err := ui.New(uiDeps)135 app, err := ui.New(uiDeps)
156 if err != nil {136 if err != nil {
157 log.Error().Err(err).Msg("failed to create application")137 log.Error().Err(err).Msg("failed to create application")
158 return 1138 return 1
159 }139 }
140+ timer.Mark("ui_deps")
141+ timer.Log(ctx)
160 142
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.143+ // Signal handling
162- // Without this, the process may exit immediately and skip defers,144+ setupSignalHandler(ctx, app)
163- // leaving the DB session stuck in "active" state.145+
164- sigCh := make(chan os.Signal, 1)146+ return app.Run(ctx, os.Args)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)147+}
166- defer signal.Stop(sigCh)148+
167- go func() {149+func initStartupContext(cfg *config.Config) context.Context {
168- <-sigCh150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
169- log.Info().Msg("received interrupt, quitting")151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
170- app.Quit()152+ cfg.Logging.Level,
171- }()153+ cfg.Logging.Format,
154+ logging.ConsoleTimeFormat,
155+ )
156+ bootstrapLogger.Info().
157+ Str("version", version).
158+ Str("commit", commit).
159+ Str("build_date", buildDate).
160+ Msg("starting dumber")
161+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
162+ return ctx
163+}
164+
165+func initStackAndRepos(
166+ ctx context.Context,
167+ cfg *config.Config,
168+ initResult *bootstrap.ParallelInitResult,
169+ needsEagerDB bool,
170+) (bootstrap.WebKitStack, *repositories, func(), error) {
171+ if needsEagerDB {
172+ // Parallel phase 2: Database + WebKit stack initialize concurrently
173+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174+ Ctx: ctx,
175+ Config: cfg,
176+ DataDir: initResult.DataDir,
177+ CacheDir: initResult.CacheDir,
178+ ThemeManager: initResult.ThemeManager,
179+ })
180+ if err != nil {
181+ return bootstrap.WebKitStack{}, nil, nil, err
182+ }
183+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
184+ }
172 185
173- // Run the application186+ log := logging.FromContext(ctx)
174- exitCode := app.Run(ctx, os.Args)187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
175- return exitCode188+
189+ lazyDB, err := bootstrap.CreateLazyDatabase()
190+ if err != nil {
191+ return stack, nil, nil, err
192+ }
193+ dbCleanup := func() { _ = lazyDB.Close() }
194+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
195+}
196+
197+func configureDeferredInit(
198+ uiDeps *ui.Dependencies,
199+ cfg *config.Config,
200+ session *bootstrap.BrowserSession,
201+) {
202+ if uiDeps == nil {
203+ return
204+ }
205+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
206+ logger := logging.FromContext(cbCtx)
207+ bgCtx := logging.WithContext(context.Background(), *logger)
208+ go func() {
209+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
210+ Ctx: bgCtx,
211+ Config: cfg,
212+ })
213+ logDeferredInitResults(bgCtx, result)
214+ }()
215+ if session != nil && session.Persist != nil {
216+ go func() {
217+ if persistErr := session.Persist(bgCtx); persistErr != nil {
218+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
219+ }
220+ }()
221+ }
222+ }
176 }223 }
177 224
178 func initConfig() *config.Config {225 func initConfig() *config.Config {
@@ -183,77 +230,74 @@ func initConfig() *config.Config {
183 return config.Get()230 return config.Get()
184 }231 }
185 232
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {233+func handleParallelInitError(ctx context.Context, err error) {
187- probe := deps.NewPkgConfigProbe()234+ log := logging.FromContext(ctx)
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)235+ if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{236+ runtimeErr.LogDetails(ctx)
190- Prefix: cfg.Runtime.Prefix,237+ log.Fatal().Err(runtimeErr).
191- })238+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
192- if err != nil {239+ Msg("runtime requirements not met")
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
194 }240 }
195- if runtimeOut.OK {241+ log.Fatal().Err(err).Msg("initialization failed")
196- return242+}
243+
244+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
245+ log := logging.FromContext(ctx)
246+ if result.SQLiteErr != nil {
247+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
197 }248 }
198- for _, c := range runtimeOut.Checks {249+ if result.RuntimeErr != nil {
199- if c.Installed {250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
200- logger.Error().251+ runtimeErr.LogDetails(ctx)
201- Str("dependency", c.PkgConfigName).252+ log.Warn().Err(runtimeErr).
202- Str("have", c.Version).253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
203- Str("need", c.RequiredVersion).254+ Msg("runtime requirements not met")
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
206 } else {255 } else {
207- logger.Error().256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
211 }257 }
212 }258 }
213- logger.Fatal().259+ if result.MediaErr != nil {
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
215- Msg("runtime requirements not met")
216-}
217-
218-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
219- mediaDiagAdapter := media.New()
220- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
221- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
222- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
223- }); mediaErr != nil {
224- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
225 }261 }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
226 }263 }
227 264
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {265+func handleAutoRestore(
229- const cacheDirPerm = 0o755266+ ctx context.Context,
230- dataDir, err := config.GetDataDir()267+ cfg *config.Config,
231- if err != nil {268+ uc *useCases,
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")269+ currentSessionID entity.SessionID,
270+) {
271+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
272+ return
233 }273 }
234- stateDir, err := config.GetStateDir()274+ log := logging.FromContext(ctx)
275+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
276+ ExcludeSessionID: currentSessionID,
277+ })
235 if err != nil {278 if err != nil {
236- logger.Fatal().Err(err).Msg("failed to resolve state directory")279+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
280+ return
237 }281 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")282+ if out.SessionID != "" {
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {283+ restoreSessionID = string(out.SessionID)
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")284+ log.Info().
285+ Str("session_id", restoreSessionID).
286+ Int("tabs", len(out.State.Tabs)).
287+ Msg("auto-restore: found last session")
241 }288 }
242- return dataDir, cacheDir
243 }289 }
244 290
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {291+func setupSignalHandler(ctx context.Context, app *ui.App) {
246- dbPath := filepath.Join(dataDir, "dumber.db")292+ log := logging.FromContext(ctx)
247- db, err := sqlite.NewConnection(ctx, dbPath)293+ sigCh := make(chan os.Signal, 1)
248- if err != nil {294+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")295+ go func() {
250- }296+ sig := <-sigCh
251- cleanup := func() {297+ signal.Stop(sigCh)
252- if err := sqlite.Close(db); err != nil {298+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
253- logger.Error().Err(err).Msg("failed to close database")299+ app.Quit()
254- }300+ }()
255- }
256- return db, cleanup
257 }301 }
258 302
259 // repositories groups infrastructure layer repository implementations.303 // repositories groups infrastructure layer repository implementations.
@@ -279,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
279 }323 }
280 }324 }
281 325
326+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
327+ return &repositories{
328+ history: sqlite.NewLazyHistoryRepository(provider),
329+ favorite: sqlite.NewLazyFavoriteRepository(provider),
330+ folder: sqlite.NewLazyFolderRepository(provider),
331+ tag: sqlite.NewLazyTagRepository(provider),
332+ zoom: sqlite.NewLazyZoomRepository(provider),
333+ session: sqlite.NewLazySessionRepository(provider),
334+ sessionState: sqlite.NewLazySessionStateRepository(provider),
335+ }
336+}
337+
282 // useCases groups application layer use case implementations.338 // useCases groups application layer use case implementations.
283 type useCases struct {339 type useCases struct {
284 tabs *usecase.ManageTabsUseCase340 tabs *usecase.ManageTabsUseCase
@@ -296,7 +352,7 @@ type useCases struct {
296 favicon *favicon.Service352 favicon *favicon.Service
297 }353 }
298 354
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {355+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300 const idAlphabetSize = 26356 const idAlphabetSize = 26
301 idCounter := uint64(0)357 idCounter := uint64(0)
302 idGenerator := func() string {358 idGenerator := func() string {
@@ -306,13 +362,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306 362
307 clipboardAdapter := clipboard.New()363 clipboardAdapter := clipboard.New()
308 faviconCacheDir, _ := config.GetFaviconCacheDir()364 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()365+ xdgDirs, _ := config.GetXDGDirs()
366+ stateDir, _ := config.GetStateDir()
310 defaultZoom := cfg.DefaultWebpageZoom367 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)368 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314 369
315- // Create update infrastructure370+ buildInfo := build.Info{
371+ Version: version,
372+ Commit: commit,
373+ BuildDate: buildDate,
374+ GoVersion: runtime.Version(),
375+ }
316 updateChecker := updater.NewGitHubChecker()376 updateChecker := updater.NewGitHubChecker()
317 updateDownloader := updater.NewGitHubDownloader()377 updateDownloader := updater.NewGitHubDownloader()
318 updateApplier := updater.NewApplier(stateDir)378 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +388,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),388 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),389 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),390 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),391+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332 clipboard: clipboardAdapter,392 clipboard: clipboardAdapter,
333 favicon: favicon.NewService(faviconCacheDir),393 favicon: favicon.NewService(faviconCacheDir),
334 }394 }
A internal/application/port/database.go +25 -0
@@ -0,0 +1,25 @@
1+// Package port defines interfaces for infrastructure adapters.
2+package port
3+
4+import (
5+ "context"
6+ "database/sql"
7+)
8+
9+// DatabaseProvider provides access to the database connection.
10+// Implementations may initialize the database lazily on first access.
11+//
12+// Currently used by the lazy repository infrastructure (sqlite.LazyDB and lazy_repos.go),
13+// which is kept for potential future optimization scenarios where deferring database
14+// initialization past first paint provides latency benefits.
15+type DatabaseProvider interface {
16+ // DB returns the database connection, initializing it if necessary.
17+ // Returns an error if initialization fails.
18+ DB(ctx context.Context) (*sql.DB, error)
19+
20+ // Close closes the database connection if it was initialized.
21+ Close() error
22+
23+ // IsInitialized returns true if the database has been initialized.
24+ IsInitialized() bool
25+}
@@ -0,0 +1,25 @@
1+// Package port defines interfaces for infrastructure adapters.
2+package port
3+
4+import (
5+ "context"
6+ "database/sql"
7+)
8+
9+// DatabaseProvider provides access to the database connection.
10+// Implementations may initialize the database lazily on first access.
11+//
12+// Currently used by the lazy repository infrastructure (sqlite.LazyDB and lazy_repos.go),
13+// which is kept for potential future optimization scenarios where deferring database
14+// initialization past first paint provides latency benefits.
15+type DatabaseProvider interface {
16+ // DB returns the database connection, initializing it if necessary.
17+ // Returns an error if initialization fails.
18+ DB(ctx context.Context) (*sql.DB, error)
19+
20+ // Close closes the database connection if it was initialized.
21+ Close() error
22+
23+ // IsInitialized returns true if the database has been initialized.
24+ IsInitialized() bool
25+}
M internal/application/port/mocks/mocks.go +178 -0
@@ -6,6 +6,7 @@ package mocks
66
77 import (
88 "context"
9+ "database/sql"
910
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -201,6 +202,183 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er
201202 return _c
202203 }
203204
205+// NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
206+// The first argument is typically a *testing.T value.
207+func NewMockDatabaseProvider(t interface {
208+ mock.TestingT
209+ Cleanup(func())
210+}) *MockDatabaseProvider {
211+ mock := &MockDatabaseProvider{}
212+ mock.Mock.Test(t)
213+
214+ t.Cleanup(func() { mock.AssertExpectations(t) })
215+
216+ return mock
217+}
218+
219+// MockDatabaseProvider is an autogenerated mock type for the DatabaseProvider type
220+type MockDatabaseProvider struct {
221+ mock.Mock
222+}
223+
224+type MockDatabaseProvider_Expecter struct {
225+ mock *mock.Mock
226+}
227+
228+func (_m *MockDatabaseProvider) EXPECT() *MockDatabaseProvider_Expecter {
229+ return &MockDatabaseProvider_Expecter{mock: &_m.Mock}
230+}
231+
232+// Close provides a mock function for the type MockDatabaseProvider
233+func (_mock *MockDatabaseProvider) Close() error {
234+ ret := _mock.Called()
235+
236+ if len(ret) == 0 {
237+ panic("no return value specified for Close")
238+ }
239+
240+ var r0 error
241+ if returnFunc, ok := ret.Get(0).(func() error); ok {
242+ r0 = returnFunc()
243+ } else {
244+ r0 = ret.Error(0)
245+ }
246+ return r0
247+}
248+
249+// MockDatabaseProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
250+type MockDatabaseProvider_Close_Call struct {
251+ *mock.Call
252+}
253+
254+// Close is a helper method to define mock.On call
255+func (_e *MockDatabaseProvider_Expecter) Close() *MockDatabaseProvider_Close_Call {
256+ return &MockDatabaseProvider_Close_Call{Call: _e.mock.On("Close")}
257+}
258+
259+func (_c *MockDatabaseProvider_Close_Call) Run(run func()) *MockDatabaseProvider_Close_Call {
260+ _c.Call.Run(func(args mock.Arguments) {
261+ run()
262+ })
263+ return _c
264+}
265+
266+func (_c *MockDatabaseProvider_Close_Call) Return(err error) *MockDatabaseProvider_Close_Call {
267+ _c.Call.Return(err)
268+ return _c
269+}
270+
271+func (_c *MockDatabaseProvider_Close_Call) RunAndReturn(run func() error) *MockDatabaseProvider_Close_Call {
272+ _c.Call.Return(run)
273+ return _c
274+}
275+
276+// DB provides a mock function for the type MockDatabaseProvider
277+func (_mock *MockDatabaseProvider) DB(ctx context.Context) (*sql.DB, error) {
278+ ret := _mock.Called(ctx)
279+
280+ if len(ret) == 0 {
281+ panic("no return value specified for DB")
282+ }
283+
284+ var r0 *sql.DB
285+ var r1 error
286+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok {
287+ return returnFunc(ctx)
288+ }
289+ if returnFunc, ok := ret.Get(0).(func(context.Context) *sql.DB); ok {
290+ r0 = returnFunc(ctx)
291+ } else {
292+ if ret.Get(0) != nil {
293+ r0 = ret.Get(0).(*sql.DB)
294+ }
295+ }
296+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
297+ r1 = returnFunc(ctx)
298+ } else {
299+ r1 = ret.Error(1)
300+ }
301+ return r0, r1
302+}
303+
304+// MockDatabaseProvider_DB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DB'
305+type MockDatabaseProvider_DB_Call struct {
306+ *mock.Call
307+}
308+
309+// DB is a helper method to define mock.On call
310+// - ctx context.Context
311+func (_e *MockDatabaseProvider_Expecter) DB(ctx interface{}) *MockDatabaseProvider_DB_Call {
312+ return &MockDatabaseProvider_DB_Call{Call: _e.mock.On("DB", ctx)}
313+}
314+
315+func (_c *MockDatabaseProvider_DB_Call) Run(run func(ctx context.Context)) *MockDatabaseProvider_DB_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ run(
322+ arg0,
323+ )
324+ })
325+ return _c
326+}
327+
328+func (_c *MockDatabaseProvider_DB_Call) Return(dB *sql.DB, err error) *MockDatabaseProvider_DB_Call {
329+ _c.Call.Return(dB, err)
330+ return _c
331+}
332+
333+func (_c *MockDatabaseProvider_DB_Call) RunAndReturn(run func(ctx context.Context) (*sql.DB, error)) *MockDatabaseProvider_DB_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
337+
338+// IsInitialized provides a mock function for the type MockDatabaseProvider
339+func (_mock *MockDatabaseProvider) IsInitialized() bool {
340+ ret := _mock.Called()
341+
342+ if len(ret) == 0 {
343+ panic("no return value specified for IsInitialized")
344+ }
345+
346+ var r0 bool
347+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
348+ r0 = returnFunc()
349+ } else {
350+ r0 = ret.Get(0).(bool)
351+ }
352+ return r0
353+}
354+
355+// MockDatabaseProvider_IsInitialized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInitialized'
356+type MockDatabaseProvider_IsInitialized_Call struct {
357+ *mock.Call
358+}
359+
360+// IsInitialized is a helper method to define mock.On call
361+func (_e *MockDatabaseProvider_Expecter) IsInitialized() *MockDatabaseProvider_IsInitialized_Call {
362+ return &MockDatabaseProvider_IsInitialized_Call{Call: _e.mock.On("IsInitialized")}
363+}
364+
365+func (_c *MockDatabaseProvider_IsInitialized_Call) Run(run func()) *MockDatabaseProvider_IsInitialized_Call {
366+ _c.Call.Run(func(args mock.Arguments) {
367+ run()
368+ })
369+ return _c
370+}
371+
372+func (_c *MockDatabaseProvider_IsInitialized_Call) Return(b bool) *MockDatabaseProvider_IsInitialized_Call {
373+ _c.Call.Return(b)
374+ return _c
375+}
376+
377+func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) *MockDatabaseProvider_IsInitialized_Call {
378+ _c.Call.Return(run)
379+ return _c
380+}
381+
204382 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
205383 // The first argument is typically a *testing.T value.
206384 func NewMockSessionLogger(t interface {
@@ -6,6 +6,7 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9+ "database/sql"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -201,6 +202,183 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er
201 return _c202 return _c
202 }203 }
203 204
205+// NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
206+// The first argument is typically a *testing.T value.
207+func NewMockDatabaseProvider(t interface {
208+ mock.TestingT
209+ Cleanup(func())
210+}) *MockDatabaseProvider {
211+ mock := &MockDatabaseProvider{}
212+ mock.Mock.Test(t)
213+
214+ t.Cleanup(func() { mock.AssertExpectations(t) })
215+
216+ return mock
217+}
218+
219+// MockDatabaseProvider is an autogenerated mock type for the DatabaseProvider type
220+type MockDatabaseProvider struct {
221+ mock.Mock
222+}
223+
224+type MockDatabaseProvider_Expecter struct {
225+ mock *mock.Mock
226+}
227+
228+func (_m *MockDatabaseProvider) EXPECT() *MockDatabaseProvider_Expecter {
229+ return &MockDatabaseProvider_Expecter{mock: &_m.Mock}
230+}
231+
232+// Close provides a mock function for the type MockDatabaseProvider
233+func (_mock *MockDatabaseProvider) Close() error {
234+ ret := _mock.Called()
235+
236+ if len(ret) == 0 {
237+ panic("no return value specified for Close")
238+ }
239+
240+ var r0 error
241+ if returnFunc, ok := ret.Get(0).(func() error); ok {
242+ r0 = returnFunc()
243+ } else {
244+ r0 = ret.Error(0)
245+ }
246+ return r0
247+}
248+
249+// MockDatabaseProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
250+type MockDatabaseProvider_Close_Call struct {
251+ *mock.Call
252+}
253+
254+// Close is a helper method to define mock.On call
255+func (_e *MockDatabaseProvider_Expecter) Close() *MockDatabaseProvider_Close_Call {
256+ return &MockDatabaseProvider_Close_Call{Call: _e.mock.On("Close")}
257+}
258+
259+func (_c *MockDatabaseProvider_Close_Call) Run(run func()) *MockDatabaseProvider_Close_Call {
260+ _c.Call.Run(func(args mock.Arguments) {
261+ run()
262+ })
263+ return _c
264+}
265+
266+func (_c *MockDatabaseProvider_Close_Call) Return(err error) *MockDatabaseProvider_Close_Call {
267+ _c.Call.Return(err)
268+ return _c
269+}
270+
271+func (_c *MockDatabaseProvider_Close_Call) RunAndReturn(run func() error) *MockDatabaseProvider_Close_Call {
272+ _c.Call.Return(run)
273+ return _c
274+}
275+
276+// DB provides a mock function for the type MockDatabaseProvider
277+func (_mock *MockDatabaseProvider) DB(ctx context.Context) (*sql.DB, error) {
278+ ret := _mock.Called(ctx)
279+
280+ if len(ret) == 0 {
281+ panic("no return value specified for DB")
282+ }
283+
284+ var r0 *sql.DB
285+ var r1 error
286+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok {
287+ return returnFunc(ctx)
288+ }
289+ if returnFunc, ok := ret.Get(0).(func(context.Context) *sql.DB); ok {
290+ r0 = returnFunc(ctx)
291+ } else {
292+ if ret.Get(0) != nil {
293+ r0 = ret.Get(0).(*sql.DB)
294+ }
295+ }
296+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
297+ r1 = returnFunc(ctx)
298+ } else {
299+ r1 = ret.Error(1)
300+ }
301+ return r0, r1
302+}
303+
304+// MockDatabaseProvider_DB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DB'
305+type MockDatabaseProvider_DB_Call struct {
306+ *mock.Call
307+}
308+
309+// DB is a helper method to define mock.On call
310+// - ctx context.Context
311+func (_e *MockDatabaseProvider_Expecter) DB(ctx interface{}) *MockDatabaseProvider_DB_Call {
312+ return &MockDatabaseProvider_DB_Call{Call: _e.mock.On("DB", ctx)}
313+}
314+
315+func (_c *MockDatabaseProvider_DB_Call) Run(run func(ctx context.Context)) *MockDatabaseProvider_DB_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ run(
322+ arg0,
323+ )
324+ })
325+ return _c
326+}
327+
328+func (_c *MockDatabaseProvider_DB_Call) Return(dB *sql.DB, err error) *MockDatabaseProvider_DB_Call {
329+ _c.Call.Return(dB, err)
330+ return _c
331+}
332+
333+func (_c *MockDatabaseProvider_DB_Call) RunAndReturn(run func(ctx context.Context) (*sql.DB, error)) *MockDatabaseProvider_DB_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
337+
338+// IsInitialized provides a mock function for the type MockDatabaseProvider
339+func (_mock *MockDatabaseProvider) IsInitialized() bool {
340+ ret := _mock.Called()
341+
342+ if len(ret) == 0 {
343+ panic("no return value specified for IsInitialized")
344+ }
345+
346+ var r0 bool
347+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
348+ r0 = returnFunc()
349+ } else {
350+ r0 = ret.Get(0).(bool)
351+ }
352+ return r0
353+}
354+
355+// MockDatabaseProvider_IsInitialized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInitialized'
356+type MockDatabaseProvider_IsInitialized_Call struct {
357+ *mock.Call
358+}
359+
360+// IsInitialized is a helper method to define mock.On call
361+func (_e *MockDatabaseProvider_Expecter) IsInitialized() *MockDatabaseProvider_IsInitialized_Call {
362+ return &MockDatabaseProvider_IsInitialized_Call{Call: _e.mock.On("IsInitialized")}
363+}
364+
365+func (_c *MockDatabaseProvider_IsInitialized_Call) Run(run func()) *MockDatabaseProvider_IsInitialized_Call {
366+ _c.Call.Run(func(args mock.Arguments) {
367+ run()
368+ })
369+ return _c
370+}
371+
372+func (_c *MockDatabaseProvider_IsInitialized_Call) Return(b bool) *MockDatabaseProvider_IsInitialized_Call {
373+ _c.Call.Return(b)
374+ return _c
375+}
376+
377+func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) *MockDatabaseProvider_IsInitialized_Call {
378+ _c.Call.Return(run)
379+ return _c
380+}
381+
204 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.382 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
205 // The first argument is typically a *testing.T value.383 // The first argument is typically a *testing.T value.
206 func NewMockSessionLogger(t interface {384 func NewMockSessionLogger(t interface {
A internal/bootstrap/gui.go +308 -0
@@ -0,0 +1,308 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/deps"
16+ "github.com/bnema/dumber/internal/infrastructure/media"
17+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/internal/ui/theme"
20+ sqlite3 "github.com/ncruces/go-sqlite3"
21+)
22+
23+// DatabaseResult holds database connection and cleanup function.
24+type DatabaseResult struct {
25+ DB *sql.DB
26+ Cleanup func()
27+}
28+
29+// ParallelInitResult holds the results of parallel initialization phase.
30+type ParallelInitResult struct {
31+ DataDir string
32+ CacheDir string
33+ ThemeManager *theme.Manager
34+ Duration time.Duration
35+}
36+
37+// DeferredInitResult holds results from deferred initialization checks.
38+type DeferredInitResult struct {
39+ RuntimeErr error
40+ MediaErr error
41+ SQLiteErr error
42+ Duration time.Duration
43+}
44+
45+// ParallelInitInput holds the input for parallel initialization.
46+type ParallelInitInput struct {
47+ Ctx context.Context
48+ Config *config.Config
49+}
50+
51+// DeferredInitInput holds the input for deferred initialization.
52+type DeferredInitInput struct {
53+ Ctx context.Context
54+ Config *config.Config
55+}
56+
57+// RuntimeRequirementsError contains details about missing runtime dependencies.
58+type RuntimeRequirementsError struct {
59+ Checks []usecase.RuntimeDependencyStatus
60+}
61+
62+func (*RuntimeRequirementsError) Error() string {
63+ return "runtime requirements not met"
64+}
65+
66+// LogDetails logs detailed information about missing dependencies.
67+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
68+ log := logging.FromContext(ctx)
69+ for _, c := range e.Checks {
70+ if c.Installed {
71+ log.Error().
72+ Str("dependency", c.PkgConfigName).
73+ Str("have", c.Version).
74+ Str("need", c.RequiredVersion).
75+ Bool("ok", c.MeetsRequirement).
76+ Msg("runtime dependency")
77+ } else {
78+ log.Error().
79+ Str("dependency", c.PkgConfigName).
80+ Str("need", c.RequiredVersion).
81+ Msg("runtime dependency missing")
82+ }
83+ }
84+}
85+
86+// RunParallelInit runs the essential parallel initialization phase.
87+// This includes directory resolution and theme creation.
88+// Returns the first fatal error encountered, or nil with the results.
89+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
90+ var (
91+ dataDir, cacheDir string
92+ themeManager *theme.Manager
93+ dirsErr error
94+ wg sync.WaitGroup
95+ )
96+
97+ start := time.Now()
98+ wg.Add(2)
99+
100+ // Resolve directories
101+ go func() {
102+ defer wg.Done()
103+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
104+ }()
105+
106+ // Theme manager (CPU-bound, no I/O)
107+ go func() {
108+ defer wg.Done()
109+ themeManager = theme.NewManager(input.Ctx, input.Config)
110+ }()
111+
112+ wg.Wait()
113+ duration := time.Since(start)
114+
115+ // Return first fatal error
116+ if dirsErr != nil {
117+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
118+ }
119+
120+ return &ParallelInitResult{
121+ DataDir: dataDir,
122+ CacheDir: cacheDir,
123+ ThemeManager: themeManager,
124+ Duration: duration,
125+ }, nil
126+}
127+
128+// RunDeferredInit runs deferred initialization checks off the critical path.
129+// This includes SQLite WASM precompile, runtime requirements, and media checks.
130+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
131+ var (
132+ runtimeErr error
133+ mediaErr error
134+ sqliteErr error
135+ wg sync.WaitGroup
136+ )
137+
138+ start := time.Now()
139+ wg.Add(3)
140+
141+ go func() {
142+ defer wg.Done()
143+ sqliteErr = sqlite3.Initialize()
144+ }()
145+
146+ go func() {
147+ defer wg.Done()
148+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
149+ }()
150+
151+ go func() {
152+ defer wg.Done()
153+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
154+ }()
155+
156+ wg.Wait()
157+
158+ return DeferredInitResult{
159+ RuntimeErr: runtimeErr,
160+ MediaErr: mediaErr,
161+ SQLiteErr: sqliteErr,
162+ Duration: time.Since(start),
163+ }
164+}
165+
166+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
167+// Returns error if requirements are not met; caller should log details and exit.
168+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
169+ probe := deps.NewPkgConfigProbe()
170+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
171+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
172+ Prefix: cfg.Runtime.Prefix,
173+ })
174+ if err != nil {
175+ return fmt.Errorf("runtime check execution failed: %w", err)
176+ }
177+ if runtimeOut.OK {
178+ return nil
179+ }
180+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
181+}
182+
183+// CheckMediaRequirements verifies GStreamer and media codec availability.
184+// Returns error if media subsystem initialization fails.
185+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
186+ mediaDiagAdapter := media.New()
187+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
188+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
189+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
190+ }); err != nil {
191+ return fmt.Errorf("media check failed: %w", err)
192+ }
193+ return nil
194+}
195+
196+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
197+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
198+ const cacheDirPerm = 0o755
199+ dataDir, err = config.GetDataDir()
200+ if err != nil {
201+ return "", "", fmt.Errorf("resolve data directory: %w", err)
202+ }
203+ stateDir, err := config.GetStateDir()
204+ if err != nil {
205+ return "", "", fmt.Errorf("resolve state directory: %w", err)
206+ }
207+ cacheDir = filepath.Join(stateDir, "webkit-cache")
208+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
209+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
210+ }
211+ return dataDir, cacheDir, nil
212+}
213+
214+// OpenDatabase opens and initializes the SQLite database using XDG paths.
215+// Returns error if database cannot be opened or migrations fail.
216+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
217+ dbPath, err := config.GetDatabaseFile()
218+ if err != nil {
219+ return nil, fmt.Errorf("resolve database path: %w", err)
220+ }
221+ db, err := sqlite.NewConnection(ctx, dbPath)
222+ if err != nil {
223+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
224+ }
225+ return &DatabaseResult{
226+ DB: db,
227+ Cleanup: func() {
228+ _ = sqlite.Close(db)
229+ },
230+ }, nil
231+}
232+
233+// CreateLazyDatabase creates a lazy database provider that defers initialization.
234+// The database is initialized on first access, allowing the UI to render faster.
235+//
236+// Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for
237+// eager initialization. This function is kept for potential future use when lazy
238+// initialization past first paint becomes beneficial.
239+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
240+ dbPath, err := config.GetDatabaseFile()
241+ if err != nil {
242+ return nil, fmt.Errorf("resolve database path: %w", err)
243+ }
244+ return sqlite.NewLazyDB(dbPath), nil
245+}
246+
247+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
248+type ParallelDBWebKitResult struct {
249+ DB *sql.DB
250+ DBCleanup func()
251+ Stack WebKitStack
252+}
253+
254+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255+type ParallelDBWebKitInput struct {
256+ Ctx context.Context
257+ Config *config.Config
258+ DataDir string // For WebKit context
259+ CacheDir string // For WebKit cache
260+ ThemeManager *theme.Manager
261+}
262+
263+// Note: Database path is resolved via config.GetDatabaseFile() internally.
264+
265+// dbInitResult holds the result of database initialization for channel communication.
266+type dbInitResult struct {
267+ db *sql.DB
268+ cleanup func()
269+ err error
270+}
271+
272+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
273+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
274+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
275+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
276+ log := logging.FromContext(input.Ctx)
277+
278+ dbCh := make(chan dbInitResult, 1)
279+
280+ // Database initialization in background (WASM is thread-safe)
281+ go func() {
282+ dbResult, err := OpenDatabase(input.Ctx)
283+ if err != nil {
284+ dbCh <- dbInitResult{err: err}
285+ return
286+ }
287+ dbCh <- dbInitResult{
288+ db: dbResult.DB,
289+ cleanup: dbResult.Cleanup,
290+ }
291+ }()
292+
293+ // WebKit stack on main thread (GTK requirement)
294+ stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
295+
296+ // Wait for database
297+ dbRes := <-dbCh
298+
299+ if dbRes.err != nil {
300+ return nil, fmt.Errorf("database initialization: %w", dbRes.err)
301+ }
302+
303+ return &ParallelDBWebKitResult{
304+ DB: dbRes.db,
305+ DBCleanup: dbRes.cleanup,
306+ Stack: stack,
307+ }, nil
308+}
@@ -0,0 +1,308 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/deps"
16+ "github.com/bnema/dumber/internal/infrastructure/media"
17+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/internal/ui/theme"
20+ sqlite3 "github.com/ncruces/go-sqlite3"
21+)
22+
23+// DatabaseResult holds database connection and cleanup function.
24+type DatabaseResult struct {
25+ DB *sql.DB
26+ Cleanup func()
27+}
28+
29+// ParallelInitResult holds the results of parallel initialization phase.
30+type ParallelInitResult struct {
31+ DataDir string
32+ CacheDir string
33+ ThemeManager *theme.Manager
34+ Duration time.Duration
35+}
36+
37+// DeferredInitResult holds results from deferred initialization checks.
38+type DeferredInitResult struct {
39+ RuntimeErr error
40+ MediaErr error
41+ SQLiteErr error
42+ Duration time.Duration
43+}
44+
45+// ParallelInitInput holds the input for parallel initialization.
46+type ParallelInitInput struct {
47+ Ctx context.Context
48+ Config *config.Config
49+}
50+
51+// DeferredInitInput holds the input for deferred initialization.
52+type DeferredInitInput struct {
53+ Ctx context.Context
54+ Config *config.Config
55+}
56+
57+// RuntimeRequirementsError contains details about missing runtime dependencies.
58+type RuntimeRequirementsError struct {
59+ Checks []usecase.RuntimeDependencyStatus
60+}
61+
62+func (*RuntimeRequirementsError) Error() string {
63+ return "runtime requirements not met"
64+}
65+
66+// LogDetails logs detailed information about missing dependencies.
67+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
68+ log := logging.FromContext(ctx)
69+ for _, c := range e.Checks {
70+ if c.Installed {
71+ log.Error().
72+ Str("dependency", c.PkgConfigName).
73+ Str("have", c.Version).
74+ Str("need", c.RequiredVersion).
75+ Bool("ok", c.MeetsRequirement).
76+ Msg("runtime dependency")
77+ } else {
78+ log.Error().
79+ Str("dependency", c.PkgConfigName).
80+ Str("need", c.RequiredVersion).
81+ Msg("runtime dependency missing")
82+ }
83+ }
84+}
85+
86+// RunParallelInit runs the essential parallel initialization phase.
87+// This includes directory resolution and theme creation.
88+// Returns the first fatal error encountered, or nil with the results.
89+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
90+ var (
91+ dataDir, cacheDir string
92+ themeManager *theme.Manager
93+ dirsErr error
94+ wg sync.WaitGroup
95+ )
96+
97+ start := time.Now()
98+ wg.Add(2)
99+
100+ // Resolve directories
101+ go func() {
102+ defer wg.Done()
103+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
104+ }()
105+
106+ // Theme manager (CPU-bound, no I/O)
107+ go func() {
108+ defer wg.Done()
109+ themeManager = theme.NewManager(input.Ctx, input.Config)
110+ }()
111+
112+ wg.Wait()
113+ duration := time.Since(start)
114+
115+ // Return first fatal error
116+ if dirsErr != nil {
117+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
118+ }
119+
120+ return &ParallelInitResult{
121+ DataDir: dataDir,
122+ CacheDir: cacheDir,
123+ ThemeManager: themeManager,
124+ Duration: duration,
125+ }, nil
126+}
127+
128+// RunDeferredInit runs deferred initialization checks off the critical path.
129+// This includes SQLite WASM precompile, runtime requirements, and media checks.
130+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
131+ var (
132+ runtimeErr error
133+ mediaErr error
134+ sqliteErr error
135+ wg sync.WaitGroup
136+ )
137+
138+ start := time.Now()
139+ wg.Add(3)
140+
141+ go func() {
142+ defer wg.Done()
143+ sqliteErr = sqlite3.Initialize()
144+ }()
145+
146+ go func() {
147+ defer wg.Done()
148+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
149+ }()
150+
151+ go func() {
152+ defer wg.Done()
153+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
154+ }()
155+
156+ wg.Wait()
157+
158+ return DeferredInitResult{
159+ RuntimeErr: runtimeErr,
160+ MediaErr: mediaErr,
161+ SQLiteErr: sqliteErr,
162+ Duration: time.Since(start),
163+ }
164+}
165+
166+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
167+// Returns error if requirements are not met; caller should log details and exit.
168+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
169+ probe := deps.NewPkgConfigProbe()
170+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
171+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
172+ Prefix: cfg.Runtime.Prefix,
173+ })
174+ if err != nil {
175+ return fmt.Errorf("runtime check execution failed: %w", err)
176+ }
177+ if runtimeOut.OK {
178+ return nil
179+ }
180+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
181+}
182+
183+// CheckMediaRequirements verifies GStreamer and media codec availability.
184+// Returns error if media subsystem initialization fails.
185+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
186+ mediaDiagAdapter := media.New()
187+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
188+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
189+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
190+ }); err != nil {
191+ return fmt.Errorf("media check failed: %w", err)
192+ }
193+ return nil
194+}
195+
196+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
197+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
198+ const cacheDirPerm = 0o755
199+ dataDir, err = config.GetDataDir()
200+ if err != nil {
201+ return "", "", fmt.Errorf("resolve data directory: %w", err)
202+ }
203+ stateDir, err := config.GetStateDir()
204+ if err != nil {
205+ return "", "", fmt.Errorf("resolve state directory: %w", err)
206+ }
207+ cacheDir = filepath.Join(stateDir, "webkit-cache")
208+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
209+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
210+ }
211+ return dataDir, cacheDir, nil
212+}
213+
214+// OpenDatabase opens and initializes the SQLite database using XDG paths.
215+// Returns error if database cannot be opened or migrations fail.
216+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
217+ dbPath, err := config.GetDatabaseFile()
218+ if err != nil {
219+ return nil, fmt.Errorf("resolve database path: %w", err)
220+ }
221+ db, err := sqlite.NewConnection(ctx, dbPath)
222+ if err != nil {
223+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
224+ }
225+ return &DatabaseResult{
226+ DB: db,
227+ Cleanup: func() {
228+ _ = sqlite.Close(db)
229+ },
230+ }, nil
231+}
232+
233+// CreateLazyDatabase creates a lazy database provider that defers initialization.
234+// The database is initialized on first access, allowing the UI to render faster.
235+//
236+// Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for
237+// eager initialization. This function is kept for potential future use when lazy
238+// initialization past first paint becomes beneficial.
239+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
240+ dbPath, err := config.GetDatabaseFile()
241+ if err != nil {
242+ return nil, fmt.Errorf("resolve database path: %w", err)
243+ }
244+ return sqlite.NewLazyDB(dbPath), nil
245+}
246+
247+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
248+type ParallelDBWebKitResult struct {
249+ DB *sql.DB
250+ DBCleanup func()
251+ Stack WebKitStack
252+}
253+
254+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255+type ParallelDBWebKitInput struct {
256+ Ctx context.Context
257+ Config *config.Config
258+ DataDir string // For WebKit context
259+ CacheDir string // For WebKit cache
260+ ThemeManager *theme.Manager
261+}
262+
263+// Note: Database path is resolved via config.GetDatabaseFile() internally.
264+
265+// dbInitResult holds the result of database initialization for channel communication.
266+type dbInitResult struct {
267+ db *sql.DB
268+ cleanup func()
269+ err error
270+}
271+
272+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
273+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
274+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
275+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
276+ log := logging.FromContext(input.Ctx)
277+
278+ dbCh := make(chan dbInitResult, 1)
279+
280+ // Database initialization in background (WASM is thread-safe)
281+ go func() {
282+ dbResult, err := OpenDatabase(input.Ctx)
283+ if err != nil {
284+ dbCh <- dbInitResult{err: err}
285+ return
286+ }
287+ dbCh <- dbInitResult{
288+ db: dbResult.DB,
289+ cleanup: dbResult.Cleanup,
290+ }
291+ }()
292+
293+ // WebKit stack on main thread (GTK requirement)
294+ stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
295+
296+ // Wait for database
297+ dbRes := <-dbCh
298+
299+ if dbRes.err != nil {
300+ return nil, fmt.Errorf("database initialization: %w", dbRes.err)
301+ }
302+
303+ return &ParallelDBWebKitResult{
304+ DB: dbRes.db,
305+ DBCleanup: dbRes.cleanup,
306+ Stack: stack,
307+ }, nil
308+}
M internal/bootstrap/session.go +143 -52
@@ -2,20 +2,20 @@ package bootstrap
22
33 import (
44 "context"
5- "database/sql"
65 "errors"
76 "fmt"
87 "os"
98 "path/filepath"
9+ "sync"
1010 "syscall"
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
1414 "github.com/bnema/dumber/internal/application/usecase"
1515 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
1718 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1919 corelogging "github.com/bnema/dumber/internal/logging"
2020 "github.com/rs/zerolog"
2121 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
3131 Logger zerolog.Logger
3232 LogCleanup func()
3333
34- End func(context.Context) error
34+ Persist func(context.Context) error
35+ End func(context.Context) error
3536 }
3637
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
3844 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
3948
4049 lockDir := cfg.Logging.LogDir
4150 if lockDir == "" {
@@ -44,72 +53,108 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
4453 }
4554 }
4655
47- sessionRepo := sqlite.NewSessionRepository(db)
4856 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
4957 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
5059
51- // If a previous browser process died without cleanup, it may have left sessions "active".
52- // Use per-session lock files to distinguish truly running sessions from stale ones.
53- if lockDir != "" {
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56- }
60+ now := time.Now()
61+ session := &entity.Session{
62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
63+ Type: entity.SessionTypeBrowser,
64+ StartedAt: now.UTC(),
5765 }
58-
59- // Clean up old exited sessions based on config limits
60- cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61- cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62- MaxExitedSessions: cfg.Session.MaxExitedSessions,
63- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64- })
65- if err != nil {
66- log.Warn().Err(err).Msg("failed to cleanup old sessions")
67- } else if cleanupOutput.TotalDeleted > 0 {
68- log.Info().
69- Int64("deleted", cleanupOutput.TotalDeleted).
70- Msg("cleaned up old sessions on startup")
66+ if err := session.Validate(); err != nil {
67+ return nil, ctx, err
7168 }
7269
73- now := time.Now()
74- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
75- Type: entity.SessionTypeBrowser,
76- Now: now,
77- LogConfig: port.SessionLogConfig{
78- Level: cfg.Logging.Level,
79- Format: cfg.Logging.Format,
80- TimeFormat: "15:04:05",
81- LogDir: cfg.Logging.LogDir,
82- WriteToStderr: true,
83- EnableFileLog: cfg.Logging.EnableFileLog,
84- },
70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71+ Level: cfg.Logging.Level,
72+ Format: cfg.Logging.Format,
73+ TimeFormat: corelogging.ConsoleTimeFormat,
74+ LogDir: cfg.Logging.LogDir,
75+ WriteToStderr: true,
76+ EnableFileLog: cfg.Logging.EnableFileLog,
8577 })
8678 if err != nil {
87- return nil, ctx, err
79+ log.Warn().Err(err).Msg("failed to create session logger")
8880 }
8981
90- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
91- if lockErr != nil {
92- // Don’t fail startup if locking fails; it only affects stale-session cleanup.
93- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
94- }
82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
83+
84+ var (
85+ lockMu sync.Mutex
86+ lockFile *os.File
87+ lockPath string
88+ persistMu sync.Once
89+ persistErr error
90+ )
91+
92+ persistFn := func(persistCtx context.Context) error {
93+ persistMu.Do(func() {
94+ saveCtx := corelogging.WithContext(context.Background(), logger)
95+ if err := sessionRepo.Save(saveCtx, session); err != nil {
96+ persistErr = fmt.Errorf("save session: %w", err)
97+ return
98+ }
9599
96- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
100+ logger.Info().
101+ Str("session_id", string(session.ID)).
102+ Str("type", string(session.Type)).
103+ Msg("session started")
104+
105+ lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
106+ if lockErr != nil {
107+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
108+ logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
109+ } else {
110+ lockMu.Lock()
111+ lockFile = lf
112+ lockPath = lp
113+ lockMu.Unlock()
114+ }
115+
116+ // Defer stale session cleanup and old session pruning to background.
117+ // This avoids blocking startup for non-critical housekeeping tasks.
118+ runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
119+ })
120+ return persistErr
121+ }
97122
98123 endFn := func(endCtx context.Context) error {
99- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
100- if lockFile != nil {
101- _ = unlockAndClose(lockFile)
102- _ = os.Remove(lockPath)
124+ _ = persistFn(endCtx)
125+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
126+
127+ lockMu.Lock()
128+ lf := lockFile
129+ lp := lockPath
130+ lockMu.Unlock()
131+
132+ if lf != nil {
133+ _ = unlockAndClose(lf)
134+ _ = os.Remove(lp)
103135 }
104136 return endErr
105137 }
106138
107- return &BrowserSession{
108- Session: out.Session,
109- Logger: out.Logger,
110- LogCleanup: out.LogCleanup,
139+ browserSession := &BrowserSession{
140+ Session: session,
141+ Logger: logger,
142+ LogCleanup: cleanup,
111143 End: endFn,
112- }, sessionCtx, nil
144+ }
145+
146+ if deferPersist {
147+ browserSession.Persist = persistFn
148+ } else {
149+ if err := persistFn(ctx); err != nil {
150+ if cleanup != nil {
151+ cleanup()
152+ }
153+ return nil, ctx, err
154+ }
155+ }
156+
157+ return browserSession, sessionCtx, nil
113158 }
114159
115160 func endStaleActiveBrowserSessions(
@@ -213,3 +258,49 @@ func unlockAndClose(f *os.File) error {
213258 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214259 return f.Close()
215260 }
261+
262+// runSessionCleanupAsync performs stale session cleanup and old session pruning
263+// in a background goroutine. This avoids blocking startup for non-critical tasks.
264+// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
265+func runSessionCleanupAsync(
266+ startupCtx context.Context,
267+ sessionUC *usecase.ManageSessionUseCase,
268+ cleanupUC *usecase.CleanupSessionsUseCase,
269+ cfg *config.Config,
270+ lockDir string,
271+ log *zerolog.Logger,
272+) {
273+ // Silence unused parameter warning - startupCtx is intentionally unused.
274+ _ = startupCtx
275+
276+ go func() {
277+ // Use a detached background context instead of the startup context:
278+ // session cleanup is critical and must run to completion even if the
279+ // startup context is canceled or times out.
280+ bgCtx := context.Background()
281+
282+ // End stale active sessions (orphaned from crashed processes)
283+ if lockDir != "" {
284+ if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
285+ if log != nil {
286+ log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
287+ }
288+ }
289+ }
290+
291+ // Clean up old exited sessions based on config limits
292+ cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
293+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
294+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
295+ })
296+ if err != nil {
297+ if log != nil {
298+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
299+ }
300+ } else if cleanupOutput.TotalDeleted > 0 {
301+ if log != nil {
302+ log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
303+ }
304+ }
305+ }()
306+}
@@ -2,20 +2,20 @@ package bootstrap
2 2
3 import (3 import (
4 "context"4 "context"
5- "database/sql"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
9 "path/filepath"8 "path/filepath"
9+ "sync"
10 "syscall"10 "syscall"
11 "time"11 "time"
12 12
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19 corelogging "github.com/bnema/dumber/internal/logging"19 corelogging "github.com/bnema/dumber/internal/logging"
20 "github.com/rs/zerolog"20 "github.com/rs/zerolog"
21 )21 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
31 Logger zerolog.Logger31 Logger zerolog.Logger
32 LogCleanup func()32 LogCleanup func()
33 33
34- End func(context.Context) error34+ Persist func(context.Context) error
35+ End func(context.Context) error
35 }36 }
36 37
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
38 log := corelogging.FromContext(ctx)44 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
39 48
40 lockDir := cfg.Logging.LogDir49 lockDir := cfg.Logging.LogDir
41 if lockDir == "" {50 if lockDir == "" {
@@ -44,72 +53,108 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
44 }53 }
45 }54 }
46 55
47- sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()56 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)57 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
50 59
51- // If a previous browser process died without cleanup, it may have left sessions "active".60+ now := time.Now()
52- // Use per-session lock files to distinguish truly running sessions from stale ones.61+ session := &entity.Session{
53- if lockDir != "" {62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {63+ Type: entity.SessionTypeBrowser,
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")64+ StartedAt: now.UTC(),
56- }
57 }65 }
58-66+ if err := session.Validate(); err != nil {
59- // Clean up old exited sessions based on config limits67+ return nil, ctx, err
60- cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61- cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62- MaxExitedSessions: cfg.Session.MaxExitedSessions,
63- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64- })
65- if err != nil {
66- log.Warn().Err(err).Msg("failed to cleanup old sessions")
67- } else if cleanupOutput.TotalDeleted > 0 {
68- log.Info().
69- Int64("deleted", cleanupOutput.TotalDeleted).
70- Msg("cleaned up old sessions on startup")
71 }68 }
72 69
73- now := time.Now()70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
74- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{71+ Level: cfg.Logging.Level,
75- Type: entity.SessionTypeBrowser,72+ Format: cfg.Logging.Format,
76- Now: now,73+ TimeFormat: corelogging.ConsoleTimeFormat,
77- LogConfig: port.SessionLogConfig{74+ LogDir: cfg.Logging.LogDir,
78- Level: cfg.Logging.Level,75+ WriteToStderr: true,
79- Format: cfg.Logging.Format,76+ EnableFileLog: cfg.Logging.EnableFileLog,
80- TimeFormat: "15:04:05",
81- LogDir: cfg.Logging.LogDir,
82- WriteToStderr: true,
83- EnableFileLog: cfg.Logging.EnableFileLog,
84- },
85 })77 })
86 if err != nil {78 if err != nil {
87- return nil, ctx, err79+ log.Warn().Err(err).Msg("failed to create session logger")
88 }80 }
89 81
90- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
91- if lockErr != nil {83+
92- // Don’t fail startup if locking fails; it only affects stale-session cleanup.84+ var (
93- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")85+ lockMu sync.Mutex
94- }86+ lockFile *os.File
87+ lockPath string
88+ persistMu sync.Once
89+ persistErr error
90+ )
91+
92+ persistFn := func(persistCtx context.Context) error {
93+ persistMu.Do(func() {
94+ saveCtx := corelogging.WithContext(context.Background(), logger)
95+ if err := sessionRepo.Save(saveCtx, session); err != nil {
96+ persistErr = fmt.Errorf("save session: %w", err)
97+ return
98+ }
95 99
96- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)100+ logger.Info().
101+ Str("session_id", string(session.ID)).
102+ Str("type", string(session.Type)).
103+ Msg("session started")
104+
105+ lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
106+ if lockErr != nil {
107+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
108+ logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
109+ } else {
110+ lockMu.Lock()
111+ lockFile = lf
112+ lockPath = lp
113+ lockMu.Unlock()
114+ }
115+
116+ // Defer stale session cleanup and old session pruning to background.
117+ // This avoids blocking startup for non-critical housekeeping tasks.
118+ runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
119+ })
120+ return persistErr
121+ }
97 122
98 endFn := func(endCtx context.Context) error {123 endFn := func(endCtx context.Context) error {
99- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())124+ _ = persistFn(endCtx)
100- if lockFile != nil {125+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
101- _ = unlockAndClose(lockFile)126+
102- _ = os.Remove(lockPath)127+ lockMu.Lock()
128+ lf := lockFile
129+ lp := lockPath
130+ lockMu.Unlock()
131+
132+ if lf != nil {
133+ _ = unlockAndClose(lf)
134+ _ = os.Remove(lp)
103 }135 }
104 return endErr136 return endErr
105 }137 }
106 138
107- return &BrowserSession{139+ browserSession := &BrowserSession{
108- Session: out.Session,140+ Session: session,
109- Logger: out.Logger,141+ Logger: logger,
110- LogCleanup: out.LogCleanup,142+ LogCleanup: cleanup,
111 End: endFn,143 End: endFn,
112- }, sessionCtx, nil144+ }
145+
146+ if deferPersist {
147+ browserSession.Persist = persistFn
148+ } else {
149+ if err := persistFn(ctx); err != nil {
150+ if cleanup != nil {
151+ cleanup()
152+ }
153+ return nil, ctx, err
154+ }
155+ }
156+
157+ return browserSession, sessionCtx, nil
113 }158 }
114 159
115 func endStaleActiveBrowserSessions(160 func endStaleActiveBrowserSessions(
@@ -213,3 +258,49 @@ func unlockAndClose(f *os.File) error {
213 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)258 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214 return f.Close()259 return f.Close()
215 }260 }
261+
262+// runSessionCleanupAsync performs stale session cleanup and old session pruning
263+// in a background goroutine. This avoids blocking startup for non-critical tasks.
264+// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
265+func runSessionCleanupAsync(
266+ startupCtx context.Context,
267+ sessionUC *usecase.ManageSessionUseCase,
268+ cleanupUC *usecase.CleanupSessionsUseCase,
269+ cfg *config.Config,
270+ lockDir string,
271+ log *zerolog.Logger,
272+) {
273+ // Silence unused parameter warning - startupCtx is intentionally unused.
274+ _ = startupCtx
275+
276+ go func() {
277+ // Use a detached background context instead of the startup context:
278+ // session cleanup is critical and must run to completion even if the
279+ // startup context is canceled or times out.
280+ bgCtx := context.Background()
281+
282+ // End stale active sessions (orphaned from crashed processes)
283+ if lockDir != "" {
284+ if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
285+ if log != nil {
286+ log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
287+ }
288+ }
289+ }
290+
291+ // Clean up old exited sessions based on config limits
292+ cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
293+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
294+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
295+ })
296+ if err != nil {
297+ if log != nil {
298+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
299+ }
300+ } else if cleanupOutput.TotalDeleted > 0 {
301+ if log != nil {
302+ log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
303+ }
304+ }
305+ }()
306+}
A internal/bootstrap/timing.go +94 -0
@@ -0,0 +1,94 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// StartupTimer tracks timing for cold start phases.
13+// Thread-safe for use with parallel initialization.
14+type StartupTimer struct {
15+ start time.Time
16+ phases map[string]time.Duration
17+ order []string // Track insertion order for logging
18+ last time.Time
19+ mu sync.Mutex
20+}
21+
22+// NewStartupTimer creates a new timer starting from now.
23+func NewStartupTimer() *StartupTimer {
24+ now := time.Now()
25+ return &StartupTimer{
26+ start: now,
27+ phases: make(map[string]time.Duration),
28+ order: make([]string, 0),
29+ last: now,
30+ }
31+}
32+
33+// Mark records the duration since the last mark (or start) for the given phase.
34+func (t *StartupTimer) Mark(phase string) {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+
38+ now := time.Now()
39+ t.phases[phase] = now.Sub(t.last)
40+ t.order = append(t.order, phase)
41+ t.last = now
42+}
43+
44+// MarkDuration records a specific duration for a phase.
45+// Useful for operations timed independently (e.g., parallel goroutines).
46+func (t *StartupTimer) MarkDuration(phase string, d time.Duration) {
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+
50+ t.phases[phase] = d
51+ t.order = append(t.order, phase)
52+}
53+
54+// Total returns the total elapsed time since timer creation.
55+// Thread-safe: uses mutex for memory visibility guarantees.
56+func (t *StartupTimer) Total() time.Duration {
57+ t.mu.Lock()
58+ defer t.mu.Unlock()
59+ return time.Since(t.start)
60+}
61+
62+// Log outputs all timing information to the context logger.
63+func (t *StartupTimer) Log(ctx context.Context) {
64+ t.mu.Lock()
65+ defer t.mu.Unlock()
66+
67+ log := logging.FromContext(ctx)
68+ total := time.Since(t.start)
69+
70+ event := log.Info().Dur("total", total)
71+ for _, phase := range t.order {
72+ if dur, ok := t.phases[phase]; ok {
73+ event = event.Dur(phase, dur)
74+ }
75+ }
76+ event.Msg("startup timing")
77+}
78+
79+// LogDebug outputs timing as debug level (for less verbose production logs).
80+func (t *StartupTimer) LogDebug(ctx context.Context) {
81+ t.mu.Lock()
82+ defer t.mu.Unlock()
83+
84+ log := logging.FromContext(ctx)
85+ total := time.Since(t.start)
86+
87+ event := log.Debug().Dur("total", total)
88+ for _, phase := range t.order {
89+ if dur, ok := t.phases[phase]; ok {
90+ event = event.Dur(phase, dur)
91+ }
92+ }
93+ event.Msg("startup timing")
94+}
@@ -0,0 +1,94 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// StartupTimer tracks timing for cold start phases.
13+// Thread-safe for use with parallel initialization.
14+type StartupTimer struct {
15+ start time.Time
16+ phases map[string]time.Duration
17+ order []string // Track insertion order for logging
18+ last time.Time
19+ mu sync.Mutex
20+}
21+
22+// NewStartupTimer creates a new timer starting from now.
23+func NewStartupTimer() *StartupTimer {
24+ now := time.Now()
25+ return &StartupTimer{
26+ start: now,
27+ phases: make(map[string]time.Duration),
28+ order: make([]string, 0),
29+ last: now,
30+ }
31+}
32+
33+// Mark records the duration since the last mark (or start) for the given phase.
34+func (t *StartupTimer) Mark(phase string) {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+
38+ now := time.Now()
39+ t.phases[phase] = now.Sub(t.last)
40+ t.order = append(t.order, phase)
41+ t.last = now
42+}
43+
44+// MarkDuration records a specific duration for a phase.
45+// Useful for operations timed independently (e.g., parallel goroutines).
46+func (t *StartupTimer) MarkDuration(phase string, d time.Duration) {
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+
50+ t.phases[phase] = d
51+ t.order = append(t.order, phase)
52+}
53+
54+// Total returns the total elapsed time since timer creation.
55+// Thread-safe: uses mutex for memory visibility guarantees.
56+func (t *StartupTimer) Total() time.Duration {
57+ t.mu.Lock()
58+ defer t.mu.Unlock()
59+ return time.Since(t.start)
60+}
61+
62+// Log outputs all timing information to the context logger.
63+func (t *StartupTimer) Log(ctx context.Context) {
64+ t.mu.Lock()
65+ defer t.mu.Unlock()
66+
67+ log := logging.FromContext(ctx)
68+ total := time.Since(t.start)
69+
70+ event := log.Info().Dur("total", total)
71+ for _, phase := range t.order {
72+ if dur, ok := t.phases[phase]; ok {
73+ event = event.Dur(phase, dur)
74+ }
75+ }
76+ event.Msg("startup timing")
77+}
78+
79+// LogDebug outputs timing as debug level (for less verbose production logs).
80+func (t *StartupTimer) LogDebug(ctx context.Context) {
81+ t.mu.Lock()
82+ defer t.mu.Unlock()
83+
84+ log := logging.FromContext(ctx)
85+ total := time.Since(t.start)
86+
87+ event := log.Debug().Dur("total", total)
88+ for _, phase := range t.order {
89+ if dur, ok := t.phases[phase]; ok {
90+ event = event.Dur(phase, dur)
91+ }
92+ }
93+ event.Msg("startup timing")
94+}
M internal/bootstrap/webkit_stack.go +7 -0
@@ -113,6 +113,13 @@ func BuildWebKitStack(
113113 pool.SetFilterApplier(filterManager)
114114 }
115115
116+ // Pre-create ONE WebView synchronously for instant first navigation.
117+ // This is the heaviest operation but happens before window is shown,
118+ // so users perceive it as part of the normal "loading" phase.
119+ if err := pool.PrewarmFirst(ctx); err != nil {
120+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
121+ }
122+
116123 return WebKitStack{
117124 Context: wkCtx,
118125 Settings: settings,
@@ -113,6 +113,13 @@ func BuildWebKitStack(
113 pool.SetFilterApplier(filterManager)113 pool.SetFilterApplier(filterManager)
114 }114 }
115 115
116+ // Pre-create ONE WebView synchronously for instant first navigation.
117+ // This is the heaviest operation but happens before window is shown,
118+ // so users perceive it as part of the normal "loading" phase.
119+ if err := pool.PrewarmFirst(ctx); err != nil {
120+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
121+ }
122+
116 return WebKitStack{123 return WebKitStack{
117 Context: wkCtx,124 Context: wkCtx,
118 Settings: settings,125 Settings: settings,
M internal/infrastructure/persistence/sqlite/connection.go +23 -6
@@ -33,6 +33,9 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
3333 return nil, fmt.Errorf("failed to open database: %w", err)
3434 }
3535
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
3639 // Test the connection
3740 if err := db.Ping(); err != nil {
3841 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
5962 // applyPragmas configures SQLite for optimal performance.
6063 func applyPragmas(db *sql.DB) error {
6164 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
67+ "PRAGMA cache_size = -64000", // 64MB cache
68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
6872 }
6973
7074 for _, pragma := range pragmas {
@@ -76,6 +80,19 @@ func applyPragmas(db *sql.DB) error {
7680 return nil
7781 }
7882
83+// configurePool sets connection pool parameters optimized for SQLite.
84+// SQLite only supports one writer at a time, so we limit connections.
85+//
86+// Connections are configured to never expire or close while idle. This is safe
87+// because the application's lifecycle matches the database connection's lifecycle:
88+// the process terminates before the underlying database file is rotated or replaced.
89+func configurePool(db *sql.DB) {
90+ db.SetMaxOpenConns(1) // SQLite is single-writer
91+ db.SetMaxIdleConns(1) // Keep one connection alive
92+ db.SetConnMaxLifetime(0) // Never expire connections
93+ db.SetConnMaxIdleTime(0) // Never close idle connections
94+}
95+
7996 // Close closes the database connection gracefully.
8097 func Close(db *sql.DB) error {
8198 if db == nil {
@@ -33,6 +33,9 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
33 return nil, fmt.Errorf("failed to open database: %w", err)33 return nil, fmt.Errorf("failed to open database: %w", err)
34 }34 }
35 35
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
36 // Test the connection39 // Test the connection
37 if err := db.Ping(); err != nil {40 if err := db.Ping(); err != nil {
38 _ = db.Close()41 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
59 // applyPragmas configures SQLite for optimal performance.62 // applyPragmas configures SQLite for optimal performance.
60 func applyPragmas(db *sql.DB) error {63 func applyPragmas(db *sql.DB) error {
61 pragmas := []string{64 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache67+ "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
68 }72 }
69 73
70 for _, pragma := range pragmas {74 for _, pragma := range pragmas {
@@ -76,6 +80,19 @@ func applyPragmas(db *sql.DB) error {
76 return nil80 return nil
77 }81 }
78 82
83+// configurePool sets connection pool parameters optimized for SQLite.
84+// SQLite only supports one writer at a time, so we limit connections.
85+//
86+// Connections are configured to never expire or close while idle. This is safe
87+// because the application's lifecycle matches the database connection's lifecycle:
88+// the process terminates before the underlying database file is rotated or replaced.
89+func configurePool(db *sql.DB) {
90+ db.SetMaxOpenConns(1) // SQLite is single-writer
91+ db.SetMaxIdleConns(1) // Keep one connection alive
92+ db.SetConnMaxLifetime(0) // Never expire connections
93+ db.SetConnMaxIdleTime(0) // Never close idle connections
94+}
95+
79 // Close closes the database connection gracefully.96 // Close closes the database connection gracefully.
80 func Close(db *sql.DB) error {97 func Close(db *sql.DB) error {
81 if db == nil {98 if db == nil {
A internal/infrastructure/persistence/sqlite/lazy_db.go +80 -0
@@ -0,0 +1,80 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// LazyDB implements port.DatabaseProvider with lazy initialization.
14+// The database connection is created on first access, deferring the ~300-400ms
15+// WASM compilation and migration overhead until actually needed.
16+//
17+// Currently unused: the application uses eager initialization via OpenDatabase
18+// in RunParallelDBWebKit. This implementation is kept for potential future use
19+// in scenarios where deferring database initialization past first paint provides
20+// additional latency benefits, or for CLI commands that may not need DB access.
21+type LazyDB struct {
22+ dbPath string
23+ db *sql.DB
24+ err error
25+ once sync.Once
26+ mu sync.RWMutex
27+}
28+
29+// Compile-time interface check.
30+var _ port.DatabaseProvider = (*LazyDB)(nil)
31+
32+// NewLazyDB creates a new lazy database provider.
33+// The actual connection is not established until DB() is called.
34+func NewLazyDB(dbPath string) *LazyDB {
35+ return &LazyDB{dbPath: dbPath}
36+}
37+
38+// DB returns the database connection, initializing it if necessary.
39+// This method is thread-safe and will only initialize once.
40+func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
41+ l.once.Do(func() {
42+ log := logging.FromContext(ctx)
43+ log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
44+
45+ l.db, l.err = NewConnection(ctx, l.dbPath)
46+ if l.err != nil {
47+ log.Error().Err(l.err).Msg("lazy database initialization failed")
48+ } else {
49+ log.Debug().Msg("lazy database initialized successfully")
50+ }
51+ })
52+
53+ if l.err != nil {
54+ return nil, fmt.Errorf("database initialization failed: %w", l.err)
55+ }
56+ return l.db, nil
57+}
58+
59+// Close closes the database connection if it was initialized.
60+func (l *LazyDB) Close() error {
61+ l.mu.Lock()
62+ defer l.mu.Unlock()
63+
64+ if l.db != nil {
65+ return l.db.Close()
66+ }
67+ return nil
68+}
69+
70+// IsInitialized returns true if the database has been initialized.
71+func (l *LazyDB) IsInitialized() bool {
72+ l.mu.RLock()
73+ defer l.mu.RUnlock()
74+ return l.db != nil
75+}
76+
77+// Path returns the database path.
78+func (l *LazyDB) Path() string {
79+ return l.dbPath
80+}
@@ -0,0 +1,80 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// LazyDB implements port.DatabaseProvider with lazy initialization.
14+// The database connection is created on first access, deferring the ~300-400ms
15+// WASM compilation and migration overhead until actually needed.
16+//
17+// Currently unused: the application uses eager initialization via OpenDatabase
18+// in RunParallelDBWebKit. This implementation is kept for potential future use
19+// in scenarios where deferring database initialization past first paint provides
20+// additional latency benefits, or for CLI commands that may not need DB access.
21+type LazyDB struct {
22+ dbPath string
23+ db *sql.DB
24+ err error
25+ once sync.Once
26+ mu sync.RWMutex
27+}
28+
29+// Compile-time interface check.
30+var _ port.DatabaseProvider = (*LazyDB)(nil)
31+
32+// NewLazyDB creates a new lazy database provider.
33+// The actual connection is not established until DB() is called.
34+func NewLazyDB(dbPath string) *LazyDB {
35+ return &LazyDB{dbPath: dbPath}
36+}
37+
38+// DB returns the database connection, initializing it if necessary.
39+// This method is thread-safe and will only initialize once.
40+func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
41+ l.once.Do(func() {
42+ log := logging.FromContext(ctx)
43+ log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
44+
45+ l.db, l.err = NewConnection(ctx, l.dbPath)
46+ if l.err != nil {
47+ log.Error().Err(l.err).Msg("lazy database initialization failed")
48+ } else {
49+ log.Debug().Msg("lazy database initialized successfully")
50+ }
51+ })
52+
53+ if l.err != nil {
54+ return nil, fmt.Errorf("database initialization failed: %w", l.err)
55+ }
56+ return l.db, nil
57+}
58+
59+// Close closes the database connection if it was initialized.
60+func (l *LazyDB) Close() error {
61+ l.mu.Lock()
62+ defer l.mu.Unlock()
63+
64+ if l.db != nil {
65+ return l.db.Close()
66+ }
67+ return nil
68+}
69+
70+// IsInitialized returns true if the database has been initialized.
71+func (l *LazyDB) IsInitialized() bool {
72+ l.mu.RLock()
73+ defer l.mu.RUnlock()
74+ return l.db != nil
75+}
76+
77+// Path returns the database path.
78+func (l *LazyDB) Path() string {
79+ return l.dbPath
80+}
A internal/infrastructure/persistence/sqlite/lazy_db_test.go +125 -0
@@ -0,0 +1,125 @@
1+package sqlite_test
2+
3+import (
4+ "path/filepath"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestLazyDB_NotInitializedByDefault(t *testing.T) {
14+ dbPath := filepath.Join(t.TempDir(), "test.db")
15+ lazy := sqlite.NewLazyDB(dbPath)
16+
17+ assert.False(t, lazy.IsInitialized(), "LazyDB should not be initialized before DB() is called")
18+}
19+
20+func TestLazyDB_InitializesOnFirstAccess(t *testing.T) {
21+ ctx := testCtx()
22+ dbPath := filepath.Join(t.TempDir(), "test.db")
23+ lazy := sqlite.NewLazyDB(dbPath)
24+
25+ db, err := lazy.DB(ctx)
26+ require.NoError(t, err)
27+ require.NotNil(t, db)
28+
29+ assert.True(t, lazy.IsInitialized(), "LazyDB should be initialized after DB() is called")
30+
31+ // Cleanup
32+ require.NoError(t, lazy.Close())
33+}
34+
35+func TestLazyDB_ReturnsSameConnection(t *testing.T) {
36+ ctx := testCtx()
37+ dbPath := filepath.Join(t.TempDir(), "test.db")
38+ lazy := sqlite.NewLazyDB(dbPath)
39+
40+ db1, err := lazy.DB(ctx)
41+ require.NoError(t, err)
42+
43+ db2, err := lazy.DB(ctx)
44+ require.NoError(t, err)
45+
46+ assert.Same(t, db1, db2, "DB() should return the same connection instance")
47+
48+ require.NoError(t, lazy.Close())
49+}
50+
51+func TestLazyDB_ConcurrentAccess(t *testing.T) {
52+ ctx := testCtx()
53+ dbPath := filepath.Join(t.TempDir(), "test.db")
54+ lazy := sqlite.NewLazyDB(dbPath)
55+
56+ const goroutines = 10
57+ var wg sync.WaitGroup
58+ wg.Add(goroutines)
59+
60+ results := make(chan *struct {
61+ db interface{}
62+ err error
63+ }, goroutines)
64+
65+ for range goroutines {
66+ go func() {
67+ defer wg.Done()
68+ db, err := lazy.DB(ctx)
69+ results <- &struct {
70+ db interface{}
71+ err error
72+ }{db, err}
73+ }()
74+ }
75+
76+ wg.Wait()
77+ close(results)
78+
79+ var firstDB interface{}
80+ for result := range results {
81+ require.NoError(t, result.err)
82+ require.NotNil(t, result.db)
83+
84+ if firstDB == nil {
85+ firstDB = result.db
86+ } else {
87+ assert.Same(t, firstDB, result.db, "All goroutines should receive the same DB instance")
88+ }
89+ }
90+
91+ require.NoError(t, lazy.Close())
92+}
93+
94+func TestLazyDB_CloseBeforeInit(t *testing.T) {
95+ dbPath := filepath.Join(t.TempDir(), "test.db")
96+ lazy := sqlite.NewLazyDB(dbPath)
97+
98+ // Close without ever calling DB() should not error
99+ err := lazy.Close()
100+ assert.NoError(t, err)
101+}
102+
103+func TestLazyDB_Path(t *testing.T) {
104+ dbPath := "/some/path/to/db.sqlite"
105+ lazy := sqlite.NewLazyDB(dbPath)
106+
107+ assert.Equal(t, dbPath, lazy.Path())
108+}
109+
110+func TestLazyDB_DBIsUsable(t *testing.T) {
111+ ctx := testCtx()
112+ dbPath := filepath.Join(t.TempDir(), "test.db")
113+ lazy := sqlite.NewLazyDB(dbPath)
114+
115+ db, err := lazy.DB(ctx)
116+ require.NoError(t, err)
117+
118+ // Verify we can execute a simple query
119+ var result int
120+ err = db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
121+ require.NoError(t, err)
122+ assert.Equal(t, 1, result)
123+
124+ require.NoError(t, lazy.Close())
125+}
@@ -0,0 +1,125 @@
1+package sqlite_test
2+
3+import (
4+ "path/filepath"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestLazyDB_NotInitializedByDefault(t *testing.T) {
14+ dbPath := filepath.Join(t.TempDir(), "test.db")
15+ lazy := sqlite.NewLazyDB(dbPath)
16+
17+ assert.False(t, lazy.IsInitialized(), "LazyDB should not be initialized before DB() is called")
18+}
19+
20+func TestLazyDB_InitializesOnFirstAccess(t *testing.T) {
21+ ctx := testCtx()
22+ dbPath := filepath.Join(t.TempDir(), "test.db")
23+ lazy := sqlite.NewLazyDB(dbPath)
24+
25+ db, err := lazy.DB(ctx)
26+ require.NoError(t, err)
27+ require.NotNil(t, db)
28+
29+ assert.True(t, lazy.IsInitialized(), "LazyDB should be initialized after DB() is called")
30+
31+ // Cleanup
32+ require.NoError(t, lazy.Close())
33+}
34+
35+func TestLazyDB_ReturnsSameConnection(t *testing.T) {
36+ ctx := testCtx()
37+ dbPath := filepath.Join(t.TempDir(), "test.db")
38+ lazy := sqlite.NewLazyDB(dbPath)
39+
40+ db1, err := lazy.DB(ctx)
41+ require.NoError(t, err)
42+
43+ db2, err := lazy.DB(ctx)
44+ require.NoError(t, err)
45+
46+ assert.Same(t, db1, db2, "DB() should return the same connection instance")
47+
48+ require.NoError(t, lazy.Close())
49+}
50+
51+func TestLazyDB_ConcurrentAccess(t *testing.T) {
52+ ctx := testCtx()
53+ dbPath := filepath.Join(t.TempDir(), "test.db")
54+ lazy := sqlite.NewLazyDB(dbPath)
55+
56+ const goroutines = 10
57+ var wg sync.WaitGroup
58+ wg.Add(goroutines)
59+
60+ results := make(chan *struct {
61+ db interface{}
62+ err error
63+ }, goroutines)
64+
65+ for range goroutines {
66+ go func() {
67+ defer wg.Done()
68+ db, err := lazy.DB(ctx)
69+ results <- &struct {
70+ db interface{}
71+ err error
72+ }{db, err}
73+ }()
74+ }
75+
76+ wg.Wait()
77+ close(results)
78+
79+ var firstDB interface{}
80+ for result := range results {
81+ require.NoError(t, result.err)
82+ require.NotNil(t, result.db)
83+
84+ if firstDB == nil {
85+ firstDB = result.db
86+ } else {
87+ assert.Same(t, firstDB, result.db, "All goroutines should receive the same DB instance")
88+ }
89+ }
90+
91+ require.NoError(t, lazy.Close())
92+}
93+
94+func TestLazyDB_CloseBeforeInit(t *testing.T) {
95+ dbPath := filepath.Join(t.TempDir(), "test.db")
96+ lazy := sqlite.NewLazyDB(dbPath)
97+
98+ // Close without ever calling DB() should not error
99+ err := lazy.Close()
100+ assert.NoError(t, err)
101+}
102+
103+func TestLazyDB_Path(t *testing.T) {
104+ dbPath := "/some/path/to/db.sqlite"
105+ lazy := sqlite.NewLazyDB(dbPath)
106+
107+ assert.Equal(t, dbPath, lazy.Path())
108+}
109+
110+func TestLazyDB_DBIsUsable(t *testing.T) {
111+ ctx := testCtx()
112+ dbPath := filepath.Join(t.TempDir(), "test.db")
113+ lazy := sqlite.NewLazyDB(dbPath)
114+
115+ db, err := lazy.DB(ctx)
116+ require.NoError(t, err)
117+
118+ // Verify we can execute a simple query
119+ var result int
120+ err = db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
121+ require.NoError(t, err)
122+ assert.Equal(t, 1, result)
123+
124+ require.NoError(t, lazy.Close())
125+}
A internal/infrastructure/persistence/sqlite/lazy_repos.go +669 -0
@@ -0,0 +1,669 @@
1+// Package sqlite provides SQLite implementations of domain repositories.
2+//
3+// # Lazy Repository Infrastructure
4+//
5+// This file contains lazy-loading repository wrappers that defer database initialization
6+// until first access. While currently unused (the application uses eager initialization
7+// via OpenDatabase in RunParallelDBWebKit), this infrastructure is kept for potential
8+// future optimization scenarios:
9+//
10+// - CLI commands that may not need database access
11+// - Further startup latency reduction by deferring DB init past first paint
12+// - Testing with mock DatabaseProvider implementations
13+//
14+// The lazy wrappers implement the same repository interfaces as their eager counterparts,
15+// making them drop-in replacements when lazy initialization becomes beneficial.
16+package sqlite
17+
18+import (
19+ "context"
20+ "database/sql"
21+ "sync"
22+ "time"
23+
24+ "github.com/bnema/dumber/internal/application/port"
25+ "github.com/bnema/dumber/internal/domain/entity"
26+ "github.com/bnema/dumber/internal/domain/repository"
27+)
28+
29+// LazyHistoryRepository wraps a history repository with lazy database initialization.
30+type LazyHistoryRepository struct {
31+ provider port.DatabaseProvider
32+ repo repository.HistoryRepository
33+ once sync.Once
34+ initErr error
35+}
36+
37+// NewLazyHistoryRepository creates a lazy-loading history repository.
38+func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.HistoryRepository {
39+ return &LazyHistoryRepository{provider: provider}
40+}
41+
42+func (r *LazyHistoryRepository) init(ctx context.Context) error {
43+ r.once.Do(func() {
44+ db, err := r.provider.DB(ctx)
45+ if err != nil {
46+ r.initErr = err
47+ return
48+ }
49+ r.repo = NewHistoryRepository(db)
50+ })
51+ return r.initErr
52+}
53+
54+func (r *LazyHistoryRepository) Save(ctx context.Context, entry *entity.HistoryEntry) error {
55+ if err := r.init(ctx); err != nil {
56+ return err
57+ }
58+ return r.repo.Save(ctx, entry)
59+}
60+
61+func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
62+ if err := r.init(ctx); err != nil {
63+ return nil, err
64+ }
65+ return r.repo.FindByURL(ctx, url)
66+}
67+
68+func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
69+ if err := r.init(ctx); err != nil {
70+ return nil, err
71+ }
72+ return r.repo.Search(ctx, query, limit)
73+}
74+
75+func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
76+ if err := r.init(ctx); err != nil {
77+ return nil, err
78+ }
79+ return r.repo.GetRecent(ctx, limit, offset)
80+}
81+
82+func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83+ if err := r.init(ctx); err != nil {
84+ return err
85+ }
86+ return r.repo.IncrementVisitCount(ctx, url)
87+}
88+
89+func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
90+ if err := r.init(ctx); err != nil {
91+ return err
92+ }
93+ return r.repo.Delete(ctx, id)
94+}
95+
96+func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time.Time) error {
97+ if err := r.init(ctx); err != nil {
98+ return err
99+ }
100+ return r.repo.DeleteOlderThan(ctx, before)
101+}
102+
103+func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
104+ if err := r.init(ctx); err != nil {
105+ return err
106+ }
107+ return r.repo.DeleteAll(ctx)
108+}
109+
110+func (r *LazyHistoryRepository) DeleteByDomain(ctx context.Context, domain string) error {
111+ if err := r.init(ctx); err != nil {
112+ return err
113+ }
114+ return r.repo.DeleteByDomain(ctx, domain)
115+}
116+
117+func (r *LazyHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
118+ if err := r.init(ctx); err != nil {
119+ return nil, err
120+ }
121+ return r.repo.GetStats(ctx)
122+}
123+
124+func (r *LazyHistoryRepository) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
125+ if err := r.init(ctx); err != nil {
126+ return nil, err
127+ }
128+ return r.repo.GetDomainStats(ctx, limit)
129+}
130+
131+func (r *LazyHistoryRepository) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
132+ if err := r.init(ctx); err != nil {
133+ return nil, err
134+ }
135+ return r.repo.GetHourlyDistribution(ctx)
136+}
137+
138+func (r *LazyHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
139+ if err := r.init(ctx); err != nil {
140+ return nil, err
141+ }
142+ return r.repo.GetDailyVisitCount(ctx, daysAgo)
143+}
144+
145+// LazyFavoriteRepository wraps a favorite repository with lazy database initialization.
146+type LazyFavoriteRepository struct {
147+ provider port.DatabaseProvider
148+ repo repository.FavoriteRepository
149+ once sync.Once
150+ initErr error
151+}
152+
153+// NewLazyFavoriteRepository creates a lazy-loading favorite repository.
154+func NewLazyFavoriteRepository(provider port.DatabaseProvider) repository.FavoriteRepository {
155+ return &LazyFavoriteRepository{provider: provider}
156+}
157+
158+func (r *LazyFavoriteRepository) init(ctx context.Context) error {
159+ r.once.Do(func() {
160+ db, err := r.provider.DB(ctx)
161+ if err != nil {
162+ r.initErr = err
163+ return
164+ }
165+ r.repo = NewFavoriteRepository(db)
166+ })
167+ return r.initErr
168+}
169+
170+func (r *LazyFavoriteRepository) Save(ctx context.Context, fav *entity.Favorite) error {
171+ if err := r.init(ctx); err != nil {
172+ return err
173+ }
174+ return r.repo.Save(ctx, fav)
175+}
176+
177+func (r *LazyFavoriteRepository) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
178+ if err := r.init(ctx); err != nil {
179+ return nil, err
180+ }
181+ return r.repo.FindByID(ctx, id)
182+}
183+
184+func (r *LazyFavoriteRepository) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
185+ if err := r.init(ctx); err != nil {
186+ return nil, err
187+ }
188+ return r.repo.FindByURL(ctx, url)
189+}
190+
191+func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
192+ if err := r.init(ctx); err != nil {
193+ return nil, err
194+ }
195+ return r.repo.GetAll(ctx)
196+}
197+
198+func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
199+ if err := r.init(ctx); err != nil {
200+ return nil, err
201+ }
202+ return r.repo.GetByFolder(ctx, folderID)
203+}
204+
205+func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
206+ if err := r.init(ctx); err != nil {
207+ return nil, err
208+ }
209+ return r.repo.GetByShortcut(ctx, key)
210+}
211+
212+func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
213+ if err := r.init(ctx); err != nil {
214+ return err
215+ }
216+ return r.repo.UpdatePosition(ctx, id, position)
217+}
218+
219+func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
220+ if err := r.init(ctx); err != nil {
221+ return err
222+ }
223+ return r.repo.SetFolder(ctx, id, folderID)
224+}
225+
226+func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
227+ if err := r.init(ctx); err != nil {
228+ return err
229+ }
230+ return r.repo.SetShortcut(ctx, id, key)
231+}
232+
233+func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteID) error {
234+ if err := r.init(ctx); err != nil {
235+ return err
236+ }
237+ return r.repo.Delete(ctx, id)
238+}
239+
240+// LazyFolderRepository wraps a folder repository with lazy database initialization.
241+type LazyFolderRepository struct {
242+ provider port.DatabaseProvider
243+ repo repository.FolderRepository
244+ once sync.Once
245+ initErr error
246+}
247+
248+// NewLazyFolderRepository creates a lazy-loading folder repository.
249+func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
250+ return &LazyFolderRepository{provider: provider}
251+}
252+
253+func (r *LazyFolderRepository) init(ctx context.Context) error {
254+ r.once.Do(func() {
255+ db, err := r.provider.DB(ctx)
256+ if err != nil {
257+ r.initErr = err
258+ return
259+ }
260+ r.repo = NewFolderRepository(db)
261+ })
262+ return r.initErr
263+}
264+
265+func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
266+ if err := r.init(ctx); err != nil {
267+ return err
268+ }
269+ return r.repo.Save(ctx, folder)
270+}
271+
272+func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
273+ if err := r.init(ctx); err != nil {
274+ return nil, err
275+ }
276+ return r.repo.FindByID(ctx, id)
277+}
278+
279+func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
280+ if err := r.init(ctx); err != nil {
281+ return nil, err
282+ }
283+ return r.repo.GetAll(ctx)
284+}
285+
286+func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
287+ if err := r.init(ctx); err != nil {
288+ return nil, err
289+ }
290+ return r.repo.GetChildren(ctx, parentID)
291+}
292+
293+func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
294+ if err := r.init(ctx); err != nil {
295+ return err
296+ }
297+ return r.repo.UpdatePosition(ctx, id, position)
298+}
299+
300+func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
301+ if err := r.init(ctx); err != nil {
302+ return err
303+ }
304+ return r.repo.Delete(ctx, id)
305+}
306+
307+// LazyTagRepository wraps a tag repository with lazy database initialization.
308+type LazyTagRepository struct {
309+ provider port.DatabaseProvider
310+ repo repository.TagRepository
311+ once sync.Once
312+ initErr error
313+}
314+
315+// NewLazyTagRepository creates a lazy-loading tag repository.
316+func NewLazyTagRepository(provider port.DatabaseProvider) repository.TagRepository {
317+ return &LazyTagRepository{provider: provider}
318+}
319+
320+func (r *LazyTagRepository) init(ctx context.Context) error {
321+ r.once.Do(func() {
322+ db, err := r.provider.DB(ctx)
323+ if err != nil {
324+ r.initErr = err
325+ return
326+ }
327+ r.repo = NewTagRepository(db)
328+ })
329+ return r.initErr
330+}
331+
332+func (r *LazyTagRepository) Save(ctx context.Context, tag *entity.Tag) error {
333+ if err := r.init(ctx); err != nil {
334+ return err
335+ }
336+ return r.repo.Save(ctx, tag)
337+}
338+
339+func (r *LazyTagRepository) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
340+ if err := r.init(ctx); err != nil {
341+ return nil, err
342+ }
343+ return r.repo.FindByID(ctx, id)
344+}
345+
346+func (r *LazyTagRepository) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
347+ if err := r.init(ctx); err != nil {
348+ return nil, err
349+ }
350+ return r.repo.FindByName(ctx, name)
351+}
352+
353+func (r *LazyTagRepository) GetAll(ctx context.Context) ([]*entity.Tag, error) {
354+ if err := r.init(ctx); err != nil {
355+ return nil, err
356+ }
357+ return r.repo.GetAll(ctx)
358+}
359+
360+func (r *LazyTagRepository) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
361+ if err := r.init(ctx); err != nil {
362+ return err
363+ }
364+ return r.repo.AssignToFavorite(ctx, tagID, favID)
365+}
366+
367+func (r *LazyTagRepository) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
368+ if err := r.init(ctx); err != nil {
369+ return err
370+ }
371+ return r.repo.RemoveFromFavorite(ctx, tagID, favID)
372+}
373+
374+func (r *LazyTagRepository) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
375+ if err := r.init(ctx); err != nil {
376+ return nil, err
377+ }
378+ return r.repo.GetForFavorite(ctx, favID)
379+}
380+
381+func (r *LazyTagRepository) Delete(ctx context.Context, id entity.TagID) error {
382+ if err := r.init(ctx); err != nil {
383+ return err
384+ }
385+ return r.repo.Delete(ctx, id)
386+}
387+
388+// LazyZoomRepository wraps a zoom repository with lazy database initialization.
389+type LazyZoomRepository struct {
390+ provider port.DatabaseProvider
391+ repo repository.ZoomRepository
392+ once sync.Once
393+ initErr error
394+}
395+
396+// NewLazyZoomRepository creates a lazy-loading zoom repository.
397+func NewLazyZoomRepository(provider port.DatabaseProvider) repository.ZoomRepository {
398+ return &LazyZoomRepository{provider: provider}
399+}
400+
401+func (r *LazyZoomRepository) init(ctx context.Context) error {
402+ r.once.Do(func() {
403+ db, err := r.provider.DB(ctx)
404+ if err != nil {
405+ r.initErr = err
406+ return
407+ }
408+ r.repo = NewZoomRepository(db)
409+ })
410+ return r.initErr
411+}
412+
413+func (r *LazyZoomRepository) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
414+ if err := r.init(ctx); err != nil {
415+ return nil, err
416+ }
417+ return r.repo.Get(ctx, domain)
418+}
419+
420+func (r *LazyZoomRepository) Set(ctx context.Context, zoom *entity.ZoomLevel) error {
421+ if err := r.init(ctx); err != nil {
422+ return err
423+ }
424+ return r.repo.Set(ctx, zoom)
425+}
426+
427+func (r *LazyZoomRepository) Delete(ctx context.Context, domain string) error {
428+ if err := r.init(ctx); err != nil {
429+ return err
430+ }
431+ return r.repo.Delete(ctx, domain)
432+}
433+
434+func (r *LazyZoomRepository) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
435+ if err := r.init(ctx); err != nil {
436+ return nil, err
437+ }
438+ return r.repo.GetAll(ctx)
439+}
440+
441+// LazySessionRepository wraps a session repository with lazy database initialization.
442+type LazySessionRepository struct {
443+ provider port.DatabaseProvider
444+ repo repository.SessionRepository
445+ once sync.Once
446+ initErr error
447+}
448+
449+// NewLazySessionRepository creates a lazy-loading session repository.
450+func NewLazySessionRepository(provider port.DatabaseProvider) repository.SessionRepository {
451+ return &LazySessionRepository{provider: provider}
452+}
453+
454+func (r *LazySessionRepository) init(ctx context.Context) error {
455+ r.once.Do(func() {
456+ db, err := r.provider.DB(ctx)
457+ if err != nil {
458+ r.initErr = err
459+ return
460+ }
461+ r.repo = NewSessionRepository(db)
462+ })
463+ return r.initErr
464+}
465+
466+func (r *LazySessionRepository) Save(ctx context.Context, session *entity.Session) error {
467+ if err := r.init(ctx); err != nil {
468+ return err
469+ }
470+ return r.repo.Save(ctx, session)
471+}
472+
473+func (r *LazySessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
474+ if err := r.init(ctx); err != nil {
475+ return nil, err
476+ }
477+ return r.repo.FindByID(ctx, id)
478+}
479+
480+func (r *LazySessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
481+ if err := r.init(ctx); err != nil {
482+ return nil, err
483+ }
484+ return r.repo.GetActive(ctx)
485+}
486+
487+func (r *LazySessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
488+ if err := r.init(ctx); err != nil {
489+ return nil, err
490+ }
491+ return r.repo.GetRecent(ctx, limit)
492+}
493+
494+func (r *LazySessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
495+ if err := r.init(ctx); err != nil {
496+ return err
497+ }
498+ return r.repo.MarkEnded(ctx, id, endedAt)
499+}
500+
501+func (r *LazySessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
502+ if err := r.init(ctx); err != nil {
503+ return err
504+ }
505+ return r.repo.Delete(ctx, id)
506+}
507+
508+func (r *LazySessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
509+ if err := r.init(ctx); err != nil {
510+ return 0, err
511+ }
512+ return r.repo.DeleteOldestExited(ctx, keepCount)
513+}
514+
515+func (r *LazySessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
516+ if err := r.init(ctx); err != nil {
517+ return 0, err
518+ }
519+ return r.repo.DeleteExitedBefore(ctx, cutoff)
520+}
521+
522+// LazySessionStateRepository wraps a session state repository with lazy database initialization.
523+type LazySessionStateRepository struct {
524+ provider port.DatabaseProvider
525+ repo repository.SessionStateRepository
526+ once sync.Once
527+ initErr error
528+}
529+
530+// NewLazySessionStateRepository creates a lazy-loading session state repository.
531+func NewLazySessionStateRepository(provider port.DatabaseProvider) repository.SessionStateRepository {
532+ return &LazySessionStateRepository{provider: provider}
533+}
534+
535+func (r *LazySessionStateRepository) init(ctx context.Context) error {
536+ r.once.Do(func() {
537+ db, err := r.provider.DB(ctx)
538+ if err != nil {
539+ r.initErr = err
540+ return
541+ }
542+ r.repo = NewSessionStateRepository(db)
543+ })
544+ return r.initErr
545+}
546+
547+func (r *LazySessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
548+ if err := r.init(ctx); err != nil {
549+ return err
550+ }
551+ return r.repo.SaveSnapshot(ctx, state)
552+}
553+
554+func (r *LazySessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
555+ if err := r.init(ctx); err != nil {
556+ return nil, err
557+ }
558+ return r.repo.GetSnapshot(ctx, sessionID)
559+}
560+
561+func (r *LazySessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
562+ if err := r.init(ctx); err != nil {
563+ return err
564+ }
565+ return r.repo.DeleteSnapshot(ctx, sessionID)
566+}
567+
568+func (r *LazySessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
569+ if err := r.init(ctx); err != nil {
570+ return nil, err
571+ }
572+ return r.repo.GetAllSnapshots(ctx)
573+}
574+
575+func (r *LazySessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
576+ if err := r.init(ctx); err != nil {
577+ return 0, err
578+ }
579+ return r.repo.GetTotalSnapshotsSize(ctx)
580+}
581+
582+// LazyContentWhitelistRepository wraps a content whitelist repository with lazy database initialization.
583+type LazyContentWhitelistRepository struct {
584+ provider port.DatabaseProvider
585+ repo repository.ContentWhitelistRepository
586+ once sync.Once
587+ initErr error
588+}
589+
590+// NewLazyContentWhitelistRepository creates a lazy-loading content whitelist repository.
591+func NewLazyContentWhitelistRepository(provider port.DatabaseProvider) repository.ContentWhitelistRepository {
592+ return &LazyContentWhitelistRepository{provider: provider}
593+}
594+
595+func (r *LazyContentWhitelistRepository) init(ctx context.Context) error {
596+ r.once.Do(func() {
597+ db, err := r.provider.DB(ctx)
598+ if err != nil {
599+ r.initErr = err
600+ return
601+ }
602+ r.repo = NewContentWhitelistRepository(db)
603+ })
604+ return r.initErr
605+}
606+
607+func (r *LazyContentWhitelistRepository) Add(ctx context.Context, domain string) error {
608+ if err := r.init(ctx); err != nil {
609+ return err
610+ }
611+ return r.repo.Add(ctx, domain)
612+}
613+
614+func (r *LazyContentWhitelistRepository) Remove(ctx context.Context, domain string) error {
615+ if err := r.init(ctx); err != nil {
616+ return err
617+ }
618+ return r.repo.Remove(ctx, domain)
619+}
620+
621+func (r *LazyContentWhitelistRepository) Contains(ctx context.Context, domain string) (bool, error) {
622+ if err := r.init(ctx); err != nil {
623+ return false, err
624+ }
625+ return r.repo.Contains(ctx, domain)
626+}
627+
628+func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, error) {
629+ if err := r.init(ctx); err != nil {
630+ return nil, err
631+ }
632+ return r.repo.GetAll(ctx)
633+}
634+
635+// LazyRepositories holds all lazy-loaded repositories.
636+type LazyRepositories struct {
637+ History repository.HistoryRepository
638+ Favorite repository.FavoriteRepository
639+ Folder repository.FolderRepository
640+ Tag repository.TagRepository
641+ Zoom repository.ZoomRepository
642+ Session repository.SessionRepository
643+ SessionState repository.SessionStateRepository
644+ Filter repository.ContentWhitelistRepository
645+}
646+
647+// NewLazyRepositories creates all lazy repositories from a database provider.
648+func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
649+ return &LazyRepositories{
650+ History: NewLazyHistoryRepository(provider),
651+ Favorite: NewLazyFavoriteRepository(provider),
652+ Folder: NewLazyFolderRepository(provider),
653+ Tag: NewLazyTagRepository(provider),
654+ Zoom: NewLazyZoomRepository(provider),
655+ Session: NewLazySessionRepository(provider),
656+ SessionState: NewLazySessionStateRepository(provider),
657+ Filter: NewLazyContentWhitelistRepository(provider),
658+ }
659+}
660+
661+// GetDB returns the underlying *sql.DB for operations that need it directly.
662+// Returns nil if the database hasn't been initialized yet.
663+func GetDB(provider port.DatabaseProvider, ctx context.Context) *sql.DB {
664+ db, err := provider.DB(ctx)
665+ if err != nil {
666+ return nil
667+ }
668+ return db
669+}
@@ -0,0 +1,669 @@
1+// Package sqlite provides SQLite implementations of domain repositories.
2+//
3+// # Lazy Repository Infrastructure
4+//
5+// This file contains lazy-loading repository wrappers that defer database initialization
6+// until first access. While currently unused (the application uses eager initialization
7+// via OpenDatabase in RunParallelDBWebKit), this infrastructure is kept for potential
8+// future optimization scenarios:
9+//
10+// - CLI commands that may not need database access
11+// - Further startup latency reduction by deferring DB init past first paint
12+// - Testing with mock DatabaseProvider implementations
13+//
14+// The lazy wrappers implement the same repository interfaces as their eager counterparts,
15+// making them drop-in replacements when lazy initialization becomes beneficial.
16+package sqlite
17+
18+import (
19+ "context"
20+ "database/sql"
21+ "sync"
22+ "time"
23+
24+ "github.com/bnema/dumber/internal/application/port"
25+ "github.com/bnema/dumber/internal/domain/entity"
26+ "github.com/bnema/dumber/internal/domain/repository"
27+)
28+
29+// LazyHistoryRepository wraps a history repository with lazy database initialization.
30+type LazyHistoryRepository struct {
31+ provider port.DatabaseProvider
32+ repo repository.HistoryRepository
33+ once sync.Once
34+ initErr error
35+}
36+
37+// NewLazyHistoryRepository creates a lazy-loading history repository.
38+func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.HistoryRepository {
39+ return &LazyHistoryRepository{provider: provider}
40+}
41+
42+func (r *LazyHistoryRepository) init(ctx context.Context) error {
43+ r.once.Do(func() {
44+ db, err := r.provider.DB(ctx)
45+ if err != nil {
46+ r.initErr = err
47+ return
48+ }
49+ r.repo = NewHistoryRepository(db)
50+ })
51+ return r.initErr
52+}
53+
54+func (r *LazyHistoryRepository) Save(ctx context.Context, entry *entity.HistoryEntry) error {
55+ if err := r.init(ctx); err != nil {
56+ return err
57+ }
58+ return r.repo.Save(ctx, entry)
59+}
60+
61+func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
62+ if err := r.init(ctx); err != nil {
63+ return nil, err
64+ }
65+ return r.repo.FindByURL(ctx, url)
66+}
67+
68+func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
69+ if err := r.init(ctx); err != nil {
70+ return nil, err
71+ }
72+ return r.repo.Search(ctx, query, limit)
73+}
74+
75+func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
76+ if err := r.init(ctx); err != nil {
77+ return nil, err
78+ }
79+ return r.repo.GetRecent(ctx, limit, offset)
80+}
81+
82+func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83+ if err := r.init(ctx); err != nil {
84+ return err
85+ }
86+ return r.repo.IncrementVisitCount(ctx, url)
87+}
88+
89+func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
90+ if err := r.init(ctx); err != nil {
91+ return err
92+ }
93+ return r.repo.Delete(ctx, id)
94+}
95+
96+func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time.Time) error {
97+ if err := r.init(ctx); err != nil {
98+ return err
99+ }
100+ return r.repo.DeleteOlderThan(ctx, before)
101+}
102+
103+func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
104+ if err := r.init(ctx); err != nil {
105+ return err
106+ }
107+ return r.repo.DeleteAll(ctx)
108+}
109+
110+func (r *LazyHistoryRepository) DeleteByDomain(ctx context.Context, domain string) error {
111+ if err := r.init(ctx); err != nil {
112+ return err
113+ }
114+ return r.repo.DeleteByDomain(ctx, domain)
115+}
116+
117+func (r *LazyHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
118+ if err := r.init(ctx); err != nil {
119+ return nil, err
120+ }
121+ return r.repo.GetStats(ctx)
122+}
123+
124+func (r *LazyHistoryRepository) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
125+ if err := r.init(ctx); err != nil {
126+ return nil, err
127+ }
128+ return r.repo.GetDomainStats(ctx, limit)
129+}
130+
131+func (r *LazyHistoryRepository) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
132+ if err := r.init(ctx); err != nil {
133+ return nil, err
134+ }
135+ return r.repo.GetHourlyDistribution(ctx)
136+}
137+
138+func (r *LazyHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
139+ if err := r.init(ctx); err != nil {
140+ return nil, err
141+ }
142+ return r.repo.GetDailyVisitCount(ctx, daysAgo)
143+}
144+
145+// LazyFavoriteRepository wraps a favorite repository with lazy database initialization.
146+type LazyFavoriteRepository struct {
147+ provider port.DatabaseProvider
148+ repo repository.FavoriteRepository
149+ once sync.Once
150+ initErr error
151+}
152+
153+// NewLazyFavoriteRepository creates a lazy-loading favorite repository.
154+func NewLazyFavoriteRepository(provider port.DatabaseProvider) repository.FavoriteRepository {
155+ return &LazyFavoriteRepository{provider: provider}
156+}
157+
158+func (r *LazyFavoriteRepository) init(ctx context.Context) error {
159+ r.once.Do(func() {
160+ db, err := r.provider.DB(ctx)
161+ if err != nil {
162+ r.initErr = err
163+ return
164+ }
165+ r.repo = NewFavoriteRepository(db)
166+ })
167+ return r.initErr
168+}
169+
170+func (r *LazyFavoriteRepository) Save(ctx context.Context, fav *entity.Favorite) error {
171+ if err := r.init(ctx); err != nil {
172+ return err
173+ }
174+ return r.repo.Save(ctx, fav)
175+}
176+
177+func (r *LazyFavoriteRepository) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
178+ if err := r.init(ctx); err != nil {
179+ return nil, err
180+ }
181+ return r.repo.FindByID(ctx, id)
182+}
183+
184+func (r *LazyFavoriteRepository) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
185+ if err := r.init(ctx); err != nil {
186+ return nil, err
187+ }
188+ return r.repo.FindByURL(ctx, url)
189+}
190+
191+func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
192+ if err := r.init(ctx); err != nil {
193+ return nil, err
194+ }
195+ return r.repo.GetAll(ctx)
196+}
197+
198+func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
199+ if err := r.init(ctx); err != nil {
200+ return nil, err
201+ }
202+ return r.repo.GetByFolder(ctx, folderID)
203+}
204+
205+func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
206+ if err := r.init(ctx); err != nil {
207+ return nil, err
208+ }
209+ return r.repo.GetByShortcut(ctx, key)
210+}
211+
212+func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
213+ if err := r.init(ctx); err != nil {
214+ return err
215+ }
216+ return r.repo.UpdatePosition(ctx, id, position)
217+}
218+
219+func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
220+ if err := r.init(ctx); err != nil {
221+ return err
222+ }
223+ return r.repo.SetFolder(ctx, id, folderID)
224+}
225+
226+func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
227+ if err := r.init(ctx); err != nil {
228+ return err
229+ }
230+ return r.repo.SetShortcut(ctx, id, key)
231+}
232+
233+func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteID) error {
234+ if err := r.init(ctx); err != nil {
235+ return err
236+ }
237+ return r.repo.Delete(ctx, id)
238+}
239+
240+// LazyFolderRepository wraps a folder repository with lazy database initialization.
241+type LazyFolderRepository struct {
242+ provider port.DatabaseProvider
243+ repo repository.FolderRepository
244+ once sync.Once
245+ initErr error
246+}
247+
248+// NewLazyFolderRepository creates a lazy-loading folder repository.
249+func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
250+ return &LazyFolderRepository{provider: provider}
251+}
252+
253+func (r *LazyFolderRepository) init(ctx context.Context) error {
254+ r.once.Do(func() {
255+ db, err := r.provider.DB(ctx)
256+ if err != nil {
257+ r.initErr = err
258+ return
259+ }
260+ r.repo = NewFolderRepository(db)
261+ })
262+ return r.initErr
263+}
264+
265+func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
266+ if err := r.init(ctx); err != nil {
267+ return err
268+ }
269+ return r.repo.Save(ctx, folder)
270+}
271+
272+func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
273+ if err := r.init(ctx); err != nil {
274+ return nil, err
275+ }
276+ return r.repo.FindByID(ctx, id)
277+}
278+
279+func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
280+ if err := r.init(ctx); err != nil {
281+ return nil, err
282+ }
283+ return r.repo.GetAll(ctx)
284+}
285+
286+func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
287+ if err := r.init(ctx); err != nil {
288+ return nil, err
289+ }
290+ return r.repo.GetChildren(ctx, parentID)
291+}
292+
293+func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
294+ if err := r.init(ctx); err != nil {
295+ return err
296+ }
297+ return r.repo.UpdatePosition(ctx, id, position)
298+}
299+
300+func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
301+ if err := r.init(ctx); err != nil {
302+ return err
303+ }
304+ return r.repo.Delete(ctx, id)
305+}
306+
307+// LazyTagRepository wraps a tag repository with lazy database initialization.
308+type LazyTagRepository struct {
309+ provider port.DatabaseProvider
310+ repo repository.TagRepository
311+ once sync.Once
312+ initErr error
313+}
314+
315+// NewLazyTagRepository creates a lazy-loading tag repository.
316+func NewLazyTagRepository(provider port.DatabaseProvider) repository.TagRepository {
317+ return &LazyTagRepository{provider: provider}
318+}
319+
320+func (r *LazyTagRepository) init(ctx context.Context) error {
321+ r.once.Do(func() {
322+ db, err := r.provider.DB(ctx)
323+ if err != nil {
324+ r.initErr = err
325+ return
326+ }
327+ r.repo = NewTagRepository(db)
328+ })
329+ return r.initErr
330+}
331+
332+func (r *LazyTagRepository) Save(ctx context.Context, tag *entity.Tag) error {
333+ if err := r.init(ctx); err != nil {
334+ return err
335+ }
336+ return r.repo.Save(ctx, tag)
337+}
338+
339+func (r *LazyTagRepository) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
340+ if err := r.init(ctx); err != nil {
341+ return nil, err
342+ }
343+ return r.repo.FindByID(ctx, id)
344+}
345+
346+func (r *LazyTagRepository) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
347+ if err := r.init(ctx); err != nil {
348+ return nil, err
349+ }
350+ return r.repo.FindByName(ctx, name)
351+}
352+
353+func (r *LazyTagRepository) GetAll(ctx context.Context) ([]*entity.Tag, error) {
354+ if err := r.init(ctx); err != nil {
355+ return nil, err
356+ }
357+ return r.repo.GetAll(ctx)
358+}
359+
360+func (r *LazyTagRepository) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
361+ if err := r.init(ctx); err != nil {
362+ return err
363+ }
364+ return r.repo.AssignToFavorite(ctx, tagID, favID)
365+}
366+
367+func (r *LazyTagRepository) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
368+ if err := r.init(ctx); err != nil {
369+ return err
370+ }
371+ return r.repo.RemoveFromFavorite(ctx, tagID, favID)
372+}
373+
374+func (r *LazyTagRepository) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
375+ if err := r.init(ctx); err != nil {
376+ return nil, err
377+ }
378+ return r.repo.GetForFavorite(ctx, favID)
379+}
380+
381+func (r *LazyTagRepository) Delete(ctx context.Context, id entity.TagID) error {
382+ if err := r.init(ctx); err != nil {
383+ return err
384+ }
385+ return r.repo.Delete(ctx, id)
386+}
387+
388+// LazyZoomRepository wraps a zoom repository with lazy database initialization.
389+type LazyZoomRepository struct {
390+ provider port.DatabaseProvider
391+ repo repository.ZoomRepository
392+ once sync.Once
393+ initErr error
394+}
395+
396+// NewLazyZoomRepository creates a lazy-loading zoom repository.
397+func NewLazyZoomRepository(provider port.DatabaseProvider) repository.ZoomRepository {
398+ return &LazyZoomRepository{provider: provider}
399+}
400+
401+func (r *LazyZoomRepository) init(ctx context.Context) error {
402+ r.once.Do(func() {
403+ db, err := r.provider.DB(ctx)
404+ if err != nil {
405+ r.initErr = err
406+ return
407+ }
408+ r.repo = NewZoomRepository(db)
409+ })
410+ return r.initErr
411+}
412+
413+func (r *LazyZoomRepository) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
414+ if err := r.init(ctx); err != nil {
415+ return nil, err
416+ }
417+ return r.repo.Get(ctx, domain)
418+}
419+
420+func (r *LazyZoomRepository) Set(ctx context.Context, zoom *entity.ZoomLevel) error {
421+ if err := r.init(ctx); err != nil {
422+ return err
423+ }
424+ return r.repo.Set(ctx, zoom)
425+}
426+
427+func (r *LazyZoomRepository) Delete(ctx context.Context, domain string) error {
428+ if err := r.init(ctx); err != nil {
429+ return err
430+ }
431+ return r.repo.Delete(ctx, domain)
432+}
433+
434+func (r *LazyZoomRepository) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
435+ if err := r.init(ctx); err != nil {
436+ return nil, err
437+ }
438+ return r.repo.GetAll(ctx)
439+}
440+
441+// LazySessionRepository wraps a session repository with lazy database initialization.
442+type LazySessionRepository struct {
443+ provider port.DatabaseProvider
444+ repo repository.SessionRepository
445+ once sync.Once
446+ initErr error
447+}
448+
449+// NewLazySessionRepository creates a lazy-loading session repository.
450+func NewLazySessionRepository(provider port.DatabaseProvider) repository.SessionRepository {
451+ return &LazySessionRepository{provider: provider}
452+}
453+
454+func (r *LazySessionRepository) init(ctx context.Context) error {
455+ r.once.Do(func() {
456+ db, err := r.provider.DB(ctx)
457+ if err != nil {
458+ r.initErr = err
459+ return
460+ }
461+ r.repo = NewSessionRepository(db)
462+ })
463+ return r.initErr
464+}
465+
466+func (r *LazySessionRepository) Save(ctx context.Context, session *entity.Session) error {
467+ if err := r.init(ctx); err != nil {
468+ return err
469+ }
470+ return r.repo.Save(ctx, session)
471+}
472+
473+func (r *LazySessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
474+ if err := r.init(ctx); err != nil {
475+ return nil, err
476+ }
477+ return r.repo.FindByID(ctx, id)
478+}
479+
480+func (r *LazySessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
481+ if err := r.init(ctx); err != nil {
482+ return nil, err
483+ }
484+ return r.repo.GetActive(ctx)
485+}
486+
487+func (r *LazySessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
488+ if err := r.init(ctx); err != nil {
489+ return nil, err
490+ }
491+ return r.repo.GetRecent(ctx, limit)
492+}
493+
494+func (r *LazySessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
495+ if err := r.init(ctx); err != nil {
496+ return err
497+ }
498+ return r.repo.MarkEnded(ctx, id, endedAt)
499+}
500+
501+func (r *LazySessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
502+ if err := r.init(ctx); err != nil {
503+ return err
504+ }
505+ return r.repo.Delete(ctx, id)
506+}
507+
508+func (r *LazySessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
509+ if err := r.init(ctx); err != nil {
510+ return 0, err
511+ }
512+ return r.repo.DeleteOldestExited(ctx, keepCount)
513+}
514+
515+func (r *LazySessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
516+ if err := r.init(ctx); err != nil {
517+ return 0, err
518+ }
519+ return r.repo.DeleteExitedBefore(ctx, cutoff)
520+}
521+
522+// LazySessionStateRepository wraps a session state repository with lazy database initialization.
523+type LazySessionStateRepository struct {
524+ provider port.DatabaseProvider
525+ repo repository.SessionStateRepository
526+ once sync.Once
527+ initErr error
528+}
529+
530+// NewLazySessionStateRepository creates a lazy-loading session state repository.
531+func NewLazySessionStateRepository(provider port.DatabaseProvider) repository.SessionStateRepository {
532+ return &LazySessionStateRepository{provider: provider}
533+}
534+
535+func (r *LazySessionStateRepository) init(ctx context.Context) error {
536+ r.once.Do(func() {
537+ db, err := r.provider.DB(ctx)
538+ if err != nil {
539+ r.initErr = err
540+ return
541+ }
542+ r.repo = NewSessionStateRepository(db)
543+ })
544+ return r.initErr
545+}
546+
547+func (r *LazySessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
548+ if err := r.init(ctx); err != nil {
549+ return err
550+ }
551+ return r.repo.SaveSnapshot(ctx, state)
552+}
553+
554+func (r *LazySessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
555+ if err := r.init(ctx); err != nil {
556+ return nil, err
557+ }
558+ return r.repo.GetSnapshot(ctx, sessionID)
559+}
560+
561+func (r *LazySessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
562+ if err := r.init(ctx); err != nil {
563+ return err
564+ }
565+ return r.repo.DeleteSnapshot(ctx, sessionID)
566+}
567+
568+func (r *LazySessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
569+ if err := r.init(ctx); err != nil {
570+ return nil, err
571+ }
572+ return r.repo.GetAllSnapshots(ctx)
573+}
574+
575+func (r *LazySessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
576+ if err := r.init(ctx); err != nil {
577+ return 0, err
578+ }
579+ return r.repo.GetTotalSnapshotsSize(ctx)
580+}
581+
582+// LazyContentWhitelistRepository wraps a content whitelist repository with lazy database initialization.
583+type LazyContentWhitelistRepository struct {
584+ provider port.DatabaseProvider
585+ repo repository.ContentWhitelistRepository
586+ once sync.Once
587+ initErr error
588+}
589+
590+// NewLazyContentWhitelistRepository creates a lazy-loading content whitelist repository.
591+func NewLazyContentWhitelistRepository(provider port.DatabaseProvider) repository.ContentWhitelistRepository {
592+ return &LazyContentWhitelistRepository{provider: provider}
593+}
594+
595+func (r *LazyContentWhitelistRepository) init(ctx context.Context) error {
596+ r.once.Do(func() {
597+ db, err := r.provider.DB(ctx)
598+ if err != nil {
599+ r.initErr = err
600+ return
601+ }
602+ r.repo = NewContentWhitelistRepository(db)
603+ })
604+ return r.initErr
605+}
606+
607+func (r *LazyContentWhitelistRepository) Add(ctx context.Context, domain string) error {
608+ if err := r.init(ctx); err != nil {
609+ return err
610+ }
611+ return r.repo.Add(ctx, domain)
612+}
613+
614+func (r *LazyContentWhitelistRepository) Remove(ctx context.Context, domain string) error {
615+ if err := r.init(ctx); err != nil {
616+ return err
617+ }
618+ return r.repo.Remove(ctx, domain)
619+}
620+
621+func (r *LazyContentWhitelistRepository) Contains(ctx context.Context, domain string) (bool, error) {
622+ if err := r.init(ctx); err != nil {
623+ return false, err
624+ }
625+ return r.repo.Contains(ctx, domain)
626+}
627+
628+func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, error) {
629+ if err := r.init(ctx); err != nil {
630+ return nil, err
631+ }
632+ return r.repo.GetAll(ctx)
633+}
634+
635+// LazyRepositories holds all lazy-loaded repositories.
636+type LazyRepositories struct {
637+ History repository.HistoryRepository
638+ Favorite repository.FavoriteRepository
639+ Folder repository.FolderRepository
640+ Tag repository.TagRepository
641+ Zoom repository.ZoomRepository
642+ Session repository.SessionRepository
643+ SessionState repository.SessionStateRepository
644+ Filter repository.ContentWhitelistRepository
645+}
646+
647+// NewLazyRepositories creates all lazy repositories from a database provider.
648+func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
649+ return &LazyRepositories{
650+ History: NewLazyHistoryRepository(provider),
651+ Favorite: NewLazyFavoriteRepository(provider),
652+ Folder: NewLazyFolderRepository(provider),
653+ Tag: NewLazyTagRepository(provider),
654+ Zoom: NewLazyZoomRepository(provider),
655+ Session: NewLazySessionRepository(provider),
656+ SessionState: NewLazySessionStateRepository(provider),
657+ Filter: NewLazyContentWhitelistRepository(provider),
658+ }
659+}
660+
661+// GetDB returns the underlying *sql.DB for operations that need it directly.
662+// Returns nil if the database hasn't been initialized yet.
663+func GetDB(provider port.DatabaseProvider, ctx context.Context) *sql.DB {
664+ db, err := provider.DB(ctx)
665+ if err != nil {
666+ return nil
667+ }
668+ return db
669+}
M internal/infrastructure/webkit/messaging.go +11 -0
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
6262 }
6363 }
6464
65+// SetBaseContext updates the base context used for logging and handler execution.
66+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67+ if r == nil {
68+ return
69+ }
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ r.baseCtx = ctx
74+}
75+
6576 // RegisterHandler registers a handler for a message type.
6677 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
6778 if msgType == "" {
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
62 }62 }
63 }63 }
64 64
65+// SetBaseContext updates the base context used for logging and handler execution.
66+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67+ if r == nil {
68+ return
69+ }
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ r.baseCtx = ctx
74+}
75+
65 // RegisterHandler registers a handler for a message type.76 // RegisterHandler registers a handler for a message type.
66 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {77 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
67 if msgType == "" {78 if msgType == "" {
M internal/infrastructure/webkit/pool.go +33 -1
@@ -2,6 +2,7 @@ package webkit
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "sync/atomic"
78 "time"
@@ -11,6 +12,9 @@ import (
1112 "github.com/jwijenbergh/puregotk/v4/glib"
1213 )
1314
15+// ErrPoolClosed is returned when operations are attempted on a closed pool.
16+var ErrPoolClosed = errors.New("webview pool is closed")
17+
1418 // FilterApplier applies content filters to a UserContentManager.
1519 // This interface decouples the pool from the filtering package.
1620 type FilterApplier interface {
@@ -114,7 +118,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
114118 log := logging.FromContext(ctx)
115119
116120 if p.closed.Load() {
117- return nil, context.Canceled
121+ return nil, ErrPoolClosed
118122 }
119123
120124 // Try to get from pool first (non-blocking)
@@ -196,6 +200,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
196200 wv.Destroy()
197201 }
198202
203+// PrewarmFirst creates exactly one WebView synchronously.
204+// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
205+// This is the most impactful optimization for cold start since WebView creation
206+// is the heaviest operation (spawns WebKit web process).
207+// Returns error if WebView creation fails; caller should log and continue.
208+func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
209+ if p.closed.Load() {
210+ return ErrPoolClosed
211+ }
212+ if len(p.pool) > 0 {
213+ return nil // Already have at least one
214+ }
215+
216+ log := logging.FromContext(ctx)
217+ wv, err := p.createWebView(ctx)
218+ if err != nil {
219+ return err
220+ }
221+
222+ select {
223+ case p.pool <- wv:
224+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
225+ default:
226+ wv.Destroy() // Pool somehow full (shouldn't happen)
227+ }
228+ return nil
229+}
230+
199231 // Prewarm creates WebViews synchronously to populate the pool.
200232 // Must be called from the GTK main thread (after GTK application is initialized).
201233 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
@@ -2,6 +2,7 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "sync/atomic"7 "sync/atomic"
7 "time"8 "time"
@@ -11,6 +12,9 @@ import (
11 "github.com/jwijenbergh/puregotk/v4/glib"12 "github.com/jwijenbergh/puregotk/v4/glib"
12 )13 )
13 14
15+// ErrPoolClosed is returned when operations are attempted on a closed pool.
16+var ErrPoolClosed = errors.New("webview pool is closed")
17+
14 // FilterApplier applies content filters to a UserContentManager.18 // FilterApplier applies content filters to a UserContentManager.
15 // This interface decouples the pool from the filtering package.19 // This interface decouples the pool from the filtering package.
16 type FilterApplier interface {20 type FilterApplier interface {
@@ -114,7 +118,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
114 log := logging.FromContext(ctx)118 log := logging.FromContext(ctx)
115 119
116 if p.closed.Load() {120 if p.closed.Load() {
117- return nil, context.Canceled121+ return nil, ErrPoolClosed
118 }122 }
119 123
120 // Try to get from pool first (non-blocking)124 // Try to get from pool first (non-blocking)
@@ -196,6 +200,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
196 wv.Destroy()200 wv.Destroy()
197 }201 }
198 202
203+// PrewarmFirst creates exactly one WebView synchronously.
204+// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
205+// This is the most impactful optimization for cold start since WebView creation
206+// is the heaviest operation (spawns WebKit web process).
207+// Returns error if WebView creation fails; caller should log and continue.
208+func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
209+ if p.closed.Load() {
210+ return ErrPoolClosed
211+ }
212+ if len(p.pool) > 0 {
213+ return nil // Already have at least one
214+ }
215+
216+ log := logging.FromContext(ctx)
217+ wv, err := p.createWebView(ctx)
218+ if err != nil {
219+ return err
220+ }
221+
222+ select {
223+ case p.pool <- wv:
224+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
225+ default:
226+ wv.Destroy() // Pool somehow full (shouldn't happen)
227+ }
228+ return nil
229+}
230+
199 // Prewarm creates WebViews synchronously to populate the pool.231 // Prewarm creates WebViews synchronously to populate the pool.
200 // Must be called from the GTK main thread (after GTK application is initialized).232 // Must be called from the GTK main thread (after GTK application is initialized).
201 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {233 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
M internal/logging/logger.go +12 -3
@@ -10,9 +10,10 @@ import (
1010 )
1111
1212 const (
13- logFormatConsole = "console"
14- logFormatJSON = "json"
15- logFormatText = "text"
13+ logFormatConsole = "console"
14+ logFormatJSON = "json"
15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
1617 )
1718
1819 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146147 // This is used by main.go to create a logger from the config package's LoggingConfig
147148 // without creating an import cycle.
148149 func NewFromConfigValues(level, format string) zerolog.Logger {
150+ return NewFromConfigValuesWithTimeFormat(level, format, "")
151+}
152+
153+// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154+func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
149155 cfg := DefaultConfig()
150156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151160
152161 switch format {
153162 case logFormatJSON:
@@ -10,9 +10,10 @@ import (
10 )10 )
11 11
12 const (12 const (
13- logFormatConsole = "console"13+ logFormatConsole = "console"
14- logFormatJSON = "json"14+ logFormatJSON = "json"
15- logFormatText = "text"15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
16 )17 )
17 18
18 // Config holds logging configuration19 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146 // This is used by main.go to create a logger from the config package's LoggingConfig147 // This is used by main.go to create a logger from the config package's LoggingConfig
147 // without creating an import cycle.148 // without creating an import cycle.
148 func NewFromConfigValues(level, format string) zerolog.Logger {149 func NewFromConfigValues(level, format string) zerolog.Logger {
150+ return NewFromConfigValuesWithTimeFormat(level, format, "")
151+}
152+
153+// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154+func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
149 cfg := DefaultConfig()155 cfg := DefaultConfig()
150 cfg.Level = ParseLevel(level)156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151 160
152 switch format {161 switch format {
153 case logFormatJSON:162 case logFormatJSON:
M internal/ui/app.go +8 -2
@@ -99,8 +99,9 @@ type App struct {
9999 updateCoord *coordinator.UpdateCoordinator
100100
101101 // ID generator for tabs/panes
102- idCounter uint64
103- idMu sync.Mutex
102+ idCounter uint64
103+ idMu sync.Mutex
104+ firstWebViewShownOnce sync.Once
104105
105106 movePaneToTabUC *usecase.MovePaneToTabUseCase
106107
@@ -1128,6 +1129,11 @@ func (a *App) initCoordinators(ctx context.Context) {
11281129 pv.HideLoadingSkeleton()
11291130 }
11301131 }
1132+ if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1133+ a.firstWebViewShownOnce.Do(func() {
1134+ a.deps.OnFirstWebViewShown(ctx)
1135+ })
1136+ }
11311137 })
11321138
11331139 // 5. Keyboard Dispatcher
@@ -99,8 +99,9 @@ type App struct {
99 updateCoord *coordinator.UpdateCoordinator99 updateCoord *coordinator.UpdateCoordinator
100 100
101 // ID generator for tabs/panes101 // ID generator for tabs/panes
102- idCounter uint64102+ idCounter uint64
103- idMu sync.Mutex103+ idMu sync.Mutex
104+ firstWebViewShownOnce sync.Once
104 105
105 movePaneToTabUC *usecase.MovePaneToTabUseCase106 movePaneToTabUC *usecase.MovePaneToTabUseCase
106 107
@@ -1128,6 +1129,11 @@ func (a *App) initCoordinators(ctx context.Context) {
1128 pv.HideLoadingSkeleton()1129 pv.HideLoadingSkeleton()
1129 }1130 }
1130 }1131 }
1132+ if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1133+ a.firstWebViewShownOnce.Do(func() {
1134+ a.deps.OnFirstWebViewShown(ctx)
1135+ })
1136+ }
1131 })1137 })
1132 1138
1133 // 5. Keyboard Dispatcher1139 // 5. Keyboard Dispatcher
M internal/ui/deps.go +5 -4
@@ -19,10 +19,11 @@ import (
1919 // This struct is created once at startup and passed to UI components.
2020 type Dependencies struct {
2121 // Core context and configuration
22- Ctx context.Context
23- Config *config.Config
24- InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)
22+ Ctx context.Context
23+ Config *config.Config
24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
2627
2728 // Theme management
2829 Theme *theme.Manager
@@ -19,10 +19,11 @@ import (
19 // This struct is created once at startup and passed to UI components.19 // This struct is created once at startup and passed to UI components.
20 type Dependencies struct {20 type Dependencies struct {
21 // Core context and configuration21 // Core context and configuration
22- Ctx context.Context22+ Ctx context.Context
23- Config *config.Config23+ Config *config.Config
24- InitialURL string // URL to open on startup (optional)24+ InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
26 27
27 // Theme management28 // Theme management
28 Theme *theme.Manager29 Theme *theme.Manager

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