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:
@@ -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:
|
||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||
|
||||
@@ -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")
|
||||
|
||||
+3
-3
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -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"
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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("Проверка обновлений…");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+3
-3
@@ -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"
|
||||
},
|
||||
|
||||
+4
-4
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user