179 lines
4.5 KiB
Go
179 lines
4.5 KiB
Go
// 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
|
||
}
|