Save and replace profiles via system file dialog; buttons after Best; prefs unchanged on import. Co-authored-by: Cursor <cursoragent@cursor.com>
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
ServersFileFormat = "navis-servers"
|
|
ServersFileVersion = 1
|
|
)
|
|
|
|
// ServersFile is the on-disk export/import format for the profile list.
|
|
type ServersFile struct {
|
|
Format string `json:"format,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
Active string `json:"active,omitempty"`
|
|
Profiles []Profile `json:"profiles"`
|
|
}
|
|
|
|
// ExportServersFile builds a portable snapshot of profiles + active name.
|
|
func ExportServersFile(cfg *Config) ServersFile {
|
|
profiles := append([]Profile(nil), cfg.Profiles...)
|
|
return ServersFile{
|
|
Format: ServersFileFormat,
|
|
Version: ServersFileVersion,
|
|
Active: cfg.Active,
|
|
Profiles: profiles,
|
|
}
|
|
}
|
|
|
|
// WriteServersFile writes the export snapshot as indented JSON (0600).
|
|
func WriteServersFile(path string, cfg *Config) (int, error) {
|
|
doc := ExportServersFile(cfg)
|
|
data, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
data = append(data, '\n')
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(doc.Profiles), nil
|
|
}
|
|
|
|
// ParseServersFile parses export JSON. Accepts:
|
|
// - { "format":"navis-servers", "profiles":[...], "active":"..." }
|
|
// - { "profiles":[...] }
|
|
// - a bare JSON array of profiles
|
|
func ParseServersFile(data []byte) (ServersFile, error) {
|
|
data = bytes.TrimSpace(stripUTF8BOM(data))
|
|
if len(data) == 0 {
|
|
return ServersFile{}, fmt.Errorf("пустой файл списка серверов")
|
|
}
|
|
|
|
var doc ServersFile
|
|
if data[0] == '[' {
|
|
var profiles []Profile
|
|
if err := json.Unmarshal(data, &profiles); err != nil {
|
|
return ServersFile{}, fmt.Errorf("разбор списка серверов: %w", err)
|
|
}
|
|
doc.Profiles = profiles
|
|
} else {
|
|
if err := json.Unmarshal(data, &doc); err != nil {
|
|
return ServersFile{}, fmt.Errorf("разбор файла серверов: %w", err)
|
|
}
|
|
}
|
|
|
|
if doc.Format != "" && doc.Format != ServersFileFormat {
|
|
return ServersFile{}, fmt.Errorf("неизвестный формат %q (ожидается %s)", doc.Format, ServersFileFormat)
|
|
}
|
|
if err := normalizeServersFile(&doc); err != nil {
|
|
return ServersFile{}, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// ReadServersFile loads and validates a servers export file.
|
|
func ReadServersFile(path string) (ServersFile, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ServersFile{}, err
|
|
}
|
|
return ParseServersFile(data)
|
|
}
|
|
|
|
func normalizeServersFile(doc *ServersFile) error {
|
|
if len(doc.Profiles) == 0 {
|
|
return fmt.Errorf("в файле нет серверов")
|
|
}
|
|
seen := make(map[string]struct{}, len(doc.Profiles))
|
|
for i := range doc.Profiles {
|
|
p := &doc.Profiles[i]
|
|
p.Name = strings.TrimSpace(p.Name)
|
|
if p.Name == "" {
|
|
return fmt.Errorf("профиль[%d]: пустое имя", i)
|
|
}
|
|
if _, ok := seen[p.Name]; ok {
|
|
return fmt.Errorf("дублируется имя профиля %q", p.Name)
|
|
}
|
|
seen[p.Name] = struct{}{}
|
|
switch p.Protocol {
|
|
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan:
|
|
case "":
|
|
p.Protocol = ProtocolNaive
|
|
default:
|
|
return fmt.Errorf("профиль %q: неподдерживаемый протокол %q", p.Name, p.Protocol)
|
|
}
|
|
}
|
|
doc.Active = strings.TrimSpace(doc.Active)
|
|
if doc.Active == "" {
|
|
doc.Active = doc.Profiles[0].Name
|
|
} else if _, ok := seen[doc.Active]; !ok {
|
|
doc.Active = doc.Profiles[0].Name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyServersReplace replaces profiles with the import document (prefs untouched).
|
|
func (c *Config) ApplyServersReplace(doc ServersFile) error {
|
|
if err := normalizeServersFile(&doc); err != nil {
|
|
return err
|
|
}
|
|
c.Profiles = append([]Profile(nil), doc.Profiles...)
|
|
c.Active = doc.Active
|
|
c.SubscriptionNames = nil
|
|
InvalidateHostCache()
|
|
return c.Validate()
|
|
}
|