Release 3.8.2.12: export/import full server list as JSON.
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:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user