Release 1.3.0: Hysteria2 BBR/Salamander/masquerade options and subscription URL import.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -54,6 +54,8 @@ type uiState struct {
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -133,6 +135,8 @@ func main() {
|
||||
mustBind(w, "pingServers", a.pingServers)
|
||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
||||
mustBind(w, "saveHy2", a.saveHy2)
|
||||
mustBind(w, "importSubscription", a.importSubscription)
|
||||
|
||||
go a.autoCheckUpdate()
|
||||
|
||||
@@ -190,6 +194,8 @@ func (a *app) getState() (uiState, error) {
|
||||
Version: update.CurrentVersion,
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
Hy2: a.mgr.ActiveHy2Options(),
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
@@ -274,6 +280,16 @@ func (a *app) installCore() (string, error) {
|
||||
return strings.Join(parts, " | "), nil
|
||||
}
|
||||
|
||||
func (a *app) saveHy2(opts core.Hy2Options) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.SaveHy2Options(opts)
|
||||
}
|
||||
|
||||
func (a *app) importSubscription(rawURL string) (int, error) {
|
||||
return a.mgr.ImportSubscription(rawURL)
|
||||
}
|
||||
|
||||
func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
a.mu.Lock()
|
||||
list := a.mgr.Profiles()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"active": "naive-main",
|
||||
"system_proxy": true,
|
||||
"bin_dir": "bin",
|
||||
"subscription_url": "",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "naive-main",
|
||||
@@ -11,6 +12,20 @@
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hy2-example",
|
||||
"protocol": "hysteria2",
|
||||
"proxy": "",
|
||||
"hy2_congestion": "bbr",
|
||||
"hy2_bbr_profile": "standard",
|
||||
"hy2_obfs": "salamander",
|
||||
"hy2_obfs_password": "",
|
||||
"hy2_sni": "",
|
||||
"listen": [
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"version": "1.3.0",
|
||||
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
|
||||
"mandatory": false
|
||||
}
|
||||
Vendored
+3
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"version": "1.3.0",
|
||||
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
|
||||
"mandatory": false
|
||||
}
|
||||
+157
-4
@@ -112,6 +112,32 @@
|
||||
|
||||
.stack { display: grid; gap: 10px; margin-bottom: 14px; }
|
||||
|
||||
details.hy2 {
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,.55);
|
||||
}
|
||||
details.hy2 summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: .84rem;
|
||||
color: var(--accent);
|
||||
list-style: none;
|
||||
}
|
||||
details.hy2 .grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
details.hy2 label.field { margin-top: 6px; }
|
||||
.check {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: .88rem; margin-top: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 12px 14px;
|
||||
@@ -299,7 +325,70 @@
|
||||
<label class="field" for="proxy">Ссылка сервера</label>
|
||||
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="subUrl">URL подписки</label>
|
||||
<div class="profile-bar">
|
||||
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
|
||||
<button class="icon-btn" id="subBtn" type="button" title="Обновить подписку" style="width:auto;padding:0 14px;font-size:.8rem">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="hy2" id="hy2Box">
|
||||
<summary>Hysteria 2 · BBR / Salamander / маскировка</summary>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label class="field" for="hy2Congestion">Congestion</label>
|
||||
<select id="hy2Congestion">
|
||||
<option value="bbr">BBR</option>
|
||||
<option value="reno">Reno</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Bbr">BBR profile</label>
|
||||
<select id="hy2Bbr">
|
||||
<option value="standard">standard</option>
|
||||
<option value="conservative">conservative</option>
|
||||
<option value="aggressive">aggressive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Up">Brutal up</label>
|
||||
<input id="hy2Up" type="text" placeholder="пусто = BBR" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Down">Brutal down</label>
|
||||
<input id="hy2Down" type="text" placeholder="пусто = BBR" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Obfs">Obfs</label>
|
||||
<select id="hy2Obfs">
|
||||
<option value="">нет</option>
|
||||
<option value="salamander">salamander</option>
|
||||
<option value="gecko">gecko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2ObfsPass">Obfs password</label>
|
||||
<input id="hy2ObfsPass" type="text" placeholder="пароль salamander/gecko" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Sni">SNI (маскарад HTTPS)</label>
|
||||
<input id="hy2Sni" type="text" placeholder="cdn.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Pin">pinSHA256</label>
|
||||
<input id="hy2Pin" type="text" placeholder="опционально" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="hy2Hop">Hop interval</label>
|
||||
<input id="hy2Hop" type="text" placeholder="30s" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="check"><input id="hy2Insecure" type="checkbox" /> TLS insecure</label>
|
||||
<label class="check"><input id="hy2Fast" type="checkbox" /> Fast Open</label>
|
||||
<label class="check"><input id="hy2Lazy" type="checkbox" /> Lazy connect</label>
|
||||
</details>
|
||||
|
||||
<div class="row">
|
||||
<span>Системный прокси Windows</span>
|
||||
@@ -373,8 +462,51 @@
|
||||
const updateBtn = $("updateBtn");
|
||||
const pingList = $("pingList");
|
||||
const verLabel = $("verLabel");
|
||||
const subUrl = $("subUrl");
|
||||
const subBtn = $("subBtn");
|
||||
const SHOP_URL = "https://evilfox.win/";
|
||||
|
||||
function readHy2() {
|
||||
return {
|
||||
congestion: $("hy2Congestion").value,
|
||||
bbr_profile: $("hy2Bbr").value,
|
||||
bandwidth_up: $("hy2Up").value.trim(),
|
||||
bandwidth_down: $("hy2Down").value.trim(),
|
||||
obfs: $("hy2Obfs").value,
|
||||
obfs_password: $("hy2ObfsPass").value.trim(),
|
||||
sni: $("hy2Sni").value.trim(),
|
||||
insecure: !!$("hy2Insecure").checked,
|
||||
pin_sha256: $("hy2Pin").value.trim(),
|
||||
fast_open: !!$("hy2Fast").checked,
|
||||
lazy: !!$("hy2Lazy").checked,
|
||||
hop_interval: $("hy2Hop").value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function fillHy2(h) {
|
||||
if (!h) return;
|
||||
if (h.congestion) $("hy2Congestion").value = h.congestion;
|
||||
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
|
||||
$("hy2Up").value = h.bandwidth_up || "";
|
||||
$("hy2Down").value = h.bandwidth_down || "";
|
||||
$("hy2Obfs").value = h.obfs || "";
|
||||
$("hy2ObfsPass").value = h.obfs_password || "";
|
||||
$("hy2Sni").value = h.sni || "";
|
||||
$("hy2Pin").value = h.pin_sha256 || "";
|
||||
$("hy2Hop").value = h.hop_interval || "";
|
||||
$("hy2Insecure").checked = !!h.insecure;
|
||||
$("hy2Fast").checked = !!h.fast_open;
|
||||
$("hy2Lazy").checked = !!h.lazy;
|
||||
}
|
||||
|
||||
async function persistHy2IfNeeded(protocol) {
|
||||
const p = (protocol || "").toLowerCase();
|
||||
const link = proxy.value.trim().toLowerCase();
|
||||
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
|
||||
await saveHy2(readHy2());
|
||||
}
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
let connected = false;
|
||||
let formHydrated = false;
|
||||
@@ -382,10 +514,15 @@
|
||||
let profiles = [];
|
||||
|
||||
function markDirty() { dirty = true; }
|
||||
[proxy, nameInput, sysproxy].forEach((el) => {
|
||||
[
|
||||
proxy, nameInput, sysproxy, subUrl,
|
||||
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
|
||||
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
|
||||
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
|
||||
].forEach((el) => {
|
||||
if (!el) return;
|
||||
el.addEventListener("input", markDirty);
|
||||
el.addEventListener("change", markDirty);
|
||||
el.addEventListener("focus", markDirty);
|
||||
});
|
||||
|
||||
function setMeta(text, kind) {
|
||||
@@ -465,12 +602,16 @@
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
renderPings(state.pings || []);
|
||||
renderUpdate(state.update, state.version);
|
||||
if (typeof state.subscription_url === "string" && !dirty) {
|
||||
subUrl.value = state.subscription_url;
|
||||
}
|
||||
|
||||
if (syncForm || (!formHydrated && !dirty)) {
|
||||
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
|
||||
nameInput.value = active.name || state.active_profile || "";
|
||||
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
||||
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
|
||||
fillHy2(state.hy2);
|
||||
formHydrated = true;
|
||||
if (syncForm) dirty = false;
|
||||
}
|
||||
@@ -498,7 +639,7 @@
|
||||
async function withBusy(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = true; });
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = true; });
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
@@ -507,7 +648,7 @@
|
||||
await refresh({ syncForm: true });
|
||||
} catch (e) {
|
||||
setMeta(String(e), "err");
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = false; });
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,6 +668,7 @@
|
||||
} else {
|
||||
setMeta("Подключение…");
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
await persistHy2IfNeeded();
|
||||
await connect();
|
||||
setMeta("Туннель активен", "ok");
|
||||
}
|
||||
@@ -536,6 +678,7 @@
|
||||
saveBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
await persistHy2IfNeeded();
|
||||
setMeta("Профиль сохранён", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
@@ -589,6 +732,16 @@
|
||||
shopBtn.addEventListener("click", openShop);
|
||||
shopLink.addEventListener("click", openShop);
|
||||
|
||||
subBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Загрузка подписки…");
|
||||
const n = await importSubscription(subUrl.value.trim());
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Импортировано профилей: " + n, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
pingBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Проверка серверов…");
|
||||
|
||||
@@ -23,9 +23,12 @@ type Config struct {
|
||||
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
|
||||
// BinDir is where protocol binaries (naive.exe) are stored.
|
||||
// BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
|
||||
BinDir string `json:"bin_dir,omitempty"`
|
||||
|
||||
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
|
||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
||||
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
@@ -47,6 +50,22 @@ type Profile struct {
|
||||
NoPostQuantum bool `json:"no_post_quantum,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Hysteria 2 options (client-side). Masquerade on server is separate;
|
||||
// here SNI/insecure/pin emulate HTTPS look + salamander/gecko obfuscation.
|
||||
Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno
|
||||
Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive
|
||||
Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal
|
||||
Hy2BandwidthDown string `json:"hy2_bandwidth_down,omitempty"`
|
||||
Hy2Obfs string `json:"hy2_obfs,omitempty"` // salamander | gecko
|
||||
Hy2ObfsPassword string `json:"hy2_obfs_password,omitempty"`
|
||||
Hy2SNI string `json:"hy2_sni,omitempty"`
|
||||
Hy2Insecure bool `json:"hy2_insecure,omitempty"`
|
||||
Hy2PinSHA256 string `json:"hy2_pin_sha256,omitempty"`
|
||||
Hy2FastOpen bool `json:"hy2_fast_open,omitempty"`
|
||||
Hy2Lazy bool `json:"hy2_lazy,omitempty"`
|
||||
Hy2HopInterval string `json:"hy2_hop_interval,omitempty"` // e.g. 30s
|
||||
Hy2ALPN string `json:"hy2_alpn,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultListen returns listen addresses suitable for Windows system proxy.
|
||||
|
||||
@@ -80,11 +80,7 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == name {
|
||||
c.Profiles[i].Proxy = proxy
|
||||
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 {
|
||||
c.Profiles[i].Listen = defaultListen()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/subscription"
|
||||
"vpnclient/internal/sysproxy"
|
||||
)
|
||||
|
||||
@@ -250,6 +251,9 @@ func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
hysteria2.EnrichProfile(p)
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
@@ -299,6 +303,9 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
||||
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
hysteria2.EnrichProfile(p)
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
@@ -320,6 +327,110 @@ func (m *Manager) Profiles() []config.ProfileInfo {
|
||||
return m.cfg.ListProfiles()
|
||||
}
|
||||
|
||||
// Hy2Options are editable Hysteria 2 extras for the active profile.
|
||||
type Hy2Options struct {
|
||||
Congestion string `json:"congestion"`
|
||||
BBRProfile string `json:"bbr_profile"`
|
||||
BandwidthUp string `json:"bandwidth_up"`
|
||||
BandwidthDown string `json:"bandwidth_down"`
|
||||
Obfs string `json:"obfs"`
|
||||
ObfsPassword string `json:"obfs_password"`
|
||||
SNI string `json:"sni"`
|
||||
Insecure bool `json:"insecure"`
|
||||
PinSHA256 string `json:"pin_sha256"`
|
||||
FastOpen bool `json:"fast_open"`
|
||||
Lazy bool `json:"lazy"`
|
||||
HopInterval string `json:"hop_interval"`
|
||||
}
|
||||
|
||||
func (m *Manager) ActiveHy2Options() Hy2Options {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, err := m.cfg.ActiveProfile()
|
||||
if err != nil {
|
||||
return Hy2Options{Congestion: "bbr", BBRProfile: "standard"}
|
||||
}
|
||||
return Hy2Options{
|
||||
Congestion: p.Hy2Congestion,
|
||||
BBRProfile: p.Hy2BBRProfile,
|
||||
BandwidthUp: p.Hy2BandwidthUp,
|
||||
BandwidthDown: p.Hy2BandwidthDown,
|
||||
Obfs: p.Hy2Obfs,
|
||||
ObfsPassword: p.Hy2ObfsPassword,
|
||||
SNI: p.Hy2SNI,
|
||||
Insecure: p.Hy2Insecure,
|
||||
PinSHA256: p.Hy2PinSHA256,
|
||||
FastOpen: p.Hy2FastOpen,
|
||||
Lazy: p.Hy2Lazy,
|
||||
HopInterval: p.Hy2HopInterval,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SaveHy2Options(opts Hy2Options) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
p, err := m.cfg.ActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Protocol = config.ProtocolHysteria2
|
||||
p.Hy2Congestion = strings.TrimSpace(opts.Congestion)
|
||||
p.Hy2BBRProfile = strings.TrimSpace(opts.BBRProfile)
|
||||
p.Hy2BandwidthUp = strings.TrimSpace(opts.BandwidthUp)
|
||||
p.Hy2BandwidthDown = strings.TrimSpace(opts.BandwidthDown)
|
||||
p.Hy2Obfs = strings.TrimSpace(opts.Obfs)
|
||||
p.Hy2ObfsPassword = strings.TrimSpace(opts.ObfsPassword)
|
||||
p.Hy2SNI = strings.TrimSpace(opts.SNI)
|
||||
p.Hy2Insecure = opts.Insecure
|
||||
p.Hy2PinSHA256 = strings.TrimSpace(opts.PinSHA256)
|
||||
p.Hy2FastOpen = opts.FastOpen
|
||||
p.Hy2Lazy = opts.Lazy
|
||||
p.Hy2HopInterval = strings.TrimSpace(opts.HopInterval)
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) SubscriptionURL() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg.SubscriptionURL
|
||||
}
|
||||
|
||||
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
||||
m.mu.Lock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
m.mu.Unlock()
|
||||
return 0, fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
items, err := subscription.Fetch(ctx, rawURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
|
||||
for _, it := range items {
|
||||
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
|
||||
for i := range m.cfg.Profiles {
|
||||
if m.cfg.Profiles[i].Name == it.Name {
|
||||
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func (m *Manager) SaveConfig() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
ep, err := ParseEndpoint(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var insecureOverride *bool
|
||||
if profile.Hy2Insecure {
|
||||
v := true
|
||||
insecureOverride = &v
|
||||
}
|
||||
ep.ApplyOverrides(profile.Hy2SNI, profile.Hy2Obfs, profile.Hy2ObfsPassword, profile.Hy2PinSHA256, insecureOverride)
|
||||
|
||||
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")
|
||||
|
||||
// Expanded form so congestion/obfs/tls can coexist with auth.
|
||||
b.WriteString("server: " + yamlQuote(ep.Server) + "\n")
|
||||
if ep.Auth != "" {
|
||||
b.WriteString("auth: " + yamlQuote(ep.Auth) + "\n")
|
||||
}
|
||||
|
||||
if ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" {
|
||||
b.WriteString("tls:\n")
|
||||
if ep.SNI != "" {
|
||||
b.WriteString(" sni: " + yamlQuote(ep.SNI) + "\n")
|
||||
}
|
||||
if ep.Insecure {
|
||||
b.WriteString(" insecure: true\n")
|
||||
}
|
||||
if ep.PinSHA256 != "" {
|
||||
b.WriteString(" pinSHA256: " + yamlQuote(ep.PinSHA256) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
obfs := strings.ToLower(strings.TrimSpace(ep.Obfs))
|
||||
if obfs == "" {
|
||||
obfs = strings.ToLower(strings.TrimSpace(profile.Hy2Obfs))
|
||||
}
|
||||
obfsPass := ep.ObfsPassword
|
||||
if obfsPass == "" {
|
||||
obfsPass = profile.Hy2ObfsPassword
|
||||
}
|
||||
switch obfs {
|
||||
case "salamander":
|
||||
if obfsPass == "" {
|
||||
return fmt.Errorf("hysteria2: salamander требует пароль (obfs-password)")
|
||||
}
|
||||
b.WriteString("obfs:\n")
|
||||
b.WriteString(" type: salamander\n")
|
||||
b.WriteString(" salamander:\n")
|
||||
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
|
||||
case "gecko":
|
||||
if obfsPass == "" {
|
||||
return fmt.Errorf("hysteria2: gecko требует пароль (obfs-password)")
|
||||
}
|
||||
b.WriteString("obfs:\n")
|
||||
b.WriteString(" type: gecko\n")
|
||||
b.WriteString(" gecko:\n")
|
||||
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
|
||||
}
|
||||
|
||||
// Congestion: BBR (default) or reno. Bandwidth enables Brutal instead.
|
||||
up := ParseBandwidthMbps(profile.Hy2BandwidthUp)
|
||||
down := ParseBandwidthMbps(profile.Hy2BandwidthDown)
|
||||
cong := strings.ToLower(strings.TrimSpace(profile.Hy2Congestion))
|
||||
if cong == "" {
|
||||
cong = "bbr"
|
||||
}
|
||||
bbrProfile := strings.ToLower(strings.TrimSpace(profile.Hy2BBRProfile))
|
||||
if bbrProfile == "" {
|
||||
bbrProfile = "standard"
|
||||
}
|
||||
|
||||
if up != "" || down != "" {
|
||||
b.WriteString("bandwidth:\n")
|
||||
if up != "" {
|
||||
b.WriteString(" up: " + yamlQuote(up) + "\n")
|
||||
}
|
||||
if down != "" {
|
||||
b.WriteString(" down: " + yamlQuote(down) + "\n")
|
||||
}
|
||||
} else {
|
||||
// No Brutal → use BBR/reno
|
||||
b.WriteString("congestion:\n")
|
||||
b.WriteString(" type: " + yamlQuote(cong) + "\n")
|
||||
if cong == "bbr" {
|
||||
b.WriteString(" bbrProfile: " + yamlQuote(bbrProfile) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if hop := strings.TrimSpace(profile.Hy2HopInterval); hop != "" {
|
||||
b.WriteString("transport:\n")
|
||||
b.WriteString(" type: udp\n")
|
||||
b.WriteString(" udp:\n")
|
||||
b.WriteString(" hopInterval: " + yamlQuote(hop) + "\n")
|
||||
}
|
||||
|
||||
if profile.Hy2FastOpen {
|
||||
b.WriteString("fastOpen: true\n")
|
||||
}
|
||||
if profile.Hy2Lazy {
|
||||
b.WriteString("lazy: true\n")
|
||||
}
|
||||
|
||||
// "Masquerade extras" on client = look like normal HTTPS via SNI already above;
|
||||
// optional ALPN hint kept for compatibility with some panels.
|
||||
if alpn := strings.TrimSpace(profile.Hy2ALPN); alpn != "" {
|
||||
// Official client uses TLS defaults; keep as comment for operators.
|
||||
b.WriteString("# alpn-hint: " + yamlQuote(alpn) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("socks5:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
|
||||
b.WriteString("http:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
|
||||
|
||||
return writeFile(path, b.String())
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Endpoint is a parsed Hysteria 2 share link plus optional overrides.
|
||||
type Endpoint struct {
|
||||
Server string // host:port or multi-port host
|
||||
Auth string
|
||||
SNI string
|
||||
Insecure bool
|
||||
PinSHA256 string
|
||||
Obfs string // salamander | gecko
|
||||
ObfsPassword string
|
||||
Remark string
|
||||
RawURI string
|
||||
}
|
||||
|
||||
// ParseEndpoint parses hysteria2:// / hy2:// into discrete fields.
|
||||
func ParseEndpoint(raw string) (Endpoint, error) {
|
||||
normalized, remark, err := NormalizeShareLink(raw)
|
||||
if err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
ep := Endpoint{Remark: remark, RawURI: normalized}
|
||||
if u.User != nil {
|
||||
user := u.User.Username()
|
||||
if pass, ok := u.User.Password(); ok {
|
||||
ep.Auth = user + ":" + pass
|
||||
} else {
|
||||
ep.Auth, _ = url.QueryUnescape(user)
|
||||
}
|
||||
}
|
||||
ep.Server = u.Host
|
||||
if ep.Server == "" {
|
||||
return Endpoint{}, fmt.Errorf("hysteria2 URI missing host")
|
||||
}
|
||||
q := u.Query()
|
||||
ep.SNI = first(q, "sni", "peer")
|
||||
ep.PinSHA256 = first(q, "pinSHA256", "pinsha256")
|
||||
ep.Obfs = first(q, "obfs")
|
||||
ep.ObfsPassword = first(q, "obfs-password", "obfs_password")
|
||||
if v := first(q, "insecure"); v == "1" || strings.EqualFold(v, "true") {
|
||||
ep.Insecure = true
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
|
||||
func first(q url.Values, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := strings.TrimSpace(q.Get(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ApplyOverrides merges non-empty profile overrides into the endpoint.
|
||||
func (ep *Endpoint) ApplyOverrides(sni, obfs, obfsPass, pin string, insecure *bool) {
|
||||
if sni != "" {
|
||||
ep.SNI = sni
|
||||
}
|
||||
if obfs != "" {
|
||||
ep.Obfs = obfs
|
||||
}
|
||||
if obfsPass != "" {
|
||||
ep.ObfsPassword = obfsPass
|
||||
}
|
||||
if pin != "" {
|
||||
ep.PinSHA256 = pin
|
||||
}
|
||||
if insecure != nil {
|
||||
ep.Insecure = *insecure
|
||||
}
|
||||
}
|
||||
|
||||
// BoolPtr helper for optional bool overrides.
|
||||
func BoolPtr(v bool) *bool { return &v }
|
||||
|
||||
// HasTLSExtras reports whether discrete TLS/obfs fields should be written.
|
||||
func (ep Endpoint) HasTLSExtras() bool {
|
||||
return ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" || ep.Obfs != ""
|
||||
}
|
||||
|
||||
// PortHint returns a single port for ping.
|
||||
func (ep Endpoint) PortHint() string {
|
||||
_, port, err := HostPort(ep.RawURI)
|
||||
if err != nil || port == "" {
|
||||
return "443"
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// ParseBandwidthMbps tries to normalize user input like "100" or "100 mbps".
|
||||
func ParseBandwidthMbps(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" || raw == "0" || raw == "auto" || raw == "bbr" {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(raw, "bkmgt") {
|
||||
return raw
|
||||
}
|
||||
if _, err := strconv.Atoi(raw); err == nil {
|
||||
return raw + " mbps"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -180,32 +180,7 @@ func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
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,35 @@
|
||||
package hysteria2
|
||||
|
||||
import "vpnclient/internal/config"
|
||||
|
||||
// EnrichProfile fills empty hy2 option fields from the share URI.
|
||||
func EnrichProfile(p *config.Profile) {
|
||||
if p == nil || p.Protocol != config.ProtocolHysteria2 || p.Proxy == "" {
|
||||
return
|
||||
}
|
||||
ep, err := ParseEndpoint(p.Proxy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if p.Hy2SNI == "" {
|
||||
p.Hy2SNI = ep.SNI
|
||||
}
|
||||
if p.Hy2Obfs == "" {
|
||||
p.Hy2Obfs = ep.Obfs
|
||||
}
|
||||
if p.Hy2ObfsPassword == "" {
|
||||
p.Hy2ObfsPassword = ep.ObfsPassword
|
||||
}
|
||||
if p.Hy2PinSHA256 == "" {
|
||||
p.Hy2PinSHA256 = ep.PinSHA256
|
||||
}
|
||||
if ep.Insecure {
|
||||
p.Hy2Insecure = true
|
||||
}
|
||||
if p.Hy2Congestion == "" {
|
||||
p.Hy2Congestion = "bbr"
|
||||
}
|
||||
if p.Hy2BBRProfile == "" {
|
||||
p.Hy2BBRProfile = "standard"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
|
||||
// Item is one imported share link.
|
||||
type Item struct {
|
||||
Name string
|
||||
Protocol config.Protocol
|
||||
URI string
|
||||
}
|
||||
|
||||
// Fetch downloads a subscription body and parses proxy share links.
|
||||
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("пустой URL подписки")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return nil, fmt.Errorf("нужен http(s) URL подписки")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/1.3.0")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseBody(string(body))
|
||||
}
|
||||
|
||||
// ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription).
|
||||
func ParseBody(body string) ([]Item, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустая подписка")
|
||||
}
|
||||
text := body
|
||||
if !looksLikeLinks(body) {
|
||||
if decoded, err := decodeMaybeBase64(body); err == nil && looksLikeLinks(decoded) {
|
||||
text = decoded
|
||||
}
|
||||
}
|
||||
lines := splitLines(text)
|
||||
out := make([]Item, 0, len(lines))
|
||||
seen := map[string]int{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Some panels wrap links in quotes
|
||||
line = strings.Trim(line, `"'`)
|
||||
proto := config.DetectProtocol(line)
|
||||
if proto == "" {
|
||||
continue
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := remark
|
||||
if name == "" {
|
||||
name = defaultName(detected, normalized)
|
||||
}
|
||||
base := name
|
||||
for n := 2; ; n++ {
|
||||
if _, ok := seen[name]; !ok {
|
||||
break
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
seen[name] = 1
|
||||
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func looksLikeLinks(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
return strings.Contains(lower, "hysteria2://") ||
|
||||
strings.Contains(lower, "hy2://") ||
|
||||
strings.Contains(lower, "naive+") ||
|
||||
strings.Contains(lower, "https://") ||
|
||||
strings.Contains(lower, "quic://")
|
||||
}
|
||||
|
||||
func decodeMaybeBase64(s string) (string, error) {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
raw, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
raw, err = base64.RawStdEncoding.DecodeString(s)
|
||||
}
|
||||
if err != nil {
|
||||
raw, err = base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func defaultName(proto config.Protocol, uri string) string {
|
||||
host := ""
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
if h, _, err := hysteria2.HostPort(uri); err == nil {
|
||||
host = h
|
||||
}
|
||||
default:
|
||||
if u, _, err := naive.ParseShareLink(uri); err == nil {
|
||||
if parsed, err := url.Parse(u); err == nil {
|
||||
host = parsed.Hostname()
|
||||
}
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
host = string(proto)
|
||||
}
|
||||
return string(proto) + "-" + host
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package subscription
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBodyPlain(t *testing.T) {
|
||||
body := `# comment
|
||||
hysteria2://pass@ex.com:443/?sni=ex.com&obfs=salamander&obfs-password=x#EU
|
||||
naive+https://u:p@n.example.com#naive1
|
||||
`
|
||||
items, err := ParseBody(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items", len(items))
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "1.2.0"
|
||||
const CurrentVersion = "1.3.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"
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
|
||||
"version": "1.3.0",
|
||||
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
|
||||
"sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
|
||||
"mandatory": false
|
||||
}
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 },
|
||||
"ProductVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 },
|
||||
"FileVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 },
|
||||
"ProductVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 },
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
@@ -11,11 +11,11 @@
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "Navis",
|
||||
"FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows",
|
||||
"FileVersion": "1.2.0.0",
|
||||
"FileVersion": "1.3.0.0",
|
||||
"InternalName": "Navis",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "1.2.0.0"
|
||||
"ProductVersion": "1.3.0.0"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
"Translation": {
|
||||
|
||||
Reference in New Issue
Block a user