feat(logging): add comprehensive XDG-compliant logging system with C output capture
- commit
- 9d2827d46123b254ce73fb85a65fe41c25cd60d4
- author
- bamen · · 2025-09-12 04:53 UTC
- parent
- 9f6285b
- signature
- unsigned
14 files · +1259 -34
M internal/cache/binary_format.go +14 -9
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "syscall" | |
| 10 | 10 | "unsafe" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // Binary cache format layout: | |
| @@ -46,7 +48,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error { | |||
| 46 | 48 | } | |
| 47 | 49 | defer func() { | |
| 48 | 50 | if closeErr := file.Close(); closeErr != nil { | |
| 49 | - | log.Printf("Warning: failed to close cache file: %v", closeErr) | |
| 51 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr)) | |
| 50 | 52 | } | |
| 51 | 53 | }() | |
| 52 | 54 | ||
| @@ -62,7 +64,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error { | |||
| 62 | 64 | } | |
| 63 | 65 | defer func() { | |
| 64 | 66 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | |
| 65 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | |
| 67 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) | |
| 66 | 68 | } | |
| 67 | 69 | }() | |
| 68 | 70 | ||
| @@ -96,7 +98,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||
| 96 | 98 | } | |
| 97 | 99 | defer func() { | |
| 98 | 100 | if closeErr := file.Close(); closeErr != nil { | |
| 99 | - | log.Printf("Warning: failed to close cache file: %v", closeErr) | |
| 101 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr)) | |
| 100 | 102 | } | |
| 101 | 103 | }() | |
| 102 | 104 | ||
| @@ -123,7 +125,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||
| 123 | 125 | header, err := c.parseHeader(data) | |
| 124 | 126 | if err != nil { | |
| 125 | 127 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | |
| 126 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | |
| 128 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) | |
| 127 | 129 | } | |
| 128 | 130 | return fmt.Errorf("failed to parse header: %w", err) | |
| 129 | 131 | } | |
| @@ -135,7 +137,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||
| 135 | 137 | entries, err := c.parseEntries(data, HeaderSize, header.EntryCount) | |
| 136 | 138 | if err != nil { | |
| 137 | 139 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | |
| 138 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | |
| 140 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) | |
| 139 | 141 | } | |
| 140 | 142 | return fmt.Errorf("failed to parse entries: %w", err) | |
| 141 | 143 | } | |
| @@ -147,7 +149,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||
| 147 | 149 | trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above | |
| 148 | 150 | if err != nil { | |
| 149 | 151 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | |
| 150 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | |
| 152 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) | |
| 151 | 153 | } | |
| 152 | 154 | return fmt.Errorf("failed to parse trigram index: %w", err) | |
| 153 | 155 | } | |
| @@ -156,7 +158,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||
| 156 | 158 | sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset) | |
| 157 | 159 | if err != nil { | |
| 158 | 160 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | |
| 159 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | |
| 161 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) | |
| 160 | 162 | } | |
| 161 | 163 | return fmt.Errorf("failed to parse sorted index: %w", err) | |
| 162 | 164 | } | |
| @@ -197,7 +199,10 @@ func (c *DmenuFuzzyCache) calculateFileSize() int { | |||
| 197 | 199 | ||
| 198 | 200 | // writeHeader writes the binary header. | |
| 199 | 201 | func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int { | |
| 200 | - | log.Printf("[cache] Writing header: totalSize=%d bytes", totalSize) | |
| 202 | + | // Log header info to file only (not stdout) to avoid interfering with dmenu | |
| 203 | + | if logger := logging.GetLogger(); logger != nil { | |
| 204 | + | logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("writing header: totalSize=%d bytes", totalSize), "CACHE") | |
| 205 | + | } | |
| 201 | 206 | // Calculate index offset (header + all entries) | |
| 202 | 207 | indexOffset := HeaderSize | |
| 203 | 208 | for _, entry := range c.entries { | |
| @@ -206,7 +211,7 @@ func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) in | |||
| 206 | 211 | ||
| 207 | 212 | // Check bounds for integer conversions | |
| 208 | 213 | if len(c.entries) > 0xFFFFFFFF { // uint32 max | |
| 209 | - | log.Printf("Warning: too many entries for binary format: %d, truncating", len(c.entries)) | |
| 214 | + | logging.Warn(fmt.Sprintf("too many entries for binary format: %d, truncating", len(c.entries))) | |
| 210 | 215 | } | |
| 211 | 216 | if indexOffset < 0 { | |
| 212 | 217 | log.Printf("Warning: negative index offset: %d, using 0", indexOffset) | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "syscall" | 9 | "syscall" | ||
| 10 | "unsafe" | 10 | "unsafe" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | // Binary cache format layout: | 15 | // Binary cache format layout: | ||
| @@ -46,7 +48,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error { | |||||
| 46 | } | 48 | } | ||
| 47 | defer func() { | 49 | defer func() { | ||
| 48 | if closeErr := file.Close(); closeErr != nil { | 50 | if closeErr := file.Close(); closeErr != nil { | ||
| 49 | - | log.Printf("Warning: failed to close cache file: %v", closeErr) | 51 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr)) |
| 50 | } | 52 | } | ||
| 51 | }() | 53 | }() | ||
| 52 | 54 | ||||
| @@ -62,7 +64,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error { | |||||
| 62 | } | 64 | } | ||
| 63 | defer func() { | 65 | defer func() { | ||
| 64 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | 66 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | ||
| 65 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | 67 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) |
| 66 | } | 68 | } | ||
| 67 | }() | 69 | }() | ||
| 68 | 70 | ||||
| @@ -96,7 +98,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||||
| 96 | } | 98 | } | ||
| 97 | defer func() { | 99 | defer func() { | ||
| 98 | if closeErr := file.Close(); closeErr != nil { | 100 | if closeErr := file.Close(); closeErr != nil { | ||
| 99 | - | log.Printf("Warning: failed to close cache file: %v", closeErr) | 101 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr)) |
| 100 | } | 102 | } | ||
| 101 | }() | 103 | }() | ||
| 102 | 104 | ||||
| @@ -123,7 +125,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||||
| 123 | header, err := c.parseHeader(data) | 125 | header, err := c.parseHeader(data) | ||
| 124 | if err != nil { | 126 | if err != nil { | ||
| 125 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | 127 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | ||
| 126 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | 128 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) |
| 127 | } | 129 | } | ||
| 128 | return fmt.Errorf("failed to parse header: %w", err) | 130 | return fmt.Errorf("failed to parse header: %w", err) | ||
| 129 | } | 131 | } | ||
| @@ -135,7 +137,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||||
| 135 | entries, err := c.parseEntries(data, HeaderSize, header.EntryCount) | 137 | entries, err := c.parseEntries(data, HeaderSize, header.EntryCount) | ||
| 136 | if err != nil { | 138 | if err != nil { | ||
| 137 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | 139 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | ||
| 138 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | 140 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) |
| 139 | } | 141 | } | ||
| 140 | return fmt.Errorf("failed to parse entries: %w", err) | 142 | return fmt.Errorf("failed to parse entries: %w", err) | ||
| 141 | } | 143 | } | ||
| @@ -147,7 +149,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||||
| 147 | trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above | 149 | trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above | ||
| 148 | if err != nil { | 150 | if err != nil { | ||
| 149 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | 151 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | ||
| 150 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | 152 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) |
| 151 | } | 153 | } | ||
| 152 | return fmt.Errorf("failed to parse trigram index: %w", err) | 154 | return fmt.Errorf("failed to parse trigram index: %w", err) | ||
| 153 | } | 155 | } | ||
| @@ -156,7 +158,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error { | |||||
| 156 | sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset) | 158 | sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset) | ||
| 157 | if err != nil { | 159 | if err != nil { | ||
| 158 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | 160 | if unmapErr := syscall.Munmap(data); unmapErr != nil { | ||
| 159 | - | log.Printf("Warning: failed to unmap cache file: %v", unmapErr) | 161 | + | logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr)) |
| 160 | } | 162 | } | ||
| 161 | return fmt.Errorf("failed to parse sorted index: %w", err) | 163 | return fmt.Errorf("failed to parse sorted index: %w", err) | ||
| 162 | } | 164 | } | ||
| @@ -197,7 +199,10 @@ func (c *DmenuFuzzyCache) calculateFileSize() int { | |||||
| 197 | 199 | ||||
| 198 | // writeHeader writes the binary header. | 200 | // writeHeader writes the binary header. | ||
| 199 | func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int { | 201 | func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int { | ||
| 200 | - | log.Printf("[cache] Writing header: totalSize=%d bytes", totalSize) | 202 | + | // Log header info to file only (not stdout) to avoid interfering with dmenu |
| 203 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 204 | + | logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("writing header: totalSize=%d bytes", totalSize), "CACHE") | |||
| 205 | + | } | |||
| 201 | // Calculate index offset (header + all entries) | 206 | // Calculate index offset (header + all entries) | ||
| 202 | indexOffset := HeaderSize | 207 | indexOffset := HeaderSize | ||
| 203 | for _, entry := range c.entries { | 208 | for _, entry := range c.entries { | ||
| @@ -206,7 +211,7 @@ func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) in | |||||
| 206 | 211 | ||||
| 207 | // Check bounds for integer conversions | 212 | // Check bounds for integer conversions | ||
| 208 | if len(c.entries) > 0xFFFFFFFF { // uint32 max | 213 | if len(c.entries) > 0xFFFFFFFF { // uint32 max | ||
| 209 | - | log.Printf("Warning: too many entries for binary format: %d, truncating", len(c.entries)) | 214 | + | logging.Warn(fmt.Sprintf("too many entries for binary format: %d, truncating", len(c.entries))) |
| 210 | } | 215 | } | ||
| 211 | if indexOffset < 0 { | 216 | if indexOffset < 0 { | ||
| 212 | log.Printf("Warning: negative index offset: %d, using 0", indexOffset) | 217 | log.Printf("Warning: negative index offset: %d, using 0", indexOffset) | ||
M internal/cache/fuzzy_cache.go +15 -11
| @@ -4,12 +4,13 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/md5" | |
| 6 | 6 | "fmt" | |
| 7 | - | "log" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "sync/atomic" | |
| 12 | 11 | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // Cache update constants | |
| @@ -199,7 +200,10 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||
| 199 | 200 | // Save to filesystem in background | |
| 200 | 201 | go cm.saveToFilesystemAsync() | |
| 201 | 202 | ||
| 202 | - | log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) | |
| 203 | + | // Log cache timing to file only (not stdout) to avoid interfering with dmenu | |
| 204 | + | if logger := logging.GetLogger(); logger != nil { | |
| 205 | + | logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("built in %v with %d entries", time.Since(startTime), len(history)), "CACHE") | |
| 206 | + | } | |
| 203 | 207 | return cm.cache, nil | |
| 204 | 208 | } | |
| 205 | 209 | ||
| @@ -216,13 +220,13 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) { | |||
| 216 | 220 | ||
| 217 | 221 | // Remove cache file | |
| 218 | 222 | if err := os.Remove(cm.config.CacheFile); err != nil { | |
| 219 | - | log.Printf("Warning: failed to remove cache file %s: %v", cm.config.CacheFile, err) | |
| 223 | + | logging.Warn(fmt.Sprintf("failed to remove cache file %s: %v", cm.config.CacheFile, err)) | |
| 220 | 224 | } | |
| 221 | 225 | ||
| 222 | 226 | // Build new cache | |
| 223 | 227 | _, err := cm.buildCacheFromDB(ctx) | |
| 224 | 228 | if err != nil { | |
| 225 | - | log.Printf("Warning: failed to refresh cache: %v\n", err) | |
| 229 | + | logging.Warn(fmt.Sprintf("failed to refresh cache: %v", err)) | |
| 226 | 230 | } | |
| 227 | 231 | }() | |
| 228 | 232 | } | |
| @@ -239,7 +243,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) { | |||
| 239 | 243 | ||
| 240 | 244 | _, err := cm.buildCacheFromDB(ctx) | |
| 241 | 245 | if err != nil { | |
| 242 | - | log.Printf("Warning: failed to refresh cache on exit: %v\n", err) | |
| 246 | + | logging.Warn(fmt.Sprintf("failed to refresh cache on exit: %v", err)) | |
| 243 | 247 | } | |
| 244 | 248 | }() | |
| 245 | 249 | } | |
| @@ -250,25 +254,25 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||
| 250 | 254 | // Create cache directory if it doesn't exist | |
| 251 | 255 | cacheDir := filepath.Dir(cm.config.CacheFile) | |
| 252 | 256 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | |
| 253 | - | log.Printf("Warning: failed to create cache directory: %v\n", err) | |
| 257 | + | logging.Warn(fmt.Sprintf("failed to create cache directory: %v", err)) | |
| 254 | 258 | return | |
| 255 | 259 | } | |
| 256 | 260 | ||
| 257 | 261 | // Save to temporary file first, then atomic rename | |
| 258 | 262 | tempFile := cm.config.CacheFile + ".tmp" | |
| 259 | 263 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | |
| 260 | - | log.Printf("Warning: failed to save cache: %v\n", err) | |
| 264 | + | logging.Warn(fmt.Sprintf("failed to save cache: %v", err)) | |
| 261 | 265 | if err := os.Remove(tempFile); err != nil { | |
| 262 | - | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | |
| 266 | + | logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err)) | |
| 263 | 267 | } | |
| 264 | 268 | return | |
| 265 | 269 | } | |
| 266 | 270 | ||
| 267 | 271 | // Atomic rename | |
| 268 | 272 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | |
| 269 | - | log.Printf("Warning: failed to rename cache file: %v\n", err) | |
| 273 | + | logging.Warn(fmt.Sprintf("failed to rename cache file: %v", err)) | |
| 270 | 274 | if err := os.Remove(tempFile); err != nil { | |
| 271 | - | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | |
| 275 | + | logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err)) | |
| 272 | 276 | } | |
| 273 | 277 | } | |
| 274 | 278 | } | |
| @@ -287,7 +291,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) { | |||
| 287 | 291 | hasher.Write([]byte(entry.Url)) | |
| 288 | 292 | if entry.VisitCount.Valid { | |
| 289 | 293 | if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil { | |
| 290 | - | log.Printf("Warning: failed to write visit count to hasher: %v", err) | |
| 294 | + | logging.Warn(fmt.Sprintf("failed to write visit count to hasher: %v", err)) | |
| 291 | 295 | } | |
| 292 | 296 | } | |
| 293 | 297 | if entry.LastVisited.Valid { | |
| @@ -4,12 +4,13 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/md5" | 5 | "crypto/md5" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "log" | |||
| 8 | "os" | 7 | "os" | ||
| 9 | "path/filepath" | 8 | "path/filepath" | ||
| 10 | "sync" | 9 | "sync" | ||
| 11 | "sync/atomic" | 10 | "sync/atomic" | ||
| 12 | "time" | 11 | "time" | ||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // Cache update constants | 16 | // Cache update constants | ||
| @@ -199,7 +200,10 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||||
| 199 | // Save to filesystem in background | 200 | // Save to filesystem in background | ||
| 200 | go cm.saveToFilesystemAsync() | 201 | go cm.saveToFilesystemAsync() | ||
| 201 | 202 | ||||
| 202 | - | log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) | 203 | + | // Log cache timing to file only (not stdout) to avoid interfering with dmenu |
| 204 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 205 | + | logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("built in %v with %d entries", time.Since(startTime), len(history)), "CACHE") | |||
| 206 | + | } | |||
| 203 | return cm.cache, nil | 207 | return cm.cache, nil | ||
| 204 | } | 208 | } | ||
| 205 | 209 | ||||
| @@ -216,13 +220,13 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) { | |||||
| 216 | 220 | ||||
| 217 | // Remove cache file | 221 | // Remove cache file | ||
| 218 | if err := os.Remove(cm.config.CacheFile); err != nil { | 222 | if err := os.Remove(cm.config.CacheFile); err != nil { | ||
| 219 | - | log.Printf("Warning: failed to remove cache file %s: %v", cm.config.CacheFile, err) | 223 | + | logging.Warn(fmt.Sprintf("failed to remove cache file %s: %v", cm.config.CacheFile, err)) |
| 220 | } | 224 | } | ||
| 221 | 225 | ||||
| 222 | // Build new cache | 226 | // Build new cache | ||
| 223 | _, err := cm.buildCacheFromDB(ctx) | 227 | _, err := cm.buildCacheFromDB(ctx) | ||
| 224 | if err != nil { | 228 | if err != nil { | ||
| 225 | - | log.Printf("Warning: failed to refresh cache: %v\n", err) | 229 | + | logging.Warn(fmt.Sprintf("failed to refresh cache: %v", err)) |
| 226 | } | 230 | } | ||
| 227 | }() | 231 | }() | ||
| 228 | } | 232 | } | ||
| @@ -239,7 +243,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) { | |||||
| 239 | 243 | ||||
| 240 | _, err := cm.buildCacheFromDB(ctx) | 244 | _, err := cm.buildCacheFromDB(ctx) | ||
| 241 | if err != nil { | 245 | if err != nil { | ||
| 242 | - | log.Printf("Warning: failed to refresh cache on exit: %v\n", err) | 246 | + | logging.Warn(fmt.Sprintf("failed to refresh cache on exit: %v", err)) |
| 243 | } | 247 | } | ||
| 244 | }() | 248 | }() | ||
| 245 | } | 249 | } | ||
| @@ -250,25 +254,25 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||||
| 250 | // Create cache directory if it doesn't exist | 254 | // Create cache directory if it doesn't exist | ||
| 251 | cacheDir := filepath.Dir(cm.config.CacheFile) | 255 | cacheDir := filepath.Dir(cm.config.CacheFile) | ||
| 252 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | 256 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | ||
| 253 | - | log.Printf("Warning: failed to create cache directory: %v\n", err) | 257 | + | logging.Warn(fmt.Sprintf("failed to create cache directory: %v", err)) |
| 254 | return | 258 | return | ||
| 255 | } | 259 | } | ||
| 256 | 260 | ||||
| 257 | // Save to temporary file first, then atomic rename | 261 | // Save to temporary file first, then atomic rename | ||
| 258 | tempFile := cm.config.CacheFile + ".tmp" | 262 | tempFile := cm.config.CacheFile + ".tmp" | ||
| 259 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | 263 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | ||
| 260 | - | log.Printf("Warning: failed to save cache: %v\n", err) | 264 | + | logging.Warn(fmt.Sprintf("failed to save cache: %v", err)) |
| 261 | if err := os.Remove(tempFile); err != nil { | 265 | if err := os.Remove(tempFile); err != nil { | ||
| 262 | - | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | 266 | + | logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err)) |
| 263 | } | 267 | } | ||
| 264 | return | 268 | return | ||
| 265 | } | 269 | } | ||
| 266 | 270 | ||||
| 267 | // Atomic rename | 271 | // Atomic rename | ||
| 268 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | 272 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | ||
| 269 | - | log.Printf("Warning: failed to rename cache file: %v\n", err) | 273 | + | logging.Warn(fmt.Sprintf("failed to rename cache file: %v", err)) |
| 270 | if err := os.Remove(tempFile); err != nil { | 274 | if err := os.Remove(tempFile); err != nil { | ||
| 271 | - | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | 275 | + | logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err)) |
| 272 | } | 276 | } | ||
| 273 | } | 277 | } | ||
| 274 | } | 278 | } | ||
| @@ -287,7 +291,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) { | |||||
| 287 | hasher.Write([]byte(entry.Url)) | 291 | hasher.Write([]byte(entry.Url)) | ||
| 288 | if entry.VisitCount.Valid { | 292 | if entry.VisitCount.Valid { | ||
| 289 | if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil { | 293 | if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil { | ||
| 290 | - | log.Printf("Warning: failed to write visit count to hasher: %v", err) | 294 | + | logging.Warn(fmt.Sprintf("failed to write visit count to hasher: %v", err)) |
| 291 | } | 295 | } | ||
| 292 | } | 296 | } | ||
| 293 | if entry.LastVisited.Valid { | 297 | if entry.LastVisited.Valid { | ||
A internal/cli/logs.go +299 -0
| @@ -0,0 +1,299 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/config" | |
| 14 | + | "github.com/spf13/cobra" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // NewLogsCmd creates the logs command and its subcommands | |
| 18 | + | func NewLogsCmd() *cobra.Command { | |
| 19 | + | cmd := &cobra.Command{ | |
| 20 | + | Use: "logs", | |
| 21 | + | Short: "View and manage application logs", | |
| 22 | + | Long: "Access logs stored in XDG-compliant directory for debugging purposes", | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | cmd.AddCommand(newLogsTailCmd()) | |
| 26 | + | cmd.AddCommand(newLogsListCmd()) | |
| 27 | + | cmd.AddCommand(newLogsCleanCmd()) | |
| 28 | + | cmd.AddCommand(newLogsPathCmd()) | |
| 29 | + | ||
| 30 | + | return cmd | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // newLogsTailCmd creates the tail subcommand | |
| 34 | + | func newLogsTailCmd() *cobra.Command { | |
| 35 | + | var lines int | |
| 36 | + | ||
| 37 | + | cmd := &cobra.Command{ | |
| 38 | + | Use: "tail [lines]", | |
| 39 | + | Short: "Show last N lines of current log", | |
| 40 | + | Long: "Display the last N lines from the current log file. Defaults to 50 lines.", | |
| 41 | + | Args: cobra.MaximumNArgs(1), | |
| 42 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 43 | + | // Parse lines argument if provided | |
| 44 | + | if len(args) > 0 { | |
| 45 | + | var err error | |
| 46 | + | lines, err = strconv.Atoi(args[0]) | |
| 47 | + | if err != nil { | |
| 48 | + | return fmt.Errorf("invalid number of lines: %s", args[0]) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | logDir, err := config.GetLogDir() | |
| 53 | + | if err != nil { | |
| 54 | + | return fmt.Errorf("failed to get log directory: %w", err) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | currentLog := filepath.Join(logDir, "dumber.log") | |
| 58 | + | return tailFile(currentLog, lines) | |
| 59 | + | }, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show") | |
| 63 | + | return cmd | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // newLogsListCmd creates the list subcommand | |
| 67 | + | func newLogsListCmd() *cobra.Command { | |
| 68 | + | cmd := &cobra.Command{ | |
| 69 | + | Use: "list", | |
| 70 | + | Short: "List all available log files", | |
| 71 | + | Long: "Display all log files in the log directory with their sizes and modification times.", | |
| 72 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 73 | + | logDir, err := config.GetLogDir() | |
| 74 | + | if err != nil { | |
| 75 | + | return fmt.Errorf("failed to get log directory: %w", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return listLogFiles(logDir) | |
| 79 | + | }, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | return cmd | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // newLogsCleanCmd creates the clean subcommand | |
| 86 | + | func newLogsCleanCmd() *cobra.Command { | |
| 87 | + | var dryRun bool | |
| 88 | + | var maxAge int | |
| 89 | + | ||
| 90 | + | cmd := &cobra.Command{ | |
| 91 | + | Use: "clean", | |
| 92 | + | Short: "Remove old log files", | |
| 93 | + | Long: "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.", | |
| 94 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 95 | + | logDir, err := config.GetLogDir() | |
| 96 | + | if err != nil { | |
| 97 | + | return fmt.Errorf("failed to get log directory: %w", err) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return cleanOldLogs(logDir, maxAge, dryRun) | |
| 101 | + | }, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting") | |
| 105 | + | cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days") | |
| 106 | + | return cmd | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // newLogsPathCmd creates the path subcommand | |
| 110 | + | func newLogsPathCmd() *cobra.Command { | |
| 111 | + | cmd := &cobra.Command{ | |
| 112 | + | Use: "path", | |
| 113 | + | Short: "Show the path to the log directory", | |
| 114 | + | Long: "Display the XDG-compliant path where logs are stored.", | |
| 115 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 116 | + | logDir, err := config.GetLogDir() | |
| 117 | + | if err != nil { | |
| 118 | + | return fmt.Errorf("failed to get log directory: %w", err) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | fmt.Println(logDir) | |
| 122 | + | return nil | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | return cmd | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // tailFile displays the last N lines of a file | |
| 130 | + | func tailFile(filePath string, lines int) error { | |
| 131 | + | file, err := os.Open(filePath) | |
| 132 | + | if err != nil { | |
| 133 | + | if os.IsNotExist(err) { | |
| 134 | + | fmt.Printf("Log file does not exist: %s\n", filePath) | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | return fmt.Errorf("failed to open log file: %w", err) | |
| 138 | + | } | |
| 139 | + | defer file.Close() | |
| 140 | + | ||
| 141 | + | // Read all lines | |
| 142 | + | var allLines []string | |
| 143 | + | scanner := bufio.NewScanner(file) | |
| 144 | + | for scanner.Scan() { | |
| 145 | + | allLines = append(allLines, scanner.Text()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if err := scanner.Err(); err != nil { | |
| 149 | + | return fmt.Errorf("error reading log file: %w", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Display last N lines | |
| 153 | + | start := len(allLines) - lines | |
| 154 | + | if start < 0 { | |
| 155 | + | start = 0 | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | for i := start; i < len(allLines); i++ { | |
| 159 | + | fmt.Println(allLines[i]) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // listLogFiles displays all log files with their information | |
| 166 | + | func listLogFiles(logDir string) error { | |
| 167 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |
| 168 | + | fmt.Printf("Log directory does not exist: %s\n", logDir) | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | files, err := os.ReadDir(logDir) | |
| 173 | + | if err != nil { | |
| 174 | + | return fmt.Errorf("failed to read log directory: %w", err) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Filter and collect log files | |
| 178 | + | var logFiles []os.FileInfo | |
| 179 | + | for _, file := range files { | |
| 180 | + | if file.IsDir() { | |
| 181 | + | continue | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | name := file.Name() | |
| 185 | + | if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") { | |
| 186 | + | info, err := file.Info() | |
| 187 | + | if err != nil { | |
| 188 | + | continue | |
| 189 | + | } | |
| 190 | + | logFiles = append(logFiles, info) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | if len(logFiles) == 0 { | |
| 195 | + | fmt.Println("No log files found") | |
| 196 | + | return nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Sort by modification time (newest first) | |
| 200 | + | sort.Slice(logFiles, func(i, j int) bool { | |
| 201 | + | return logFiles[i].ModTime().After(logFiles[j].ModTime()) | |
| 202 | + | }) | |
| 203 | + | ||
| 204 | + | // Display files | |
| 205 | + | fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE") | |
| 206 | + | fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----") | |
| 207 | + | ||
| 208 | + | for _, file := range logFiles { | |
| 209 | + | size := formatSize(file.Size()) | |
| 210 | + | modTime := file.ModTime().Format("2006-01-02 15:04:05") | |
| 211 | + | fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // cleanOldLogs removes log files older than maxAge days | |
| 218 | + | func cleanOldLogs(logDir string, maxAge int, dryRun bool) error { | |
| 219 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |
| 220 | + | fmt.Printf("Log directory does not exist: %s\n", logDir) | |
| 221 | + | return nil | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | files, err := os.ReadDir(logDir) | |
| 225 | + | if err != nil { | |
| 226 | + | return fmt.Errorf("failed to read log directory: %w", err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | cutoffTime := time.Now().AddDate(0, 0, -maxAge) | |
| 230 | + | var deletedCount int | |
| 231 | + | var deletedSize int64 | |
| 232 | + | ||
| 233 | + | for _, file := range files { | |
| 234 | + | if file.IsDir() { | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | name := file.Name() | |
| 239 | + | // Skip the current log file | |
| 240 | + | if name == "dumber.log" { | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Only process log files | |
| 245 | + | if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") { | |
| 246 | + | continue | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | info, err := file.Info() | |
| 250 | + | if err != nil { | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | if info.ModTime().Before(cutoffTime) { | |
| 255 | + | filePath := filepath.Join(logDir, name) | |
| 256 | + | ||
| 257 | + | if dryRun { | |
| 258 | + | fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n", | |
| 259 | + | name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05")) | |
| 260 | + | } else { | |
| 261 | + | err := os.Remove(filePath) | |
| 262 | + | if err != nil { | |
| 263 | + | fmt.Printf("Warning: failed to delete %s: %v\n", name, err) | |
| 264 | + | } else { | |
| 265 | + | fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | deletedCount++ | |
| 270 | + | deletedSize += info.Size() | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | if dryRun { | |
| 275 | + | fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n", | |
| 276 | + | deletedCount, float64(deletedSize)/1024) | |
| 277 | + | } else { | |
| 278 | + | fmt.Printf("\nCleaned %d files (%.2f KB total)\n", | |
| 279 | + | deletedCount, float64(deletedSize)/1024) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // formatSize formats file size in human-readable format | |
| 286 | + | func formatSize(size int64) string { | |
| 287 | + | const unit = 1024 | |
| 288 | + | if size < unit { | |
| 289 | + | return fmt.Sprintf("%d B", size) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | div, exp := int64(unit), 0 | |
| 293 | + | for n := size / unit; n >= unit; n /= unit { | |
| 294 | + | div *= unit | |
| 295 | + | exp++ | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) | |
| 299 | + | }⏎̸ | |
| @@ -0,0 +1,299 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/config" | |||
| 14 | + | "github.com/spf13/cobra" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // NewLogsCmd creates the logs command and its subcommands | |||
| 18 | + | func NewLogsCmd() *cobra.Command { | |||
| 19 | + | cmd := &cobra.Command{ | |||
| 20 | + | Use: "logs", | |||
| 21 | + | Short: "View and manage application logs", | |||
| 22 | + | Long: "Access logs stored in XDG-compliant directory for debugging purposes", | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | cmd.AddCommand(newLogsTailCmd()) | |||
| 26 | + | cmd.AddCommand(newLogsListCmd()) | |||
| 27 | + | cmd.AddCommand(newLogsCleanCmd()) | |||
| 28 | + | cmd.AddCommand(newLogsPathCmd()) | |||
| 29 | + | ||||
| 30 | + | return cmd | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // newLogsTailCmd creates the tail subcommand | |||
| 34 | + | func newLogsTailCmd() *cobra.Command { | |||
| 35 | + | var lines int | |||
| 36 | + | ||||
| 37 | + | cmd := &cobra.Command{ | |||
| 38 | + | Use: "tail [lines]", | |||
| 39 | + | Short: "Show last N lines of current log", | |||
| 40 | + | Long: "Display the last N lines from the current log file. Defaults to 50 lines.", | |||
| 41 | + | Args: cobra.MaximumNArgs(1), | |||
| 42 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 43 | + | // Parse lines argument if provided | |||
| 44 | + | if len(args) > 0 { | |||
| 45 | + | var err error | |||
| 46 | + | lines, err = strconv.Atoi(args[0]) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return fmt.Errorf("invalid number of lines: %s", args[0]) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | logDir, err := config.GetLogDir() | |||
| 53 | + | if err != nil { | |||
| 54 | + | return fmt.Errorf("failed to get log directory: %w", err) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | currentLog := filepath.Join(logDir, "dumber.log") | |||
| 58 | + | return tailFile(currentLog, lines) | |||
| 59 | + | }, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show") | |||
| 63 | + | return cmd | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // newLogsListCmd creates the list subcommand | |||
| 67 | + | func newLogsListCmd() *cobra.Command { | |||
| 68 | + | cmd := &cobra.Command{ | |||
| 69 | + | Use: "list", | |||
| 70 | + | Short: "List all available log files", | |||
| 71 | + | Long: "Display all log files in the log directory with their sizes and modification times.", | |||
| 72 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 73 | + | logDir, err := config.GetLogDir() | |||
| 74 | + | if err != nil { | |||
| 75 | + | return fmt.Errorf("failed to get log directory: %w", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return listLogFiles(logDir) | |||
| 79 | + | }, | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | return cmd | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // newLogsCleanCmd creates the clean subcommand | |||
| 86 | + | func newLogsCleanCmd() *cobra.Command { | |||
| 87 | + | var dryRun bool | |||
| 88 | + | var maxAge int | |||
| 89 | + | ||||
| 90 | + | cmd := &cobra.Command{ | |||
| 91 | + | Use: "clean", | |||
| 92 | + | Short: "Remove old log files", | |||
| 93 | + | Long: "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.", | |||
| 94 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 95 | + | logDir, err := config.GetLogDir() | |||
| 96 | + | if err != nil { | |||
| 97 | + | return fmt.Errorf("failed to get log directory: %w", err) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | return cleanOldLogs(logDir, maxAge, dryRun) | |||
| 101 | + | }, | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting") | |||
| 105 | + | cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days") | |||
| 106 | + | return cmd | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // newLogsPathCmd creates the path subcommand | |||
| 110 | + | func newLogsPathCmd() *cobra.Command { | |||
| 111 | + | cmd := &cobra.Command{ | |||
| 112 | + | Use: "path", | |||
| 113 | + | Short: "Show the path to the log directory", | |||
| 114 | + | Long: "Display the XDG-compliant path where logs are stored.", | |||
| 115 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 116 | + | logDir, err := config.GetLogDir() | |||
| 117 | + | if err != nil { | |||
| 118 | + | return fmt.Errorf("failed to get log directory: %w", err) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | fmt.Println(logDir) | |||
| 122 | + | return nil | |||
| 123 | + | }, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return cmd | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // tailFile displays the last N lines of a file | |||
| 130 | + | func tailFile(filePath string, lines int) error { | |||
| 131 | + | file, err := os.Open(filePath) | |||
| 132 | + | if err != nil { | |||
| 133 | + | if os.IsNotExist(err) { | |||
| 134 | + | fmt.Printf("Log file does not exist: %s\n", filePath) | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | return fmt.Errorf("failed to open log file: %w", err) | |||
| 138 | + | } | |||
| 139 | + | defer file.Close() | |||
| 140 | + | ||||
| 141 | + | // Read all lines | |||
| 142 | + | var allLines []string | |||
| 143 | + | scanner := bufio.NewScanner(file) | |||
| 144 | + | for scanner.Scan() { | |||
| 145 | + | allLines = append(allLines, scanner.Text()) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if err := scanner.Err(); err != nil { | |||
| 149 | + | return fmt.Errorf("error reading log file: %w", err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Display last N lines | |||
| 153 | + | start := len(allLines) - lines | |||
| 154 | + | if start < 0 { | |||
| 155 | + | start = 0 | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | for i := start; i < len(allLines); i++ { | |||
| 159 | + | fmt.Println(allLines[i]) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // listLogFiles displays all log files with their information | |||
| 166 | + | func listLogFiles(logDir string) error { | |||
| 167 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |||
| 168 | + | fmt.Printf("Log directory does not exist: %s\n", logDir) | |||
| 169 | + | return nil | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | files, err := os.ReadDir(logDir) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return fmt.Errorf("failed to read log directory: %w", err) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Filter and collect log files | |||
| 178 | + | var logFiles []os.FileInfo | |||
| 179 | + | for _, file := range files { | |||
| 180 | + | if file.IsDir() { | |||
| 181 | + | continue | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | name := file.Name() | |||
| 185 | + | if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") { | |||
| 186 | + | info, err := file.Info() | |||
| 187 | + | if err != nil { | |||
| 188 | + | continue | |||
| 189 | + | } | |||
| 190 | + | logFiles = append(logFiles, info) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | if len(logFiles) == 0 { | |||
| 195 | + | fmt.Println("No log files found") | |||
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Sort by modification time (newest first) | |||
| 200 | + | sort.Slice(logFiles, func(i, j int) bool { | |||
| 201 | + | return logFiles[i].ModTime().After(logFiles[j].ModTime()) | |||
| 202 | + | }) | |||
| 203 | + | ||||
| 204 | + | // Display files | |||
| 205 | + | fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE") | |||
| 206 | + | fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----") | |||
| 207 | + | ||||
| 208 | + | for _, file := range logFiles { | |||
| 209 | + | size := formatSize(file.Size()) | |||
| 210 | + | modTime := file.ModTime().Format("2006-01-02 15:04:05") | |||
| 211 | + | fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // cleanOldLogs removes log files older than maxAge days | |||
| 218 | + | func cleanOldLogs(logDir string, maxAge int, dryRun bool) error { | |||
| 219 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |||
| 220 | + | fmt.Printf("Log directory does not exist: %s\n", logDir) | |||
| 221 | + | return nil | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | files, err := os.ReadDir(logDir) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return fmt.Errorf("failed to read log directory: %w", err) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | cutoffTime := time.Now().AddDate(0, 0, -maxAge) | |||
| 230 | + | var deletedCount int | |||
| 231 | + | var deletedSize int64 | |||
| 232 | + | ||||
| 233 | + | for _, file := range files { | |||
| 234 | + | if file.IsDir() { | |||
| 235 | + | continue | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | name := file.Name() | |||
| 239 | + | // Skip the current log file | |||
| 240 | + | if name == "dumber.log" { | |||
| 241 | + | continue | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Only process log files | |||
| 245 | + | if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") { | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | info, err := file.Info() | |||
| 250 | + | if err != nil { | |||
| 251 | + | continue | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if info.ModTime().Before(cutoffTime) { | |||
| 255 | + | filePath := filepath.Join(logDir, name) | |||
| 256 | + | ||||
| 257 | + | if dryRun { | |||
| 258 | + | fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n", | |||
| 259 | + | name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05")) | |||
| 260 | + | } else { | |||
| 261 | + | err := os.Remove(filePath) | |||
| 262 | + | if err != nil { | |||
| 263 | + | fmt.Printf("Warning: failed to delete %s: %v\n", name, err) | |||
| 264 | + | } else { | |||
| 265 | + | fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | deletedCount++ | |||
| 270 | + | deletedSize += info.Size() | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | if dryRun { | |||
| 275 | + | fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n", | |||
| 276 | + | deletedCount, float64(deletedSize)/1024) | |||
| 277 | + | } else { | |||
| 278 | + | fmt.Printf("\nCleaned %d files (%.2f KB total)\n", | |||
| 279 | + | deletedCount, float64(deletedSize)/1024) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // formatSize formats file size in human-readable format | |||
| 286 | + | func formatSize(size int64) string { | |||
| 287 | + | const unit = 1024 | |||
| 288 | + | if size < unit { | |||
| 289 | + | return fmt.Sprintf("%d B", size) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | div, exp := int64(unit), 0 | |||
| 293 | + | for n := size / unit; n >= unit; n /= unit { | |||
| 294 | + | div *= unit | |||
| 295 | + | exp++ | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) | |||
| 299 | + | }⏎̸ | |||
M internal/cli/root.go +1 -0
| @@ -187,6 +187,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 187 | 187 | rootCmd.AddCommand(NewDmenuCmd()) | |
| 188 | 188 | rootCmd.AddCommand(NewHistoryCmd()) | |
| 189 | 189 | rootCmd.AddCommand(NewPurgeCmd()) | |
| 190 | + | rootCmd.AddCommand(NewLogsCmd()) | |
| 190 | 191 | ||
| 191 | 192 | return rootCmd | |
| 192 | 193 | } | |
| @@ -187,6 +187,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||||
| 187 | rootCmd.AddCommand(NewDmenuCmd()) | 187 | rootCmd.AddCommand(NewDmenuCmd()) | ||
| 188 | rootCmd.AddCommand(NewHistoryCmd()) | 188 | rootCmd.AddCommand(NewHistoryCmd()) | ||
| 189 | rootCmd.AddCommand(NewPurgeCmd()) | 189 | rootCmd.AddCommand(NewPurgeCmd()) | ||
| 190 | + | rootCmd.AddCommand(NewLogsCmd()) | |||
| 190 | 191 | ||||
| 191 | return rootCmd | 192 | return rootCmd | ||
| 192 | } | 193 | } | ||
M internal/config/config.go +27 -7
| @@ -77,13 +77,26 @@ type DmenuConfig struct { | |||
| 77 | 77 | ||
| 78 | 78 | // LoggingConfig holds logging configuration. | |
| 79 | 79 | type LoggingConfig struct { | |
| 80 | - | Level string `mapstructure:"level" yaml:"level"` | |
| 81 | - | Format string `mapstructure:"format" yaml:"format"` | |
| 82 | - | Filename string `mapstructure:"filename" yaml:"filename"` | |
| 83 | - | MaxSize int `mapstructure:"max_size" yaml:"max_size"` | |
| 84 | - | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` | |
| 85 | - | MaxAge int `mapstructure:"max_age" yaml:"max_age"` | |
| 86 | - | Compress bool `mapstructure:"compress" yaml:"compress"` | |
| 80 | + | Level string `mapstructure:"level" yaml:"level"` | |
| 81 | + | Format string `mapstructure:"format" yaml:"format"` | |
| 82 | + | Filename string `mapstructure:"filename" yaml:"filename"` | |
| 83 | + | MaxSize int `mapstructure:"max_size" yaml:"max_size"` | |
| 84 | + | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` | |
| 85 | + | MaxAge int `mapstructure:"max_age" yaml:"max_age"` | |
| 86 | + | Compress bool `mapstructure:"compress" yaml:"compress"` | |
| 87 | + | ||
| 88 | + | // File output configuration | |
| 89 | + | LogDir string `mapstructure:"log_dir" yaml:"log_dir"` | |
| 90 | + | EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"` | |
| 91 | + | ||
| 92 | + | // Capture settings | |
| 93 | + | CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout"` | |
| 94 | + | CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr"` | |
| 95 | + | CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"` | |
| 96 | + | ||
| 97 | + | // Debug output | |
| 98 | + | DebugFile string `mapstructure:"debug_file" yaml:"debug_file"` | |
| 99 | + | VerboseWebKit bool `mapstructure:"verbose_webkit" yaml:"verbose_webkit"` | |
| 87 | 100 | } | |
| 88 | 101 | ||
| 89 | 102 | // AppearanceConfig holds UI/rendering preferences. | |
| @@ -352,6 +365,13 @@ func (m *Manager) setDefaults() { | |||
| 352 | 365 | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | |
| 353 | 366 | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | |
| 354 | 367 | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | |
| 368 | + | m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir) | |
| 369 | + | m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog) | |
| 370 | + | m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout) | |
| 371 | + | m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr) | |
| 372 | + | m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput) | |
| 373 | + | m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile) | |
| 374 | + | m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit) | |
| 355 | 375 | ||
| 356 | 376 | // Appearance defaults | |
| 357 | 377 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | |
| @@ -77,13 +77,26 @@ type DmenuConfig struct { | |||||
| 77 | 77 | ||||
| 78 | // LoggingConfig holds logging configuration. | 78 | // LoggingConfig holds logging configuration. | ||
| 79 | type LoggingConfig struct { | 79 | type LoggingConfig struct { | ||
| 80 | - | Level string `mapstructure:"level" yaml:"level"` | 80 | + | Level string `mapstructure:"level" yaml:"level"` |
| 81 | - | Format string `mapstructure:"format" yaml:"format"` | 81 | + | Format string `mapstructure:"format" yaml:"format"` |
| 82 | - | Filename string `mapstructure:"filename" yaml:"filename"` | 82 | + | Filename string `mapstructure:"filename" yaml:"filename"` |
| 83 | - | MaxSize int `mapstructure:"max_size" yaml:"max_size"` | 83 | + | MaxSize int `mapstructure:"max_size" yaml:"max_size"` |
| 84 | - | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` | 84 | + | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` |
| 85 | - | MaxAge int `mapstructure:"max_age" yaml:"max_age"` | 85 | + | MaxAge int `mapstructure:"max_age" yaml:"max_age"` |
| 86 | - | Compress bool `mapstructure:"compress" yaml:"compress"` | 86 | + | Compress bool `mapstructure:"compress" yaml:"compress"` |
| 87 | + | ||||
| 88 | + | // File output configuration | |||
| 89 | + | LogDir string `mapstructure:"log_dir" yaml:"log_dir"` | |||
| 90 | + | EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"` | |||
| 91 | + | ||||
| 92 | + | // Capture settings | |||
| 93 | + | CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout"` | |||
| 94 | + | CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr"` | |||
| 95 | + | CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"` | |||
| 96 | + | ||||
| 97 | + | // Debug output | |||
| 98 | + | DebugFile string `mapstructure:"debug_file" yaml:"debug_file"` | |||
| 99 | + | VerboseWebKit bool `mapstructure:"verbose_webkit" yaml:"verbose_webkit"` | |||
| 87 | } | 100 | } | ||
| 88 | 101 | ||||
| 89 | // AppearanceConfig holds UI/rendering preferences. | 102 | // AppearanceConfig holds UI/rendering preferences. | ||
| @@ -352,6 +365,13 @@ func (m *Manager) setDefaults() { | |||||
| 352 | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | 365 | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | ||
| 353 | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | 366 | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | ||
| 354 | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | 367 | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | ||
| 368 | + | m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir) | |||
| 369 | + | m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog) | |||
| 370 | + | m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout) | |||
| 371 | + | m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr) | |||
| 372 | + | m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput) | |||
| 373 | + | m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile) | |||
| 374 | + | m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit) | |||
| 355 | 375 | ||||
| 356 | // Appearance defaults | 376 | // Appearance defaults | ||
| 357 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | 377 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | ||
M internal/config/defaults.go +23 -7
| @@ -27,6 +27,15 @@ const ( | |||
| 27 | 27 | defaultFontSize = 16 // points | |
| 28 | 28 | ) | |
| 29 | 29 | ||
| 30 | + | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| 31 | + | func getDefaultLogDir() string { | |
| 32 | + | logDir, err := GetLogDir() | |
| 33 | + | if err != nil { | |
| 34 | + | return "" | |
| 35 | + | } | |
| 36 | + | return logDir | |
| 37 | + | } | |
| 38 | + | ||
| 30 | 39 | // DefaultConfig returns the default configuration values for dumber. | |
| 31 | 40 | func DefaultConfig() *Config { | |
| 32 | 41 | return &Config{ | |
| @@ -69,13 +78,20 @@ func DefaultConfig() *Config { | |||
| 69 | 78 | SortByVisitCount: true, | |
| 70 | 79 | }, | |
| 71 | 80 | Logging: LoggingConfig{ | |
| 72 | - | Level: "info", | |
| 73 | - | Format: "text", // text or json | |
| 74 | - | Filename: "", // empty means stdout | |
| 75 | - | MaxSize: defaultMaxLogSizeMB, // MB | |
| 76 | - | MaxBackups: defaultMaxBackups, | |
| 77 | - | MaxAge: defaultMaxLogAgeDays, // days | |
| 78 | - | Compress: true, | |
| 81 | + | Level: "info", | |
| 82 | + | Format: "text", // text or json | |
| 83 | + | Filename: "", // empty means stdout | |
| 84 | + | MaxSize: defaultMaxLogSizeMB, // MB | |
| 85 | + | MaxBackups: defaultMaxBackups, | |
| 86 | + | MaxAge: defaultMaxLogAgeDays, // days | |
| 87 | + | Compress: true, | |
| 88 | + | LogDir: getDefaultLogDir(), | |
| 89 | + | EnableFileLog: true, | |
| 90 | + | CaptureStdout: false, | |
| 91 | + | CaptureStderr: false, | |
| 92 | + | CaptureCOutput: false, // Disabled by default to avoid performance impact | |
| 93 | + | DebugFile: "debug.log", | |
| 94 | + | VerboseWebKit: false, | |
| 79 | 95 | }, | |
| 80 | 96 | Appearance: AppearanceConfig{ | |
| 81 | 97 | SansFont: "Fira Sans", | |
| @@ -27,6 +27,15 @@ const ( | |||||
| 27 | defaultFontSize = 16 // points | 27 | defaultFontSize = 16 // points | ||
| 28 | ) | 28 | ) | ||
| 29 | 29 | ||||
| 30 | + | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |||
| 31 | + | func getDefaultLogDir() string { | |||
| 32 | + | logDir, err := GetLogDir() | |||
| 33 | + | if err != nil { | |||
| 34 | + | return "" | |||
| 35 | + | } | |||
| 36 | + | return logDir | |||
| 37 | + | } | |||
| 38 | + | ||||
| 30 | // DefaultConfig returns the default configuration values for dumber. | 39 | // DefaultConfig returns the default configuration values for dumber. | ||
| 31 | func DefaultConfig() *Config { | 40 | func DefaultConfig() *Config { | ||
| 32 | return &Config{ | 41 | return &Config{ | ||
| @@ -69,13 +78,20 @@ func DefaultConfig() *Config { | |||||
| 69 | SortByVisitCount: true, | 78 | SortByVisitCount: true, | ||
| 70 | }, | 79 | }, | ||
| 71 | Logging: LoggingConfig{ | 80 | Logging: LoggingConfig{ | ||
| 72 | - | Level: "info", | 81 | + | Level: "info", |
| 73 | - | Format: "text", // text or json | 82 | + | Format: "text", // text or json |
| 74 | - | Filename: "", // empty means stdout | 83 | + | Filename: "", // empty means stdout |
| 75 | - | MaxSize: defaultMaxLogSizeMB, // MB | 84 | + | MaxSize: defaultMaxLogSizeMB, // MB |
| 76 | - | MaxBackups: defaultMaxBackups, | 85 | + | MaxBackups: defaultMaxBackups, |
| 77 | - | MaxAge: defaultMaxLogAgeDays, // days | 86 | + | MaxAge: defaultMaxLogAgeDays, // days |
| 78 | - | Compress: true, | 87 | + | Compress: true, |
| 88 | + | LogDir: getDefaultLogDir(), | |||
| 89 | + | EnableFileLog: true, | |||
| 90 | + | CaptureStdout: false, | |||
| 91 | + | CaptureStderr: false, | |||
| 92 | + | CaptureCOutput: false, // Disabled by default to avoid performance impact | |||
| 93 | + | DebugFile: "debug.log", | |||
| 94 | + | VerboseWebKit: false, | |||
| 79 | }, | 95 | }, | ||
| 80 | Appearance: AppearanceConfig{ | 96 | Appearance: AppearanceConfig{ | ||
| 81 | SansFont: "Fira Sans", | 97 | SansFont: "Fira Sans", | ||
M internal/config/xdg.go +10 -0
| @@ -81,6 +81,16 @@ func GetStateDir() (string, error) { | |||
| 81 | 81 | return dirs.StateHome, nil | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | // GetLogDir returns the XDG-compliant log directory for dumber. | |
| 85 | + | // Logs are stored in XDG_STATE_HOME as per specification. | |
| 86 | + | func GetLogDir() (string, error) { | |
| 87 | + | stateDir, err := GetStateDir() | |
| 88 | + | if err != nil { | |
| 89 | + | return "", err | |
| 90 | + | } | |
| 91 | + | return filepath.Join(stateDir, "logs"), nil | |
| 92 | + | } | |
| 93 | + | ||
| 84 | 94 | ||
| 85 | 95 | // GetConfigFile returns the path to the main configuration file. | |
| 86 | 96 | func GetConfigFile() (string, error) { | |
| @@ -81,6 +81,16 @@ func GetStateDir() (string, error) { | |||||
| 81 | return dirs.StateHome, nil | 81 | return dirs.StateHome, nil | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | // GetLogDir returns the XDG-compliant log directory for dumber. | |||
| 85 | + | // Logs are stored in XDG_STATE_HOME as per specification. | |||
| 86 | + | func GetLogDir() (string, error) { | |||
| 87 | + | stateDir, err := GetStateDir() | |||
| 88 | + | if err != nil { | |||
| 89 | + | return "", err | |||
| 90 | + | } | |||
| 91 | + | return filepath.Join(stateDir, "logs"), nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 84 | 94 | ||||
| 85 | // GetConfigFile returns the path to the main configuration file. | 95 | // GetConfigFile returns the path to the main configuration file. | ||
| 86 | func GetConfigFile() (string, error) { | 96 | func GetConfigFile() (string, error) { | ||
A internal/logging/capture.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "syscall" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type OutputCapture struct { | |
| 14 | + | originalStdout *os.File | |
| 15 | + | originalStderr *os.File | |
| 16 | + | stdoutRead *os.File | |
| 17 | + | stdoutWrite *os.File | |
| 18 | + | stderrRead *os.File | |
| 19 | + | stderrWrite *os.File | |
| 20 | + | logger *Logger | |
| 21 | + | stopChan chan struct{} | |
| 22 | + | started bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func NewOutputCapture(logger *Logger) *OutputCapture { | |
| 26 | + | return &OutputCapture{ | |
| 27 | + | originalStdout: os.Stdout, | |
| 28 | + | originalStderr: os.Stderr, | |
| 29 | + | logger: logger, | |
| 30 | + | stopChan: make(chan struct{}), | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (c *OutputCapture) Start() error { | |
| 35 | + | if c.started { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Create pipes for stdout | |
| 40 | + | stdoutR, stdoutW, err := os.Pipe() | |
| 41 | + | if err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Create pipes for stderr | |
| 46 | + | stderrR, stderrW, err := os.Pipe() | |
| 47 | + | if err != nil { | |
| 48 | + | stdoutR.Close() | |
| 49 | + | stdoutW.Close() | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Store pipe handles | |
| 54 | + | c.stdoutRead = stdoutR | |
| 55 | + | c.stdoutWrite = stdoutW | |
| 56 | + | c.stderrRead = stderrR | |
| 57 | + | c.stderrWrite = stderrW | |
| 58 | + | ||
| 59 | + | // Redirect stdout and stderr | |
| 60 | + | os.Stdout = stdoutW | |
| 61 | + | os.Stderr = stderrW | |
| 62 | + | ||
| 63 | + | // Also redirect file descriptors at syscall level for C code | |
| 64 | + | syscall.Dup2(int(stdoutW.Fd()), 1) | |
| 65 | + | syscall.Dup2(int(stderrW.Fd()), 2) | |
| 66 | + | ||
| 67 | + | // Start goroutines to read and log | |
| 68 | + | go c.pipeToLogger(stdoutR, "STDOUT") | |
| 69 | + | go c.pipeToLogger(stderrR, "STDERR") | |
| 70 | + | ||
| 71 | + | c.started = true | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (c *OutputCapture) Stop() { | |
| 76 | + | if !c.started { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | close(c.stopChan) | |
| 81 | + | ||
| 82 | + | // Restore original stdout and stderr | |
| 83 | + | os.Stdout = c.originalStdout | |
| 84 | + | os.Stderr = c.originalStderr | |
| 85 | + | ||
| 86 | + | // Restore file descriptors | |
| 87 | + | syscall.Dup2(int(c.originalStdout.Fd()), 1) | |
| 88 | + | syscall.Dup2(int(c.originalStderr.Fd()), 2) | |
| 89 | + | ||
| 90 | + | // Close pipes | |
| 91 | + | if c.stdoutWrite != nil { | |
| 92 | + | c.stdoutWrite.Close() | |
| 93 | + | } | |
| 94 | + | if c.stderrWrite != nil { | |
| 95 | + | c.stderrWrite.Close() | |
| 96 | + | } | |
| 97 | + | if c.stdoutRead != nil { | |
| 98 | + | c.stdoutRead.Close() | |
| 99 | + | } | |
| 100 | + | if c.stderrRead != nil { | |
| 101 | + | c.stderrRead.Close() | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | c.started = false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) { | |
| 108 | + | scanner := bufio.NewScanner(r) | |
| 109 | + | for scanner.Scan() { | |
| 110 | + | select { | |
| 111 | + | case <-c.stopChan: | |
| 112 | + | return | |
| 113 | + | default: | |
| 114 | + | line := scanner.Text() | |
| 115 | + | if line != "" && c.logger != nil { | |
| 116 | + | // Avoid feedback loops by not logging our own log messages | |
| 117 | + | if !strings.Contains(line, "[STDOUT]") && !strings.Contains(line, "[STDERR]") { | |
| 118 | + | // Write directly to original stdout/stderr instead of going through logger | |
| 119 | + | // to avoid capture recursion | |
| 120 | + | timestamp := time.Now().Format("2006-01-02 15:04:05") | |
| 121 | + | logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line) | |
| 122 | + | if prefix == "STDERR" { | |
| 123 | + | c.originalStderr.WriteString(logLine) | |
| 124 | + | } else { | |
| 125 | + | c.originalStdout.WriteString(logLine) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | }⏎̸ | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type OutputCapture struct { | |||
| 14 | + | originalStdout *os.File | |||
| 15 | + | originalStderr *os.File | |||
| 16 | + | stdoutRead *os.File | |||
| 17 | + | stdoutWrite *os.File | |||
| 18 | + | stderrRead *os.File | |||
| 19 | + | stderrWrite *os.File | |||
| 20 | + | logger *Logger | |||
| 21 | + | stopChan chan struct{} | |||
| 22 | + | started bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func NewOutputCapture(logger *Logger) *OutputCapture { | |||
| 26 | + | return &OutputCapture{ | |||
| 27 | + | originalStdout: os.Stdout, | |||
| 28 | + | originalStderr: os.Stderr, | |||
| 29 | + | logger: logger, | |||
| 30 | + | stopChan: make(chan struct{}), | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (c *OutputCapture) Start() error { | |||
| 35 | + | if c.started { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Create pipes for stdout | |||
| 40 | + | stdoutR, stdoutW, err := os.Pipe() | |||
| 41 | + | if err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Create pipes for stderr | |||
| 46 | + | stderrR, stderrW, err := os.Pipe() | |||
| 47 | + | if err != nil { | |||
| 48 | + | stdoutR.Close() | |||
| 49 | + | stdoutW.Close() | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Store pipe handles | |||
| 54 | + | c.stdoutRead = stdoutR | |||
| 55 | + | c.stdoutWrite = stdoutW | |||
| 56 | + | c.stderrRead = stderrR | |||
| 57 | + | c.stderrWrite = stderrW | |||
| 58 | + | ||||
| 59 | + | // Redirect stdout and stderr | |||
| 60 | + | os.Stdout = stdoutW | |||
| 61 | + | os.Stderr = stderrW | |||
| 62 | + | ||||
| 63 | + | // Also redirect file descriptors at syscall level for C code | |||
| 64 | + | syscall.Dup2(int(stdoutW.Fd()), 1) | |||
| 65 | + | syscall.Dup2(int(stderrW.Fd()), 2) | |||
| 66 | + | ||||
| 67 | + | // Start goroutines to read and log | |||
| 68 | + | go c.pipeToLogger(stdoutR, "STDOUT") | |||
| 69 | + | go c.pipeToLogger(stderrR, "STDERR") | |||
| 70 | + | ||||
| 71 | + | c.started = true | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (c *OutputCapture) Stop() { | |||
| 76 | + | if !c.started { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | close(c.stopChan) | |||
| 81 | + | ||||
| 82 | + | // Restore original stdout and stderr | |||
| 83 | + | os.Stdout = c.originalStdout | |||
| 84 | + | os.Stderr = c.originalStderr | |||
| 85 | + | ||||
| 86 | + | // Restore file descriptors | |||
| 87 | + | syscall.Dup2(int(c.originalStdout.Fd()), 1) | |||
| 88 | + | syscall.Dup2(int(c.originalStderr.Fd()), 2) | |||
| 89 | + | ||||
| 90 | + | // Close pipes | |||
| 91 | + | if c.stdoutWrite != nil { | |||
| 92 | + | c.stdoutWrite.Close() | |||
| 93 | + | } | |||
| 94 | + | if c.stderrWrite != nil { | |||
| 95 | + | c.stderrWrite.Close() | |||
| 96 | + | } | |||
| 97 | + | if c.stdoutRead != nil { | |||
| 98 | + | c.stdoutRead.Close() | |||
| 99 | + | } | |||
| 100 | + | if c.stderrRead != nil { | |||
| 101 | + | c.stderrRead.Close() | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | c.started = false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) { | |||
| 108 | + | scanner := bufio.NewScanner(r) | |||
| 109 | + | for scanner.Scan() { | |||
| 110 | + | select { | |||
| 111 | + | case <-c.stopChan: | |||
| 112 | + | return | |||
| 113 | + | default: | |||
| 114 | + | line := scanner.Text() | |||
| 115 | + | if line != "" && c.logger != nil { | |||
| 116 | + | // Avoid feedback loops by not logging our own log messages | |||
| 117 | + | if !strings.Contains(line, "[STDOUT]") && !strings.Contains(line, "[STDERR]") { | |||
| 118 | + | // Write directly to original stdout/stderr instead of going through logger | |||
| 119 | + | // to avoid capture recursion | |||
| 120 | + | timestamp := time.Now().Format("2006-01-02 15:04:05") | |||
| 121 | + | logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line) | |||
| 122 | + | if prefix == "STDERR" { | |||
| 123 | + | c.originalStderr.WriteString(logLine) | |||
| 124 | + | } else { | |||
| 125 | + | c.originalStdout.WriteString(logLine) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | }⏎̸ | |||
A internal/logging/logger.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "log" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type LogLevel int | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | DEBUG LogLevel = iota | |
| 17 | + | INFO | |
| 18 | + | WARN | |
| 19 | + | ERROR | |
| 20 | + | FATAL | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | var levelNames = map[LogLevel]string{ | |
| 24 | + | DEBUG: "DEBUG", | |
| 25 | + | INFO: "INFO", | |
| 26 | + | WARN: "WARN", | |
| 27 | + | ERROR: "ERROR", | |
| 28 | + | FATAL: "FATAL", | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type Logger struct { | |
| 32 | + | mu sync.RWMutex | |
| 33 | + | level LogLevel | |
| 34 | + | outputs []io.Writer | |
| 35 | + | rotator *LogRotator | |
| 36 | + | formatter LogFormatter | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type LogFormatter interface { | |
| 40 | + | Format(level LogLevel, message string, source string) string | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type TextFormatter struct{} | |
| 44 | + | ||
| 45 | + | func (f *TextFormatter) Format(level LogLevel, message string, source string) string { | |
| 46 | + | timestamp := time.Now().Format("2006-01-02 15:04:05") | |
| 47 | + | levelName := levelNames[level] | |
| 48 | + | if source != "" { | |
| 49 | + | return fmt.Sprintf("[%s] %s [%s] %s\n", timestamp, levelName, source, message) | |
| 50 | + | } | |
| 51 | + | return fmt.Sprintf("[%s] %s %s\n", timestamp, levelName, message) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | type JSONFormatter struct{} | |
| 55 | + | ||
| 56 | + | func (f *JSONFormatter) Format(level LogLevel, message string, source string) string { | |
| 57 | + | timestamp := time.Now().Format(time.RFC3339) | |
| 58 | + | levelName := levelNames[level] | |
| 59 | + | if source != "" { | |
| 60 | + | return fmt.Sprintf(`{"timestamp":"%s","level":"%s","source":"%s","message":"%s"}`+"\n", | |
| 61 | + | timestamp, levelName, source, strings.ReplaceAll(message, `"`, `\"`)) | |
| 62 | + | } | |
| 63 | + | return fmt.Sprintf(`{"timestamp":"%s","level":"%s","message":"%s"}`+"\n", | |
| 64 | + | timestamp, levelName, strings.ReplaceAll(message, `"`, `\"`)) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func NewFormatter(format string) LogFormatter { | |
| 68 | + | switch strings.ToLower(format) { | |
| 69 | + | case "json": | |
| 70 | + | return &JSONFormatter{} | |
| 71 | + | default: | |
| 72 | + | return &TextFormatter{} | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func parseLevel(levelStr string) LogLevel { | |
| 77 | + | switch strings.ToLower(levelStr) { | |
| 78 | + | case "debug": | |
| 79 | + | return DEBUG | |
| 80 | + | case "info": | |
| 81 | + | return INFO | |
| 82 | + | case "warn": | |
| 83 | + | return WARN | |
| 84 | + | case "error": | |
| 85 | + | return ERROR | |
| 86 | + | case "fatal": | |
| 87 | + | return FATAL | |
| 88 | + | default: | |
| 89 | + | return INFO | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | var globalLogger *Logger | |
| 94 | + | var globalLoggerMux sync.RWMutex | |
| 95 | + | ||
| 96 | + | func Init(logDir string, level string, format string, enableFileLog bool, maxSize, maxBackups, maxAge int, compress bool) error { | |
| 97 | + | globalLoggerMux.Lock() | |
| 98 | + | defer globalLoggerMux.Unlock() | |
| 99 | + | ||
| 100 | + | err := os.MkdirAll(logDir, 0755) | |
| 101 | + | if err != nil { | |
| 102 | + | return fmt.Errorf("failed to create log directory: %w", err) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | outputs := []io.Writer{} | |
| 106 | + | ||
| 107 | + | // Always include stdout | |
| 108 | + | outputs = append(outputs, os.Stdout) | |
| 109 | + | ||
| 110 | + | var rotator *LogRotator | |
| 111 | + | if enableFileLog { | |
| 112 | + | rotator, err = NewLogRotator(logDir, maxSize, maxBackups, maxAge, compress) | |
| 113 | + | if err != nil { | |
| 114 | + | return fmt.Errorf("failed to create log rotator: %w", err) | |
| 115 | + | } | |
| 116 | + | outputs = append(outputs, rotator) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | globalLogger = &Logger{ | |
| 120 | + | level: parseLevel(level), | |
| 121 | + | outputs: outputs, | |
| 122 | + | rotator: rotator, | |
| 123 | + | formatter: NewFormatter(format), | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Redirect standard log package | |
| 127 | + | log.SetOutput(globalLogger) | |
| 128 | + | log.SetFlags(0) // We'll handle formatting | |
| 129 | + | ||
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func GetLogger() *Logger { | |
| 134 | + | globalLoggerMux.RLock() | |
| 135 | + | defer globalLoggerMux.RUnlock() | |
| 136 | + | return globalLogger | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Implement io.Writer interface for standard log package redirection | |
| 140 | + | func (l *Logger) Write(p []byte) (n int, err error) { | |
| 141 | + | message := strings.TrimSpace(string(p)) | |
| 142 | + | if message == "" { | |
| 143 | + | return len(p), nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | l.writeLog(INFO, message, "") | |
| 147 | + | return len(p), nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (l *Logger) writeLog(level LogLevel, message string, source string) { | |
| 151 | + | l.mu.RLock() | |
| 152 | + | defer l.mu.RUnlock() | |
| 153 | + | ||
| 154 | + | if level < l.level { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | formatted := l.formatter.Format(level, message, source) | |
| 159 | + | ||
| 160 | + | for _, output := range l.outputs { | |
| 161 | + | output.Write([]byte(formatted)) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (l *Logger) WriteTagged(source string, message string) { | |
| 166 | + | l.writeLog(INFO, message, source) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // WriteFileOnly writes a log entry only to the file (not stdout) to avoid interfering with CLI operations | |
| 170 | + | func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) { | |
| 171 | + | l.mu.RLock() | |
| 172 | + | defer l.mu.RUnlock() | |
| 173 | + | ||
| 174 | + | if level < l.level { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | formatted := l.formatter.Format(level, message, source) | |
| 179 | + | ||
| 180 | + | // Write only to file outputs (skip stdout) | |
| 181 | + | for _, output := range l.outputs { | |
| 182 | + | if output != os.Stdout { | |
| 183 | + | output.Write([]byte(formatted)) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func Debug(message string) { | |
| 189 | + | if l := GetLogger(); l != nil { | |
| 190 | + | l.writeLog(DEBUG, message, "") | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func Info(message string) { | |
| 195 | + | if l := GetLogger(); l != nil { | |
| 196 | + | l.writeLog(INFO, message, "") | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func Warn(message string) { | |
| 201 | + | if l := GetLogger(); l != nil { | |
| 202 | + | l.writeLog(WARN, message, "") | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func Error(message string) { | |
| 207 | + | if l := GetLogger(); l != nil { | |
| 208 | + | l.writeLog(ERROR, message, "") | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func Fatal(message string) { | |
| 213 | + | if l := GetLogger(); l != nil { | |
| 214 | + | l.writeLog(FATAL, message, "") | |
| 215 | + | } | |
| 216 | + | os.Exit(1) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func CaptureWebKitLog(message string) { | |
| 220 | + | if l := GetLogger(); l != nil { | |
| 221 | + | l.WriteTagged("WEBKIT", message) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // LogLevelInfo returns the INFO log level constant for external packages | |
| 226 | + | func LogLevelInfo() LogLevel { | |
| 227 | + | return INFO | |
| 228 | + | }⏎̸ | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "log" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type LogLevel int | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | DEBUG LogLevel = iota | |||
| 17 | + | INFO | |||
| 18 | + | WARN | |||
| 19 | + | ERROR | |||
| 20 | + | FATAL | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | var levelNames = map[LogLevel]string{ | |||
| 24 | + | DEBUG: "DEBUG", | |||
| 25 | + | INFO: "INFO", | |||
| 26 | + | WARN: "WARN", | |||
| 27 | + | ERROR: "ERROR", | |||
| 28 | + | FATAL: "FATAL", | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type Logger struct { | |||
| 32 | + | mu sync.RWMutex | |||
| 33 | + | level LogLevel | |||
| 34 | + | outputs []io.Writer | |||
| 35 | + | rotator *LogRotator | |||
| 36 | + | formatter LogFormatter | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type LogFormatter interface { | |||
| 40 | + | Format(level LogLevel, message string, source string) string | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type TextFormatter struct{} | |||
| 44 | + | ||||
| 45 | + | func (f *TextFormatter) Format(level LogLevel, message string, source string) string { | |||
| 46 | + | timestamp := time.Now().Format("2006-01-02 15:04:05") | |||
| 47 | + | levelName := levelNames[level] | |||
| 48 | + | if source != "" { | |||
| 49 | + | return fmt.Sprintf("[%s] %s [%s] %s\n", timestamp, levelName, source, message) | |||
| 50 | + | } | |||
| 51 | + | return fmt.Sprintf("[%s] %s %s\n", timestamp, levelName, message) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type JSONFormatter struct{} | |||
| 55 | + | ||||
| 56 | + | func (f *JSONFormatter) Format(level LogLevel, message string, source string) string { | |||
| 57 | + | timestamp := time.Now().Format(time.RFC3339) | |||
| 58 | + | levelName := levelNames[level] | |||
| 59 | + | if source != "" { | |||
| 60 | + | return fmt.Sprintf(`{"timestamp":"%s","level":"%s","source":"%s","message":"%s"}`+"\n", | |||
| 61 | + | timestamp, levelName, source, strings.ReplaceAll(message, `"`, `\"`)) | |||
| 62 | + | } | |||
| 63 | + | return fmt.Sprintf(`{"timestamp":"%s","level":"%s","message":"%s"}`+"\n", | |||
| 64 | + | timestamp, levelName, strings.ReplaceAll(message, `"`, `\"`)) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func NewFormatter(format string) LogFormatter { | |||
| 68 | + | switch strings.ToLower(format) { | |||
| 69 | + | case "json": | |||
| 70 | + | return &JSONFormatter{} | |||
| 71 | + | default: | |||
| 72 | + | return &TextFormatter{} | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func parseLevel(levelStr string) LogLevel { | |||
| 77 | + | switch strings.ToLower(levelStr) { | |||
| 78 | + | case "debug": | |||
| 79 | + | return DEBUG | |||
| 80 | + | case "info": | |||
| 81 | + | return INFO | |||
| 82 | + | case "warn": | |||
| 83 | + | return WARN | |||
| 84 | + | case "error": | |||
| 85 | + | return ERROR | |||
| 86 | + | case "fatal": | |||
| 87 | + | return FATAL | |||
| 88 | + | default: | |||
| 89 | + | return INFO | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | var globalLogger *Logger | |||
| 94 | + | var globalLoggerMux sync.RWMutex | |||
| 95 | + | ||||
| 96 | + | func Init(logDir string, level string, format string, enableFileLog bool, maxSize, maxBackups, maxAge int, compress bool) error { | |||
| 97 | + | globalLoggerMux.Lock() | |||
| 98 | + | defer globalLoggerMux.Unlock() | |||
| 99 | + | ||||
| 100 | + | err := os.MkdirAll(logDir, 0755) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return fmt.Errorf("failed to create log directory: %w", err) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | outputs := []io.Writer{} | |||
| 106 | + | ||||
| 107 | + | // Always include stdout | |||
| 108 | + | outputs = append(outputs, os.Stdout) | |||
| 109 | + | ||||
| 110 | + | var rotator *LogRotator | |||
| 111 | + | if enableFileLog { | |||
| 112 | + | rotator, err = NewLogRotator(logDir, maxSize, maxBackups, maxAge, compress) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return fmt.Errorf("failed to create log rotator: %w", err) | |||
| 115 | + | } | |||
| 116 | + | outputs = append(outputs, rotator) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | globalLogger = &Logger{ | |||
| 120 | + | level: parseLevel(level), | |||
| 121 | + | outputs: outputs, | |||
| 122 | + | rotator: rotator, | |||
| 123 | + | formatter: NewFormatter(format), | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // Redirect standard log package | |||
| 127 | + | log.SetOutput(globalLogger) | |||
| 128 | + | log.SetFlags(0) // We'll handle formatting | |||
| 129 | + | ||||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func GetLogger() *Logger { | |||
| 134 | + | globalLoggerMux.RLock() | |||
| 135 | + | defer globalLoggerMux.RUnlock() | |||
| 136 | + | return globalLogger | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Implement io.Writer interface for standard log package redirection | |||
| 140 | + | func (l *Logger) Write(p []byte) (n int, err error) { | |||
| 141 | + | message := strings.TrimSpace(string(p)) | |||
| 142 | + | if message == "" { | |||
| 143 | + | return len(p), nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | l.writeLog(INFO, message, "") | |||
| 147 | + | return len(p), nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (l *Logger) writeLog(level LogLevel, message string, source string) { | |||
| 151 | + | l.mu.RLock() | |||
| 152 | + | defer l.mu.RUnlock() | |||
| 153 | + | ||||
| 154 | + | if level < l.level { | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | formatted := l.formatter.Format(level, message, source) | |||
| 159 | + | ||||
| 160 | + | for _, output := range l.outputs { | |||
| 161 | + | output.Write([]byte(formatted)) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (l *Logger) WriteTagged(source string, message string) { | |||
| 166 | + | l.writeLog(INFO, message, source) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // WriteFileOnly writes a log entry only to the file (not stdout) to avoid interfering with CLI operations | |||
| 170 | + | func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) { | |||
| 171 | + | l.mu.RLock() | |||
| 172 | + | defer l.mu.RUnlock() | |||
| 173 | + | ||||
| 174 | + | if level < l.level { | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | formatted := l.formatter.Format(level, message, source) | |||
| 179 | + | ||||
| 180 | + | // Write only to file outputs (skip stdout) | |||
| 181 | + | for _, output := range l.outputs { | |||
| 182 | + | if output != os.Stdout { | |||
| 183 | + | output.Write([]byte(formatted)) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func Debug(message string) { | |||
| 189 | + | if l := GetLogger(); l != nil { | |||
| 190 | + | l.writeLog(DEBUG, message, "") | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func Info(message string) { | |||
| 195 | + | if l := GetLogger(); l != nil { | |||
| 196 | + | l.writeLog(INFO, message, "") | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func Warn(message string) { | |||
| 201 | + | if l := GetLogger(); l != nil { | |||
| 202 | + | l.writeLog(WARN, message, "") | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func Error(message string) { | |||
| 207 | + | if l := GetLogger(); l != nil { | |||
| 208 | + | l.writeLog(ERROR, message, "") | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func Fatal(message string) { | |||
| 213 | + | if l := GetLogger(); l != nil { | |||
| 214 | + | l.writeLog(FATAL, message, "") | |||
| 215 | + | } | |||
| 216 | + | os.Exit(1) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func CaptureWebKitLog(message string) { | |||
| 220 | + | if l := GetLogger(); l != nil { | |||
| 221 | + | l.WriteTagged("WEBKIT", message) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // LogLevelInfo returns the INFO log level constant for external packages | |||
| 226 | + | func LogLevelInfo() LogLevel { | |||
| 227 | + | return INFO | |||
| 228 | + | }⏎̸ | |||
A internal/logging/rotator.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "compress/gzip" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type LogRotator struct { | |
| 16 | + | mu sync.Mutex | |
| 17 | + | baseDir string | |
| 18 | + | baseName string | |
| 19 | + | maxSize int64 // bytes | |
| 20 | + | maxAge time.Duration | |
| 21 | + | maxBackups int | |
| 22 | + | compress bool | |
| 23 | + | currentFile *os.File | |
| 24 | + | currentSize int64 | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compress bool) (*LogRotator, error) { | |
| 28 | + | r := &LogRotator{ | |
| 29 | + | baseDir: baseDir, | |
| 30 | + | baseName: "dumber.log", | |
| 31 | + | maxSize: int64(maxSizeMB) * 1024 * 1024, // Convert MB to bytes | |
| 32 | + | maxAge: time.Duration(maxAgeDays) * 24 * time.Hour, | |
| 33 | + | maxBackups: maxBackups, | |
| 34 | + | compress: compress, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if err := r.openCurrentFile(); err != nil { | |
| 38 | + | return nil, err | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | return r, nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (r *LogRotator) openCurrentFile() error { | |
| 45 | + | logPath := filepath.Join(r.baseDir, r.baseName) | |
| 46 | + | ||
| 47 | + | // Get current file size if it exists | |
| 48 | + | if info, err := os.Stat(logPath); err == nil { | |
| 49 | + | r.currentSize = info.Size() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 53 | + | if err != nil { | |
| 54 | + | return fmt.Errorf("failed to open log file: %w", err) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | r.currentFile = file | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *LogRotator) Write(p []byte) (n int, err error) { | |
| 62 | + | r.mu.Lock() | |
| 63 | + | defer r.mu.Unlock() | |
| 64 | + | ||
| 65 | + | if r.currentFile == nil { | |
| 66 | + | if err := r.openCurrentFile(); err != nil { | |
| 67 | + | return 0, err | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Check if rotation is needed | |
| 72 | + | if r.currentSize+int64(len(p)) > r.maxSize { | |
| 73 | + | if err := r.rotate(); err != nil { | |
| 74 | + | return 0, err | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | n, err = r.currentFile.Write(p) | |
| 79 | + | if err != nil { | |
| 80 | + | return n, err | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | r.currentSize += int64(n) | |
| 84 | + | return n, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (r *LogRotator) rotate() error { | |
| 88 | + | if r.currentFile != nil { | |
| 89 | + | r.currentFile.Close() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Generate backup filename with timestamp | |
| 93 | + | timestamp := time.Now().Format("2006-01-02-15-04-05") | |
| 94 | + | backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp) | |
| 95 | + | ||
| 96 | + | currentPath := filepath.Join(r.baseDir, r.baseName) | |
| 97 | + | backupPath := filepath.Join(r.baseDir, backupName) | |
| 98 | + | ||
| 99 | + | // Move current file to backup | |
| 100 | + | if err := os.Rename(currentPath, backupPath); err != nil { | |
| 101 | + | return fmt.Errorf("failed to rotate log file: %w", err) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Compress if enabled | |
| 105 | + | if r.compress { | |
| 106 | + | if err := r.compressFile(backupPath); err != nil { | |
| 107 | + | // Log warning but don't fail rotation | |
| 108 | + | fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err) | |
| 109 | + | } else { | |
| 110 | + | // Remove uncompressed file after successful compression | |
| 111 | + | os.Remove(backupPath) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Clean up old files | |
| 116 | + | r.cleanup() | |
| 117 | + | ||
| 118 | + | // Reset size counter and open new file | |
| 119 | + | r.currentSize = 0 | |
| 120 | + | return r.openCurrentFile() | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (r *LogRotator) compressFile(filePath string) error { | |
| 124 | + | inputFile, err := os.Open(filePath) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | defer inputFile.Close() | |
| 129 | + | ||
| 130 | + | outputPath := filePath + ".gz" | |
| 131 | + | outputFile, err := os.Create(outputPath) | |
| 132 | + | if err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | defer outputFile.Close() | |
| 136 | + | ||
| 137 | + | gzipWriter := gzip.NewWriter(outputFile) | |
| 138 | + | defer gzipWriter.Close() | |
| 139 | + | ||
| 140 | + | _, err = io.Copy(gzipWriter, inputFile) | |
| 141 | + | return err | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (r *LogRotator) cleanup() { | |
| 145 | + | files, err := os.ReadDir(r.baseDir) | |
| 146 | + | if err != nil { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | var backupFiles []os.FileInfo | |
| 151 | + | now := time.Now() | |
| 152 | + | ||
| 153 | + | for _, file := range files { | |
| 154 | + | if file.IsDir() { | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | name := file.Name() | |
| 159 | + | if !strings.HasPrefix(name, r.baseName+".") { | |
| 160 | + | continue | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | info, err := file.Info() | |
| 164 | + | if err != nil { | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Remove files older than maxAge | |
| 169 | + | if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge { | |
| 170 | + | os.Remove(filepath.Join(r.baseDir, name)) | |
| 171 | + | continue | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | backupFiles = append(backupFiles, info) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Remove excess backup files (keep only maxBackups) | |
| 178 | + | if r.maxBackups > 0 && len(backupFiles) > r.maxBackups { | |
| 179 | + | // Sort by modification time (oldest first) | |
| 180 | + | sort.Slice(backupFiles, func(i, j int) bool { | |
| 181 | + | return backupFiles[i].ModTime().Before(backupFiles[j].ModTime()) | |
| 182 | + | }) | |
| 183 | + | ||
| 184 | + | // Remove oldest files | |
| 185 | + | for i := 0; i < len(backupFiles)-r.maxBackups; i++ { | |
| 186 | + | os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (r *LogRotator) Close() error { | |
| 192 | + | r.mu.Lock() | |
| 193 | + | defer r.mu.Unlock() | |
| 194 | + | ||
| 195 | + | if r.currentFile != nil { | |
| 196 | + | return r.currentFile.Close() | |
| 197 | + | } | |
| 198 | + | return nil | |
| 199 | + | }⏎̸ | |
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "compress/gzip" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type LogRotator struct { | |||
| 16 | + | mu sync.Mutex | |||
| 17 | + | baseDir string | |||
| 18 | + | baseName string | |||
| 19 | + | maxSize int64 // bytes | |||
| 20 | + | maxAge time.Duration | |||
| 21 | + | maxBackups int | |||
| 22 | + | compress bool | |||
| 23 | + | currentFile *os.File | |||
| 24 | + | currentSize int64 | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compress bool) (*LogRotator, error) { | |||
| 28 | + | r := &LogRotator{ | |||
| 29 | + | baseDir: baseDir, | |||
| 30 | + | baseName: "dumber.log", | |||
| 31 | + | maxSize: int64(maxSizeMB) * 1024 * 1024, // Convert MB to bytes | |||
| 32 | + | maxAge: time.Duration(maxAgeDays) * 24 * time.Hour, | |||
| 33 | + | maxBackups: maxBackups, | |||
| 34 | + | compress: compress, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if err := r.openCurrentFile(); err != nil { | |||
| 38 | + | return nil, err | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | return r, nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (r *LogRotator) openCurrentFile() error { | |||
| 45 | + | logPath := filepath.Join(r.baseDir, r.baseName) | |||
| 46 | + | ||||
| 47 | + | // Get current file size if it exists | |||
| 48 | + | if info, err := os.Stat(logPath); err == nil { | |||
| 49 | + | r.currentSize = info.Size() | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return fmt.Errorf("failed to open log file: %w", err) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | r.currentFile = file | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r *LogRotator) Write(p []byte) (n int, err error) { | |||
| 62 | + | r.mu.Lock() | |||
| 63 | + | defer r.mu.Unlock() | |||
| 64 | + | ||||
| 65 | + | if r.currentFile == nil { | |||
| 66 | + | if err := r.openCurrentFile(); err != nil { | |||
| 67 | + | return 0, err | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Check if rotation is needed | |||
| 72 | + | if r.currentSize+int64(len(p)) > r.maxSize { | |||
| 73 | + | if err := r.rotate(); err != nil { | |||
| 74 | + | return 0, err | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | n, err = r.currentFile.Write(p) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return n, err | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | r.currentSize += int64(n) | |||
| 84 | + | return n, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (r *LogRotator) rotate() error { | |||
| 88 | + | if r.currentFile != nil { | |||
| 89 | + | r.currentFile.Close() | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Generate backup filename with timestamp | |||
| 93 | + | timestamp := time.Now().Format("2006-01-02-15-04-05") | |||
| 94 | + | backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp) | |||
| 95 | + | ||||
| 96 | + | currentPath := filepath.Join(r.baseDir, r.baseName) | |||
| 97 | + | backupPath := filepath.Join(r.baseDir, backupName) | |||
| 98 | + | ||||
| 99 | + | // Move current file to backup | |||
| 100 | + | if err := os.Rename(currentPath, backupPath); err != nil { | |||
| 101 | + | return fmt.Errorf("failed to rotate log file: %w", err) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Compress if enabled | |||
| 105 | + | if r.compress { | |||
| 106 | + | if err := r.compressFile(backupPath); err != nil { | |||
| 107 | + | // Log warning but don't fail rotation | |||
| 108 | + | fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err) | |||
| 109 | + | } else { | |||
| 110 | + | // Remove uncompressed file after successful compression | |||
| 111 | + | os.Remove(backupPath) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Clean up old files | |||
| 116 | + | r.cleanup() | |||
| 117 | + | ||||
| 118 | + | // Reset size counter and open new file | |||
| 119 | + | r.currentSize = 0 | |||
| 120 | + | return r.openCurrentFile() | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (r *LogRotator) compressFile(filePath string) error { | |||
| 124 | + | inputFile, err := os.Open(filePath) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | defer inputFile.Close() | |||
| 129 | + | ||||
| 130 | + | outputPath := filePath + ".gz" | |||
| 131 | + | outputFile, err := os.Create(outputPath) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | defer outputFile.Close() | |||
| 136 | + | ||||
| 137 | + | gzipWriter := gzip.NewWriter(outputFile) | |||
| 138 | + | defer gzipWriter.Close() | |||
| 139 | + | ||||
| 140 | + | _, err = io.Copy(gzipWriter, inputFile) | |||
| 141 | + | return err | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (r *LogRotator) cleanup() { | |||
| 145 | + | files, err := os.ReadDir(r.baseDir) | |||
| 146 | + | if err != nil { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | var backupFiles []os.FileInfo | |||
| 151 | + | now := time.Now() | |||
| 152 | + | ||||
| 153 | + | for _, file := range files { | |||
| 154 | + | if file.IsDir() { | |||
| 155 | + | continue | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | name := file.Name() | |||
| 159 | + | if !strings.HasPrefix(name, r.baseName+".") { | |||
| 160 | + | continue | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | info, err := file.Info() | |||
| 164 | + | if err != nil { | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Remove files older than maxAge | |||
| 169 | + | if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge { | |||
| 170 | + | os.Remove(filepath.Join(r.baseDir, name)) | |||
| 171 | + | continue | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | backupFiles = append(backupFiles, info) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Remove excess backup files (keep only maxBackups) | |||
| 178 | + | if r.maxBackups > 0 && len(backupFiles) > r.maxBackups { | |||
| 179 | + | // Sort by modification time (oldest first) | |||
| 180 | + | sort.Slice(backupFiles, func(i, j int) bool { | |||
| 181 | + | return backupFiles[i].ModTime().Before(backupFiles[j].ModTime()) | |||
| 182 | + | }) | |||
| 183 | + | ||||
| 184 | + | // Remove oldest files | |||
| 185 | + | for i := 0; i < len(backupFiles)-r.maxBackups; i++ { | |||
| 186 | + | os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (r *LogRotator) Close() error { | |||
| 192 | + | r.mu.Lock() | |||
| 193 | + | defer r.mu.Unlock() | |||
| 194 | + | ||||
| 195 | + | if r.currentFile != nil { | |||
| 196 | + | return r.currentFile.Close() | |||
| 197 | + | } | |||
| 198 | + | return nil | |||
| 199 | + | }⏎̸ | |||
M main.go +55 -0
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/bnema/dumber/internal/cli" | |
| 21 | 21 | "github.com/bnema/dumber/internal/config" | |
| 22 | 22 | "github.com/bnema/dumber/internal/db" | |
| 23 | + | "github.com/bnema/dumber/internal/logging" | |
| 23 | 24 | "github.com/bnema/dumber/pkg/clipboard" | |
| 24 | 25 | "github.com/bnema/dumber/pkg/webkit" | |
| 25 | 26 | "github.com/bnema/dumber/services" | |
| @@ -90,6 +91,21 @@ func runCLI() { | |||
| 90 | 91 | os.Exit(1) | |
| 91 | 92 | } | |
| 92 | 93 | ||
| 94 | + | // Initialize logging system for CLI mode (file logging only, no output capture) | |
| 95 | + | cfg := config.Get() | |
| 96 | + | if err := logging.Init( | |
| 97 | + | cfg.Logging.LogDir, | |
| 98 | + | cfg.Logging.Level, | |
| 99 | + | cfg.Logging.Format, | |
| 100 | + | cfg.Logging.EnableFileLog, | |
| 101 | + | cfg.Logging.MaxSize, | |
| 102 | + | cfg.Logging.MaxBackups, | |
| 103 | + | cfg.Logging.MaxAge, | |
| 104 | + | cfg.Logging.Compress, | |
| 105 | + | ); err != nil { | |
| 106 | + | fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err) | |
| 107 | + | } | |
| 108 | + | ||
| 93 | 109 | // Start configuration watching for live reload | |
| 94 | 110 | if err := config.Watch(); err != nil { | |
| 95 | 111 | // Don't exit on watch error, just warn | |
| @@ -175,6 +191,45 @@ func runBrowser() { | |||
| 175 | 191 | os.Exit(1) | |
| 176 | 192 | } | |
| 177 | 193 | cfg := config.Get() | |
| 194 | + | ||
| 195 | + | // Initialize logging system early | |
| 196 | + | if err := logging.Init( | |
| 197 | + | cfg.Logging.LogDir, | |
| 198 | + | cfg.Logging.Level, | |
| 199 | + | cfg.Logging.Format, | |
| 200 | + | cfg.Logging.EnableFileLog, | |
| 201 | + | cfg.Logging.MaxSize, | |
| 202 | + | cfg.Logging.MaxBackups, | |
| 203 | + | cfg.Logging.MaxAge, | |
| 204 | + | cfg.Logging.Compress, | |
| 205 | + | ); err != nil { | |
| 206 | + | log.Printf("Warning: failed to initialize logging: %v", err) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // Output capture disabled by default to avoid interference with normal operations | |
| 210 | + | // Can be enabled via config if needed for debugging | |
| 211 | + | var outputCapture *logging.OutputCapture | |
| 212 | + | if cfg.Logging.CaptureStdout || cfg.Logging.CaptureStderr { | |
| 213 | + | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | |
| 214 | + | if logger := logging.GetLogger(); logger != nil { | |
| 215 | + | outputCapture = logging.NewOutputCapture(logger) | |
| 216 | + | if err := outputCapture.Start(); err != nil { | |
| 217 | + | log.Printf("Warning: failed to start output capture: %v", err) | |
| 218 | + | } else { | |
| 219 | + | defer outputCapture.Stop() | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // Initialize WebKit log capture if configured | |
| 225 | + | if cfg.Logging.CaptureCOutput { | |
| 226 | + | if err := webkit.InitWebKitLogCapture(); err != nil { | |
| 227 | + | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | |
| 228 | + | } else { | |
| 229 | + | defer webkit.StopWebKitLogCapture() | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 178 | 233 | log.Printf("Config initialized") | |
| 179 | 234 | ||
| 180 | 235 | // Detect keyboard layout/locale and hint to webkit layer | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/bnema/dumber/internal/cli" | 20 | "github.com/bnema/dumber/internal/cli" | ||
| 21 | "github.com/bnema/dumber/internal/config" | 21 | "github.com/bnema/dumber/internal/config" | ||
| 22 | "github.com/bnema/dumber/internal/db" | 22 | "github.com/bnema/dumber/internal/db" | ||
| 23 | + | "github.com/bnema/dumber/internal/logging" | |||
| 23 | "github.com/bnema/dumber/pkg/clipboard" | 24 | "github.com/bnema/dumber/pkg/clipboard" | ||
| 24 | "github.com/bnema/dumber/pkg/webkit" | 25 | "github.com/bnema/dumber/pkg/webkit" | ||
| 25 | "github.com/bnema/dumber/services" | 26 | "github.com/bnema/dumber/services" | ||
| @@ -90,6 +91,21 @@ func runCLI() { | |||||
| 90 | os.Exit(1) | 91 | os.Exit(1) | ||
| 91 | } | 92 | } | ||
| 92 | 93 | ||||
| 94 | + | // Initialize logging system for CLI mode (file logging only, no output capture) | |||
| 95 | + | cfg := config.Get() | |||
| 96 | + | if err := logging.Init( | |||
| 97 | + | cfg.Logging.LogDir, | |||
| 98 | + | cfg.Logging.Level, | |||
| 99 | + | cfg.Logging.Format, | |||
| 100 | + | cfg.Logging.EnableFileLog, | |||
| 101 | + | cfg.Logging.MaxSize, | |||
| 102 | + | cfg.Logging.MaxBackups, | |||
| 103 | + | cfg.Logging.MaxAge, | |||
| 104 | + | cfg.Logging.Compress, | |||
| 105 | + | ); err != nil { | |||
| 106 | + | fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 93 | // Start configuration watching for live reload | 109 | // Start configuration watching for live reload | ||
| 94 | if err := config.Watch(); err != nil { | 110 | if err := config.Watch(); err != nil { | ||
| 95 | // Don't exit on watch error, just warn | 111 | // Don't exit on watch error, just warn | ||
| @@ -175,6 +191,45 @@ func runBrowser() { | |||||
| 175 | os.Exit(1) | 191 | os.Exit(1) | ||
| 176 | } | 192 | } | ||
| 177 | cfg := config.Get() | 193 | cfg := config.Get() | ||
| 194 | + | ||||
| 195 | + | // Initialize logging system early | |||
| 196 | + | if err := logging.Init( | |||
| 197 | + | cfg.Logging.LogDir, | |||
| 198 | + | cfg.Logging.Level, | |||
| 199 | + | cfg.Logging.Format, | |||
| 200 | + | cfg.Logging.EnableFileLog, | |||
| 201 | + | cfg.Logging.MaxSize, | |||
| 202 | + | cfg.Logging.MaxBackups, | |||
| 203 | + | cfg.Logging.MaxAge, | |||
| 204 | + | cfg.Logging.Compress, | |||
| 205 | + | ); err != nil { | |||
| 206 | + | log.Printf("Warning: failed to initialize logging: %v", err) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // Output capture disabled by default to avoid interference with normal operations | |||
| 210 | + | // Can be enabled via config if needed for debugging | |||
| 211 | + | var outputCapture *logging.OutputCapture | |||
| 212 | + | if cfg.Logging.CaptureStdout || cfg.Logging.CaptureStderr { | |||
| 213 | + | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | |||
| 214 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 215 | + | outputCapture = logging.NewOutputCapture(logger) | |||
| 216 | + | if err := outputCapture.Start(); err != nil { | |||
| 217 | + | log.Printf("Warning: failed to start output capture: %v", err) | |||
| 218 | + | } else { | |||
| 219 | + | defer outputCapture.Stop() | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // Initialize WebKit log capture if configured | |||
| 225 | + | if cfg.Logging.CaptureCOutput { | |||
| 226 | + | if err := webkit.InitWebKitLogCapture(); err != nil { | |||
| 227 | + | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | |||
| 228 | + | } else { | |||
| 229 | + | defer webkit.StopWebKitLogCapture() | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 178 | log.Printf("Config initialized") | 233 | log.Printf("Config initialized") | ||
| 179 | 234 | ||||
| 180 | // Detect keyboard layout/locale and hint to webkit layer | 235 | // Detect keyboard layout/locale and hint to webkit layer | ||
A pkg/webkit/log_capture.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | //go:build webkit_cgo | |
| 2 | + | ||
| 3 | + | package webkit | |
| 4 | + | ||
| 5 | + | /* | |
| 6 | + | #include "log_capture.h" | |
| 7 | + | */ | |
| 8 | + | import "C" | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "time" | |
| 12 | + | "unsafe" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/logging" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var webkitCaptureActive = false | |
| 18 | + | var webkitCaptureChan chan struct{} | |
| 19 | + | ||
| 20 | + | //export goWebKitLogHandler | |
| 21 | + | func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) { | |
| 22 | + | goDomain := C.GoString(domain) | |
| 23 | + | goMessage := C.GoString(message) | |
| 24 | + | ||
| 25 | + | var levelStr string | |
| 26 | + | switch level { | |
| 27 | + | case C.G_LOG_LEVEL_ERROR: | |
| 28 | + | levelStr = "ERROR" | |
| 29 | + | case C.G_LOG_LEVEL_CRITICAL: | |
| 30 | + | levelStr = "CRITICAL" | |
| 31 | + | case C.G_LOG_LEVEL_WARNING: | |
| 32 | + | levelStr = "WARNING" | |
| 33 | + | case C.G_LOG_LEVEL_MESSAGE: | |
| 34 | + | levelStr = "MESSAGE" | |
| 35 | + | case C.G_LOG_LEVEL_INFO: | |
| 36 | + | levelStr = "INFO" | |
| 37 | + | case C.G_LOG_LEVEL_DEBUG: | |
| 38 | + | levelStr = "DEBUG" | |
| 39 | + | default: | |
| 40 | + | levelStr = "UNKNOWN" | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | logMessage := goMessage | |
| 44 | + | if goDomain != "" { | |
| 45 | + | logMessage = "[" + goDomain + "] " + goMessage | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Send to logging system with appropriate level | |
| 49 | + | if logger := logging.GetLogger(); logger != nil { | |
| 50 | + | logger.WriteTagged("WEBKIT-"+levelStr, logMessage) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // InitWebKitLogCapture initializes the WebKit log capture system | |
| 55 | + | func InitWebKitLogCapture() error { | |
| 56 | + | if webkitCaptureActive { | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Initialize C-level log capture | |
| 61 | + | C.webkit_init_log_capture() | |
| 62 | + | ||
| 63 | + | // Set up GLib log handlers | |
| 64 | + | C.webkit_setup_glib_log_handlers() | |
| 65 | + | ||
| 66 | + | // Create channel for stopping capture | |
| 67 | + | webkitCaptureChan = make(chan struct{}) | |
| 68 | + | ||
| 69 | + | // Start goroutine to read captured C output | |
| 70 | + | go webkitLogReader() | |
| 71 | + | ||
| 72 | + | webkitCaptureActive = true | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // StopWebKitLogCapture stops the WebKit log capture | |
| 77 | + | func StopWebKitLogCapture() { | |
| 78 | + | if !webkitCaptureActive { | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Signal the reader goroutine to stop | |
| 83 | + | close(webkitCaptureChan) | |
| 84 | + | ||
| 85 | + | // Stop C-level capture | |
| 86 | + | C.webkit_stop_log_capture() | |
| 87 | + | ||
| 88 | + | webkitCaptureActive = false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // webkitLogReader reads captured C output in a goroutine | |
| 92 | + | func webkitLogReader() { | |
| 93 | + | buffer := make([]byte, 4096) | |
| 94 | + | ||
| 95 | + | for { | |
| 96 | + | select { | |
| 97 | + | case <-webkitCaptureChan: | |
| 98 | + | return | |
| 99 | + | default: | |
| 100 | + | // Try to read captured output | |
| 101 | + | n := int(C.webkit_read_captured_log((*C.char)(unsafe.Pointer(&buffer[0])), C.int(len(buffer)))) | |
| 102 | + | if n > 0 { | |
| 103 | + | message := string(buffer[:n]) | |
| 104 | + | if logger := logging.GetLogger(); logger != nil { | |
| 105 | + | logger.WriteTagged("WEBKIT-PRINTF", message) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Small delay to prevent busy waiting | |
| 110 | + | time.Sleep(10 * time.Millisecond) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // StartWebKitOutputCapture starts capturing webkit stdout/stderr | |
| 116 | + | func StartWebKitOutputCapture() { | |
| 117 | + | if webkitCaptureActive { | |
| 118 | + | C.webkit_start_log_capture() | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // IsWebKitLogCaptureActive returns whether WebKit log capture is active | |
| 123 | + | func IsWebKitLogCaptureActive() bool { | |
| 124 | + | return webkitCaptureActive | |
| 125 | + | }⏎̸ | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | //go:build webkit_cgo | |||
| 2 | + | ||||
| 3 | + | package webkit | |||
| 4 | + | ||||
| 5 | + | /* | |||
| 6 | + | #include "log_capture.h" | |||
| 7 | + | */ | |||
| 8 | + | import "C" | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "time" | |||
| 12 | + | "unsafe" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/logging" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var webkitCaptureActive = false | |||
| 18 | + | var webkitCaptureChan chan struct{} | |||
| 19 | + | ||||
| 20 | + | //export goWebKitLogHandler | |||
| 21 | + | func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) { | |||
| 22 | + | goDomain := C.GoString(domain) | |||
| 23 | + | goMessage := C.GoString(message) | |||
| 24 | + | ||||
| 25 | + | var levelStr string | |||
| 26 | + | switch level { | |||
| 27 | + | case C.G_LOG_LEVEL_ERROR: | |||
| 28 | + | levelStr = "ERROR" | |||
| 29 | + | case C.G_LOG_LEVEL_CRITICAL: | |||
| 30 | + | levelStr = "CRITICAL" | |||
| 31 | + | case C.G_LOG_LEVEL_WARNING: | |||
| 32 | + | levelStr = "WARNING" | |||
| 33 | + | case C.G_LOG_LEVEL_MESSAGE: | |||
| 34 | + | levelStr = "MESSAGE" | |||
| 35 | + | case C.G_LOG_LEVEL_INFO: | |||
| 36 | + | levelStr = "INFO" | |||
| 37 | + | case C.G_LOG_LEVEL_DEBUG: | |||
| 38 | + | levelStr = "DEBUG" | |||
| 39 | + | default: | |||
| 40 | + | levelStr = "UNKNOWN" | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | logMessage := goMessage | |||
| 44 | + | if goDomain != "" { | |||
| 45 | + | logMessage = "[" + goDomain + "] " + goMessage | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Send to logging system with appropriate level | |||
| 49 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 50 | + | logger.WriteTagged("WEBKIT-"+levelStr, logMessage) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // InitWebKitLogCapture initializes the WebKit log capture system | |||
| 55 | + | func InitWebKitLogCapture() error { | |||
| 56 | + | if webkitCaptureActive { | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Initialize C-level log capture | |||
| 61 | + | C.webkit_init_log_capture() | |||
| 62 | + | ||||
| 63 | + | // Set up GLib log handlers | |||
| 64 | + | C.webkit_setup_glib_log_handlers() | |||
| 65 | + | ||||
| 66 | + | // Create channel for stopping capture | |||
| 67 | + | webkitCaptureChan = make(chan struct{}) | |||
| 68 | + | ||||
| 69 | + | // Start goroutine to read captured C output | |||
| 70 | + | go webkitLogReader() | |||
| 71 | + | ||||
| 72 | + | webkitCaptureActive = true | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // StopWebKitLogCapture stops the WebKit log capture | |||
| 77 | + | func StopWebKitLogCapture() { | |||
| 78 | + | if !webkitCaptureActive { | |||
| 79 | + | return | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Signal the reader goroutine to stop | |||
| 83 | + | close(webkitCaptureChan) | |||
| 84 | + | ||||
| 85 | + | // Stop C-level capture | |||
| 86 | + | C.webkit_stop_log_capture() | |||
| 87 | + | ||||
| 88 | + | webkitCaptureActive = false | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // webkitLogReader reads captured C output in a goroutine | |||
| 92 | + | func webkitLogReader() { | |||
| 93 | + | buffer := make([]byte, 4096) | |||
| 94 | + | ||||
| 95 | + | for { | |||
| 96 | + | select { | |||
| 97 | + | case <-webkitCaptureChan: | |||
| 98 | + | return | |||
| 99 | + | default: | |||
| 100 | + | // Try to read captured output | |||
| 101 | + | n := int(C.webkit_read_captured_log((*C.char)(unsafe.Pointer(&buffer[0])), C.int(len(buffer)))) | |||
| 102 | + | if n > 0 { | |||
| 103 | + | message := string(buffer[:n]) | |||
| 104 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 105 | + | logger.WriteTagged("WEBKIT-PRINTF", message) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Small delay to prevent busy waiting | |||
| 110 | + | time.Sleep(10 * time.Millisecond) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // StartWebKitOutputCapture starts capturing webkit stdout/stderr | |||
| 116 | + | func StartWebKitOutputCapture() { | |||
| 117 | + | if webkitCaptureActive { | |||
| 118 | + | C.webkit_start_log_capture() | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // IsWebKitLogCaptureActive returns whether WebKit log capture is active | |||
| 123 | + | func IsWebKitLogCaptureActive() bool { | |||
| 124 | + | return webkitCaptureActive | |||
| 125 | + | }⏎̸ | |||
A pkg/webkit/log_capture.h +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | #ifndef WEBKIT_LOG_CAPTURE_H | |
| 2 | + | #define WEBKIT_LOG_CAPTURE_H | |
| 3 | + | ||
| 4 | + | #include <stdio.h> | |
| 5 | + | #include <stdlib.h> | |
| 6 | + | #include <string.h> | |
| 7 | + | #include <unistd.h> | |
| 8 | + | #include <glib.h> | |
| 9 | + | ||
| 10 | + | static int webkit_log_pipe[2]; | |
| 11 | + | static FILE* webkit_original_stdout; | |
| 12 | + | static FILE* webkit_original_stderr; | |
| 13 | + | static gboolean webkit_capture_enabled = FALSE; | |
| 14 | + | ||
| 15 | + | // Initialize log capture pipes | |
| 16 | + | static inline void webkit_init_log_capture() { | |
| 17 | + | if (webkit_capture_enabled) return; | |
| 18 | + | ||
| 19 | + | // Create pipe for capturing C output | |
| 20 | + | if (pipe(webkit_log_pipe) == 0) { | |
| 21 | + | // Duplicate stdout/stderr for restoration later | |
| 22 | + | webkit_original_stdout = fdopen(dup(1), "w"); | |
| 23 | + | webkit_original_stderr = fdopen(dup(2), "w"); | |
| 24 | + | webkit_capture_enabled = TRUE; | |
| 25 | + | ||
| 26 | + | g_debug("WebKit log capture initialized"); | |
| 27 | + | } else { | |
| 28 | + | g_warning("Failed to initialize WebKit log capture"); | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Start capturing WebKit output | |
| 33 | + | static inline void webkit_start_log_capture() { | |
| 34 | + | if (!webkit_capture_enabled) return; | |
| 35 | + | ||
| 36 | + | // Redirect stdout/stderr to pipe (for C printf statements) | |
| 37 | + | dup2(webkit_log_pipe[1], 1); | |
| 38 | + | dup2(webkit_log_pipe[1], 2); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Stop capturing and restore original output | |
| 42 | + | static inline void webkit_stop_log_capture() { | |
| 43 | + | if (!webkit_capture_enabled) return; | |
| 44 | + | ||
| 45 | + | // Restore original stdout/stderr | |
| 46 | + | if (webkit_original_stdout) { | |
| 47 | + | dup2(fileno(webkit_original_stdout), 1); | |
| 48 | + | fclose(webkit_original_stdout); | |
| 49 | + | } | |
| 50 | + | if (webkit_original_stderr) { | |
| 51 | + | dup2(fileno(webkit_original_stderr), 2); | |
| 52 | + | fclose(webkit_original_stderr); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | close(webkit_log_pipe[0]); | |
| 56 | + | close(webkit_log_pipe[1]); | |
| 57 | + | webkit_capture_enabled = FALSE; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Read captured output (non-blocking) | |
| 61 | + | static inline int webkit_read_captured_log(char* buffer, int buffer_size) { | |
| 62 | + | if (!webkit_capture_enabled) return 0; | |
| 63 | + | ||
| 64 | + | fd_set read_fds; | |
| 65 | + | struct timeval timeout; | |
| 66 | + | ||
| 67 | + | FD_ZERO(&read_fds); | |
| 68 | + | FD_SET(webkit_log_pipe[0], &read_fds); | |
| 69 | + | ||
| 70 | + | // Non-blocking check | |
| 71 | + | timeout.tv_sec = 0; | |
| 72 | + | timeout.tv_usec = 0; | |
| 73 | + | ||
| 74 | + | int result = select(webkit_log_pipe[0] + 1, &read_fds, NULL, NULL, &timeout); | |
| 75 | + | if (result > 0 && FD_ISSET(webkit_log_pipe[0], &read_fds)) { | |
| 76 | + | int n = read(webkit_log_pipe[0], buffer, buffer_size - 1); | |
| 77 | + | if (n > 0) { | |
| 78 | + | buffer[n] = '\0'; | |
| 79 | + | return n; | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return 0; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // GLib log handler to capture GLib/GTK/WebKit messages | |
| 86 | + | static inline void webkit_glib_log_handler(const gchar *log_domain, | |
| 87 | + | GLogLevelFlags log_level, | |
| 88 | + | const gchar *message, | |
| 89 | + | gpointer user_data) { | |
| 90 | + | // Forward to Go callback | |
| 91 | + | extern void goWebKitLogHandler(char* domain, int level, char* message); | |
| 92 | + | goWebKitLogHandler((char*)log_domain, (int)log_level, (char*)message); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Set up GLib log handlers for various domains | |
| 96 | + | static inline void webkit_setup_glib_log_handlers() { | |
| 97 | + | // Set up log handlers for WebKit and GTK domains | |
| 98 | + | g_log_set_handler("WebKit", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 99 | + | webkit_glib_log_handler, NULL); | |
| 100 | + | g_log_set_handler("Gtk", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 101 | + | webkit_glib_log_handler, NULL); | |
| 102 | + | g_log_set_handler("GLib", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 103 | + | webkit_glib_log_handler, NULL); | |
| 104 | + | g_log_set_handler(NULL, G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 105 | + | webkit_glib_log_handler, NULL); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #endif // WEBKIT_LOG_CAPTURE_H⏎̸ | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | #ifndef WEBKIT_LOG_CAPTURE_H | |||
| 2 | + | #define WEBKIT_LOG_CAPTURE_H | |||
| 3 | + | ||||
| 4 | + | #include <stdio.h> | |||
| 5 | + | #include <stdlib.h> | |||
| 6 | + | #include <string.h> | |||
| 7 | + | #include <unistd.h> | |||
| 8 | + | #include <glib.h> | |||
| 9 | + | ||||
| 10 | + | static int webkit_log_pipe[2]; | |||
| 11 | + | static FILE* webkit_original_stdout; | |||
| 12 | + | static FILE* webkit_original_stderr; | |||
| 13 | + | static gboolean webkit_capture_enabled = FALSE; | |||
| 14 | + | ||||
| 15 | + | // Initialize log capture pipes | |||
| 16 | + | static inline void webkit_init_log_capture() { | |||
| 17 | + | if (webkit_capture_enabled) return; | |||
| 18 | + | ||||
| 19 | + | // Create pipe for capturing C output | |||
| 20 | + | if (pipe(webkit_log_pipe) == 0) { | |||
| 21 | + | // Duplicate stdout/stderr for restoration later | |||
| 22 | + | webkit_original_stdout = fdopen(dup(1), "w"); | |||
| 23 | + | webkit_original_stderr = fdopen(dup(2), "w"); | |||
| 24 | + | webkit_capture_enabled = TRUE; | |||
| 25 | + | ||||
| 26 | + | g_debug("WebKit log capture initialized"); | |||
| 27 | + | } else { | |||
| 28 | + | g_warning("Failed to initialize WebKit log capture"); | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Start capturing WebKit output | |||
| 33 | + | static inline void webkit_start_log_capture() { | |||
| 34 | + | if (!webkit_capture_enabled) return; | |||
| 35 | + | ||||
| 36 | + | // Redirect stdout/stderr to pipe (for C printf statements) | |||
| 37 | + | dup2(webkit_log_pipe[1], 1); | |||
| 38 | + | dup2(webkit_log_pipe[1], 2); | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Stop capturing and restore original output | |||
| 42 | + | static inline void webkit_stop_log_capture() { | |||
| 43 | + | if (!webkit_capture_enabled) return; | |||
| 44 | + | ||||
| 45 | + | // Restore original stdout/stderr | |||
| 46 | + | if (webkit_original_stdout) { | |||
| 47 | + | dup2(fileno(webkit_original_stdout), 1); | |||
| 48 | + | fclose(webkit_original_stdout); | |||
| 49 | + | } | |||
| 50 | + | if (webkit_original_stderr) { | |||
| 51 | + | dup2(fileno(webkit_original_stderr), 2); | |||
| 52 | + | fclose(webkit_original_stderr); | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | close(webkit_log_pipe[0]); | |||
| 56 | + | close(webkit_log_pipe[1]); | |||
| 57 | + | webkit_capture_enabled = FALSE; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Read captured output (non-blocking) | |||
| 61 | + | static inline int webkit_read_captured_log(char* buffer, int buffer_size) { | |||
| 62 | + | if (!webkit_capture_enabled) return 0; | |||
| 63 | + | ||||
| 64 | + | fd_set read_fds; | |||
| 65 | + | struct timeval timeout; | |||
| 66 | + | ||||
| 67 | + | FD_ZERO(&read_fds); | |||
| 68 | + | FD_SET(webkit_log_pipe[0], &read_fds); | |||
| 69 | + | ||||
| 70 | + | // Non-blocking check | |||
| 71 | + | timeout.tv_sec = 0; | |||
| 72 | + | timeout.tv_usec = 0; | |||
| 73 | + | ||||
| 74 | + | int result = select(webkit_log_pipe[0] + 1, &read_fds, NULL, NULL, &timeout); | |||
| 75 | + | if (result > 0 && FD_ISSET(webkit_log_pipe[0], &read_fds)) { | |||
| 76 | + | int n = read(webkit_log_pipe[0], buffer, buffer_size - 1); | |||
| 77 | + | if (n > 0) { | |||
| 78 | + | buffer[n] = '\0'; | |||
| 79 | + | return n; | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return 0; | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // GLib log handler to capture GLib/GTK/WebKit messages | |||
| 86 | + | static inline void webkit_glib_log_handler(const gchar *log_domain, | |||
| 87 | + | GLogLevelFlags log_level, | |||
| 88 | + | const gchar *message, | |||
| 89 | + | gpointer user_data) { | |||
| 90 | + | // Forward to Go callback | |||
| 91 | + | extern void goWebKitLogHandler(char* domain, int level, char* message); | |||
| 92 | + | goWebKitLogHandler((char*)log_domain, (int)log_level, (char*)message); | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Set up GLib log handlers for various domains | |||
| 96 | + | static inline void webkit_setup_glib_log_handlers() { | |||
| 97 | + | // Set up log handlers for WebKit and GTK domains | |||
| 98 | + | g_log_set_handler("WebKit", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |||
| 99 | + | webkit_glib_log_handler, NULL); | |||
| 100 | + | g_log_set_handler("Gtk", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |||
| 101 | + | webkit_glib_log_handler, NULL); | |||
| 102 | + | g_log_set_handler("GLib", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |||
| 103 | + | webkit_glib_log_handler, NULL); | |||
| 104 | + | g_log_set_handler(NULL, G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |||
| 105 | + | webkit_glib_log_handler, NULL); | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | #endif // WEBKIT_LOG_CAPTURE_H⏎̸ | |||
A pkg/webkit/log_capture_stub.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | //go:build !webkit_cgo | |
| 2 | + | ||
| 3 | + | package webkit | |
| 4 | + | ||
| 5 | + | // InitWebKitLogCapture initializes the WebKit log capture system (stub version) | |
| 6 | + | func InitWebKitLogCapture() error { | |
| 7 | + | // No-op for stub version | |
| 8 | + | return nil | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // StopWebKitLogCapture stops the WebKit log capture (stub version) | |
| 12 | + | func StopWebKitLogCapture() { | |
| 13 | + | // No-op for stub version | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // StartWebKitOutputCapture starts capturing webkit stdout/stderr (stub version) | |
| 17 | + | func StartWebKitOutputCapture() { | |
| 18 | + | // No-op for stub version | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version) | |
| 22 | + | func IsWebKitLogCaptureActive() bool { | |
| 23 | + | return false | |
| 24 | + | }⏎̸ | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | //go:build !webkit_cgo | |||
| 2 | + | ||||
| 3 | + | package webkit | |||
| 4 | + | ||||
| 5 | + | // InitWebKitLogCapture initializes the WebKit log capture system (stub version) | |||
| 6 | + | func InitWebKitLogCapture() error { | |||
| 7 | + | // No-op for stub version | |||
| 8 | + | return nil | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // StopWebKitLogCapture stops the WebKit log capture (stub version) | |||
| 12 | + | func StopWebKitLogCapture() { | |||
| 13 | + | // No-op for stub version | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // StartWebKitOutputCapture starts capturing webkit stdout/stderr (stub version) | |||
| 17 | + | func StartWebKitOutputCapture() { | |||
| 18 | + | // No-op for stub version | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version) | |||
| 22 | + | func IsWebKitLogCaptureActive() bool { | |||
| 23 | + | return false | |||
| 24 | + | }⏎̸ | |||