perf(startup): defer checks and unify logging
- commit
- c73da61e705430fac47ea1bf299efe2915ec2b25
- author
- bnema · · 2025-12-27 22:31 UTC
- parent
- 1db819e
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
7 files · +317 -101
M cmd/dumber/main.go +127 -26
| @@ -73,17 +73,11 @@ func runGUI() int { | |||
| 73 | 73 | cfg := initConfig() | |
| 74 | 74 | timer.Mark("config") | |
| 75 | 75 | ||
| 76 | - | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 77 | - | bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 78 | - | bootstrapLogger.Info(). | |
| 79 | - | Str("version", version). | |
| 80 | - | Str("commit", commit). | |
| 81 | - | Str("build_date", buildDate). | |
| 82 | - | Msg("starting dumber") | |
| 83 | - | ctx := logging.WithContext(context.Background(), bootstrapLogger) | |
| 76 | + | ctx := initStartupContext(cfg) | |
| 84 | 77 | timer.Mark("logger") | |
| 78 | + | bootstrapLog := logging.FromContext(ctx) | |
| 85 | 79 | ||
| 86 | - | // Parallel phase: directories, runtime/media checks, theme, WASM precompile | |
| 80 | + | // Parallel phase: directories and theme setup | |
| 87 | 81 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |
| 88 | 82 | Ctx: ctx, | |
| 89 | 83 | Config: cfg, | |
| @@ -94,27 +88,20 @@ func runGUI() int { | |||
| 94 | 88 | } | |
| 95 | 89 | timer.MarkDuration("parallel_phase", initResult.Duration) | |
| 96 | 90 | ||
| 97 | - | // Parallel phase 2: Database + WebKit stack initialize concurrently | |
| 98 | - | dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{ | |
| 99 | - | Ctx: ctx, | |
| 100 | - | Config: cfg, | |
| 101 | - | DataDir: initResult.DataDir, | |
| 102 | - | CacheDir: initResult.CacheDir, | |
| 103 | - | ThemeManager: initResult.ThemeManager, | |
| 104 | - | }) | |
| 91 | + | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | |
| 92 | + | ||
| 93 | + | stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| 105 | 94 | if err != nil { | |
| 106 | - | bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit") | |
| 95 | + | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") | |
| 107 | 96 | } | |
| 108 | - | if dbWebKit.DBCleanup != nil { | |
| 109 | - | defer dbWebKit.DBCleanup() | |
| 97 | + | if dbCleanup != nil { | |
| 98 | + | defer dbCleanup() | |
| 110 | 99 | } | |
| 111 | - | db := dbWebKit.DB | |
| 112 | - | stack := dbWebKit.Stack | |
| 113 | 100 | timer.Mark("db_webkit_parallel") | |
| 114 | 101 | ||
| 115 | - | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db) | |
| 102 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB) | |
| 116 | 103 | if err != nil { | |
| 117 | - | bootstrapLogger.Fatal().Err(err).Msg("failed to start session") | |
| 104 | + | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 118 | 105 | } | |
| 119 | 106 | if browserSession.LogCleanup != nil { | |
| 120 | 107 | defer browserSession.LogCleanup() | |
| @@ -124,11 +111,15 @@ func runGUI() int { | |||
| 124 | 111 | ||
| 125 | 112 | ctx = sessionCtx | |
| 126 | 113 | log := logging.FromContext(ctx) | |
| 114 | + | if stack.MessageRouter != nil { | |
| 115 | + | stack.MessageRouter.SetBaseContext(ctx) | |
| 116 | + | } | |
| 127 | 117 | ||
| 128 | 118 | // Repositories and use cases | |
| 129 | - | repos := createRepositories(db) | |
| 130 | 119 | useCases := createUseCases(repos, cfg) | |
| 131 | - | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | |
| 120 | + | if needsEagerDB { | |
| 121 | + | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | |
| 122 | + | } | |
| 132 | 123 | ||
| 133 | 124 | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 134 | 125 | defer func() { | |
| @@ -140,6 +131,7 @@ func runGUI() int { | |||
| 140 | 131 | ||
| 141 | 132 | // Build UI | |
| 142 | 133 | uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID) | |
| 134 | + | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 143 | 135 | app, err := ui.New(uiDeps) | |
| 144 | 136 | if err != nil { | |
| 145 | 137 | log.Error().Err(err).Msg("failed to create application") | |
| @@ -154,6 +146,82 @@ func runGUI() int { | |||
| 154 | 146 | return app.Run(ctx, os.Args) | |
| 155 | 147 | } | |
| 156 | 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 | + | } | |
| 185 | + | ||
| 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 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 157 | 225 | func initConfig() *config.Config { | |
| 158 | 226 | if err := config.Init(); err != nil { | |
| 159 | 227 | fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err) | |
| @@ -173,6 +241,27 @@ func handleParallelInitError(ctx context.Context, err error) { | |||
| 173 | 241 | log.Fatal().Err(err).Msg("initialization failed") | |
| 174 | 242 | } | |
| 175 | 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") | |
| 248 | + | } | |
| 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") | |
| 255 | + | } else { | |
| 256 | + | log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed") | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | if result.MediaErr != nil { | |
| 260 | + | log.Warn().Err(result.MediaErr).Msg("media check failed") | |
| 261 | + | } | |
| 262 | + | log.Debug().Dur("duration", result.Duration).Msg("deferred init complete") | |
| 263 | + | } | |
| 264 | + | ||
| 176 | 265 | func handleAutoRestore( | |
| 177 | 266 | ctx context.Context, | |
| 178 | 267 | cfg *config.Config, | |
| @@ -234,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories { | |||
| 234 | 323 | } | |
| 235 | 324 | } | |
| 236 | 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 | + | ||
| 237 | 338 | // useCases groups application layer use case implementations. | |
| 238 | 339 | type useCases struct { | |
| 239 | 340 | tabs *usecase.ManageTabsUseCase | |
| @@ -73,17 +73,11 @@ func runGUI() int { | |||||
| 73 | cfg := initConfig() | 73 | cfg := initConfig() | ||
| 74 | timer.Mark("config") | 74 | timer.Mark("config") | ||
| 75 | 75 | ||||
| 76 | - | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | 76 | + | ctx := initStartupContext(cfg) |
| 77 | - | bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |||
| 78 | - | bootstrapLogger.Info(). | |||
| 79 | - | Str("version", version). | |||
| 80 | - | Str("commit", commit). | |||
| 81 | - | Str("build_date", buildDate). | |||
| 82 | - | Msg("starting dumber") | |||
| 83 | - | ctx := logging.WithContext(context.Background(), bootstrapLogger) | |||
| 84 | timer.Mark("logger") | 77 | timer.Mark("logger") | ||
| 78 | + | bootstrapLog := logging.FromContext(ctx) | |||
| 85 | 79 | ||||
| 86 | - | // Parallel phase: directories, runtime/media checks, theme, WASM precompile | 80 | + | // Parallel phase: directories and theme setup |
| 87 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | 81 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | ||
| 88 | Ctx: ctx, | 82 | Ctx: ctx, | ||
| 89 | Config: cfg, | 83 | Config: cfg, | ||
| @@ -94,27 +88,20 @@ func runGUI() int { | |||||
| 94 | } | 88 | } | ||
| 95 | timer.MarkDuration("parallel_phase", initResult.Duration) | 89 | timer.MarkDuration("parallel_phase", initResult.Duration) | ||
| 96 | 90 | ||||
| 97 | - | // Parallel phase 2: Database + WebKit stack initialize concurrently | 91 | + | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore |
| 98 | - | dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{ | 92 | + | |
| 99 | - | Ctx: ctx, | 93 | + | stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) |
| 100 | - | Config: cfg, | |||
| 101 | - | DataDir: initResult.DataDir, | |||
| 102 | - | CacheDir: initResult.CacheDir, | |||
| 103 | - | ThemeManager: initResult.ThemeManager, | |||
| 104 | - | }) | |||
| 105 | if err != nil { | 94 | if err != nil { | ||
| 106 | - | bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit") | 95 | + | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") |
| 107 | } | 96 | } | ||
| 108 | - | if dbWebKit.DBCleanup != nil { | 97 | + | if dbCleanup != nil { |
| 109 | - | defer dbWebKit.DBCleanup() | 98 | + | defer dbCleanup() |
| 110 | } | 99 | } | ||
| 111 | - | db := dbWebKit.DB | |||
| 112 | - | stack := dbWebKit.Stack | |||
| 113 | timer.Mark("db_webkit_parallel") | 100 | timer.Mark("db_webkit_parallel") | ||
| 114 | 101 | ||||
| 115 | - | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db) | 102 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB) |
| 116 | if err != nil { | 103 | if err != nil { | ||
| 117 | - | bootstrapLogger.Fatal().Err(err).Msg("failed to start session") | 104 | + | bootstrapLog.Fatal().Err(err).Msg("failed to start session") |
| 118 | } | 105 | } | ||
| 119 | if browserSession.LogCleanup != nil { | 106 | if browserSession.LogCleanup != nil { | ||
| 120 | defer browserSession.LogCleanup() | 107 | defer browserSession.LogCleanup() | ||
| @@ -124,11 +111,15 @@ func runGUI() int { | |||||
| 124 | 111 | ||||
| 125 | ctx = sessionCtx | 112 | ctx = sessionCtx | ||
| 126 | log := logging.FromContext(ctx) | 113 | log := logging.FromContext(ctx) | ||
| 114 | + | if stack.MessageRouter != nil { | |||
| 115 | + | stack.MessageRouter.SetBaseContext(ctx) | |||
| 116 | + | } | |||
| 127 | 117 | ||||
| 128 | // Repositories and use cases | 118 | // Repositories and use cases | ||
| 129 | - | repos := createRepositories(db) | |||
| 130 | useCases := createUseCases(repos, cfg) | 119 | useCases := createUseCases(repos, cfg) | ||
| 131 | - | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | 120 | + | if needsEagerDB { |
| 121 | + | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | |||
| 122 | + | } | |||
| 132 | 123 | ||||
| 133 | idleInhibitor := idle.NewPortalInhibitor(ctx) | 124 | idleInhibitor := idle.NewPortalInhibitor(ctx) | ||
| 134 | defer func() { | 125 | defer func() { | ||
| @@ -140,6 +131,7 @@ func runGUI() int { | |||||
| 140 | 131 | ||||
| 141 | // Build UI | 132 | // Build UI | ||
| 142 | uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID) | 133 | uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID) | ||
| 134 | + | configureDeferredInit(uiDeps, cfg, browserSession) | |||
| 143 | app, err := ui.New(uiDeps) | 135 | app, err := ui.New(uiDeps) | ||
| 144 | if err != nil { | 136 | if err != nil { | ||
| 145 | log.Error().Err(err).Msg("failed to create application") | 137 | log.Error().Err(err).Msg("failed to create application") | ||
| @@ -154,6 +146,82 @@ func runGUI() int { | |||||
| 154 | return app.Run(ctx, os.Args) | 146 | return app.Run(ctx, os.Args) | ||
| 155 | } | 147 | } | ||
| 156 | 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 | + | } | |||
| 185 | + | ||||
| 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 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 157 | func initConfig() *config.Config { | 225 | func initConfig() *config.Config { | ||
| 158 | if err := config.Init(); err != nil { | 226 | if err := config.Init(); err != nil { | ||
| 159 | fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err) | 227 | fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err) | ||
| @@ -173,6 +241,27 @@ func handleParallelInitError(ctx context.Context, err error) { | |||||
| 173 | log.Fatal().Err(err).Msg("initialization failed") | 241 | log.Fatal().Err(err).Msg("initialization failed") | ||
| 174 | } | 242 | } | ||
| 175 | 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") | |||
| 248 | + | } | |||
| 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") | |||
| 255 | + | } else { | |||
| 256 | + | log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed") | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | if result.MediaErr != nil { | |||
| 260 | + | log.Warn().Err(result.MediaErr).Msg("media check failed") | |||
| 261 | + | } | |||
| 262 | + | log.Debug().Dur("duration", result.Duration).Msg("deferred init complete") | |||
| 263 | + | } | |||
| 264 | + | ||||
| 176 | func handleAutoRestore( | 265 | func handleAutoRestore( | ||
| 177 | ctx context.Context, | 266 | ctx context.Context, | ||
| 178 | cfg *config.Config, | 267 | cfg *config.Config, | ||
| @@ -234,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories { | |||||
| 234 | } | 323 | } | ||
| 235 | } | 324 | } | ||
| 236 | 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 | + | ||||
| 237 | // useCases groups application layer use case implementations. | 338 | // useCases groups application layer use case implementations. | ||
| 238 | type useCases struct { | 339 | type useCases struct { | ||
| 239 | tabs *usecase.ManageTabsUseCase | 340 | tabs *usecase.ManageTabsUseCase | ||
M internal/bootstrap/gui.go +56 -30
| @@ -34,12 +34,26 @@ type ParallelInitResult struct { | |||
| 34 | 34 | Duration time.Duration | |
| 35 | 35 | } | |
| 36 | 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 | + | ||
| 37 | 45 | // ParallelInitInput holds the input for parallel initialization. | |
| 38 | 46 | type ParallelInitInput struct { | |
| 39 | 47 | Ctx context.Context | |
| 40 | 48 | Config *config.Config | |
| 41 | 49 | } | |
| 42 | 50 | ||
| 51 | + | // DeferredInitInput holds the input for deferred initialization. | |
| 52 | + | type DeferredInitInput struct { | |
| 53 | + | Ctx context.Context | |
| 54 | + | Config *config.Config | |
| 55 | + | } | |
| 56 | + | ||
| 43 | 57 | // RuntimeRequirementsError contains details about missing runtime dependencies. | |
| 44 | 58 | type RuntimeRequirementsError struct { | |
| 45 | 59 | Checks []usecase.RuntimeDependencyStatus | |
| @@ -69,28 +83,19 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||
| 69 | 83 | } | |
| 70 | 84 | } | |
| 71 | 85 | ||
| 72 | - | // RunParallelInit runs the parallel initialization phase. | |
| 73 | - | // This includes directory resolution, runtime/media checks, and theme creation. | |
| 86 | + | // RunParallelInit runs the essential parallel initialization phase. | |
| 87 | + | // This includes directory resolution and theme creation. | |
| 74 | 88 | // Returns the first fatal error encountered, or nil with the results. | |
| 75 | 89 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 76 | 90 | var ( | |
| 77 | 91 | dataDir, cacheDir string | |
| 78 | 92 | themeManager *theme.Manager | |
| 79 | 93 | dirsErr error | |
| 80 | - | runtimeErr error | |
| 81 | - | mediaErr error | |
| 82 | 94 | wg sync.WaitGroup | |
| 83 | 95 | ) | |
| 84 | 96 | ||
| 85 | 97 | start := time.Now() | |
| 86 | - | wg.Add(5) | |
| 87 | - | ||
| 88 | - | // Pre-initialize SQLite WASM runtime (expensive compilation) | |
| 89 | - | // This runs concurrently with other checks so it's hidden from critical path. | |
| 90 | - | go func() { | |
| 91 | - | defer wg.Done() | |
| 92 | - | _ = sqlite3.Initialize() | |
| 93 | - | }() | |
| 98 | + | wg.Add(2) | |
| 94 | 99 | ||
| 95 | 100 | // Resolve directories | |
| 96 | 101 | go func() { | |
| @@ -98,18 +103,6 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 98 | 103 | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | |
| 99 | 104 | }() | |
| 100 | 105 | ||
| 101 | - | // Runtime checks (pkg-config subprocess) | |
| 102 | - | go func() { | |
| 103 | - | defer wg.Done() | |
| 104 | - | runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config) | |
| 105 | - | }() | |
| 106 | - | ||
| 107 | - | // Media checks (GStreamer subprocess) | |
| 108 | - | go func() { | |
| 109 | - | defer wg.Done() | |
| 110 | - | mediaErr = CheckMediaRequirements(input.Ctx, input.Config) | |
| 111 | - | }() | |
| 112 | - | ||
| 113 | 106 | // Theme manager (CPU-bound, no I/O) | |
| 114 | 107 | go func() { | |
| 115 | 108 | defer wg.Done() | |
| @@ -120,13 +113,8 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 120 | 113 | duration := time.Since(start) | |
| 121 | 114 | ||
| 122 | 115 | // Return first fatal error | |
| 123 | - | switch { | |
| 124 | - | case dirsErr != nil: | |
| 116 | + | if dirsErr != nil { | |
| 125 | 117 | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | |
| 126 | - | case runtimeErr != nil: | |
| 127 | - | return nil, runtimeErr | |
| 128 | - | case mediaErr != nil: | |
| 129 | - | return nil, fmt.Errorf("media check: %w", mediaErr) | |
| 130 | 118 | } | |
| 131 | 119 | ||
| 132 | 120 | return &ParallelInitResult{ | |
| @@ -137,6 +125,44 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 137 | 125 | }, nil | |
| 138 | 126 | } | |
| 139 | 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 | + | ||
| 140 | 166 | // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies. | |
| 141 | 167 | // Returns error if requirements are not met; caller should log details and exit. | |
| 142 | 168 | func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error { | |
| @@ -34,12 +34,26 @@ type ParallelInitResult struct { | |||||
| 34 | Duration time.Duration | 34 | Duration time.Duration | ||
| 35 | } | 35 | } | ||
| 36 | 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 | + | ||||
| 37 | // ParallelInitInput holds the input for parallel initialization. | 45 | // ParallelInitInput holds the input for parallel initialization. | ||
| 38 | type ParallelInitInput struct { | 46 | type ParallelInitInput struct { | ||
| 39 | Ctx context.Context | 47 | Ctx context.Context | ||
| 40 | Config *config.Config | 48 | Config *config.Config | ||
| 41 | } | 49 | } | ||
| 42 | 50 | ||||
| 51 | + | // DeferredInitInput holds the input for deferred initialization. | |||
| 52 | + | type DeferredInitInput struct { | |||
| 53 | + | Ctx context.Context | |||
| 54 | + | Config *config.Config | |||
| 55 | + | } | |||
| 56 | + | ||||
| 43 | // RuntimeRequirementsError contains details about missing runtime dependencies. | 57 | // RuntimeRequirementsError contains details about missing runtime dependencies. | ||
| 44 | type RuntimeRequirementsError struct { | 58 | type RuntimeRequirementsError struct { | ||
| 45 | Checks []usecase.RuntimeDependencyStatus | 59 | Checks []usecase.RuntimeDependencyStatus | ||
| @@ -69,28 +83,19 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||||
| 69 | } | 83 | } | ||
| 70 | } | 84 | } | ||
| 71 | 85 | ||||
| 72 | - | // RunParallelInit runs the parallel initialization phase. | 86 | + | // RunParallelInit runs the essential parallel initialization phase. |
| 73 | - | // This includes directory resolution, runtime/media checks, and theme creation. | 87 | + | // This includes directory resolution and theme creation. |
| 74 | // Returns the first fatal error encountered, or nil with the results. | 88 | // Returns the first fatal error encountered, or nil with the results. | ||
| 75 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | 89 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | ||
| 76 | var ( | 90 | var ( | ||
| 77 | dataDir, cacheDir string | 91 | dataDir, cacheDir string | ||
| 78 | themeManager *theme.Manager | 92 | themeManager *theme.Manager | ||
| 79 | dirsErr error | 93 | dirsErr error | ||
| 80 | - | runtimeErr error | |||
| 81 | - | mediaErr error | |||
| 82 | wg sync.WaitGroup | 94 | wg sync.WaitGroup | ||
| 83 | ) | 95 | ) | ||
| 84 | 96 | ||||
| 85 | start := time.Now() | 97 | start := time.Now() | ||
| 86 | - | wg.Add(5) | 98 | + | wg.Add(2) |
| 87 | - | ||||
| 88 | - | // Pre-initialize SQLite WASM runtime (expensive compilation) | |||
| 89 | - | // This runs concurrently with other checks so it's hidden from critical path. | |||
| 90 | - | go func() { | |||
| 91 | - | defer wg.Done() | |||
| 92 | - | _ = sqlite3.Initialize() | |||
| 93 | - | }() | |||
| 94 | 99 | ||||
| 95 | // Resolve directories | 100 | // Resolve directories | ||
| 96 | go func() { | 101 | go func() { | ||
| @@ -98,18 +103,6 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 98 | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | 103 | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | ||
| 99 | }() | 104 | }() | ||
| 100 | 105 | ||||
| 101 | - | // Runtime checks (pkg-config subprocess) | |||
| 102 | - | go func() { | |||
| 103 | - | defer wg.Done() | |||
| 104 | - | runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config) | |||
| 105 | - | }() | |||
| 106 | - | ||||
| 107 | - | // Media checks (GStreamer subprocess) | |||
| 108 | - | go func() { | |||
| 109 | - | defer wg.Done() | |||
| 110 | - | mediaErr = CheckMediaRequirements(input.Ctx, input.Config) | |||
| 111 | - | }() | |||
| 112 | - | ||||
| 113 | // Theme manager (CPU-bound, no I/O) | 106 | // Theme manager (CPU-bound, no I/O) | ||
| 114 | go func() { | 107 | go func() { | ||
| 115 | defer wg.Done() | 108 | defer wg.Done() | ||
| @@ -120,13 +113,8 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 120 | duration := time.Since(start) | 113 | duration := time.Since(start) | ||
| 121 | 114 | ||||
| 122 | // Return first fatal error | 115 | // Return first fatal error | ||
| 123 | - | switch { | 116 | + | if dirsErr != nil { |
| 124 | - | case dirsErr != nil: | |||
| 125 | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | 117 | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | ||
| 126 | - | case runtimeErr != nil: | |||
| 127 | - | return nil, runtimeErr | |||
| 128 | - | case mediaErr != nil: | |||
| 129 | - | return nil, fmt.Errorf("media check: %w", mediaErr) | |||
| 130 | } | 118 | } | ||
| 131 | 119 | ||||
| 132 | return &ParallelInitResult{ | 120 | return &ParallelInitResult{ | ||
| @@ -137,6 +125,44 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 137 | }, nil | 125 | }, nil | ||
| 138 | } | 126 | } | ||
| 139 | 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 | + | ||||
| 140 | // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies. | 166 | // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies. | ||
| 141 | // Returns error if requirements are not met; caller should log details and exit. | 167 | // Returns error if requirements are not met; caller should log details and exit. | ||
| 142 | func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error { | 168 | func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error { | ||
M internal/bootstrap/session.go +98 -36
| @@ -2,20 +2,20 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "database/sql" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | 18 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 18 | - | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 19 | 19 | corelogging "github.com/bnema/dumber/internal/logging" | |
| 20 | 20 | "github.com/rs/zerolog" | |
| 21 | 21 | ) | |
| @@ -31,11 +31,20 @@ type BrowserSession struct { | |||
| 31 | 31 | Logger zerolog.Logger | |
| 32 | 32 | LogCleanup func() | |
| 33 | 33 | ||
| 34 | - | End func(context.Context) error | |
| 34 | + | 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 | 44 | log := corelogging.FromContext(ctx) | |
| 45 | + | if sessionRepo == nil { | |
| 46 | + | return nil, ctx, errors.New("session repository is nil") | |
| 47 | + | } | |
| 39 | 48 | ||
| 40 | 49 | lockDir := cfg.Logging.LogDir | |
| 41 | 50 | if lockDir == "" { | |
| @@ -44,55 +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 | 56 | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |
| 49 | 57 | sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter) | |
| 50 | 58 | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 51 | 59 | ||
| 52 | - | // Defer stale session cleanup and old session pruning to background. | |
| 53 | - | // This avoids blocking startup for non-critical housekeeping tasks. | |
| 54 | - | runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log) | |
| 55 | - | ||
| 56 | 60 | now := time.Now() | |
| 57 | - | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | |
| 58 | - | Type: entity.SessionTypeBrowser, | |
| 59 | - | Now: now, | |
| 60 | - | LogConfig: port.SessionLogConfig{ | |
| 61 | - | Level: cfg.Logging.Level, | |
| 62 | - | Format: cfg.Logging.Format, | |
| 63 | - | TimeFormat: "15:04:05", | |
| 64 | - | LogDir: cfg.Logging.LogDir, | |
| 65 | - | WriteToStderr: true, | |
| 66 | - | EnableFileLog: cfg.Logging.EnableFileLog, | |
| 67 | - | }, | |
| 68 | - | }) | |
| 69 | - | if err != nil { | |
| 61 | + | session := &entity.Session{ | |
| 62 | + | ID: entity.SessionID(corelogging.GenerateSessionID()), | |
| 63 | + | Type: entity.SessionTypeBrowser, | |
| 64 | + | StartedAt: now.UTC(), | |
| 65 | + | } | |
| 66 | + | if err := session.Validate(); err != nil { | |
| 70 | 67 | return nil, ctx, err | |
| 71 | 68 | } | |
| 72 | 69 | ||
| 73 | - | lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID) | |
| 74 | - | if lockErr != nil { | |
| 75 | - | // Don’t fail startup if locking fails; it only affects stale-session cleanup. | |
| 76 | - | out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock") | |
| 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, | |
| 77 | + | }) | |
| 78 | + | if err != nil { | |
| 79 | + | log.Warn().Err(err).Msg("failed to create session logger") | |
| 77 | 80 | } | |
| 78 | 81 | ||
| 79 | - | sessionCtx := corelogging.WithContext(context.Background(), out.Logger) | |
| 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 | + | } | |
| 99 | + | ||
| 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 | + | } | |
| 80 | 122 | ||
| 81 | 123 | endFn := func(endCtx context.Context) error { | |
| 82 | - | endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now()) | |
| 83 | - | if lockFile != nil { | |
| 84 | - | _ = unlockAndClose(lockFile) | |
| 85 | - | _ = 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) | |
| 86 | 135 | } | |
| 87 | 136 | return endErr | |
| 88 | 137 | } | |
| 89 | 138 | ||
| 90 | - | return &BrowserSession{ | |
| 91 | - | Session: out.Session, | |
| 92 | - | Logger: out.Logger, | |
| 93 | - | LogCleanup: out.LogCleanup, | |
| 139 | + | browserSession := &BrowserSession{ | |
| 140 | + | Session: session, | |
| 141 | + | Logger: logger, | |
| 142 | + | LogCleanup: cleanup, | |
| 94 | 143 | End: endFn, | |
| 95 | - | }, 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 | |
| 96 | 158 | } | |
| 97 | 159 | ||
| 98 | 160 | func endStaleActiveBrowserSessions( | |
| @@ -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.Logger | 31 | Logger zerolog.Logger | ||
| 32 | LogCleanup func() | 32 | LogCleanup func() | ||
| 33 | 33 | ||||
| 34 | - | End func(context.Context) error | 34 | + | 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.LogDir | 49 | lockDir := cfg.Logging.LogDir | ||
| 41 | if lockDir == "" { | 50 | if lockDir == "" { | ||
| @@ -44,55 +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) | ||
| 50 | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | 58 | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | ||
| 51 | 59 | ||||
| 52 | - | // Defer stale session cleanup and old session pruning to background. | |||
| 53 | - | // This avoids blocking startup for non-critical housekeeping tasks. | |||
| 54 | - | runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log) | |||
| 55 | - | ||||
| 56 | now := time.Now() | 60 | now := time.Now() | ||
| 57 | - | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | 61 | + | session := &entity.Session{ |
| 58 | - | Type: entity.SessionTypeBrowser, | 62 | + | ID: entity.SessionID(corelogging.GenerateSessionID()), |
| 59 | - | Now: now, | 63 | + | Type: entity.SessionTypeBrowser, |
| 60 | - | LogConfig: port.SessionLogConfig{ | 64 | + | StartedAt: now.UTC(), |
| 61 | - | Level: cfg.Logging.Level, | 65 | + | } |
| 62 | - | Format: cfg.Logging.Format, | 66 | + | if err := session.Validate(); err != nil { |
| 63 | - | TimeFormat: "15:04:05", | |||
| 64 | - | LogDir: cfg.Logging.LogDir, | |||
| 65 | - | WriteToStderr: true, | |||
| 66 | - | EnableFileLog: cfg.Logging.EnableFileLog, | |||
| 67 | - | }, | |||
| 68 | - | }) | |||
| 69 | - | if err != nil { | |||
| 70 | return nil, ctx, err | 67 | return nil, ctx, err | ||
| 71 | } | 68 | } | ||
| 72 | 69 | ||||
| 73 | - | lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID) | 70 | + | logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{ |
| 74 | - | if lockErr != nil { | 71 | + | Level: cfg.Logging.Level, |
| 75 | - | // Don’t fail startup if locking fails; it only affects stale-session cleanup. | 72 | + | Format: cfg.Logging.Format, |
| 76 | - | out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock") | 73 | + | TimeFormat: corelogging.ConsoleTimeFormat, |
| 74 | + | LogDir: cfg.Logging.LogDir, | |||
| 75 | + | WriteToStderr: true, | |||
| 76 | + | EnableFileLog: cfg.Logging.EnableFileLog, | |||
| 77 | + | }) | |||
| 78 | + | if err != nil { | |||
| 79 | + | log.Warn().Err(err).Msg("failed to create session logger") | |||
| 77 | } | 80 | } | ||
| 78 | 81 | ||||
| 79 | - | sessionCtx := corelogging.WithContext(context.Background(), out.Logger) | 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 | + | } | |||
| 99 | + | ||||
| 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 | + | } | |||
| 80 | 122 | ||||
| 81 | endFn := func(endCtx context.Context) error { | 123 | endFn := func(endCtx context.Context) error { | ||
| 82 | - | endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now()) | 124 | + | _ = persistFn(endCtx) |
| 83 | - | if lockFile != nil { | 125 | + | endErr := sessionUC.EndSession(endCtx, session.ID, time.Now()) |
| 84 | - | _ = unlockAndClose(lockFile) | 126 | + | |
| 85 | - | _ = 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) | |||
| 86 | } | 135 | } | ||
| 87 | return endErr | 136 | return endErr | ||
| 88 | } | 137 | } | ||
| 89 | 138 | ||||
| 90 | - | return &BrowserSession{ | 139 | + | browserSession := &BrowserSession{ |
| 91 | - | Session: out.Session, | 140 | + | Session: session, |
| 92 | - | Logger: out.Logger, | 141 | + | Logger: logger, |
| 93 | - | LogCleanup: out.LogCleanup, | 142 | + | LogCleanup: cleanup, |
| 94 | End: endFn, | 143 | End: endFn, | ||
| 95 | - | }, 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 | |||
| 96 | } | 158 | } | ||
| 97 | 159 | ||||
| 98 | func endStaleActiveBrowserSessions( | 160 | func endStaleActiveBrowserSessions( | ||
M internal/infrastructure/webkit/messaging.go +11 -0
| @@ -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 | 76 | // RegisterHandler registers a handler for a message type. | |
| 66 | 77 | func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error { | |
| 67 | 78 | 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/logging/logger.go +12 -3
| @@ -10,9 +10,10 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | const ( | |
| 13 | - | logFormatConsole = "console" | |
| 14 | - | logFormatJSON = "json" | |
| 15 | - | logFormatText = "text" | |
| 13 | + | logFormatConsole = "console" | |
| 14 | + | logFormatJSON = "json" | |
| 15 | + | logFormatText = "text" | |
| 16 | + | ConsoleTimeFormat = "15:04:05" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | // Config holds logging configuration | |
| @@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger { | |||
| 146 | 147 | // This is used by main.go to create a logger from the config package's LoggingConfig | |
| 147 | 148 | // without creating an import cycle. | |
| 148 | 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 | 155 | cfg := DefaultConfig() | |
| 150 | 156 | cfg.Level = ParseLevel(level) | |
| 157 | + | if timeFormat != "" { | |
| 158 | + | cfg.TimeFormat = timeFormat | |
| 159 | + | } | |
| 151 | 160 | ||
| 152 | 161 | switch format { | |
| 153 | 162 | 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 configuration | 19 | // 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 LoggingConfig | 147 | // 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 { | |||
| 99 | 99 | updateCoord *coordinator.UpdateCoordinator | |
| 100 | 100 | ||
| 101 | 101 | // ID generator for tabs/panes | |
| 102 | - | idCounter uint64 | |
| 103 | - | idMu sync.Mutex | |
| 102 | + | idCounter uint64 | |
| 103 | + | idMu sync.Mutex | |
| 104 | + | firstWebViewShownOnce sync.Once | |
| 104 | 105 | ||
| 105 | 106 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 106 | 107 | ||
| @@ -1128,6 +1129,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1128 | 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 | 1139 | // 5. Keyboard Dispatcher | |
| @@ -99,8 +99,9 @@ type App struct { | |||||
| 99 | updateCoord *coordinator.UpdateCoordinator | 99 | updateCoord *coordinator.UpdateCoordinator | ||
| 100 | 100 | ||||
| 101 | // ID generator for tabs/panes | 101 | // ID generator for tabs/panes | ||
| 102 | - | idCounter uint64 | 102 | + | idCounter uint64 |
| 103 | - | idMu sync.Mutex | 103 | + | idMu sync.Mutex |
| 104 | + | firstWebViewShownOnce sync.Once | |||
| 104 | 105 | ||||
| 105 | movePaneToTabUC *usecase.MovePaneToTabUseCase | 106 | 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 Dispatcher | 1139 | // 5. Keyboard Dispatcher | ||
M internal/ui/deps.go +5 -4
| @@ -19,10 +19,11 @@ import ( | |||
| 19 | 19 | // This struct is created once at startup and passed to UI components. | |
| 20 | 20 | type Dependencies struct { | |
| 21 | 21 | // 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) | |
| 26 | 27 | ||
| 27 | 28 | // Theme management | |
| 28 | 29 | 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 configuration | 21 | // Core context and configuration | ||
| 22 | - | Ctx context.Context | 22 | + | Ctx context.Context |
| 23 | - | Config *config.Config | 23 | + | 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 management | 28 | // Theme management | ||
| 28 | Theme *theme.Manager | 29 | Theme *theme.Manager | ||