// 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 }