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()
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestServersFileRoundTrip(t *testing.T) {
cfg := &Config{
Active: "b",
Profiles: []Profile{
{Name: "a", Protocol: ProtocolNaive, Proxy: "https://u:p@a.example"},
{Name: "b", Protocol: ProtocolHysteria2, Proxy: "hy2://tok@b.example:443"},
},
SystemProxy: true,
ConnectOnLaunch: true,
SubscriptionURL: "https://sub.example/x",
}
dir := t.TempDir()
path := filepath.Join(dir, "servers.json")
n, err := WriteServersFile(path, cfg)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("exported %d, want 2", n)
}
other := &Config{
Active: "old",
SystemProxy: false,
ConnectOnLaunch: false,
SubscriptionURL: "https://keep.example",
SubscriptionNames: []string{"old"},
Profiles: []Profile{
{Name: "old", Protocol: ProtocolNaive, Proxy: "https://old"},
},
}
doc, err := ReadServersFile(path)
if err != nil {
t.Fatal(err)
}
if err := other.ApplyServersReplace(doc); err != nil {
t.Fatal(err)
}
if other.SubscriptionURL != "https://keep.example" {
t.Fatalf("prefs changed: %q", other.SubscriptionURL)
}
if other.SystemProxy {
t.Fatal("system_proxy should stay false")
}
if len(other.Profiles) != 2 || other.Active != "b" {
t.Fatalf("got active=%q profiles=%+v", other.Active, other.Profiles)
}
if other.SubscriptionNames != nil {
t.Fatalf("subscription_names should clear, got %v", other.SubscriptionNames)
}
}
func TestParseServersFileBareArray(t *testing.T) {
doc, err := ParseServersFile([]byte(`[{"name":"x","protocol":"naive","proxy":"https://x"}]`))
if err != nil {
t.Fatal(err)
}
if len(doc.Profiles) != 1 || doc.Active != "x" {
t.Fatalf("unexpected: %+v", doc)
}
}
func TestParseServersFileEmptyRejected(t *testing.T) {
if _, err := ParseServersFile([]byte(`{"profiles":[]}`)); err == nil {
t.Fatal("expected error")
}
if _, err := ParseServersFile([]byte(`[]`)); err == nil {
t.Fatal("expected error")
}
}
func TestWriteServersFilePermissions(t *testing.T) {
cfg := &Config{
Active: "a",
Profiles: []Profile{{Name: "a", Protocol: ProtocolNaive}},
}
path := filepath.Join(t.TempDir(), "out.json")
if _, err := WriteServersFile(path, cfg); err != nil {
t.Fatal(err)
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Size() == 0 {
t.Fatal("empty file")
}
}