DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,684 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
// EditorService: 文件内容读取、内存编辑、保存/另存为、导出与整包解包。
|
||||
type EditorService struct {
|
||||
c *core
|
||||
settings *SettingsService
|
||||
}
|
||||
|
||||
func NewEditorService(c *core, settings ...*SettingsService) *EditorService {
|
||||
service := &EditorService{c: c}
|
||||
if len(settings) > 0 {
|
||||
service.settings = settings[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// editableByteLimit reads the current setting so changes apply on the next open.
|
||||
func (s *EditorService) editableByteLimit() int32 {
|
||||
if s.settings != nil {
|
||||
return editableByteLimit(s.settings)
|
||||
}
|
||||
return s.c.editableByteLimit()
|
||||
}
|
||||
|
||||
func editableByteLimit(service *SettingsService) int32 {
|
||||
if service != nil {
|
||||
if settings, err := service.GetSettings(); err == nil {
|
||||
return int32(settings.MaxEditableSizeMB) << 20
|
||||
}
|
||||
}
|
||||
return DefaultMaxEditableSizeMB << 20
|
||||
}
|
||||
|
||||
// FileMeta 返回给前端的单个文件视图。
|
||||
type FileMeta struct {
|
||||
Index int32 `json:"index"`
|
||||
Path string `json:"path"`
|
||||
DataType int32 `json:"dataType"`
|
||||
Size int32 `json:"size"`
|
||||
Tags []TreeTag `json:"tags,omitempty"`
|
||||
Editable bool `json:"editable"`
|
||||
Text string `json:"text"`
|
||||
Modified bool `json:"modified"`
|
||||
Annotations []EditorAnnotation `json:"annotations,omitempty"`
|
||||
Icon *ImageReference `json:"icon,omitempty"`
|
||||
FieldImage *ImageReference `json:"fieldImage,omitempty"`
|
||||
}
|
||||
|
||||
// GetFile 返回文件的反编译文本(内存编辑视图)。
|
||||
func (s *EditorService) GetFile(index int32) (*FileMeta, error) {
|
||||
return s.getFile(index)
|
||||
}
|
||||
|
||||
// GetFileBasic returns the rendered file body without annotations or indexed
|
||||
// metadata so the editor can display useful content before enrichment loads.
|
||||
func (s *EditorService) GetFileBasic(index int32) (*FileMeta, error) {
|
||||
defer debugPhase("GetFileBasic", fmt.Sprintf("file=%d", index))()
|
||||
wait := time.Now()
|
||||
diagnoseCoreLock("GetFileBasic", fmt.Sprintf("file=%d", index), s.c.mu.RLock)
|
||||
debugLockAcquired("GetFileBasic", wait)
|
||||
defer s.c.mu.RUnlock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if err := validateAnnotationIndex(a, index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := a.File(index)
|
||||
meta := &FileMeta{
|
||||
Index: index, Path: a.Path(index), DataType: file.DataType,
|
||||
Size: file.DataSize, Editable: false, Modified: a.IsModified(index),
|
||||
}
|
||||
switch file.DataType {
|
||||
case pvf.TypeScript, pvf.TypeUnicode:
|
||||
if maxEditableBytes := s.editableByteLimit(); file.DataSize > maxEditableBytes {
|
||||
meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", file.DataSize, maxEditableBytes)
|
||||
return meta, nil
|
||||
}
|
||||
text, ok := s.c.editorText[index]
|
||||
debugLog("GetFileBasic file=%d edited-text-cache=%t", index, ok)
|
||||
if !ok {
|
||||
var err error
|
||||
finish := debugPhase("GetFileBasic.text", meta.Path)
|
||||
text, err = a.Text(index)
|
||||
finish()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
meta.Editable = a.WriteCapabilities().CanSave
|
||||
meta.Text = text
|
||||
default:
|
||||
if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") {
|
||||
return s.readBinaryANI(a, index, meta)
|
||||
}
|
||||
meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", file.DataType)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (s *EditorService) getFile(index int32) (*FileMeta, error) {
|
||||
defer debugPhase("GetFile", fmt.Sprintf("file=%d", index))()
|
||||
wait := time.Now()
|
||||
diagnoseCoreLock("GetFile", fmt.Sprintf("file=%d", index), s.c.mu.Lock)
|
||||
debugLockAcquired("GetFile", wait)
|
||||
defer s.c.mu.Unlock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if err := validateAnnotationIndex(a, index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := a.File(index)
|
||||
meta := &FileMeta{
|
||||
Index: index,
|
||||
Path: a.Path(index),
|
||||
DataType: f.DataType,
|
||||
Size: f.DataSize,
|
||||
Editable: false,
|
||||
}
|
||||
finishMetadata := debugPhase("GetFile.metadata", meta.Path)
|
||||
meta.Tags = cloneTreeTags(s.c.treeTagsByFile[index])
|
||||
if s.c.diskIndex != nil {
|
||||
meta.Tags, _ = s.c.diskIndex.tags(index)
|
||||
}
|
||||
visuals := s.c.fileVisualsLocked(index)
|
||||
meta.Icon = cloneImageReference(visuals.icon)
|
||||
meta.FieldImage = cloneImageReference(visuals.fieldImage)
|
||||
finishMetadata()
|
||||
switch f.DataType {
|
||||
case pvf.TypeScript, pvf.TypeUnicode:
|
||||
if maxEditableBytes := s.editableByteLimit(); f.DataSize > maxEditableBytes {
|
||||
meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", f.DataSize, maxEditableBytes)
|
||||
return meta, nil
|
||||
}
|
||||
text, ok := s.c.editorText[index]
|
||||
debugLog("GetFile file=%d edited-text-cache=%t", index, ok)
|
||||
if !ok {
|
||||
var err error
|
||||
finish := debugPhase("GetFile.text", meta.Path)
|
||||
text, err = a.Text(index)
|
||||
finish()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
meta.Editable = a.WriteCapabilities().CanSave
|
||||
meta.Text = text
|
||||
if f.DataType == pvf.TypeScript {
|
||||
annotations, err := s.c.editorAnnotationsLocked(index, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Annotations = annotations
|
||||
}
|
||||
default:
|
||||
if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") {
|
||||
return s.readBinaryANI(a, index, meta)
|
||||
}
|
||||
meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", f.DataType)
|
||||
}
|
||||
meta.Modified = a.IsModified(index)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// GetAnnotations recomputes annotations against the exact current editor text.
|
||||
func (s *EditorService) GetAnnotations(index int32) ([]EditorAnnotation, error) {
|
||||
defer debugPhase("GetAnnotations", fmt.Sprintf("file=%d", index))()
|
||||
wait := time.Now()
|
||||
diagnoseCoreLock("GetAnnotations", fmt.Sprintf("file=%d", index), s.c.mu.Lock)
|
||||
debugLockAcquired("GetAnnotations", wait)
|
||||
defer s.c.mu.Unlock()
|
||||
if s.c.archive == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if err := validateAnnotationIndex(s.c.archive, index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.c.archive.File(index).DataType != pvf.TypeScript {
|
||||
return []EditorAnnotation{}, nil
|
||||
}
|
||||
text, ok := s.c.editorText[index]
|
||||
if !ok {
|
||||
var err error
|
||||
text, err = s.c.archive.Text(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.c.editorAnnotationsLocked(index, text)
|
||||
}
|
||||
|
||||
// GetAnnotationsForText annotates a frontend draft without committing it to the archive.
|
||||
func (s *EditorService) GetAnnotationsForText(index int32, text string) ([]EditorAnnotation, error) {
|
||||
s.c.mu.Lock()
|
||||
defer s.c.mu.Unlock()
|
||||
if s.c.archive == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if err := validateAnnotationIndex(s.c.archive, index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.c.archive.File(index).DataType != pvf.TypeScript {
|
||||
return []EditorAnnotation{}, nil
|
||||
}
|
||||
return s.c.editorAnnotationsLocked(index, text)
|
||||
}
|
||||
|
||||
// SetText 把编辑后的文本写入内存 overlay(不落盘)。
|
||||
func (s *EditorService) SetText(index int32, text string) error {
|
||||
_, _, err := s.c.setText(index, text)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPlaceholderText rewrites the display text behind one `<table::key>`
|
||||
// placeholder of a script. index is the script being edited; tableIndex and key
|
||||
// come from the placeholder annotation. Only the `.str` payload changes — the
|
||||
// script keeps its placeholder, so no stored script data is altered.
|
||||
func (s *EditorService) SetPlaceholderText(index int32, tableIndex int32, key string, text string) error {
|
||||
return s.c.setPlaceholderText(index, tableIndex, key, text)
|
||||
}
|
||||
|
||||
// Save 把全部内存修改写回源文件(原子写:临时文件 + rename)。
|
||||
func (s *EditorService) Save() (ArchiveInfo, error) {
|
||||
progress := archiveSaveProgressEmitter()
|
||||
s.c.mu.Lock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, ErrNoArchive
|
||||
}
|
||||
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, err
|
||||
}
|
||||
if a.SourcePath() == "" {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, fmt.Errorf("归档没有源文件,请使用另存为")
|
||||
}
|
||||
if s.shouldBackupSource() {
|
||||
if err := backupSourceFileWithProgress(a.SourcePath(), progress); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, err
|
||||
}
|
||||
}
|
||||
if err := a.SaveWithProgress(progress); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, err
|
||||
}
|
||||
s.c.mcpSourceHash = a.SourceHash()
|
||||
s.c.mcpSaveSequence++
|
||||
s.c.markAdvancedSearchStaleLocked(a)
|
||||
saveMutations := a.MutationsSince(0)
|
||||
a.ClearMutations()
|
||||
if err := s.c.markVersionArtifactSavedLocked(a.SourcePath()); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return ArchiveInfo{}, err
|
||||
}
|
||||
info := a.Info()
|
||||
sourcePath := a.SourcePath()
|
||||
s.c.mu.Unlock()
|
||||
if err := s.c.scheduleArchiveMutations(a, saveMutations); err != nil {
|
||||
return ArchiveInfo{}, err
|
||||
}
|
||||
s.c.persistCurrentSQLiteIndexAsync()
|
||||
s.c.persistCurrentSearchIndexCacheAsync()
|
||||
if s.c.autosave != nil {
|
||||
// The workspace is on disk now, so the backup slot has nothing left to
|
||||
// protect. A cache belonging to another archive is kept.
|
||||
s.c.autosave.DropForSource(sourcePath)
|
||||
}
|
||||
emitEvent("archive:saved", info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (s *EditorService) shouldBackupSource() bool {
|
||||
if s.settings == nil {
|
||||
return true
|
||||
}
|
||||
settings, err := s.settings.GetSettings()
|
||||
if err != nil {
|
||||
// 读取设置失败时使用安全默认值,不要因为配置文件问题阻止保存。
|
||||
return true
|
||||
}
|
||||
return settings.BackupSourceOnSave
|
||||
}
|
||||
|
||||
func backupSourceFile(sourcePath string) error {
|
||||
return backupSourceFileWithProgress(sourcePath, nil)
|
||||
}
|
||||
|
||||
// Progress events are throttled per operation; phase transitions and the final
|
||||
// count always arrive, without flooding the webview for every page or chunk.
|
||||
func archiveSaveProgressEmitter() pvf.SaveProgressFunc {
|
||||
var lastPhase string
|
||||
var lastEmit time.Time
|
||||
return func(progress pvf.SaveProgress) {
|
||||
now := time.Now()
|
||||
complete := progress.Total > 0 && progress.Done >= progress.Total
|
||||
if progress.Phase != lastPhase || complete || now.Sub(lastEmit) >= 100*time.Millisecond {
|
||||
emitEvent("archive:save-progress", progress)
|
||||
lastPhase, lastEmit = progress.Phase, now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type backupProgressWriter struct {
|
||||
writer io.Writer
|
||||
done, total int64
|
||||
progress pvf.SaveProgressFunc
|
||||
}
|
||||
|
||||
func (w *backupProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := w.writer.Write(p)
|
||||
w.done += int64(n)
|
||||
w.progress(pvf.SaveProgress{Phase: "backup", Done: w.done, Total: w.total, Unit: "bytes"})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func backupSourceFileWithProgress(sourcePath string, progress pvf.SaveProgressFunc) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开源文件以创建备份失败: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
info, err := source.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取源文件信息失败: %w", err)
|
||||
}
|
||||
|
||||
backupPath := sourcePath + ".bak"
|
||||
temp, err := os.CreateTemp(filepath.Dir(backupPath), "."+filepath.Base(backupPath)+"-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建源文件备份临时文件失败: %w", err)
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
mode := info.Mode().Perm()
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("设置源文件备份权限失败: %w", err)
|
||||
}
|
||||
var destination io.Writer = temp
|
||||
if progress != nil {
|
||||
progress(pvf.SaveProgress{Phase: "backup", Total: info.Size(), Unit: "bytes"})
|
||||
destination = &backupProgressWriter{writer: temp, total: info.Size(), progress: progress}
|
||||
}
|
||||
if _, err := io.CopyBuffer(destination, source, make([]byte, 1<<20)); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("写入源文件备份失败: %w", err)
|
||||
}
|
||||
if progress != nil {
|
||||
progress(pvf.SaveProgress{Phase: "backup-sync"})
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("同步源文件备份失败: %w", err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭源文件备份失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, backupPath); err != nil {
|
||||
return fmt.Errorf("替换源文件备份失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAsDialog 弹出保存对话框并另存为新 PVF。返回保存路径。
|
||||
func (s *EditorService) SaveAsDialog() (string, error) {
|
||||
finishTask := s.c.archiveTasks.begin()
|
||||
defer finishTask()
|
||||
s.c.mu.RLock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
s.c.mu.RUnlock()
|
||||
return "", ErrNoArchive
|
||||
}
|
||||
s.c.mu.RUnlock()
|
||||
defName := "Script_new.pvf"
|
||||
if src := a.SourcePath(); src != "" {
|
||||
defName = filepath.Base(src)
|
||||
}
|
||||
path, err := application.Get().Dialog.SaveFile().
|
||||
SetFilename(defName).
|
||||
AddFilter("PVF 归档", "*.pvf").
|
||||
SetMessage("另存为 PVF").
|
||||
PromptForSingleSelection()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", nil // 用户取消
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
sameArchive := s.c.archive == a
|
||||
s.c.mu.RUnlock()
|
||||
if !sameArchive {
|
||||
return "", ErrNoArchive
|
||||
}
|
||||
return s.saveCopy(path, true, "")
|
||||
}
|
||||
|
||||
type exportSelection struct {
|
||||
index int32
|
||||
path string
|
||||
}
|
||||
|
||||
// ExportFilesDialog 将选中的文件或目录导出到目标目录,文件内容使用渲染后的 UTF-8 文本。
|
||||
// 目录会递归展开,并保留归档内的相对路径。
|
||||
func (s *EditorService) ExportFilesDialog(scopes []string) (string, error) {
|
||||
finishTask := s.c.archiveTasks.begin()
|
||||
defer finishTask()
|
||||
s.c.mu.RLock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
s.c.mu.RUnlock()
|
||||
return "", ErrNoArchive
|
||||
}
|
||||
var selections []exportSelection
|
||||
if s.c.diskIndex != nil {
|
||||
selections = collectDiskExportSelections(a, s.c.diskIndex, scopes)
|
||||
} else {
|
||||
selections = collectExportSelections(a, s.c.sortedPaths, scopes)
|
||||
}
|
||||
s.c.mu.RUnlock()
|
||||
if len(selections) == 0 {
|
||||
return "", fmt.Errorf("没有可导出的文件")
|
||||
}
|
||||
dir, err := application.Get().Dialog.OpenFile().
|
||||
CanChooseFiles(false).
|
||||
CanChooseDirectories(true).
|
||||
CanCreateDirectories(true).
|
||||
SetTitle("选择导出目标目录").
|
||||
PromptForSingleSelection()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dir == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
if s.c.archive != a {
|
||||
return "", ErrNoArchive
|
||||
}
|
||||
for _, selection := range selections {
|
||||
text, err := a.Text(selection.index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染 %q 失败: %w", selection.path, err)
|
||||
}
|
||||
dst, err := safeExportPath(dir, selection.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte(text), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func collectDiskExportSelections(a *pvf.Archive, index *sqliteArchiveIndex, scopes []string) []exportSelection {
|
||||
selections := make([]exportSelection, 0, len(scopes))
|
||||
seen := make(map[string]struct{}, len(scopes))
|
||||
add := func(fileIndex int32, filePath string) {
|
||||
filePath = normalizeExportPath(filePath)
|
||||
if filePath == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[filePath]; ok {
|
||||
return
|
||||
}
|
||||
seen[filePath] = struct{}{}
|
||||
selections = append(selections, exportSelection{index: fileIndex, path: filePath})
|
||||
}
|
||||
for _, rawScope := range scopes {
|
||||
scope := normalizeExportPath(rawScope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if fileIndex, ok := a.Find(scope); ok {
|
||||
add(fileIndex, a.Path(fileIndex))
|
||||
continue
|
||||
}
|
||||
nodes, err := index.descendants(scope)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, node := range nodes {
|
||||
add(node.FileIndex, node.Path)
|
||||
}
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
func collectExportSelections(a *pvf.Archive, sortedPaths []pathEntry, scopes []string) []exportSelection {
|
||||
selections := make([]exportSelection, 0, len(scopes))
|
||||
seenPaths := make(map[string]struct{}, len(scopes))
|
||||
add := func(index int32, path string) {
|
||||
path = normalizeExportPath(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seenPaths[path]; exists {
|
||||
return
|
||||
}
|
||||
seenPaths[path] = struct{}{}
|
||||
selections = append(selections, exportSelection{index: index, path: path})
|
||||
}
|
||||
|
||||
for _, rawScope := range scopes {
|
||||
scope := normalizeExportPath(rawScope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if index, ok := a.Find(scope); ok {
|
||||
add(index, a.Path(index))
|
||||
continue
|
||||
}
|
||||
|
||||
prefix := scope + "/"
|
||||
start := sort.Search(len(sortedPaths), func(i int) bool {
|
||||
return sortedPaths[i].path >= prefix
|
||||
})
|
||||
for _, entry := range sortedPaths[start:] {
|
||||
if !strings.HasPrefix(entry.path, prefix) {
|
||||
break
|
||||
}
|
||||
add(entry.idx, entry.path)
|
||||
}
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
func normalizeExportPath(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
return strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
func safeExportPath(base, internal string) (string, error) {
|
||||
parts := strings.Split(strings.ReplaceAll(internal, "\\", "/"), "/")
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part {
|
||||
case "", ".":
|
||||
continue
|
||||
case "..":
|
||||
return "", fmt.Errorf("导出路径越界: %q", internal)
|
||||
}
|
||||
cleaned = append(cleaned, sanitizeExportName(part))
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return "", fmt.Errorf("导出路径为空: %q", internal)
|
||||
}
|
||||
return filepath.Join(append([]string{base}, cleaned...)...), nil
|
||||
}
|
||||
|
||||
func sanitizeExportName(name string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r < 0x20 || r == 0x7f:
|
||||
return '_'
|
||||
case strings.ContainsRune(`<>:"|?*`, r):
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
}
|
||||
|
||||
// UnpackDialog 选择目录后,在后台协程把整包解包到该目录。
|
||||
// 进度通过事件 "unpack:progress" {done,total} 推送,结束发 "unpack:done"。
|
||||
func (s *EditorService) UnpackDialog() (bool, error) {
|
||||
if !s.c.unpackRunning.CompareAndSwap(false, true) {
|
||||
return false, fmt.Errorf("解包正在进行中")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
a := s.c.archive
|
||||
s.c.mu.RUnlock()
|
||||
if a == nil {
|
||||
s.c.unpackRunning.Store(false)
|
||||
return false, ErrNoArchive
|
||||
}
|
||||
dir, err := application.Get().Dialog.OpenFile().
|
||||
CanChooseFiles(false).
|
||||
CanChooseDirectories(true).
|
||||
CanCreateDirectories(true).
|
||||
SetTitle("选择解包目标目录").
|
||||
PromptForSingleSelection()
|
||||
if err != nil {
|
||||
s.c.unpackRunning.Store(false)
|
||||
return false, err
|
||||
}
|
||||
if dir == "" {
|
||||
s.c.unpackRunning.Store(false)
|
||||
return false, nil // 用户取消
|
||||
}
|
||||
|
||||
finishTask := s.c.archiveTasks.begin()
|
||||
s.c.mu.Lock()
|
||||
if s.c.archive != a || s.c.unpackCancel.Load() {
|
||||
s.c.mu.Unlock()
|
||||
finishTask()
|
||||
s.c.unpackRunning.Store(false)
|
||||
return false, nil
|
||||
}
|
||||
s.c.unpackCancel.Store(false)
|
||||
s.c.mu.Unlock()
|
||||
go func() {
|
||||
defer finishTask()
|
||||
defer s.c.unpackRunning.Store(false)
|
||||
emit := emitEvent
|
||||
total := int(a.FileCount())
|
||||
progress := func(done, tot int) { emit("unpack:progress", map[string]int{"done": done, "total": tot}) }
|
||||
cancel := func() bool { return s.c.unpackCancel.Load() }
|
||||
|
||||
err := a.ExtractTo(dir, progress, cancel)
|
||||
if err == pvf.ErrCancelled {
|
||||
emit("unpack:done", map[string]any{"ok": false, "message": "解包已取消", "dir": dir})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
emit("unpack:done", map[string]any{"ok": false, "message": "解包失败: " + err.Error(), "dir": dir})
|
||||
return
|
||||
}
|
||||
emit("unpack:done", map[string]any{"ok": true, "message": fmt.Sprintf("已解包 %d 个文件到 %s", total, dir), "dir": dir})
|
||||
}()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CancelUnpack 请求中止进行中的解包。
|
||||
func (s *EditorService) CancelUnpack() {
|
||||
s.c.unpackCancel.Store(true)
|
||||
}
|
||||
|
||||
// IsUnpacking 报告解包是否进行中。
|
||||
func (s *EditorService) IsUnpacking() bool { return s.c.unpackRunning.Load() }
|
||||
|
||||
// Binary ANI is a read-only decoded view; native payload/type remain intact for
|
||||
// extraction and byte-identical archive saves across all container families.
|
||||
func (s *EditorService) readBinaryANI(a *pvf.Archive, index int32, meta *FileMeta) (*FileMeta, error) {
|
||||
meta.Editable = false
|
||||
meta.Modified = a.IsModified(index)
|
||||
if meta.Size > s.editableByteLimit() {
|
||||
meta.Text = "; 二进制 ANI 过大,超过文本查看上限。可在编辑器设置中调整。"
|
||||
return meta, nil
|
||||
}
|
||||
text, err := a.Text(index)
|
||||
if err != nil {
|
||||
meta.Text = "; 二进制 ANI 解码失败:" + err.Error()
|
||||
return meta, nil
|
||||
}
|
||||
meta.Text = text
|
||||
return meta, nil
|
||||
}
|
||||
Reference in new issue
Block a user