diff --git a/README.md b/README.md
index 0a0dbfe..92ef870 100644
--- a/README.md
+++ b/README.md
@@ -261,7 +261,8 @@ https://evilfox.win/
- 3.8.2+8: дешёвый idle rev-cache; иконка без 400 ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
-- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей.
+- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей;
+- 3.8.2+12: экспорт/импорт списка серверов в JSON (замена при импорте).
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 03163b7..451973c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
- versionCode = 3080211
- versionName = "3.8.2+11"
+ versionCode = 3080212
+ versionName = "3.8.2+12"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
diff --git a/build-macos.bat b/build-macos.bat
index acf956c..ac63974 100644
--- a/build-macos.bat
+++ b/build-macos.bat
@@ -42,11 +42,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.11 -arch arm64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.12 -arch arm64
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.11 -arch amd64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.12 -arch amd64
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.11 -arch universal
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.12 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
diff --git a/dist/navis-release/Navis-3.8.2.12.exe b/dist/navis-release/Navis-3.8.2.12.exe
new file mode 100644
index 0000000..1445450
Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.12.exe differ
diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe
index 53e32e2..1445450 100644
Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index 3b86876..5e51212 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,16 +1,16 @@
{
"version": "3.8.2",
- "notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
+ "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows",
"arch": "amd64"
},
diff --git a/dist/navis-release/vpnclient-3.8.2.12.exe b/dist/navis-release/vpnclient-3.8.2.12.exe
new file mode 100644
index 0000000..73bfba2
Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.12.exe differ
diff --git a/dist/navis-release/vpnclient.exe b/dist/navis-release/vpnclient.exe
index 66634c4..73bfba2 100644
Binary files a/dist/navis-release/vpnclient.exe and b/dist/navis-release/vpnclient.exe differ
diff --git a/dist/update.json b/dist/update.json
index 3b86876..5e51212 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,16 +1,16 @@
{
"version": "3.8.2",
- "notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
+ "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows",
"arch": "amd64"
},
diff --git a/internal/apphost/app.go b/internal/apphost/app.go
index 41c607f..e9b5eb6 100644
--- a/internal/apphost/app.go
+++ b/internal/apphost/app.go
@@ -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":
diff --git a/internal/appui/app.js b/internal/appui/app.js
index fade72d..3e9dd4f 100644
--- a/internal/appui/app.js
+++ b/internal/appui/app.js
@@ -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("Проверка обновлений…");
diff --git a/internal/appui/index.html b/internal/appui/index.html
index bcb06d5..cf180d6 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -79,6 +79,8 @@
+
+
diff --git a/internal/config/servers_file.go b/internal/config/servers_file.go
new file mode 100644
index 0000000..71a648c
--- /dev/null
+++ b/internal/config/servers_file.go
@@ -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()
+}
diff --git a/internal/config/servers_file_test.go b/internal/config/servers_file_test.go
new file mode 100644
index 0000000..04c2871
--- /dev/null
+++ b/internal/config/servers_file_test.go
@@ -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")
+ }
+}
diff --git a/internal/core/manager.go b/internal/core/manager.go
index 63e7253..cc46664 100644
--- a/internal/core/manager.go
+++ b/internal/core/manager.go
@@ -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()
diff --git a/internal/filedialog/filedialog.go b/internal/filedialog/filedialog.go
new file mode 100644
index 0000000..cb8e45b
--- /dev/null
+++ b/internal/filedialog/filedialog.go
@@ -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")
diff --git a/internal/filedialog/filedialog_darwin.go b/internal/filedialog/filedialog_darwin.go
new file mode 100644
index 0000000..7a54ed4
--- /dev/null
+++ b/internal/filedialog/filedialog_darwin.go
@@ -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
+}
diff --git a/internal/filedialog/filedialog_other.go b/internal/filedialog/filedialog_other.go
new file mode 100644
index 0000000..d15a768
--- /dev/null
+++ b/internal/filedialog/filedialog_other.go
@@ -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
+}
diff --git a/internal/filedialog/filedialog_windows.go b/internal/filedialog/filedialog_windows.go
new file mode 100644
index 0000000..67de5bd
--- /dev/null
+++ b/internal/filedialog/filedialog_windows.go
@@ -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
+}
diff --git a/internal/update/update.go b/internal/update/update.go
index e63beb1..5d5d234 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -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"
diff --git a/server/update.json b/server/update.json
index 3b86876..5e51212 100644
--- a/server/update.json
+++ b/server/update.json
@@ -1,16 +1,16 @@
{
"version": "3.8.2",
- "notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
+ "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
+ "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows",
"arch": "amd64"
},
diff --git a/versioninfo.json b/versioninfo.json
index c07caea..5a2263c 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -4,13 +4,13 @@
"Major": 3,
"Minor": 8,
"Patch": 2,
- "Build": 11
+ "Build": 12
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
"Patch": 2,
- "Build": 11
+ "Build": 12
},
"FileFlagsMask": "3f",
"FileFlags": "00",
@@ -21,12 +21,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
- "FileVersion": "3.8.2.11",
+ "FileVersion": "3.8.2.12",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
- "ProductVersion": "3.8.2.11",
+ "ProductVersion": "3.8.2.12",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {