Files
SnapGo/internal/infrastructure/logging/logging.go
T
mamamiyear 050f2b74c3 feat: save short cut image to remote by ssh
- add settings for ssh config
- implement save remote function by ssh
2026-06-11 21:14:29 +08:00

227 lines
6.8 KiB
Go

// Package logging centralises slog configuration.
//
// 设计动机:
// - SnapGo 是 macOS GUI 应用, 由 Finder/open 启动时 stderr 会被重定向
// 到 /dev/null, slog 默认 handler 写到 stderr 因此对用户不可见.
// - 为方便排查网络 / 权限 / SCP 协议层问题, 我们将日志同时写入磁盘文件
// 和 stderr, 让 `tail -f` 与开发期 `wails dev` 都能直接看到输出.
// - 单独抽出包可避免 main / app 直接依赖文件 IO 细节, 也方便后续替换为
// os_log 或集中式日志方案.
package logging
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
)
const (
// maxLogSize is the byte threshold that triggers a rotation. 5 MiB keeps
// a useful amount of recent history while staying small enough to open
// in any editor.
maxLogSize = 5 * 1024 * 1024
// maxBackups is how many rotated files to retain (snapgo.log.1,
// snapgo.log.2). Older ones are deleted on each rotation.
maxBackups = 2
)
// teeWriter is a thin io.Writer that fans out a single slog write to many
// underlying writers. We avoid log.MultiWriter from the std library because
// io.MultiWriter already covers the use case without an extra dependency.
type teeWriter struct {
writers []io.Writer
}
// Write copies p to every underlying writer. We deliberately keep going on
// errors so a single broken writer (e.g. closed file handle) does not lose
// output to the surviving writers; the last error wins which is sufficient
// for a logger.
func (t *teeWriter) Write(p []byte) (int, error) {
var lastErr error
for _, w := range t.writers {
if w == nil {
continue
}
if _, err := w.Write(p); err != nil {
lastErr = err
}
}
return len(p), lastErr
}
// Init wires slog.Default to write to both ~/Library/Logs/SnapGo/snapgo.log
// and stderr. Returns a closer the caller (main) should defer so the file
// handle is released on shutdown.
//
// 日志级别可通过环境变量 SNAPGO_LOG_LEVEL 调整: debug|info|warn|error.
// 默认 debug 以便排查 SCP 阶段性问题, 待功能稳定后可调整为 info.
func Init() (closer func()) {
level := parseLevel(os.Getenv("SNAPGO_LOG_LEVEL"))
var fileW io.Writer
logPath, err := defaultLogPath()
if err == nil {
// Size-based rotation keeps the log directory bounded; append mode is
// preserved inside rotatingFile so prior-launch context survives.
rf, ferr := newRotatingFile(logPath, maxLogSize, maxBackups)
if ferr == nil {
fileW = rf
closer = func() { _ = rf.Close() }
}
}
if closer == nil {
closer = func() {}
}
writer := &teeWriter{writers: []io.Writer{os.Stderr}}
if fileW != nil {
writer.writers = append(writer.writers, fileW)
}
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
slog.Info("logging initialised",
"level", level.String(),
"file", logPath)
return closer
}
// rotatingFile is a size-based rotating log writer.
//
// 设计理由:
// - GUI 应用长期 append 写日志, 不加限制会无限增长, 占满磁盘.
// - 标准库不提供滚动能力, 引入 lumberjack 等第三方库又会增加依赖,
// 而我们的需求很简单 (单文件 / 按大小 / 固定份数), 自实现成本更低.
// - 实现策略: 每次 Write 前检查写入后是否超过 maxLogSize, 超过则先
// rotate (snapgo.log -> snapgo.log.1 -> snapgo.log.2, 丢弃最老的),
// 再写入新建的 snapgo.log. 用互斥锁保证并发安全 (slog handler 自身
// 不保证对 io.Writer 的串行化).
type rotatingFile struct {
mu sync.Mutex
path string
maxSize int64
backups int
file *os.File
size int64
}
// newRotatingFile opens (or creates, append mode) the target log file and
// initialises the current size from the existing file so a restart does not
// reset the rotation threshold.
func newRotatingFile(path string, maxSize int64, backups int) (*rotatingFile, error) {
r := &rotatingFile{path: path, maxSize: maxSize, backups: backups}
if err := r.openExisting(); err != nil {
return nil, err
}
return r, nil
}
// openExisting opens the active log file in append mode and records its
// current size.
func (r *rotatingFile) openExisting() error {
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
r.file = f
r.size = info.Size()
return nil
}
// Write appends p, rotating first when the write would exceed maxSize.
func (r *rotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.size+int64(len(p)) > r.maxSize {
if err := r.rotate(); err != nil {
// On rotation failure we keep writing to the current file rather
// than dropping logs; oversized-by-a-bit beats data loss.
r.size += int64(len(p))
n, werr := r.file.Write(p)
if werr != nil {
return n, werr
}
return n, err
}
}
n, err := r.file.Write(p)
r.size += int64(n)
return n, err
}
// rotate closes the current file, shifts backups (snapgo.log.1 ->
// snapgo.log.2, dropping the oldest), renames the active file to
// snapgo.log.1, then opens a fresh active file.
func (r *rotatingFile) rotate() error {
if err := r.file.Close(); err != nil {
return err
}
// Drop the oldest backup, then shift the rest up by one index.
oldest := fmt.Sprintf("%s.%d", r.path, r.backups)
_ = os.Remove(oldest)
for i := r.backups - 1; i >= 1; i-- {
src := fmt.Sprintf("%s.%d", r.path, i)
dst := fmt.Sprintf("%s.%d", r.path, i+1)
_ = os.Rename(src, dst)
}
if r.backups >= 1 {
_ = os.Rename(r.path, fmt.Sprintf("%s.1", r.path))
}
return r.openExisting()
}
// Close releases the underlying file handle.
func (r *rotatingFile) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.file == nil {
return nil
}
return r.file.Close()
}
// defaultLogPath returns ~/Library/Logs/SnapGo/snapgo.log, creating the
// parent directory if needed. macOS users expect app logs to live there
// (Console.app surfaces this directory under "Reports" and `open ~/Library/Logs`
// is muscle-memory).
func defaultLogPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, "Library", "Logs", "SnapGo")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return filepath.Join(dir, "snapgo.log"), nil
}
// parseLevel translates a free-form string into an slog.Level. We default
// to debug because the app currently has plenty of diagnostic logs and the
// user-facing impact (slightly more disk IO on uploads) is negligible.
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "error":
return slog.LevelError
case "warn", "warning":
return slog.LevelWarn
case "info":
return slog.LevelInfo
case "debug", "":
return slog.LevelDebug
default:
return slog.LevelDebug
}
}