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
+44
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -20,6 +21,7 @@ import (
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/filedialog"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
@@ -508,6 +510,44 @@ func (a *App) ImportSubscription(rawURL string) (int, error) {
return n, err
}
// ExportServers opens a Save dialog and writes the full profile list.
func (a *App) ExportServers() (int, error) {
path, err := filedialog.SaveJSON("Сохранить список серверов", "navis-servers.json")
if err != nil {
if errors.Is(err, filedialog.ErrCanceled) {
return 0, nil
}
return 0, err
}
n, err := a.Mgr.ExportServers(path)
if err != nil {
return 0, err
}
a.NotifyState("profile")
return n, nil
}
// ImportServers opens an Open dialog and replaces the profile list.
func (a *App) ImportServers() (int, error) {
path, err := filedialog.OpenJSON("Загрузить список серверов")
if err != nil {
if errors.Is(err, filedialog.ErrCanceled) {
return 0, nil
}
return 0, err
}
n, err := a.Mgr.ImportServersReplace(path)
if err != nil {
return 0, err
}
a.mu.Lock()
a.Pings = nil
a.mu.Unlock()
a.NotifyState("profile")
go a.warmActiveCore()
return n, nil
}
func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
@@ -760,6 +800,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "exportServers":
return a.ExportServers()
case "importServers":
return a.ImportServers()
case "getLogs":
return a.GetLogs(), nil
case "probeTunnel":
+38 -2
View File
@@ -3,7 +3,8 @@
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit"
"checkUpdate","applyUpdate","saveHy2","importSubscription","exportServers","importServers",
"getLogs","probeTunnel","savePrefs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
@@ -49,6 +50,8 @@
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const exportServersBtn = $("exportServersBtn");
const importServersBtn = $("importServersBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const serverSearch = $("serverSearch");
@@ -425,6 +428,8 @@
coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
if (exportServersBtn) exportServersBtn.disabled = busy;
if (importServersBtn) importServersBtn.disabled = busy || connected;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
@@ -536,7 +541,7 @@
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, exportServersBtn, importServersBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
if (b) b.disabled = locked;
});
}
@@ -699,6 +704,37 @@
} catch (e) { setMeta(String(e), "err"); }
}));
if (exportServersBtn) exportServersBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Экспорт списка…");
const n = await exportServers();
if (!n) {
setMeta("Экспорт отменён");
return;
}
setMeta("Экспортировано серверов: " + n, "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
if (importServersBtn) importServersBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Заменить весь список серверами из файла? Текущие профили будут удалены.")) {
return;
}
setMeta("Импорт списка…");
const n = await importServers();
if (!n) {
setMeta("Импорт отменён");
return;
}
pingMap = {};
dirty = false;
formHydrated = false;
await refresh({ syncForm: true, force: true });
setMeta("Импортировано серверов: " + n + " (список заменён)", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
updCheckBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка обновлений…");
+2
View File
@@ -79,6 +79,8 @@
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="mini" id="exportServersBtn" type="button" title="Сохранить список в файл">Экспорт</button>
<button class="mini" id="importServersBtn" type="button" title="Заменить список из файла">Импорт</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
</div>
+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")
}
}
+31
View File
@@ -629,6 +629,37 @@ func (m *Manager) DeleteProfile(name string) error {
return config.Save(m.cfgPath, *m.cfg)
}
// ExportServers writes the current profile list (with secrets) to path.
func (m *Manager) ExportServers(path string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return config.WriteServersFile(path, m.cfg)
}
// ImportServersReplace replaces all profiles with those from path.
// Prefs (system proxy, reconnect, subscription URL) are preserved.
func (m *Manager) ImportServersReplace(path string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return 0, fmt.Errorf("сначала отключитесь")
}
doc, err := config.ReadServersFile(path)
if err != nil {
return 0, err
}
if err := m.cfg.ApplyServersReplace(doc); err != nil {
return 0, err
}
for i := range m.cfg.Profiles {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
}
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(m.cfg.Profiles), nil
}
func (m *Manager) Profiles() []config.ProfileInfo {
m.mu.Lock()
defer m.mu.Unlock()
+9
View File
@@ -0,0 +1,9 @@
package filedialog
import "errors"
// ErrCanceled is returned when the user closes the dialog without choosing a file.
var ErrCanceled = errors.New("filedialog: canceled")
// ErrUnsupported is returned on platforms without a native picker.
var ErrUnsupported = errors.New("filedialog: not supported on this platform")
+79
View File
@@ -0,0 +1,79 @@
//go:build darwin
package filedialog
import (
"os/exec"
"strings"
)
func appleScriptQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
script := `try
set thePath to POSIX path of (choose file name with prompt "` + appleScriptQuote(title) + `" default name "` + appleScriptQuote(defaultName) + `")
return thePath
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if strings.Contains(string(out), "canceled") || strings.Contains(err.Error(), "canceled") {
return "", ErrCanceled
}
// osascript writes AppleScript errors to stderr; treat cancel-ish exits as cancel.
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
if !strings.HasSuffix(strings.ToLower(path), ".json") {
path += ".json"
}
return path, nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
script := `try
set theFile to choose file with prompt "` + appleScriptQuote(title) + `" of type {"public.json", "json", "public.plain-text"}
return POSIX path of theFile
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if exit, ok := err.(*exec.ExitError); ok {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !darwin
package filedialog
// SaveJSON is not available on this platform.
func SaveJSON(title, defaultName string) (string, error) {
return "", ErrUnsupported
}
// OpenJSON is not available on this platform.
func OpenJSON(title string) (string, error) {
return "", ErrUnsupported
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows
package filedialog
import (
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
procGetOpenFileName = comdlg32.NewProc("GetOpenFileNameW")
procGetSaveFileName = comdlg32.NewProc("GetSaveFileNameW")
)
const (
ofnExplorer = 0x00080000
ofnFileMustExist = 0x00001000
ofnPathMustExist = 0x00000800
ofnOverwritePrompt = 0x00000002
ofnHideReadOnly = 0x00000004
maxPath = 32768
)
type openFileNameW struct {
LStructSize uint32
HwndOwner windows.HWND
HInstance windows.Handle
LpstrFilter *uint16
LpstrCustomFilter *uint16
NMaxCustFilter uint32
NFilterIndex uint32
LpstrFile *uint16
NMaxFile uint32
LpstrFileTitle *uint16
NMaxFileTitle uint32
LpstrInitialDir *uint16
LpstrTitle *uint16
Flags uint32
NFileOffset uint16
NFileExtension uint16
LpstrDefExt *uint16
LCustData uintptr
LpfnHook uintptr
LpTemplateName *uint16
PvReserved unsafe.Pointer
DwReserved uint32
FlagsEx uint32
}
func jsonFilter() *uint16 {
// Double-null terminated pairs: label\0pattern\0label\0pattern\0\0
s := "JSON (*.json)\x00*.json\x00All files\x00*.*\x00"
return &utf16FromString(s)[0]
}
func utf16FromString(s string) []uint16 {
u, err := syscall.UTF16FromString(s)
if err != nil {
return []uint16{0}
}
return u
}
func ensureJSONExt(path string) string {
if strings.EqualFold(filepath.Ext(path), ".json") {
return path
}
return path + ".json"
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
buf := make([]uint16, maxPath)
copy(buf, utf16FromString(defaultName))
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
defExt, _ := syscall.UTF16PtrFromString("json")
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnPathMustExist | ofnOverwritePrompt | ofnHideReadOnly,
LpstrDefExt: defExt,
}
r, _, _ := procGetSaveFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return ensureJSONExt(path), nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
buf := make([]uint16, maxPath)
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnFileMustExist | ofnPathMustExist | ofnHideReadOnly,
}
r, _, _ := procGetOpenFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 11
const BuildNumber = 12
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"