Release 3.8.2.12: export/import full server list as JSON.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Save and replace profiles via system file dialog; buttons after Best; prefs unchanged on import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 17:51:38 +03:00
co-authored by Cursor
parent 57d719f02f
commit 8b564110d5
22 changed files with 604 additions and 22 deletions
+132
View File
@@ -0,0 +1,132 @@
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()
}