detached 9d2827d

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 (
88 "os"
99 "syscall"
1010 "unsafe"
11+
12+ "github.com/bnema/dumber/internal/logging"
1113 )
1214
1315 // Binary cache format layout:
@@ -46,7 +48,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
4648 }
4749 defer func() {
4850 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))
5052 }
5153 }()
5254
@@ -62,7 +64,7 @@ func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
6264 }
6365 defer func() {
6466 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))
6668 }
6769 }()
6870
@@ -96,7 +98,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
9698 }
9799 defer func() {
98100 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))
100102 }
101103 }()
102104
@@ -123,7 +125,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
123125 header, err := c.parseHeader(data)
124126 if err != nil {
125127 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))
127129 }
128130 return fmt.Errorf("failed to parse header: %w", err)
129131 }
@@ -135,7 +137,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
135137 entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
136138 if err != nil {
137139 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))
139141 }
140142 return fmt.Errorf("failed to parse entries: %w", err)
141143 }
@@ -147,7 +149,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
147149 trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above
148150 if err != nil {
149151 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))
151153 }
152154 return fmt.Errorf("failed to parse trigram index: %w", err)
153155 }
@@ -156,7 +158,7 @@ func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
156158 sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
157159 if err != nil {
158160 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))
160162 }
161163 return fmt.Errorf("failed to parse sorted index: %w", err)
162164 }
@@ -197,7 +199,10 @@ func (c *DmenuFuzzyCache) calculateFileSize() int {
197199
198200 // writeHeader writes the binary header.
199201 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+ }
201206 // Calculate index offset (header + all entries)
202207 indexOffset := HeaderSize
203208 for _, entry := range c.entries {
@@ -206,7 +211,7 @@ func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) in
206211
207212 // Check bounds for integer conversions
208213 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)))
210215 }
211216 if indexOffset < 0 {
212217 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 above149 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 := HeaderSize207 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 conversions212 // Check bounds for integer conversions
208 if len(c.entries) > 0xFFFFFFFF { // uint32 max213 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 (
44 "context"
55 "crypto/md5"
66 "fmt"
7- "log"
87 "os"
98 "path/filepath"
109 "sync"
1110 "sync/atomic"
1211 "time"
12+
13+ "github.com/bnema/dumber/internal/logging"
1314 )
1415
1516 // Cache update constants
@@ -199,7 +200,10 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
199200 // Save to filesystem in background
200201 go cm.saveToFilesystemAsync()
201202
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+ }
203207 return cm.cache, nil
204208 }
205209
@@ -216,13 +220,13 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
216220
217221 // Remove cache file
218222 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))
220224 }
221225
222226 // Build new cache
223227 _, err := cm.buildCacheFromDB(ctx)
224228 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))
226230 }
227231 }()
228232 }
@@ -239,7 +243,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
239243
240244 _, err := cm.buildCacheFromDB(ctx)
241245 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))
243247 }
244248 }()
245249 }
@@ -250,25 +254,25 @@ func (cm *CacheManager) saveToFilesystemAsync() {
250254 // Create cache directory if it doesn't exist
251255 cacheDir := filepath.Dir(cm.config.CacheFile)
252256 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))
254258 return
255259 }
256260
257261 // Save to temporary file first, then atomic rename
258262 tempFile := cm.config.CacheFile + ".tmp"
259263 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))
261265 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))
263267 }
264268 return
265269 }
266270
267271 // Atomic rename
268272 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))
270274 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))
272276 }
273277 }
274278 }
@@ -287,7 +291,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
287291 hasher.Write([]byte(entry.Url))
288292 if entry.VisitCount.Valid {
289293 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))
291295 }
292296 }
293297 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 constants16 // Cache update constants
@@ -199,7 +200,10 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache,
199 // Save to filesystem in background200 // 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, nil207 return cm.cache, nil
204 }208 }
205 209
@@ -216,13 +220,13 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
216 220
217 // Remove cache file221 // 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 cache226 // 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 exist254 // 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 return258 return
255 }259 }
256 260
257 // Save to temporary file first, then atomic rename261 // 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 return268 return
265 }269 }
266 270
267 // Atomic rename271 // 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 {
187187 rootCmd.AddCommand(NewDmenuCmd())
188188 rootCmd.AddCommand(NewHistoryCmd())
189189 rootCmd.AddCommand(NewPurgeCmd())
190+ rootCmd.AddCommand(NewLogsCmd())
190191
191192 return rootCmd
192193 }
@@ -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 rootCmd192 return rootCmd
192 }193 }
M internal/config/config.go +27 -7
@@ -77,13 +77,26 @@ type DmenuConfig struct {
7777
7878 // LoggingConfig holds logging configuration.
7979 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"`
87100 }
88101
89102 // AppearanceConfig holds UI/rendering preferences.
@@ -352,6 +365,13 @@ func (m *Manager) setDefaults() {
352365 m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
353366 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
354367 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)
355375
356376 // Appearance defaults
357377 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 defaults376 // 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 (
2727 defaultFontSize = 16 // points
2828 )
2929
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+
3039 // DefaultConfig returns the default configuration values for dumber.
3140 func DefaultConfig() *Config {
3241 return &Config{
@@ -69,13 +78,20 @@ func DefaultConfig() *Config {
6978 SortByVisitCount: true,
7079 },
7180 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,
7995 },
8096 Appearance: AppearanceConfig{
8197 SansFont: "Fira Sans",
@@ -27,6 +27,15 @@ const (
27 defaultFontSize = 16 // points27 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 json82+ Format: "text", // text or json
74- Filename: "", // empty means stdout83+ Filename: "", // empty means stdout
75- MaxSize: defaultMaxLogSizeMB, // MB84+ MaxSize: defaultMaxLogSizeMB, // MB
76- MaxBackups: defaultMaxBackups,85+ MaxBackups: defaultMaxBackups,
77- MaxAge: defaultMaxLogAgeDays, // days86+ 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) {
8181 return dirs.StateHome, nil
8282 }
8383
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+
8494
8595 // GetConfigFile returns the path to the main configuration file.
8696 func GetConfigFile() (string, error) {
@@ -81,6 +81,16 @@ func GetStateDir() (string, error) {
81 return dirs.StateHome, nil81 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 (
2020 "github.com/bnema/dumber/internal/cli"
2121 "github.com/bnema/dumber/internal/config"
2222 "github.com/bnema/dumber/internal/db"
23+ "github.com/bnema/dumber/internal/logging"
2324 "github.com/bnema/dumber/pkg/clipboard"
2425 "github.com/bnema/dumber/pkg/webkit"
2526 "github.com/bnema/dumber/services"
@@ -90,6 +91,21 @@ func runCLI() {
9091 os.Exit(1)
9192 }
9293
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+
93109 // Start configuration watching for live reload
94110 if err := config.Watch(); err != nil {
95111 // Don't exit on watch error, just warn
@@ -175,6 +191,45 @@ func runBrowser() {
175191 os.Exit(1)
176192 }
177193 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+
178233 log.Printf("Config initialized")
179234
180235 // 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 reload109 // 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 warn111 // 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 layer235 // 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+}⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close