feat(gui): ensure graceful shutdown on interrupt signals
- commit
- 6588389fd967ac197fdcc69aac2bf98f224c6d72
- author
- brice · · 2025-12-21 17:14 UTC
- committer
- brice · · 2025-12-21 19:35 UTC
- parent
- 6fa0e3d
- signature
- unsigned
2 files · +77 -63
M cmd/dumber/main.go +43 -40
| @@ -5,8 +5,10 @@ import ( | |||
| 5 | 5 | "database/sql" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | + | "os/signal" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "runtime" | |
| 11 | + | "syscall" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -72,34 +74,39 @@ func runGUI() int { | |||
| 72 | 74 | // Apply optional /opt-style runtime prefix overrides (if configured) | |
| 73 | 75 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 74 | 76 | ||
| 75 | - | // Generate session ID for this browser run | |
| 76 | - | sessionID := logging.GenerateSessionID() | |
| 77 | - | ||
| 78 | - | logger, logCleanup := initLogger(cfg, sessionID) | |
| 79 | - | if logCleanup != nil { | |
| 80 | - | defer logCleanup() | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | logger.Info(). | |
| 77 | + | bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 78 | + | bootstrapLogger.Info(). | |
| 84 | 79 | Str("version", version). | |
| 85 | 80 | Str("commit", commit). | |
| 86 | 81 | Str("build_date", buildDate). | |
| 87 | 82 | Msg("starting dumber") | |
| 88 | 83 | ||
| 89 | - | // Create root context with logger | |
| 90 | - | ctx := logging.WithContext(context.Background(), logger) | |
| 91 | - | ||
| 92 | - | checkRuntimeRequirements(ctx, cfg, logger) | |
| 93 | - | ||
| 94 | - | checkMediaRequirements(ctx, cfg, logger) | |
| 84 | + | ctx := logging.WithContext(context.Background(), bootstrapLogger) | |
| 95 | 85 | ||
| 96 | - | dataDir, cacheDir := resolveWebKitDirs(logger) | |
| 86 | + | dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger) | |
| 97 | 87 | ||
| 98 | - | db, dbCleanup := openDatabase(ctx, logger, dataDir) | |
| 88 | + | db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir) | |
| 99 | 89 | if dbCleanup != nil { | |
| 100 | 90 | defer dbCleanup() | |
| 101 | 91 | } | |
| 102 | 92 | ||
| 93 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db) | |
| 94 | + | if err != nil { | |
| 95 | + | bootstrapLogger.Fatal().Err(err).Msg("failed to start session") | |
| 96 | + | } | |
| 97 | + | if browserSession.LogCleanup != nil { | |
| 98 | + | defer browserSession.LogCleanup() | |
| 99 | + | } | |
| 100 | + | defer func() { | |
| 101 | + | _ = browserSession.End(sessionCtx) | |
| 102 | + | }() | |
| 103 | + | ||
| 104 | + | logger := browserSession.Logger | |
| 105 | + | ctx = sessionCtx | |
| 106 | + | ||
| 107 | + | checkRuntimeRequirements(ctx, cfg, logger) | |
| 108 | + | checkMediaRequirements(ctx, cfg, logger) | |
| 109 | + | ||
| 103 | 110 | // Create repositories | |
| 104 | 111 | historyRepo := sqlite.NewHistoryRepository(db) | |
| 105 | 112 | favoriteRepo := sqlite.NewFavoriteRepository(db) | |
| @@ -156,8 +163,26 @@ func runGUI() int { | |||
| 156 | 163 | faviconService, | |
| 157 | 164 | ) | |
| 158 | 165 | ||
| 166 | + | app, err := ui.New(uiDeps) | |
| 167 | + | if err != nil { | |
| 168 | + | logger.Error().Err(err).Msg("failed to create application") | |
| 169 | + | return 1 | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Ensure sessions are closed on Ctrl+C / SIGTERM. | |
| 173 | + | // Without this, the process may exit immediately and skip defers, | |
| 174 | + | // leaving the DB session stuck in "active" state. | |
| 175 | + | sigCh := make(chan os.Signal, 1) | |
| 176 | + | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 177 | + | defer signal.Stop(sigCh) | |
| 178 | + | go func() { | |
| 179 | + | <-sigCh | |
| 180 | + | logger.Info().Msg("received interrupt, quitting") | |
| 181 | + | app.Quit() | |
| 182 | + | }() | |
| 183 | + | ||
| 159 | 184 | // Run the application | |
| 160 | - | exitCode := ui.RunWithArgs(ctx, uiDeps) | |
| 185 | + | exitCode := app.Run(ctx, os.Args) | |
| 161 | 186 | return exitCode | |
| 162 | 187 | } | |
| 163 | 188 | ||
| @@ -169,28 +194,6 @@ func initConfig() *config.Config { | |||
| 169 | 194 | return config.Get() | |
| 170 | 195 | } | |
| 171 | 196 | ||
| 172 | - | func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) { | |
| 173 | - | logger, logCleanup, err := logging.NewWithFile( | |
| 174 | - | logging.Config{ | |
| 175 | - | Level: logging.ParseLevel(cfg.Logging.Level), | |
| 176 | - | Format: cfg.Logging.Format, | |
| 177 | - | TimeFormat: "15:04:05", | |
| 178 | - | }, | |
| 179 | - | logging.FileConfig{ | |
| 180 | - | Enabled: cfg.Logging.EnableFileLog, | |
| 181 | - | LogDir: cfg.Logging.LogDir, | |
| 182 | - | SessionID: sessionID, | |
| 183 | - | WriteToStderr: true, // GUI: keep stderr output for debugging | |
| 184 | - | }, | |
| 185 | - | ) | |
| 186 | - | if err != nil { | |
| 187 | - | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 188 | - | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | |
| 189 | - | return logger, nil | |
| 190 | - | } | |
| 191 | - | return logger, logCleanup | |
| 192 | - | } | |
| 193 | - | ||
| 194 | 197 | func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | |
| 195 | 198 | probe := deps.NewPkgConfigProbe() | |
| 196 | 199 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| @@ -5,8 +5,10 @@ import ( | |||||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | + | "os/signal" | |||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 9 | "runtime" | 10 | "runtime" | ||
| 11 | + | "syscall" | |||
| 10 | 12 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 13 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/application/usecase" | 14 | "github.com/bnema/dumber/internal/application/usecase" | ||
| @@ -72,34 +74,39 @@ func runGUI() int { | |||||
| 72 | // Apply optional /opt-style runtime prefix overrides (if configured) | 74 | // Apply optional /opt-style runtime prefix overrides (if configured) | ||
| 73 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | 75 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | ||
| 74 | 76 | ||||
| 75 | - | // Generate session ID for this browser run | 77 | + | bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) |
| 76 | - | sessionID := logging.GenerateSessionID() | 78 | + | bootstrapLogger.Info(). |
| 77 | - | ||||
| 78 | - | logger, logCleanup := initLogger(cfg, sessionID) | |||
| 79 | - | if logCleanup != nil { | |||
| 80 | - | defer logCleanup() | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | logger.Info(). | |||
| 84 | Str("version", version). | 79 | Str("version", version). | ||
| 85 | Str("commit", commit). | 80 | Str("commit", commit). | ||
| 86 | Str("build_date", buildDate). | 81 | Str("build_date", buildDate). | ||
| 87 | Msg("starting dumber") | 82 | Msg("starting dumber") | ||
| 88 | 83 | ||||
| 89 | - | // Create root context with logger | 84 | + | ctx := logging.WithContext(context.Background(), bootstrapLogger) |
| 90 | - | ctx := logging.WithContext(context.Background(), logger) | |||
| 91 | - | ||||
| 92 | - | checkRuntimeRequirements(ctx, cfg, logger) | |||
| 93 | - | ||||
| 94 | - | checkMediaRequirements(ctx, cfg, logger) | |||
| 95 | 85 | ||||
| 96 | - | dataDir, cacheDir := resolveWebKitDirs(logger) | 86 | + | dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger) |
| 97 | 87 | ||||
| 98 | - | db, dbCleanup := openDatabase(ctx, logger, dataDir) | 88 | + | db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir) |
| 99 | if dbCleanup != nil { | 89 | if dbCleanup != nil { | ||
| 100 | defer dbCleanup() | 90 | defer dbCleanup() | ||
| 101 | } | 91 | } | ||
| 102 | 92 | ||||
| 93 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db) | |||
| 94 | + | if err != nil { | |||
| 95 | + | bootstrapLogger.Fatal().Err(err).Msg("failed to start session") | |||
| 96 | + | } | |||
| 97 | + | if browserSession.LogCleanup != nil { | |||
| 98 | + | defer browserSession.LogCleanup() | |||
| 99 | + | } | |||
| 100 | + | defer func() { | |||
| 101 | + | _ = browserSession.End(sessionCtx) | |||
| 102 | + | }() | |||
| 103 | + | ||||
| 104 | + | logger := browserSession.Logger | |||
| 105 | + | ctx = sessionCtx | |||
| 106 | + | ||||
| 107 | + | checkRuntimeRequirements(ctx, cfg, logger) | |||
| 108 | + | checkMediaRequirements(ctx, cfg, logger) | |||
| 109 | + | ||||
| 103 | // Create repositories | 110 | // Create repositories | ||
| 104 | historyRepo := sqlite.NewHistoryRepository(db) | 111 | historyRepo := sqlite.NewHistoryRepository(db) | ||
| 105 | favoriteRepo := sqlite.NewFavoriteRepository(db) | 112 | favoriteRepo := sqlite.NewFavoriteRepository(db) | ||
| @@ -156,8 +163,26 @@ func runGUI() int { | |||||
| 156 | faviconService, | 163 | faviconService, | ||
| 157 | ) | 164 | ) | ||
| 158 | 165 | ||||
| 166 | + | app, err := ui.New(uiDeps) | |||
| 167 | + | if err != nil { | |||
| 168 | + | logger.Error().Err(err).Msg("failed to create application") | |||
| 169 | + | return 1 | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Ensure sessions are closed on Ctrl+C / SIGTERM. | |||
| 173 | + | // Without this, the process may exit immediately and skip defers, | |||
| 174 | + | // leaving the DB session stuck in "active" state. | |||
| 175 | + | sigCh := make(chan os.Signal, 1) | |||
| 176 | + | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |||
| 177 | + | defer signal.Stop(sigCh) | |||
| 178 | + | go func() { | |||
| 179 | + | <-sigCh | |||
| 180 | + | logger.Info().Msg("received interrupt, quitting") | |||
| 181 | + | app.Quit() | |||
| 182 | + | }() | |||
| 183 | + | ||||
| 159 | // Run the application | 184 | // Run the application | ||
| 160 | - | exitCode := ui.RunWithArgs(ctx, uiDeps) | 185 | + | exitCode := app.Run(ctx, os.Args) |
| 161 | return exitCode | 186 | return exitCode | ||
| 162 | } | 187 | } | ||
| 163 | 188 | ||||
| @@ -169,28 +194,6 @@ func initConfig() *config.Config { | |||||
| 169 | return config.Get() | 194 | return config.Get() | ||
| 170 | } | 195 | } | ||
| 171 | 196 | ||||
| 172 | - | func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) { | |||
| 173 | - | logger, logCleanup, err := logging.NewWithFile( | |||
| 174 | - | logging.Config{ | |||
| 175 | - | Level: logging.ParseLevel(cfg.Logging.Level), | |||
| 176 | - | Format: cfg.Logging.Format, | |||
| 177 | - | TimeFormat: "15:04:05", | |||
| 178 | - | }, | |||
| 179 | - | logging.FileConfig{ | |||
| 180 | - | Enabled: cfg.Logging.EnableFileLog, | |||
| 181 | - | LogDir: cfg.Logging.LogDir, | |||
| 182 | - | SessionID: sessionID, | |||
| 183 | - | WriteToStderr: true, // GUI: keep stderr output for debugging | |||
| 184 | - | }, | |||
| 185 | - | ) | |||
| 186 | - | if err != nil { | |||
| 187 | - | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |||
| 188 | - | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | |||
| 189 | - | return logger, nil | |||
| 190 | - | } | |||
| 191 | - | return logger, logCleanup | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | 197 | func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | ||
| 195 | probe := deps.NewPkgConfigProbe() | 198 | probe := deps.NewPkgConfigProbe() | ||
| 196 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | 199 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | ||
M internal/cli/app.go +34 -23
| @@ -8,11 +8,13 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 13 | "github.com/bnema/dumber/internal/cli/styles" | |
| 13 | 14 | "github.com/bnema/dumber/internal/domain/build" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/repository" | |
| 15 | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | + | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 16 | 18 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 17 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 20 | ) | |
| @@ -27,6 +29,7 @@ type App struct { | |||
| 27 | 29 | ||
| 28 | 30 | // Use cases | |
| 29 | 31 | SearchHistoryUC *usecase.SearchHistoryUseCase | |
| 32 | + | SessionUC *usecase.ManageSessionUseCase | |
| 30 | 33 | ||
| 31 | 34 | // Context with logger | |
| 32 | 35 | ctx context.Context | |
| @@ -36,40 +39,27 @@ type App struct { | |||
| 36 | 39 | // NewApp creates a new CLI application with all dependencies. | |
| 37 | 40 | func NewApp() (*App, error) { | |
| 38 | 41 | const dataDirPerm = 0o755 | |
| 42 | + | var err error | |
| 39 | 43 | // Load config | |
| 40 | 44 | cfg := loadConfig() | |
| 41 | 45 | ||
| 42 | 46 | // Create theme from config | |
| 43 | 47 | theme := styles.NewTheme(cfg) | |
| 44 | 48 | ||
| 45 | - | // Initialize logger with file output for debugging | |
| 46 | - | // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log | |
| 47 | - | logCfg := logging.DefaultConfig() | |
| 48 | - | if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" { | |
| 49 | - | logCfg.Level = logging.ParseLevel(level) | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // Create session log file | |
| 53 | - | home, _ := os.UserHomeDir() | |
| 54 | - | sessionID := logging.GenerateSessionID() | |
| 55 | - | logDir := filepath.Join(home, ".local", "state", "dumber", "logs") | |
| 56 | - | ||
| 57 | - | logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{ | |
| 58 | - | Enabled: true, | |
| 59 | - | LogDir: logDir, | |
| 60 | - | SessionID: sessionID, | |
| 61 | - | WriteToStderr: false, // CLI: log to file only, keep terminal clean | |
| 62 | - | }) | |
| 63 | - | if err != nil { | |
| 64 | - | // Fallback to no-file logging | |
| 65 | - | logger = logging.NewFromEnv() | |
| 66 | - | logCleanup = func() {} | |
| 49 | + | // Start with a quiet logger. If a browser session is active, we'll attach to it. | |
| 50 | + | logLevel := cfg.Logging.Level | |
| 51 | + | if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" { | |
| 52 | + | logLevel = envLevel | |
| 67 | 53 | } | |
| 68 | 54 | ||
| 55 | + | logger, logCleanup, _ := logging.NewWithFile( | |
| 56 | + | logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"}, | |
| 57 | + | logging.FileConfig{Enabled: false, WriteToStderr: false}, | |
| 58 | + | ) | |
| 69 | 59 | ctx := logging.WithContext(context.Background(), logger) | |
| 70 | - | logger.Info().Str("command", "cli").Msg("CLI session started") | |
| 71 | 60 | ||
| 72 | 61 | // Determine database path - use share directory per XDG spec (user data) | |
| 62 | + | home, _ := os.UserHomeDir() | |
| 73 | 63 | dbFile := cfg.Database.Path | |
| 74 | 64 | if dbFile == "" { | |
| 75 | 65 | dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db") | |
| @@ -87,6 +77,26 @@ func NewApp() (*App, error) { | |||
| 87 | 77 | return nil, fmt.Errorf("open database: %w", err) | |
| 88 | 78 | } | |
| 89 | 79 | ||
| 80 | + | // Attach CLI logs to the active browser session (if any). | |
| 81 | + | // This avoids creating extra tiny session log files for each CLI invocation. | |
| 82 | + | sessionRepo := sqlite.NewSessionRepository(db) | |
| 83 | + | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |
| 84 | + | sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter) | |
| 85 | + | if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil { | |
| 86 | + | attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{ | |
| 87 | + | Level: logLevel, | |
| 88 | + | Format: cfg.Logging.Format, | |
| 89 | + | TimeFormat: "15:04:05", | |
| 90 | + | LogDir: cfg.Logging.LogDir, | |
| 91 | + | WriteToStderr: false, | |
| 92 | + | EnableFileLog: cfg.Logging.EnableFileLog, | |
| 93 | + | }) | |
| 94 | + | logger = attachedLogger | |
| 95 | + | logCleanup = attachedCleanup | |
| 96 | + | ctx = logging.WithContext(context.Background(), logger) | |
| 97 | + | logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session") | |
| 98 | + | } | |
| 99 | + | ||
| 90 | 100 | logger.Debug().Str("db_path", dbFile).Msg("database connected") | |
| 91 | 101 | ||
| 92 | 102 | // Create repositories | |
| @@ -101,6 +111,7 @@ func NewApp() (*App, error) { | |||
| 101 | 111 | db: db, | |
| 102 | 112 | History: historyRepo, | |
| 103 | 113 | SearchHistoryUC: searchHistoryUC, | |
| 114 | + | SessionUC: sessionUC, | |
| 104 | 115 | ctx: ctx, | |
| 105 | 116 | logCleanup: logCleanup, | |
| 106 | 117 | }, nil | |
| @@ -8,11 +8,13 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 12 | "github.com/bnema/dumber/internal/cli/styles" | 13 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 13 | "github.com/bnema/dumber/internal/domain/build" | 14 | "github.com/bnema/dumber/internal/domain/build" | ||
| 14 | "github.com/bnema/dumber/internal/domain/repository" | 15 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/config" | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 17 | + | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 18 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 17 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | ) | 20 | ) | ||
| @@ -27,6 +29,7 @@ type App struct { | |||||
| 27 | 29 | ||||
| 28 | // Use cases | 30 | // Use cases | ||
| 29 | SearchHistoryUC *usecase.SearchHistoryUseCase | 31 | SearchHistoryUC *usecase.SearchHistoryUseCase | ||
| 32 | + | SessionUC *usecase.ManageSessionUseCase | |||
| 30 | 33 | ||||
| 31 | // Context with logger | 34 | // Context with logger | ||
| 32 | ctx context.Context | 35 | ctx context.Context | ||
| @@ -36,40 +39,27 @@ type App struct { | |||||
| 36 | // NewApp creates a new CLI application with all dependencies. | 39 | // NewApp creates a new CLI application with all dependencies. | ||
| 37 | func NewApp() (*App, error) { | 40 | func NewApp() (*App, error) { | ||
| 38 | const dataDirPerm = 0o755 | 41 | const dataDirPerm = 0o755 | ||
| 42 | + | var err error | |||
| 39 | // Load config | 43 | // Load config | ||
| 40 | cfg := loadConfig() | 44 | cfg := loadConfig() | ||
| 41 | 45 | ||||
| 42 | // Create theme from config | 46 | // Create theme from config | ||
| 43 | theme := styles.NewTheme(cfg) | 47 | theme := styles.NewTheme(cfg) | ||
| 44 | 48 | ||||
| 45 | - | // Initialize logger with file output for debugging | 49 | + | // Start with a quiet logger. If a browser session is active, we'll attach to it. |
| 46 | - | // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log | 50 | + | logLevel := cfg.Logging.Level |
| 47 | - | logCfg := logging.DefaultConfig() | 51 | + | if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" { |
| 48 | - | if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" { | 52 | + | logLevel = envLevel |
| 49 | - | logCfg.Level = logging.ParseLevel(level) | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | // Create session log file | |||
| 53 | - | home, _ := os.UserHomeDir() | |||
| 54 | - | sessionID := logging.GenerateSessionID() | |||
| 55 | - | logDir := filepath.Join(home, ".local", "state", "dumber", "logs") | |||
| 56 | - | ||||
| 57 | - | logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{ | |||
| 58 | - | Enabled: true, | |||
| 59 | - | LogDir: logDir, | |||
| 60 | - | SessionID: sessionID, | |||
| 61 | - | WriteToStderr: false, // CLI: log to file only, keep terminal clean | |||
| 62 | - | }) | |||
| 63 | - | if err != nil { | |||
| 64 | - | // Fallback to no-file logging | |||
| 65 | - | logger = logging.NewFromEnv() | |||
| 66 | - | logCleanup = func() {} | |||
| 67 | } | 53 | } | ||
| 68 | 54 | ||||
| 55 | + | logger, logCleanup, _ := logging.NewWithFile( | |||
| 56 | + | logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"}, | |||
| 57 | + | logging.FileConfig{Enabled: false, WriteToStderr: false}, | |||
| 58 | + | ) | |||
| 69 | ctx := logging.WithContext(context.Background(), logger) | 59 | ctx := logging.WithContext(context.Background(), logger) | ||
| 70 | - | logger.Info().Str("command", "cli").Msg("CLI session started") | |||
| 71 | 60 | ||||
| 72 | // Determine database path - use share directory per XDG spec (user data) | 61 | // Determine database path - use share directory per XDG spec (user data) | ||
| 62 | + | home, _ := os.UserHomeDir() | |||
| 73 | dbFile := cfg.Database.Path | 63 | dbFile := cfg.Database.Path | ||
| 74 | if dbFile == "" { | 64 | if dbFile == "" { | ||
| 75 | dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db") | 65 | dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db") | ||
| @@ -87,6 +77,26 @@ func NewApp() (*App, error) { | |||||
| 87 | return nil, fmt.Errorf("open database: %w", err) | 77 | return nil, fmt.Errorf("open database: %w", err) | ||
| 88 | } | 78 | } | ||
| 89 | 79 | ||||
| 80 | + | // Attach CLI logs to the active browser session (if any). | |||
| 81 | + | // This avoids creating extra tiny session log files for each CLI invocation. | |||
| 82 | + | sessionRepo := sqlite.NewSessionRepository(db) | |||
| 83 | + | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |||
| 84 | + | sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter) | |||
| 85 | + | if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil { | |||
| 86 | + | attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{ | |||
| 87 | + | Level: logLevel, | |||
| 88 | + | Format: cfg.Logging.Format, | |||
| 89 | + | TimeFormat: "15:04:05", | |||
| 90 | + | LogDir: cfg.Logging.LogDir, | |||
| 91 | + | WriteToStderr: false, | |||
| 92 | + | EnableFileLog: cfg.Logging.EnableFileLog, | |||
| 93 | + | }) | |||
| 94 | + | logger = attachedLogger | |||
| 95 | + | logCleanup = attachedCleanup | |||
| 96 | + | ctx = logging.WithContext(context.Background(), logger) | |||
| 97 | + | logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 90 | logger.Debug().Str("db_path", dbFile).Msg("database connected") | 100 | logger.Debug().Str("db_path", dbFile).Msg("database connected") | ||
| 91 | 101 | ||||
| 92 | // Create repositories | 102 | // Create repositories | ||
| @@ -101,6 +111,7 @@ func NewApp() (*App, error) { | |||||
| 101 | db: db, | 111 | db: db, | ||
| 102 | History: historyRepo, | 112 | History: historyRepo, | ||
| 103 | SearchHistoryUC: searchHistoryUC, | 113 | SearchHistoryUC: searchHistoryUC, | ||
| 114 | + | SessionUC: sessionUC, | |||
| 104 | ctx: ctx, | 115 | ctx: ctx, | ||
| 105 | logCleanup: logCleanup, | 116 | logCleanup: logCleanup, | ||
| 106 | }, nil | 117 | }, nil | ||