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": {