ituoke/internal/applog/service.go

179 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package applog persists the activity log independently of the webview cache.
package applog
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const maxFileBytes = 2 * 1024 * 1024
const maxEntryBytes = 64 * 1024
const recentLines = 2000
type Service struct {
mu sync.Mutex
dir string
initErr error
limit int64
}
func NewService() *Service {
dir, err := os.UserConfigDir()
return &Service{dir: filepath.Join(dir, "iTuoke", "logs"), initErr: err, limit: maxFileBytes}
}
func (s *Service) files() ([]string, error) {
if s.initErr != nil {
return nil, s.initErr
}
info, err := os.Stat(s.dir)
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("日志目录不可用: %s", s.dir)
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
name := e.Name()
if e.Type().IsRegular() && (name == "runtime.log" || (strings.HasPrefix(name, "runtime-") && strings.HasSuffix(name, ".log"))) {
files = append(files, filepath.Join(s.dir, name))
}
}
sort.Strings(files) // Timestamped archives first, active runtime.log last.
return files, nil
}
// Read restores the latest 2000 lines; older files remain available on disk.
func (s *Service) Read() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
files, err := s.files()
if err != nil {
return nil, fmt.Errorf("读取日志失败: %w", err)
}
lines := []string{}
for i := len(files) - 1; i >= 0 && len(lines) < recentLines; i-- {
f, err := os.Open(files[i])
if err != nil {
return nil, fmt.Errorf("读取日志失败: %w", err)
}
part := []string{}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 4096), maxEntryBytes+1)
for scanner.Scan() {
part = append(part, scanner.Text())
}
err = scanner.Err()
closeErr := f.Close()
if err != nil {
return nil, fmt.Errorf("读取日志失败: %w", err)
}
if closeErr != nil {
return nil, closeErr
}
remaining := recentLines - len(lines)
if len(part) > remaining {
part = part[len(part)-remaining:]
}
lines = append(part, lines...)
}
return lines, nil
}
// Append rotates before a complete entry would exceed 2 MiB. No archives are
// discarded automatically, and each completed call has synced its entry.
func (s *Service) Append(line string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.initErr != nil {
return s.initErr
}
line = strings.ReplaceAll(strings.ReplaceAll(line, "\r", "\\r"), "\n", "\\n")
if len(line) > maxEntryBytes {
return fmt.Errorf("单条日志超过 64 KB")
}
data := []byte(line + "\n")
if err := os.MkdirAll(s.dir, 0700); err != nil {
return fmt.Errorf("保存日志失败: %w", err)
}
path := filepath.Join(s.dir, "runtime.log")
info, err := os.Stat(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if info != nil && info.Size() > 0 && info.Size()+int64(len(data)) > s.limit {
// CreateTemp guarantees unique archive names even within the same tick.
archive, err := os.CreateTemp(s.dir, "runtime-"+time.Now().UTC().Format("20060102-150405.000000000")+"-*.log")
if err != nil {
return err
}
name := archive.Name()
if err := archive.Close(); err != nil {
return err
}
// Windows requires a non-existing destination for some rename variants.
if err := os.Remove(name); err != nil {
return err
}
if err := os.Rename(path, name); err != nil {
return fmt.Errorf("日志分卷失败: %w", err)
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("保存日志失败: %w", err)
}
defer f.Close()
start, err := f.Seek(0, io.SeekEnd)
if err != nil {
return err
}
n, err := f.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
if err == nil {
err = f.Sync()
}
if err != nil {
// Leave no incomplete record for a later retry.
if rollback := f.Truncate(start); rollback != nil {
return fmt.Errorf("保存日志失败: %v;回滚失败: %w", err, rollback)
}
return fmt.Errorf("保存日志失败: %w", err)
}
return nil
}
// Clear only removes this logger's current and archived files.
func (s *Service) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
files, err := s.files()
if err != nil {
return err
}
for _, path := range files {
if err := os.Remove(path); err != nil {
return fmt.Errorf("清空日志失败: %w", err)
}
}
return nil
}