050f2b74c3
- add settings for ssh config - implement save remote function by ssh
227 lines
6.8 KiB
Go
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
|
|
}
|
|
}
|