服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
604 lines
18 KiB
Go
604 lines
18 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"pvfine/internal/pvf"
|
|
pvfversion "pvfine/internal/version"
|
|
)
|
|
|
|
// autosaveMetaVersion guards the metadata layout. A slot written by an
|
|
// incompatible version is reported as unusable instead of restored wrongly.
|
|
const autosaveMetaVersion = 1
|
|
|
|
// autosaveVersionWait bounds the wait for the background version session,
|
|
// which has to be attached before recovered edits can be recorded against it.
|
|
const autosaveVersionWait = 30 * time.Second
|
|
|
|
// AutosaveStatus is the UI-facing state of the backup slot.
|
|
type AutosaveStatus struct {
|
|
Enabled bool `json:"enabled"`
|
|
Path string `json:"path"`
|
|
DefaultPath string `json:"defaultPath"`
|
|
Exists bool `json:"exists"`
|
|
CachedAt int64 `json:"cachedAt"`
|
|
SourcePath string `json:"sourcePath"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
Running bool `json:"running"`
|
|
LastError string `json:"lastError"`
|
|
}
|
|
|
|
// RecoveryInfo describes a backup that can be restored on startup.
|
|
type RecoveryInfo struct {
|
|
SourcePath string `json:"sourcePath"`
|
|
SourceName string `json:"sourceName"`
|
|
CachedAt int64 `json:"cachedAt"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
PendingFiles int `json:"pendingFiles"`
|
|
SourceExists bool `json:"sourceExists"`
|
|
SourceChanged bool `json:"sourceChanged"`
|
|
}
|
|
|
|
// autosaveMeta records the source file and the entries that were pending when
|
|
// the backup was written. Removed files are not listed: their paths are gone
|
|
// from the archive, so the restore derives them by comparing path sets.
|
|
type autosaveMeta struct {
|
|
Version int `json:"version"`
|
|
SourcePath string `json:"sourcePath"`
|
|
CachedAt int64 `json:"cachedAt"`
|
|
SourceSize int64 `json:"sourceSize"`
|
|
SourceModified int64 `json:"sourceModified"`
|
|
ModifiedPaths []string `json:"modifiedPaths"`
|
|
AddedPaths []string `json:"addedPaths"`
|
|
}
|
|
|
|
// pendingPaths returns the modified and added entries in a stable order.
|
|
func (m autosaveMeta) pendingPaths() []string {
|
|
seen := make(map[string]struct{}, len(m.ModifiedPaths)+len(m.AddedPaths))
|
|
result := make([]string, 0, len(m.ModifiedPaths)+len(m.AddedPaths))
|
|
for _, path := range append(append([]string{}, m.ModifiedPaths...), m.AddedPaths...) {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[path]; exists {
|
|
continue
|
|
}
|
|
seen[path] = struct{}{}
|
|
result = append(result, path)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
// AutosaveService keeps a single overwriting snapshot of the current workspace
|
|
// so an abnormal exit (crash, killed process) leaves something to recover. The
|
|
// snapshot never touches the live archive: it is written from an isolated clone
|
|
// and the workspace stays "unsaved" after the write.
|
|
type AutosaveService struct {
|
|
c *core
|
|
settings *SettingsService
|
|
|
|
running atomic.Bool
|
|
|
|
mu sync.Mutex
|
|
lastErr string
|
|
}
|
|
|
|
// NewAutosaveService creates the backup service and wires it into the shared
|
|
// core so save/close handlers can drop a stale slot.
|
|
func NewAutosaveService(c *core, settings *SettingsService) *AutosaveService {
|
|
service := &AutosaveService{c: c, settings: settings}
|
|
if c != nil {
|
|
c.attachAutosave(service)
|
|
}
|
|
return service
|
|
}
|
|
|
|
// Status reports the backup slot without writing anything.
|
|
func (s *AutosaveService) Status() AutosaveStatus {
|
|
status := AutosaveStatus{Running: s.running.Load()}
|
|
if s == nil || s.c == nil {
|
|
return status
|
|
}
|
|
status.DefaultPath, _ = DefaultAutosavePath()
|
|
if s.settings != nil {
|
|
if settings, err := s.settings.GetSettings(); err == nil {
|
|
status.Enabled = settings.AutosaveEnabled
|
|
}
|
|
}
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
status.LastError = err.Error()
|
|
return status
|
|
}
|
|
status.Path = cachePath
|
|
if info, err := os.Stat(cachePath); err == nil {
|
|
status.Exists = true
|
|
status.SizeBytes = info.Size()
|
|
}
|
|
if meta, ok := readAutosaveMeta(cachePath); ok {
|
|
status.CachedAt = meta.CachedAt
|
|
status.SourcePath = meta.SourcePath
|
|
}
|
|
status.LastError = s.lastError()
|
|
return status
|
|
}
|
|
|
|
// CreateSnapshot writes the pending edits to the backup slot. The live archive
|
|
// is left untouched: the write goes through an isolated clone, so the workspace
|
|
// keeps its unsaved state and the source file is never modified.
|
|
func (s *AutosaveService) CreateSnapshot() error {
|
|
if s == nil || s.c == nil {
|
|
return errors.New("定时缓存服务不可用")
|
|
}
|
|
if !s.running.CompareAndSwap(false, true) {
|
|
return errors.New("定时缓存正在写入,请稍后再试")
|
|
}
|
|
defer s.running.Store(false)
|
|
defer requestCacheMaintenance()
|
|
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
s.recordError(err)
|
|
return err
|
|
}
|
|
|
|
s.c.mu.RLock()
|
|
a := s.c.archive
|
|
if a == nil {
|
|
s.c.mu.RUnlock()
|
|
s.recordError(ErrNoArchive)
|
|
return ErrNoArchive
|
|
}
|
|
clone := a.CloneForBatch()
|
|
sourcePath := a.SourcePath()
|
|
edits := a.PendingEdits()
|
|
s.c.mu.RUnlock()
|
|
|
|
if !clone.Modified() {
|
|
// Nothing pending (a save can win the race against the timer). Keep the
|
|
// existing slot instead of overwriting it with a clean copy.
|
|
s.recordError(nil)
|
|
return nil
|
|
}
|
|
if sourcePath == "" {
|
|
// An archive without a source file has nowhere to recover into, so a
|
|
// backup would only produce a prompt that cannot be fulfilled.
|
|
s.recordError(nil)
|
|
return nil
|
|
}
|
|
|
|
meta := autosaveMeta{
|
|
Version: autosaveMetaVersion,
|
|
SourcePath: sourcePath,
|
|
CachedAt: time.Now().Unix(),
|
|
}
|
|
for _, edit := range edits {
|
|
path := pvfversion.DisplayPath(edit.Path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if edit.Kind == pvf.MutationAdded {
|
|
meta.AddedPaths = append(meta.AddedPaths, path)
|
|
continue
|
|
}
|
|
meta.ModifiedPaths = append(meta.ModifiedPaths, path)
|
|
}
|
|
if len(meta.ModifiedPaths)+len(meta.AddedPaths) == 0 {
|
|
// Only string-pool state differs (no entry the recovery could replay).
|
|
// Writing a slot without restorable paths would just produce an empty
|
|
// recovery prompt.
|
|
s.recordError(nil)
|
|
return nil
|
|
}
|
|
if info, statErr := os.Stat(sourcePath); statErr == nil {
|
|
meta.SourceSize = info.Size()
|
|
meta.SourceModified = info.ModTime().Unix()
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
|
s.recordError(err)
|
|
return fmt.Errorf("创建备份缓存目录失败: %w", err)
|
|
}
|
|
// The payload is replaced first and the metadata second: a crash between the
|
|
// two renames leaves an older, more conservative path list next to a newer
|
|
// payload, which restores a subset of the edits instead of failing.
|
|
if err := clone.SaveAs(cachePath); err != nil {
|
|
s.recordError(err)
|
|
return fmt.Errorf("写入备份缓存失败: %w", err)
|
|
}
|
|
if err := writeAutosaveMeta(cachePath, meta); err != nil {
|
|
s.recordError(err)
|
|
return err
|
|
}
|
|
|
|
s.recordError(nil)
|
|
return nil
|
|
}
|
|
|
|
// PendingRecovery reports the backup left behind by an interrupted session, or
|
|
// nil when the slot is empty.
|
|
func (s *AutosaveService) PendingRecovery() (*RecoveryInfo, error) {
|
|
if s == nil || s.c == nil {
|
|
return nil, nil
|
|
}
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, ok := readAutosaveMeta(cachePath)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
payload, err := os.Stat(cachePath)
|
|
if err != nil {
|
|
// Metadata without a payload is a torn write, not a usable backup.
|
|
return nil, nil
|
|
}
|
|
info := &RecoveryInfo{
|
|
SourcePath: meta.SourcePath,
|
|
CachedAt: meta.CachedAt,
|
|
SizeBytes: payload.Size(),
|
|
PendingFiles: len(meta.pendingPaths()),
|
|
}
|
|
if meta.SourcePath != "" {
|
|
info.SourceName = filepath.Base(meta.SourcePath)
|
|
if source, err := os.Stat(meta.SourcePath); err == nil {
|
|
info.SourceExists = true
|
|
info.SourceChanged = meta.SourceModified != 0 &&
|
|
(source.ModTime().Unix() != meta.SourceModified || source.Size() != meta.SourceSize)
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// Restore reopens the recorded source file and replays the backed-up edits into
|
|
// the in-memory overlay, so the workspace ends up dirty exactly like the
|
|
// session that was interrupted: modified markers, version changes and the save
|
|
// target all stay consistent. The backup itself is kept until the next save or
|
|
// an explicit discard.
|
|
func (s *AutosaveService) Restore() (ArchiveInfo, error) {
|
|
if s == nil || s.c == nil {
|
|
return ArchiveInfo{}, errors.New("定时缓存服务不可用")
|
|
}
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
meta, ok := readAutosaveMeta(cachePath)
|
|
if !ok {
|
|
return ArchiveInfo{}, errors.New("没有可恢复的备份缓存")
|
|
}
|
|
if meta.SourcePath == "" {
|
|
return ArchiveInfo{}, errors.New("备份缺少原文件路径,无法恢复")
|
|
}
|
|
// A clean workspace can be replaced, but unsaved edits of another archive
|
|
// must never be dropped by a recovery the user did not aim at them.
|
|
s.c.mu.RLock()
|
|
dirty := s.c.archive != nil &&
|
|
(s.c.archive.ModifiedCount() > 0 || len(s.c.versionArtifactDirty) > 0)
|
|
s.c.mu.RUnlock()
|
|
if dirty {
|
|
return ArchiveInfo{}, errors.New("当前工作区有未保存的修改,请先保存或关闭后再恢复备份")
|
|
}
|
|
if _, err := os.Stat(meta.SourcePath); err != nil {
|
|
return ArchiveInfo{}, fmt.Errorf("原文件不存在,请先找回文件再恢复备份: %s", meta.SourcePath)
|
|
}
|
|
|
|
info, err := s.c.openArchive(meta.SourcePath)
|
|
if err != nil {
|
|
return ArchiveInfo{}, fmt.Errorf("打开原文件失败: %w", err)
|
|
}
|
|
if err := s.waitForVersionSession(); err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
// Paged110 containers keep their page keys next to the original file, so the
|
|
// backup borrows the sidecar directory instead of carrying a copy.
|
|
backup, err := s.c.openArchiveWithRememberedTextEncoding(cachePath, meta.SourcePath, filepath.Dir(meta.SourcePath))
|
|
if err != nil {
|
|
return ArchiveInfo{}, fmt.Errorf("打开备份缓存失败: %w", err)
|
|
}
|
|
|
|
a, summary, err := s.replayBackup(meta, backup)
|
|
if err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
|
|
s.c.mu.RLock()
|
|
info, _ = s.c.archiveInfo()
|
|
s.c.mu.RUnlock()
|
|
if err := s.c.scheduleArchiveMutations(a, summary); err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
emitVersionState(s.c, "recovered")
|
|
return info, nil
|
|
}
|
|
|
|
// Discard deletes the backup slot. It reports whether a cache file was removed.
|
|
func (s *AutosaveService) Discard() (bool, error) {
|
|
if s == nil || s.c == nil {
|
|
return false, errors.New("定时缓存服务不可用")
|
|
}
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return deleteAutosaveFiles(cachePath)
|
|
}
|
|
|
|
// DropForSource deletes the backup only when it belongs to sourcePath. Saving or
|
|
// closing another archive must never destroy a backup that still protects a
|
|
// different workspace.
|
|
func (s *AutosaveService) DropForSource(sourcePath string) {
|
|
if s == nil || s.c == nil {
|
|
return
|
|
}
|
|
cachePath, err := s.cachePath()
|
|
if err != nil {
|
|
return
|
|
}
|
|
meta, ok := readAutosaveMeta(cachePath)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !sameSourcePath(meta.SourcePath, sourcePath) {
|
|
return
|
|
}
|
|
_, _ = deleteAutosaveFiles(cachePath)
|
|
}
|
|
|
|
// DropForWorkspace drops the backup of the currently open workspace. It is the
|
|
// exit path's "the user gave up on these edits" cleanup.
|
|
func (s *AutosaveService) DropForWorkspace() {
|
|
if s == nil || s.c == nil {
|
|
return
|
|
}
|
|
s.c.mu.RLock()
|
|
sourcePath := ""
|
|
if s.c.archive != nil {
|
|
sourcePath = s.c.archive.SourcePath()
|
|
}
|
|
s.c.mu.RUnlock()
|
|
s.DropForSource(sourcePath)
|
|
}
|
|
|
|
// ChooseCachePath asks the user for a backup file location.
|
|
func (s *AutosaveService) ChooseCachePath() (string, error) {
|
|
if s == nil {
|
|
return "", errors.New("定时缓存服务不可用")
|
|
}
|
|
name := "autosave.pvf"
|
|
if current, err := s.cachePath(); err == nil && current != "" {
|
|
name = filepath.Base(current)
|
|
}
|
|
path, err := application.Get().Dialog.SaveFile().
|
|
SetFilename(name).
|
|
AddFilter("PVF 备份缓存", "*.pvf").
|
|
SetMessage("选择定时缓存文件").
|
|
PromptForSingleSelection()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil // 空字符串表示用户取消
|
|
}
|
|
|
|
// replayBackup applies the backed-up contents on top of the freshly opened
|
|
// source archive. It returns the archive and the mutation summary so the caller
|
|
// can refresh the derived indexes.
|
|
func (s *AutosaveService) replayBackup(meta autosaveMeta, backup *pvf.Archive) (*pvf.Archive, pvf.MutationSummary, error) {
|
|
backupPaths := make(map[string]struct{}, backup.FileCount())
|
|
for i := int32(0); i < backup.FileCount(); i++ {
|
|
if key := pvfversion.CanonicalPath(backup.Path(i)); key != "" {
|
|
backupPaths[key] = struct{}{}
|
|
}
|
|
}
|
|
recovered := make(map[string]struct{})
|
|
for _, path := range meta.pendingPaths() {
|
|
key := pvfversion.CanonicalPath(path)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
// A pending entry the backup does not contain was deleted before the
|
|
// snapshot was taken; the path-set comparison below handles it.
|
|
if _, exists := backupPaths[key]; exists {
|
|
recovered[key] = struct{}{}
|
|
}
|
|
}
|
|
|
|
s.c.mu.Lock()
|
|
defer s.c.mu.Unlock()
|
|
if s.c.archive == nil {
|
|
return nil, pvf.MutationSummary{}, ErrNoArchive
|
|
}
|
|
a := s.c.archive
|
|
// Entries the interrupted session deleted are present in the file but
|
|
// missing from the backup.
|
|
paths := make([]string, 0, len(recovered)+8)
|
|
for key := range recovered {
|
|
paths = append(paths, key)
|
|
}
|
|
for i := int32(0); i < a.FileCount(); i++ {
|
|
key := pvfversion.CanonicalPath(a.Path(i))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := backupPaths[key]; !exists {
|
|
paths = append(paths, key)
|
|
}
|
|
}
|
|
if len(paths) == 0 {
|
|
return a, pvf.MutationSummary{}, nil
|
|
}
|
|
sort.Strings(paths)
|
|
|
|
before, err := pvfversion.ContentSnapshotFromArchive(a, paths)
|
|
if err != nil {
|
|
return nil, pvf.MutationSummary{}, err
|
|
}
|
|
desired, err := pvfversion.ContentSnapshotFromArchive(backup, paths)
|
|
if err != nil {
|
|
return nil, pvf.MutationSummary{}, err
|
|
}
|
|
// Paths the session never touched keep the file's current content: only the
|
|
// recovered and the deleted entries are materialized. The version working
|
|
// snapshot is deliberately not consulted, so a recovered payload is never
|
|
// skipped on account of another snapshot agreeing with it.
|
|
for key := range desired {
|
|
if _, exists := recovered[key]; !exists {
|
|
delete(desired, key)
|
|
}
|
|
}
|
|
if err := applyContentPathsLocked(s.c, paths, desired, false); err != nil {
|
|
return nil, pvf.MutationSummary{}, err
|
|
}
|
|
after, err := pvfversion.ContentSnapshotFromArchive(a, paths)
|
|
if err != nil {
|
|
return nil, pvf.MutationSummary{}, err
|
|
}
|
|
if err := s.c.recordVersionMutationLocked("从定时缓存恢复", before, after); err != nil {
|
|
return nil, pvf.MutationSummary{}, err
|
|
}
|
|
summary := a.MutationsSince(0)
|
|
a.ClearMutations()
|
|
return a, summary, nil
|
|
}
|
|
|
|
// waitForVersionSession waits until the background version load settles. The
|
|
// replayed edits have to be recorded against an attached session; applying them
|
|
// earlier would leave the version panel reporting a clean workspace.
|
|
func (s *AutosaveService) waitForVersionSession() error {
|
|
deadline := time.Now().Add(autosaveVersionWait)
|
|
for {
|
|
s.c.mu.RLock()
|
|
loading := s.c.versionLoading
|
|
s.c.mu.RUnlock()
|
|
if !loading {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return errors.New("版本库仍在后台加载,请稍后重试恢复")
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// cachePath resolves the configured snapshot file, falling back to the
|
|
// platform cache directory.
|
|
func (s *AutosaveService) cachePath() (string, error) {
|
|
if s == nil || s.settings == nil {
|
|
return "", errors.New("定时缓存服务不可用")
|
|
}
|
|
settings, err := s.settings.GetSettings()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if settings.AutosavePath != "" {
|
|
return settings.AutosavePath, nil
|
|
}
|
|
return DefaultAutosavePath()
|
|
}
|
|
|
|
func (s *AutosaveService) recordError(err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err == nil {
|
|
s.lastErr = ""
|
|
return
|
|
}
|
|
s.lastErr = err.Error()
|
|
recordLog("ERROR", "自动缓存", err.Error())
|
|
}
|
|
|
|
func (s *AutosaveService) lastError() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.lastErr
|
|
}
|
|
|
|
func readAutosaveMeta(cachePath string) (autosaveMeta, bool) {
|
|
data, err := os.ReadFile(cachePath + ".json")
|
|
if err != nil {
|
|
return autosaveMeta{}, false
|
|
}
|
|
var meta autosaveMeta
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return autosaveMeta{}, false
|
|
}
|
|
if meta.Version != autosaveMetaVersion {
|
|
return autosaveMeta{}, false
|
|
}
|
|
return meta, true
|
|
}
|
|
|
|
func writeAutosaveMeta(cachePath string, meta autosaveMeta) error {
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
metaPath := cachePath + ".json"
|
|
temp, err := os.CreateTemp(filepath.Dir(metaPath), ".autosave-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("创建备份元数据临时文件失败: %w", err)
|
|
}
|
|
tempPath := temp.Name()
|
|
defer os.Remove(tempPath)
|
|
if err := temp.Chmod(0o600); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
temp.Close()
|
|
return fmt.Errorf("写入备份元数据失败: %w", err)
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
temp.Close()
|
|
return fmt.Errorf("同步备份元数据失败: %w", err)
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, metaPath); err != nil {
|
|
return fmt.Errorf("替换备份元数据失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteAutosaveFiles removes the payload and its metadata. Missing files are
|
|
// not an error: the caller only cares that no backup is left.
|
|
func deleteAutosaveFiles(cachePath string) (bool, error) {
|
|
removed := false
|
|
if err := os.Remove(cachePath); err == nil {
|
|
removed = true
|
|
} else if !os.IsNotExist(err) {
|
|
return removed, fmt.Errorf("删除备份缓存失败: %w", err)
|
|
}
|
|
if err := os.Remove(cachePath + ".json"); err == nil {
|
|
removed = true
|
|
} else if !os.IsNotExist(err) {
|
|
return removed, fmt.Errorf("删除备份元数据失败: %w", err)
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
// sameSourcePath compares two archive paths, tolerating separator and case
|
|
// differences reported by different platforms.
|
|
func sameSourcePath(left, right string) bool {
|
|
if left == "" || right == "" {
|
|
return left == right
|
|
}
|
|
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
|
}
|