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+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+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
|
||||||
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
|
- 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:
|
В 3.8.1:
|
||||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||||
versionCode = 3080211
|
versionCode = 3080212
|
||||||
versionName = "3.8.2+11"
|
versionName = "3.8.2+12"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
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
|
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
||||||
if errorlevel 1 exit /b 1
|
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
|
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
|
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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
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",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
"notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
"notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
"vpnclient/internal/corebin"
|
"vpnclient/internal/corebin"
|
||||||
"vpnclient/internal/dockicon"
|
"vpnclient/internal/dockicon"
|
||||||
|
"vpnclient/internal/filedialog"
|
||||||
"vpnclient/internal/logbuf"
|
"vpnclient/internal/logbuf"
|
||||||
"vpnclient/internal/netcheck"
|
"vpnclient/internal/netcheck"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
@@ -508,6 +510,44 @@ func (a *App) ImportSubscription(rawURL string) (int, error) {
|
|||||||
return n, err
|
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) {
|
func (a *App) PingServers() ([]netcheck.Result, error) {
|
||||||
return a.runPings()
|
return a.runPings()
|
||||||
}
|
}
|
||||||
@@ -760,6 +800,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
return nil, a.SaveHy2(opts)
|
return nil, a.SaveHy2(opts)
|
||||||
case "importSubscription":
|
case "importSubscription":
|
||||||
return a.ImportSubscription(arg(args, 0, ""))
|
return a.ImportSubscription(arg(args, 0, ""))
|
||||||
|
case "exportServers":
|
||||||
|
return a.ExportServers()
|
||||||
|
case "importServers":
|
||||||
|
return a.ImportServers()
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
return a.GetLogs(), nil
|
return a.GetLogs(), nil
|
||||||
case "probeTunnel":
|
case "probeTunnel":
|
||||||
|
|||||||
+38
-2
@@ -3,7 +3,8 @@
|
|||||||
const methods = [
|
const methods = [
|
||||||
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
"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;
|
if (typeof window.getState === "function") return;
|
||||||
window.__navisHttp = true;
|
window.__navisHttp = true;
|
||||||
@@ -49,6 +50,8 @@
|
|||||||
const shopLink = $("shopLink");
|
const shopLink = $("shopLink");
|
||||||
const pingBtn = $("pingBtn");
|
const pingBtn = $("pingBtn");
|
||||||
const bestBtn = $("bestBtn");
|
const bestBtn = $("bestBtn");
|
||||||
|
const exportServersBtn = $("exportServersBtn");
|
||||||
|
const importServersBtn = $("importServersBtn");
|
||||||
const autoBest = $("autoBest");
|
const autoBest = $("autoBest");
|
||||||
const serverList = $("serverList");
|
const serverList = $("serverList");
|
||||||
const serverSearch = $("serverSearch");
|
const serverSearch = $("serverSearch");
|
||||||
@@ -425,6 +428,8 @@
|
|||||||
coreBtn.disabled = busy;
|
coreBtn.disabled = busy;
|
||||||
pingBtn.disabled = busy;
|
pingBtn.disabled = busy;
|
||||||
bestBtn.disabled = busy;
|
bestBtn.disabled = busy;
|
||||||
|
if (exportServersBtn) exportServersBtn.disabled = busy;
|
||||||
|
if (importServersBtn) importServersBtn.disabled = busy || connected;
|
||||||
updCheckBtn.disabled = busy;
|
updCheckBtn.disabled = busy;
|
||||||
updateBtn.disabled = busy;
|
updateBtn.disabled = busy;
|
||||||
subBtn.disabled = busy;
|
subBtn.disabled = busy;
|
||||||
@@ -536,7 +541,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paintButtonsLocked(locked) {
|
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;
|
if (b) b.disabled = locked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -699,6 +704,37 @@
|
|||||||
} catch (e) { setMeta(String(e), "err"); }
|
} 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 () => {
|
updCheckBtn.addEventListener("click", () => withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
setMeta("Проверка обновлений…");
|
setMeta("Проверка обновлений…");
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
<div class="server-actions">
|
<div class="server-actions">
|
||||||
<button class="mini" id="pingBtn" type="button">Пинг</button>
|
<button class="mini" id="pingBtn" type="button">Пинг</button>
|
||||||
<button class="mini accent" id="bestBtn" 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" 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>
|
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
|
||||||
</div>
|
</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)
|
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 {
|
func (m *Manager) Profiles() []config.ProfileInfo {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// 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).
|
// 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"
|
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
"notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -4,13 +4,13 @@
|
|||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 11
|
"Build": 12
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 11
|
"Build": 12
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.8.2.11",
|
"FileVersion": "3.8.2.12",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.8.2.11",
|
"ProductVersion": "3.8.2.12",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user