package log import ( "errors" "fmt" "os" "path/filepath" "runtime" "sync" "time" ) var ( logger *Logger once sync.Once logMutex sync.RWMutex verboseMode bool debugMode bool alwaysStderr bool ) type Logger struct { file *os.File filePath string size int64 mode os.FileMode enabled bool entries chan logEntry wg sync.WaitGroup closeOnce sync.Once } type logEntry struct { message string flushed chan struct{} clear chan error } const maxLogFileBytes int64 = 10 << 20 type LogLevel int const ( LevelDebug LogLevel = iota LevelInfo LevelWarn LevelError ) func Init() { once.Do(func() { debugPrintf("=== Log Init Started ===") homeDir, err := os.UserHomeDir() if err != nil { debugPrintf("ERROR: os.UserHomeDir() failed: %v", err) fmt.Fprintf(os.Stderr, "[ERROR] Failed to get user home directory: %v\n", err) homeDir = "." } else { debugPrintf("User home directory: %s", homeDir) } var logDir string var logPath string // On Windows, use portable_config in detected MPV directory if runtime.GOOS == "windows" { logDir = filepath.Join(homeDir, "mpv", "portable_config") // Check if legacy path exists if _, err := os.Stat(logDir); os.IsNotExist(err) { // Try AppData path for fresh installs appData := os.Getenv("APPDATA") if appData != "" { appDataLogDir := filepath.Join(appData, "mpv", "portable_config") // Use AppData path if legacy doesn't exist logDir = appDataLogDir } } } else { logDir = filepath.Join(homeDir, ".config", "mpv") } debugPrintf("Attempting to create log directory: %s", logDir) // Try to create log directory if err := os.MkdirAll(logDir, 0755); err != nil { debugPrintf("ERROR: os.MkdirAll(%s) failed: %v", logDir, err) fmt.Fprintf(os.Stderr, "[ERROR] Failed to create log directory '%s': %v\n", logDir, err) // Try fallback to current directory logDir = "." debugPrintf("Falling back to current directory: %s", logDir) fmt.Fprintf(os.Stderr, "[INFO] Falling back to current directory for logs\n") if err := os.MkdirAll(logDir, 0755); err != nil { debugPrintf("ERROR: Fallback os.MkdirAll() also failed: %v", err) fmt.Fprintf(os.Stderr, "[ERROR] Failed to create fallback log directory: %v\n", err) return } } else { debugPrintf("Successfully created log directory") } logPath = filepath.Join(logDir, "mpv-manager.log") mode := os.FileMode(0o644) if info, err := os.Stat(logPath); err == nil { mode = info.Mode().Perm() } debugPrintf("Log file path: %s", logPath) rotateLogIfOversize(logPath) file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, mode) if err != nil { debugPrintf("ERROR: os.OpenFile(%s) failed: %v", logPath, err) fmt.Fprintf(os.Stderr, "[ERROR] Failed to create log file '%s': %v\n", logPath, err) return } else { debugPrintf("Successfully opened log file") } newLogger := &Logger{ file: file, filePath: logPath, mode: mode, enabled: true, entries: make(chan logEntry, 100), } if info, err := file.Stat(); err == nil { newLogger.size = info.Size() } newLogger.wg.Add(1) go func() { defer newLogger.wg.Done() for entry := range newLogger.entries { if entry.message != "" { if err := newLogger.writeEntry(entry.message); err != nil { fmt.Fprintln(os.Stderr, "Could not write log entry:", err) } } if entry.flushed != nil { _ = newLogger.file.Sync() close(entry.flushed) } if entry.clear != nil { err := newLogger.clear() entry.clear <- err close(entry.clear) } } _ = newLogger.file.Sync() _ = newLogger.file.Close() }() logMutex.Lock() logger = newLogger logMutex.Unlock() Write("=== MPV.Rocks Installer Log Started ===") Write(fmt.Sprintf("Timestamp: %s", time.Now().Format(time.RFC3339))) debugPrintf("=== Log Init Complete ===") }) } // clear runs only in the writer goroutine. Windows append handles do not have // FILE_WRITE_DATA, which truncation requires, so use a separate writable handle // to the same file while retaining append semantics for subsequent records. func (l *Logger) clear() error { if err := l.file.Sync(); err != nil { return err } file := l.file if runtime.GOOS == "windows" { writable, err := os.OpenFile(l.filePath, os.O_WRONLY, 0) if err != nil { return err } defer writable.Close() ownedInfo, err := l.file.Stat() if err != nil { return err } writableInfo, err := writable.Stat() if err != nil { return err } if !os.SameFile(ownedInfo, writableInfo) { return errors.New("active log path no longer references the owned file") } file = writable } if err := file.Truncate(0); err != nil { return err } l.size = 0 // The append writer always writes at EOF; resetting its file offset is not // necessary and Seek on an O_APPEND handle has unspecified behavior. return file.Sync() } // Only the writer goroutine calls writeEntry, so rotation, flush and clear // never race a file replacement. Both the current log and one retained backup // remain bounded even when the application stays open for a long session. func (l *Logger) writeEntry(message string) error { if _, err := l.file.Stat(); err != nil { // A failed reopen during rotation must remain retryable after a disk // or permissions problem is resolved, without restarting the session. if err := l.reopen(); err != nil { return err } } if int64(len(message)) > maxLogFileBytes { const suffix = "\n[log record truncated at file size limit]\n" message = message[:maxLogFileBytes-int64(len(suffix))] + suffix } if l.size > 0 && l.size+int64(len(message)) > maxLogFileBytes { if err := l.rotate(); err != nil { return err } } written, err := l.file.WriteString(message) l.size += int64(written) return err } func (l *Logger) rotate() error { info, err := l.file.Stat() if err != nil { return err } if err := l.file.Sync(); err != nil { return err } if err := l.file.Close(); err != nil { return err } l.mode = info.Mode().Perm() backup := l.filePath + ".1" if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { return errors.Join(err, l.reopen()) } if err := os.Rename(l.filePath, backup); err != nil { return errors.Join(err, l.reopen()) } return l.reopen() } func (l *Logger) reopen() error { file, err := os.OpenFile(l.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, l.mode) if err != nil { return err } l.file = file info, err := file.Stat() if err == nil { l.size = info.Size() } return err } func SetVerboseMode(enabled bool) { logMutex.Lock() defer logMutex.Unlock() verboseMode = enabled alwaysStderr = verboseMode || debugMode } func SetDebugMode(enabled bool) { logMutex.Lock() defer logMutex.Unlock() debugMode = enabled alwaysStderr = verboseMode || debugMode } // IsVerboseMode reports whether verbose logging mode is enabled. func IsVerboseMode() bool { logMutex.RLock() defer logMutex.RUnlock() return verboseMode } // IsDebugMode reports whether debug logging mode is enabled. func IsDebugMode() bool { logMutex.RLock() defer logMutex.RUnlock() return debugMode } func debugPrintf(format string, args ...interface{}) { logMutex.RLock() shouldPrint := verboseMode || debugMode || alwaysStderr logMutex.RUnlock() if shouldPrint { msg := fmt.Sprintf(format, args...) fmt.Fprintf(os.Stderr, "[DEBUG] %s\n", msg) } } func Close() { logMutex.RLock() current := logger logMutex.RUnlock() if current == nil { return } current.closeOnce.Do(func() { logMutex.Lock() if !current.enabled { logMutex.Unlock() return } current.entries <- logEntry{message: formatMessage("=== MPV.Rocks Installer Log Ended ===")} current.enabled = false close(current.entries) logMutex.Unlock() current.wg.Wait() }) } func Flush() { logMutex.RLock() defer logMutex.RUnlock() if logger == nil || !logger.enabled { return } flushed := make(chan struct{}) logger.entries <- logEntry{flushed: flushed} <-flushed } // Clear truncates the active log through the writer goroutine so its file // offset cannot race direct truncation and create a sparse file. func Clear() error { logMutex.RLock() defer logMutex.RUnlock() if logger == nil || !logger.enabled { return nil } result := make(chan error, 1) logger.entries <- logEntry{clear: result} return <-result } func Write(msg string) { formatted := formatMessage(msg) logMutex.RLock() defer logMutex.RUnlock() if verboseMode || debugMode || alwaysStderr { _, _ = fmt.Fprint(os.Stderr, formatted) } if logger == nil || !logger.enabled { return } // Logging is part of the diagnostic record. Apply bounded-channel // backpressure during exceptional bursts instead of silently discarding // whichever entries happen to arrive while the writer is busy. logger.entries <- logEntry{message: formatted} } func formatMessage(msg string) string { timestamp := time.Now().Format("2006-01-02 15:04:05") return fmt.Sprintf("[%s] %s\n", timestamp, msg) } func Info(msg string) { Write(fmt.Sprintf("[INFO] %s", msg)) } func Debug(msg string) { Write(fmt.Sprintf("[DEBUG] %s", msg)) } func Warn(msg string) { Write(fmt.Sprintf("[WARN] %s", msg)) } func Error(msg string) { Write(fmt.Sprintf("[ERROR] %s", msg)) } func Command(cmd string) { Write(fmt.Sprintf("[COMMAND] %s", cmd)) } func Output(output string) { Write(fmt.Sprintf("[OUTPUT] %s", output)) } func ErrorOutput(output string) { Write(fmt.Sprintf("[ERROR-OUTPUT] %s", output)) } func Separator(msg string) { Write(fmt.Sprintf("--- %s ---", msg)) } func GetFilePath() string { logMutex.RLock() defer logMutex.RUnlock() if logger != nil { return logger.filePath } return "" } func rotateLogIfOversize(logPath string) { info, err := os.Stat(logPath) if err != nil || info.Size() <= maxLogFileBytes { return } backupPath := logPath + ".1" _ = os.Remove(backupPath) if err := os.Rename(logPath, backupPath); err != nil { debugPrintf("Could not rotate oversized log: %v", err) } }