Release 1.2.0 Windows amd64 with Hysteria 2
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/netcheck"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/update"
|
||||
)
|
||||
@@ -93,10 +94,7 @@ func main() {
|
||||
_ = os.MkdirAll(dataPath, 0o755)
|
||||
|
||||
go func() {
|
||||
if _, err := naive.ResolveBinary(mgr.BinDir()); err == nil {
|
||||
return
|
||||
}
|
||||
_, _ = naive.EnsureBinary(mgr.BinDir())
|
||||
_, _ = mgr.EnsureAllCores()
|
||||
}()
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
@@ -160,7 +158,22 @@ func (a *app) getState() (uiState, error) {
|
||||
proxy = p.Proxy
|
||||
active = p.Name
|
||||
}
|
||||
corePath, coreErr := naive.ResolveBinary(a.mgr.BinDir())
|
||||
corePath := ""
|
||||
coreReady := false
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
switch p.Protocol {
|
||||
case config.ProtocolHysteria2:
|
||||
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
default:
|
||||
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
}
|
||||
} else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
out := uiState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
@@ -170,7 +183,7 @@ func (a *app) getState() (uiState, error) {
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: cfg.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreErr == nil,
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.cfgPath,
|
||||
Profiles: cfg.ListProfiles(),
|
||||
@@ -178,6 +191,11 @@ func (a *app) getState() (uiState, error) {
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
out.Protocol = string(p.Protocol)
|
||||
}
|
||||
}
|
||||
if st.Connected {
|
||||
out.SystemProxy = st.SystemProxy
|
||||
}
|
||||
@@ -228,7 +246,7 @@ func (a *app) connect() error {
|
||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||
}
|
||||
|
||||
if _, err := naive.EnsureBinary(a.mgr.BinDir()); err != nil {
|
||||
if _, err := a.mgr.EnsureCore(""); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
@@ -245,7 +263,15 @@ func (a *app) disconnect() error {
|
||||
func (a *app) installCore() (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return naive.EnsureBinary(a.mgr.BinDir())
|
||||
paths, err := a.mgr.EnsureAllCores()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := make([]string, 0, len(paths))
|
||||
for k, v := range paths {
|
||||
parts = append(parts, k+": "+v)
|
||||
}
|
||||
return strings.Join(parts, " | "), nil
|
||||
}
|
||||
|
||||
func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
@@ -263,7 +289,7 @@ func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"})
|
||||
continue
|
||||
}
|
||||
out = append(out, netcheck.PingProxyURI(ctx, p.Name, proxy))
|
||||
out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy))
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
|
||||
+12
-5
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
|
||||
@@ -110,12 +111,18 @@ func runInstallCore(args []string) int {
|
||||
binDir = "bin"
|
||||
}
|
||||
}
|
||||
path, err := naive.EnsureBinary(binDir)
|
||||
pathNaive, err := naive.EnsureBinary(binDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install-core: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "install-core naive: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("naive core ready: %s\n", path)
|
||||
fmt.Printf("naive core ready: %s\n", pathNaive)
|
||||
pathHy2, err := hysteria2.EnsureBinary(binDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install-core hysteria2: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("hysteria2 core ready: %s\n", pathHy2)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -149,7 +156,7 @@ func runConnect(args []string) int {
|
||||
}
|
||||
|
||||
// Ensure official core is present.
|
||||
if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil {
|
||||
if _, err := mgr.EnsureCore(""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
@@ -203,7 +210,7 @@ func runProbe(args []string) int {
|
||||
}
|
||||
cfg.SystemProxy = false
|
||||
|
||||
if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil {
|
||||
if _, err := mgr.EnsureCore(""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+6
-3
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"version": "1.1.3",
|
||||
"notes": "Тестовый релиз автообновления 1.1.3",
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"mandatory": false
|
||||
}
|
||||
Vendored
+6
-3
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"version": "1.1.3",
|
||||
"notes": "Тестовый релиз автообновления 1.1.3",
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"mandatory": false
|
||||
}
|
||||
@@ -274,7 +274,7 @@
|
||||
</div>
|
||||
<div class="brand-wrap">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<p class="tagline">NaiveProxy · несколько серверов</p>
|
||||
<p class="tagline">NaiveProxy · Hysteria 2 · несколько серверов</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="proxy">Ссылка сервера</label>
|
||||
<input id="proxy" type="text" placeholder="naive+https://user:pass@host:443#name" spellcheck="false" />
|
||||
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
<button class="action ghost" id="pingBtn" type="button">Пинг серверов</button>
|
||||
<button class="action ghost" id="updCheckBtn" type="button">Проверить обновление</button>
|
||||
</div>
|
||||
<button class="action ghost" id="coreBtn" type="button">Установить / обновить naive core</button>
|
||||
<button class="action ghost" id="coreBtn" type="button">Установить cores (naive + hysteria2)</button>
|
||||
</div>
|
||||
<div class="ping-list" id="pingList"></div>
|
||||
<p class="meta" id="meta">Загрузка…</p>
|
||||
@@ -340,7 +340,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="newProxy">Ссылка</label>
|
||||
<input id="newProxy" type="text" placeholder="naive+https://user:pass@host:443#name" />
|
||||
<input id="newProxy" type="text" placeholder="hysteria2://pass@host:443/ или naive+https://…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -400,7 +400,7 @@
|
||||
profiles.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = p.host ? (p.name + " · " + p.host) : p.name;
|
||||
opt.textContent = (p.protocol ? ("[" + p.protocol + "] ") : "") + (p.host ? (p.name + " · " + p.host) : p.name);
|
||||
profile.appendChild(opt);
|
||||
});
|
||||
const want = active || cur || (profiles[0] && profiles[0].name);
|
||||
@@ -482,9 +482,9 @@
|
||||
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
|
||||
detail = parts.join(" · ");
|
||||
} else if (state.core_ready === false) {
|
||||
detail = "Сначала установите official naive.exe";
|
||||
detail = "Сначала установите cores (naive / hysteria2)";
|
||||
} else {
|
||||
detail = "Выберите профиль или создайте новый";
|
||||
detail = "Вставьте ссылку naive или hysteria2://";
|
||||
}
|
||||
if (!busy) setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
}
|
||||
@@ -542,9 +542,9 @@
|
||||
|
||||
coreBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Скачивание official naiveproxy…");
|
||||
setMeta("Скачивание official cores…");
|
||||
const path = await installCore();
|
||||
setMeta("Core установлен: " + path, "ok");
|
||||
setMeta("Cores: " + path, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
ProtocolNaive Protocol = "naive"
|
||||
ProtocolNaive Protocol = "naive"
|
||||
ProtocolHysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// Config is the top-level client configuration.
|
||||
@@ -161,8 +162,10 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("config: profile[%d] missing name", i)
|
||||
}
|
||||
switch p.Protocol {
|
||||
case ProtocolNaive:
|
||||
case ProtocolNaive, ProtocolHysteria2:
|
||||
// Proxy may be empty until the user pastes a share link in the UI.
|
||||
case "":
|
||||
c.Profiles[i].Protocol = ProtocolNaive
|
||||
default:
|
||||
return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// DetectProtocol infers protocol from a share/proxy URI.
|
||||
func DetectProtocol(raw string) Protocol {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
||||
return ProtocolHysteria2
|
||||
case strings.HasPrefix(lower, "naive+"), strings.HasPrefix(lower, "naive://"),
|
||||
strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"),
|
||||
strings.HasPrefix(lower, "http://"):
|
||||
return ProtocolNaive
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -60,16 +60,29 @@ func (c *Config) SetActive(name string) error {
|
||||
|
||||
// UpsertProfile creates or updates a profile by name.
|
||||
func (c *Config) UpsertProfile(name, proxy string) error {
|
||||
return c.UpsertProfileWithProtocol(name, proxy, "")
|
||||
}
|
||||
|
||||
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
|
||||
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proto == "" {
|
||||
proto = DetectProtocol(proxy)
|
||||
}
|
||||
if proto == "" {
|
||||
proto = ProtocolNaive
|
||||
}
|
||||
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == name {
|
||||
c.Profiles[i].Proxy = proxy
|
||||
if c.Profiles[i].Protocol == "" {
|
||||
if proto != "" {
|
||||
c.Profiles[i].Protocol = proto
|
||||
} else if c.Profiles[i].Protocol == "" {
|
||||
c.Profiles[i].Protocol = ProtocolNaive
|
||||
}
|
||||
if len(c.Profiles[i].Listen) == 0 {
|
||||
@@ -82,7 +95,7 @@ func (c *Config) UpsertProfile(name, proxy string) error {
|
||||
|
||||
c.Profiles = append(c.Profiles, Profile{
|
||||
Name: name,
|
||||
Protocol: ProtocolNaive,
|
||||
Protocol: proto,
|
||||
Proxy: proxy,
|
||||
Listen: defaultListen(),
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/sysproxy"
|
||||
)
|
||||
@@ -200,13 +202,20 @@ func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
active, err := m.cfg.ActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, proto, _, err := linknorm.Normalize(active.Protocol, proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.cfg.SetActiveProxy(normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if proto != "" {
|
||||
_ = m.cfg.UpsertProfileWithProtocol(m.cfg.Active, normalized, proto)
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
@@ -229,14 +238,16 @@ func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
var proto config.Protocol
|
||||
if proxy != "" {
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
normalized, detected, _, err := linknorm.Normalize("", proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxy = normalized
|
||||
proto = detected
|
||||
}
|
||||
if err := m.cfg.UpsertProfile(name, proxy); err != nil {
|
||||
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
@@ -254,12 +265,18 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
var proto config.Protocol
|
||||
if proxy != "" {
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
activeProto := config.Protocol("")
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
activeProto = p.Protocol
|
||||
}
|
||||
normalized, detected, _, err := linknorm.Normalize(activeProto, proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxy = normalized
|
||||
proto = detected
|
||||
}
|
||||
|
||||
active := m.cfg.Active
|
||||
@@ -279,7 +296,7 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := m.cfg.SetActiveProxy(proxy); err != nil {
|
||||
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
@@ -330,9 +347,42 @@ func (m *Manager) Reload() error {
|
||||
|
||||
func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
|
||||
switch p {
|
||||
case config.ProtocolNaive:
|
||||
case config.ProtocolNaive, "":
|
||||
return naive.New(m.stderr), nil
|
||||
case config.ProtocolHysteria2:
|
||||
return hysteria2.New(m.stderr), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCore downloads the binary required by the active (or given) protocol.
|
||||
func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
||||
if proto == "" {
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
proto = p.Protocol
|
||||
}
|
||||
}
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
return hysteria2.EnsureBinary(m.binDir)
|
||||
default:
|
||||
return naive.EnsureBinary(m.binDir)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureAllCores installs naive + hysteria2 cores.
|
||||
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
p1, err := naive.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("naive: %w", err)
|
||||
}
|
||||
out["naive"] = p1
|
||||
p2, err := hysteria2.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("hysteria2: %w", err)
|
||||
}
|
||||
out["hysteria2"] = p2
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package linknorm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
|
||||
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
|
||||
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", proto, "", fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
if proto == "" {
|
||||
proto = config.DetectProtocol(raw)
|
||||
}
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
u, rem, err := hysteria2.NormalizeShareLink(raw)
|
||||
return u, config.ProtocolHysteria2, rem, err
|
||||
case config.ProtocolNaive, "":
|
||||
u, rem, err := naive.ParseShareLink(raw)
|
||||
if err != nil {
|
||||
// try hy2 if naive failed and looks like hy
|
||||
if hysteria2.Detect(raw) {
|
||||
u2, rem2, err2 := hysteria2.NormalizeShareLink(raw)
|
||||
return u2, config.ProtocolHysteria2, rem2, err2
|
||||
}
|
||||
return "", config.ProtocolNaive, "", err
|
||||
}
|
||||
return u, config.ProtocolNaive, rem, nil
|
||||
default:
|
||||
return "", proto, "", fmt.Errorf("unsupported protocol %q", proto)
|
||||
}
|
||||
}
|
||||
+47
-27
@@ -4,50 +4,70 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
)
|
||||
|
||||
// Result is a TCP reachability measurement to a proxy host.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Ms int64 `json:"ms"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Ms int64 `json:"ms"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PingProxyURI measures TCP connect time to the host in a naive share/proxy URI.
|
||||
// PingProxyURI measures TCP connect time to the host in a share/proxy URI.
|
||||
func PingProxyURI(ctx context.Context, name, proxyURI string) Result {
|
||||
return PingProfile(ctx, name, "", proxyURI)
|
||||
}
|
||||
|
||||
// PingProfile pings using protocol hints when available.
|
||||
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
|
||||
res := Result{Name: name}
|
||||
if strings.TrimSpace(proxyURI) == "" {
|
||||
res.Error = "нет ссылки сервера"
|
||||
return res
|
||||
}
|
||||
normalized, err := naive.NormalizeProxyURI(proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https", "quic":
|
||||
port = "443"
|
||||
default:
|
||||
port = "80"
|
||||
|
||||
var host, port string
|
||||
if proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) {
|
||||
h, p, err := hysteria2.HostPort(proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
host, port = h, p
|
||||
} else {
|
||||
normalized, _, _, err := linknorm.Normalize(proto, proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
// Parse host from normalized URI without importing net/url dance twice
|
||||
rest := normalized
|
||||
if i := strings.Index(rest, "://"); i >= 0 {
|
||||
rest = rest[i+3:]
|
||||
}
|
||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||
rest = rest[at+1:]
|
||||
}
|
||||
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
host = rest
|
||||
port = "443"
|
||||
if i := strings.LastIndex(rest, ":"); i >= 0 {
|
||||
host = rest[:i]
|
||||
port = rest[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
res.Host = host
|
||||
res.Port = port
|
||||
if host == "" {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
|
||||
|
||||
// ResolveBinary finds hysteria client binary.
|
||||
func ResolveBinary(binDir string) (string, error) {
|
||||
names := candidateNames()
|
||||
candidates := []string{}
|
||||
for _, name := range names {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(binDir, name),
|
||||
filepath.Join(binDir, "hysteria2", name),
|
||||
filepath.Join(binDir, "hysteria", name),
|
||||
)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
for _, name := range names {
|
||||
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
|
||||
}
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hysteria2 binary not found in %s; run install-core", binDir)
|
||||
}
|
||||
|
||||
func candidateNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{
|
||||
"hysteria.exe",
|
||||
"hysteria-windows-amd64.exe",
|
||||
"hysteria-windows-amd64-avx.exe",
|
||||
}
|
||||
}
|
||||
return []string{"hysteria", "hysteria-linux-amd64"}
|
||||
}
|
||||
|
||||
// EnsureBinary downloads official Hysteria 2 release if missing.
|
||||
func EnsureBinary(binDir string) (string, error) {
|
||||
if path, err := ResolveBinary(binDir); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
want, err := releaseAssetName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
destName := "hysteria"
|
||||
if runtime.GOOS == "windows" {
|
||||
destName = "hysteria.exe"
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
tmp := filepath.Join(binDir, name+".download")
|
||||
if err := downloadFile(tmp, url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Remove(dest)
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(dest, 0o755)
|
||||
return ResolveBinary(binDir)
|
||||
}
|
||||
|
||||
func releaseAssetName() (string, error) {
|
||||
switch {
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
|
||||
return "hysteria-windows-amd64.exe", nil
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "arm64":
|
||||
return "hysteria-windows-arm64.exe", nil
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
return "hysteria-linux-amd64", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||
return "hysteria-darwin-amd64", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||
return "hysteria-darwin-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform %s/%s for hysteria2 auto-download", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Prefer exact non-avx name; fall back to avx variant on Windows.
|
||||
var fallback string
|
||||
var fallbackURL string
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
|
||||
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback, fallbackURL, nil
|
||||
}
|
||||
// Prefix match for versioned names
|
||||
for _, a := range rel.Assets {
|
||||
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Engine runs the official apernet/hysteria client (Hysteria 2).
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
workdir string
|
||||
profile config.Profile
|
||||
stderr io.Writer
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(stderr io.Writer) *Engine {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
return &Engine{stderr: stderr}
|
||||
}
|
||||
|
||||
func (e *Engine) Protocol() config.Protocol { return config.ProtocolHysteria2 }
|
||||
|
||||
func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.running {
|
||||
return fmt.Errorf("hysteria2: already running")
|
||||
}
|
||||
|
||||
bin, err := ResolveBinary(binDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workdir, err := os.MkdirTemp("", "vpnclient-hy2-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("hysteria2: temp dir: %w", err)
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(workdir, "config.yaml")
|
||||
if err := writeRuntimeConfig(cfgPath, profile); err != nil {
|
||||
os.RemoveAll(workdir)
|
||||
return err
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(runCtx, bin, "-c", cfgPath)
|
||||
cmd.Dir = workdir
|
||||
cmd.Stdout = e.stderr
|
||||
cmd.Stderr = e.stderr
|
||||
applySysProcAttr(cmd)
|
||||
env := os.Environ()
|
||||
for k, v := range profile.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
os.RemoveAll(workdir)
|
||||
return fmt.Errorf("hysteria2: start %s: %w", bin, err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
e.cmd = cmd
|
||||
e.cancel = cancel
|
||||
e.done = done
|
||||
e.workdir = workdir
|
||||
e.profile = profile
|
||||
e.running = true
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
fmt.Fprintf(e.stderr, "hysteria2: process ended: %v\n", err)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.cleanupLocked()
|
||||
e.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
e.mu.Unlock()
|
||||
timer := time.NewTimer(900 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
var startErr error
|
||||
select {
|
||||
case <-done:
|
||||
startErr = fmt.Errorf("hysteria2: process exited immediately; check URI/password and binary")
|
||||
case <-ctx.Done():
|
||||
_ = e.Stop()
|
||||
startErr = ctx.Err()
|
||||
case <-timer.C:
|
||||
e.mu.Lock()
|
||||
alive := e.running
|
||||
e.mu.Unlock()
|
||||
if !alive {
|
||||
startErr = fmt.Errorf("hysteria2: process exited during startup")
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
return startErr
|
||||
}
|
||||
|
||||
func (e *Engine) Stop() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.stopLocked()
|
||||
}
|
||||
|
||||
func (e *Engine) stopLocked() error {
|
||||
if !e.running || e.cmd == nil {
|
||||
return nil
|
||||
}
|
||||
done := e.done
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
proc := e.cmd.Process
|
||||
e.mu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if proc != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
e.mu.Lock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupLocked() {
|
||||
if e.workdir != "" {
|
||||
_ = os.RemoveAll(e.workdir)
|
||||
e.workdir = ""
|
||||
}
|
||||
e.cmd = nil
|
||||
e.cancel = nil
|
||||
e.running = false
|
||||
}
|
||||
|
||||
func (e *Engine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
|
||||
func (e *Engine) LocalHTTPProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.HTTPListenHostPort()
|
||||
}
|
||||
|
||||
func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.SOCKSListenHostPort()
|
||||
}
|
||||
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
server, _, err := NormalizeShareLink(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socks := "127.0.0.1:1080"
|
||||
httpListen := "127.0.0.1:1081"
|
||||
if hp, ok := profile.SOCKSListenHostPort(); ok {
|
||||
socks = hp
|
||||
}
|
||||
if hp, ok := profile.HTTPListenHostPort(); ok {
|
||||
httpListen = hp
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# generated by Navis — do not edit\n")
|
||||
b.WriteString("server: " + yamlQuote(server) + "\n")
|
||||
b.WriteString("socks5:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
|
||||
b.WriteString("http:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o600)
|
||||
}
|
||||
|
||||
func yamlQuote(s string) string {
|
||||
// Always quote — URIs and host:port can confuse YAML.
|
||||
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return `"` + escaped + `"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package hysteria2
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeShareLink accepts hysteria2:// or hy2:// share links (or already-normalized URI).
|
||||
// Returns a canonical hysteria2:// URI suitable for the official client's `server` field.
|
||||
func NormalizeShareLink(raw string) (string, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("empty hysteria2 URI")
|
||||
}
|
||||
|
||||
remark := ""
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
remark = strings.TrimSpace(raw[i+1:])
|
||||
if u, err := url.QueryUnescape(remark); err == nil {
|
||||
remark = u
|
||||
}
|
||||
raw = raw[:i]
|
||||
}
|
||||
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "hy2://"):
|
||||
raw = "hysteria2://" + raw[len("hy2://"):]
|
||||
case strings.HasPrefix(lower, "hysteria2://"):
|
||||
// ok
|
||||
default:
|
||||
return "", "", fmt.Errorf("ожидалась ссылка hysteria2:// или hy2://")
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse hysteria2 URI: %w", err)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", "", fmt.Errorf("hysteria2 URI missing host")
|
||||
}
|
||||
if u.Scheme != "hysteria2" {
|
||||
u.Scheme = "hysteria2"
|
||||
}
|
||||
return u.String(), remark, nil
|
||||
}
|
||||
|
||||
// Detect returns true if raw looks like a Hysteria 2 share link.
|
||||
func Detect(raw string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
return strings.HasPrefix(lower, "hysteria2://") || strings.HasPrefix(lower, "hy2://")
|
||||
}
|
||||
|
||||
// HostPort extracts host and a single port for reachability checks.
|
||||
func HostPort(shareURI string) (host, port string, err error) {
|
||||
normalized, _, err := NormalizeShareLink(shareURI)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
host = u.Hostname()
|
||||
port = u.Port()
|
||||
if port == "" {
|
||||
// Multi-port may be in Hostname() for weird parses; Host may be "ex.com:443,5000-6000"
|
||||
hostField := u.Host
|
||||
if h, p, ok := splitMultiPortHost(hostField); ok {
|
||||
return h, p, nil
|
||||
}
|
||||
port = "443"
|
||||
} else if strings.Contains(port, ",") || strings.Contains(port, "-") {
|
||||
port = firstPortToken(port)
|
||||
}
|
||||
if host == "" {
|
||||
return "", "", fmt.Errorf("нет хоста")
|
||||
}
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func splitMultiPortHost(hostField string) (host, port string, ok bool) {
|
||||
// example.com:443,5000-6000
|
||||
i := strings.LastIndex(hostField, ":")
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
host = hostField[:i]
|
||||
port = firstPortToken(hostField[i+1:])
|
||||
if host == "" || port == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return host, port, true
|
||||
}
|
||||
|
||||
func firstPortToken(ports string) string {
|
||||
ports = strings.TrimSpace(ports)
|
||||
if ports == "" {
|
||||
return ""
|
||||
}
|
||||
part := ports
|
||||
if i := strings.IndexByte(ports, ','); i >= 0 {
|
||||
part = ports[:i]
|
||||
}
|
||||
if i := strings.IndexByte(part, '-'); i >= 0 {
|
||||
part = part[:i]
|
||||
}
|
||||
return strings.TrimSpace(part)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package hysteria2
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeShareLink(t *testing.T) {
|
||||
got, remark, err := NormalizeShareLink("hy2://secret@example.com:443/?sni=example.com&insecure=1#EU")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if remark != "EU" {
|
||||
t.Fatalf("remark=%q", remark)
|
||||
}
|
||||
if !Detect(got) {
|
||||
t.Fatalf("not detected: %s", got)
|
||||
}
|
||||
host, port, err := HostPort(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host != "example.com" || port != "443" {
|
||||
t.Fatalf("host=%q port=%q", host, port)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "1.1.3"
|
||||
const CurrentVersion = "1.2.0"
|
||||
|
||||
// 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"
|
||||
|
||||
+6
-3
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"version": "1.1.3",
|
||||
"notes": "Тестовый релиз автообновления 1.1.3",
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"mandatory": false
|
||||
}
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 1, "Minor": 1, "Patch": 3, "Build": 0 },
|
||||
"ProductVersion": { "Major": 1, "Minor": 1, "Patch": 3, "Build": 0 },
|
||||
"FileVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 },
|
||||
"ProductVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 },
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
@@ -10,12 +10,12 @@
|
||||
},
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "Navis",
|
||||
"FileDescription": "Navis — NaiveProxy client",
|
||||
"FileVersion": "1.1.3.0",
|
||||
"FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows",
|
||||
"FileVersion": "1.2.0.0",
|
||||
"InternalName": "Navis",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "1.1.3.0"
|
||||
"ProductVersion": "1.2.0.0"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
"Translation": {
|
||||
|
||||
Reference in New Issue
Block a user