Skip to content

Commit 6d07db6

Browse files
committed
merge: release 3.0.2 from dev
Merge prepared release changes from dev into main for version 3.0.2 publication.
2 parents 35b22c7 + bee8c84 commit 6d07db6

20 files changed

Lines changed: 605 additions & 158 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ release
2222
*.ntvs*
2323
*.njsproj
2424
*.sln
25+
.agent
26+
.cursor
2527

2628

2729
# Claude Superpowers

README.en.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</p>
1111

1212
<p align="center">
13-
<img src="https://img.shields.io/badge/version-3.0.1-blue.svg" alt="Version">
13+
<img src="https://img.shields.io/badge/version-3.0.2-blue.svg" alt="Version">
1414
<img src="https://img.shields.io/badge/desktop-Wails-27272f.svg" alt="Wails">
1515
<img src="https://img.shields.io/badge/backend-Go-00ADD8.svg" alt="Go">
1616
<img src="https://img.shields.io/badge/frontend-React_18-61dafb.svg" alt="React">
@@ -33,7 +33,7 @@
3333

3434
## Overview
3535

36-
ResultV **3.0.1** is a native desktop application built with **[Wails v2](https://wails.io/)**. The UI is **React 18** with **Vite** and **Tailwind CSS**; traffic is handled by a **Go** backend and **[sing-box](https://github.com/SagerNet/sing-box)** (with project-specific build tags in `wails.json`). The interface is localized with **i18next** (English and Russian).
36+
ResultV **3.0.2** is a native desktop application built with **[Wails v2](https://wails.io/)**. The UI is **React 18** with **Vite** and **Tailwind CSS**; traffic is handled by a **Go** backend and **[sing-box](https://github.com/SagerNet/sing-box)** (with project-specific build tags in `wails.json`). The interface is localized with **i18next** (English and Russian).
3737

3838
**Prebuilt releases:** GitHub Actions currently publishes **Windows amd64** artifacts (portable `.exe` and NSIS installer) when a `v*` tag is pushed. **macOS and Linux** code paths exist in the repository, but automated CI releases are currently Windows-only; other platforms will be available later due to the full migration of the project to the Go stack.
3939

@@ -66,6 +66,7 @@ ResultV **3.0.1** is a native desktop application built with **[Wails v2](https:
6666
- **WireGuard** and **AmneziaWG** require **Tunnel** mode; they are **not** available in plain Proxy mode (enforced in `internal/proxy/manager.go`).
6767
- **Tunnel** mode on Windows requires **running the app as Administrator** (privilege check before connect).
6868
- **Kill Switch** on Windows may require **administrator** privileges for firewall-style rules (`internal/system/killswitch_windows.go`).
69+
- Some subscription providers enforce **HWID device limits**; the app sends a stable `x-hwid` when fetching subscriptions and shows a clear reason if the provider returns an empty response due to that limit.
6970
- **VMESS, Trojan, and SS** are **less tested** than VLESS and some other stacks; if you hit failures, contact **@resultpoint_manager** on Telegram.
7071

7172
---

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</p>
1111

1212
<p align="center">
13-
<img src="https://img.shields.io/badge/version-3.0.1-blue.svg" alt="Version">
13+
<img src="https://img.shields.io/badge/version-3.0.2-blue.svg" alt="Version">
1414
<img src="https://img.shields.io/badge/desktop-Wails-27272f.svg" alt="Wails">
1515
<img src="https://img.shields.io/badge/backend-Go-00ADD8.svg" alt="Go">
1616
<img src="https://img.shields.io/badge/frontend-React_18-61dafb.svg" alt="React">
@@ -33,7 +33,7 @@
3333

3434
## О проекте
3535

36-
ResultV **3.0.1** — нативное настольное приложение на **[Wails v2](https://wails.io/)**. Интерфейс: **React 18**, **Vite**, **Tailwind CSS**; трафик обрабатывает бэкенд на **Go** и движок **[sing-box](https://github.com/SagerNet/sing-box)** (теги сборки заданы в `wails.json`). Локализация через **i18next** (русский и английский).
36+
ResultV **3.0.2** — нативное настольное приложение на **[Wails v2](https://wails.io/)**. Интерфейс: **React 18**, **Vite**, **Tailwind CSS**; трафик обрабатывает бэкенд на **Go** и движок **[sing-box](https://github.com/SagerNet/sing-box)** (теги сборки заданы в `wails.json`). Локализация через **i18next** (русский и английский).
3737

3838
**Готовые сборки:** в GitHub Actions публикуются артефакты **Windows amd64** (portable `.exe` и установщик NSIS) при push тега `v`*. Код содержит ветки под **macOS** и **Linux**, но автоматические релизы в CI сейчас только для Windows; остальные платформы будут доступны позже, в связи с полным переносом проекта на GO стек.
3939

@@ -68,6 +68,7 @@ ResultV **3.0.1** — нативное настольное приложение
6868
- **WireGuard** и **AmneziaWG** работают только в режиме **Tunnel**, в режиме Proxy недоступны (проверка в `internal/proxy/manager.go`).
6969
- Режим **Tunnel** в Windows требует **запуска от имени администратора**.
7070
- **Kill Switch** в Windows может требовать **прав администратора** для правил брандмауэра (`internal/system/killswitch_windows.go`).
71+
- Некоторые провайдеры подписок используют **HWID-ограничение устройств**; приложение передает стабильный `x-hwid` при загрузке подписок и показывает причину, если провайдер вернул пустой ответ по лимиту.
7172
- **VMESS, Trojan и SS** протестированы **слабее**, чем VLESS и часть других стеков; при сбоях лучше написать в ТГ @resultpoint_manager.
7273

7374
---

app.go

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import (
4545
"resultproxy-wails/internal/system"
4646
)
4747

48+
var stableHWIDProvider = config.StableHardwareID
49+
4850

4951

5052
type App struct {
@@ -842,6 +844,71 @@ func parseSubscriptionUserInfoHeader(v string) (upload, download, total, expire
842844
return upload, download, total, expire
843845
}
844846

847+
func parseSubscriptionHeaderText(v string) string {
848+
v = strings.TrimSpace(v)
849+
if v == "" {
850+
return ""
851+
}
852+
if strings.HasPrefix(strings.ToLower(v), "base64:") {
853+
raw := strings.TrimSpace(v[len("base64:"):])
854+
for _, enc := range [](*base64.Encoding){base64.StdEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.RawURLEncoding} {
855+
if decoded, err := enc.DecodeString(raw); err == nil {
856+
return strings.TrimSpace(string(decoded))
857+
}
858+
}
859+
}
860+
return v
861+
}
862+
863+
func headerIsTruthy(h http.Header, key string) bool {
864+
v := strings.ToLower(strings.TrimSpace(h.Get(key)))
865+
switch v {
866+
case "1", "true", "yes", "on":
867+
return true
868+
default:
869+
return false
870+
}
871+
}
872+
873+
func subscriptionEmptyBodyError(h http.Header) error {
874+
title := parseSubscriptionHeaderText(h.Get("Profile-Title"))
875+
announce := parseSubscriptionHeaderText(h.Get("Announce"))
876+
supportURL := strings.TrimSpace(h.Get("Support-Url"))
877+
hwidLimit := headerIsTruthy(h, "X-Hwid-Limit") || headerIsTruthy(h, "X-Hwid-Max-Devices-Reached")
878+
hwidNotSupported := headerIsTruthy(h, "X-Hwid-Not-Supported")
879+
880+
reason := "подписка вернула пустой ответ"
881+
if hwidLimit {
882+
reason = "достигнут лимит устройств для подписки"
883+
} else if hwidNotSupported {
884+
reason = "провайдер требует передачу HWID"
885+
}
886+
887+
details := make([]string, 0, 3)
888+
if title != "" {
889+
details = append(details, title)
890+
}
891+
if announce != "" {
892+
details = append(details, announce)
893+
}
894+
if supportURL != "" {
895+
details = append(details, "Поддержка: "+supportURL)
896+
}
897+
if len(details) == 0 {
898+
return errors.New(reason)
899+
}
900+
return fmt.Errorf("%s. %s", reason, strings.Join(details, " | "))
901+
}
902+
903+
func (a *App) subscriptionHWID() string {
904+
hwid, err := stableHWIDProvider(a.getUserDataPath())
905+
if err != nil {
906+
a.log.Warning(fmt.Sprintf("Не удалось получить HWID для запроса подписки: %v", err))
907+
return ""
908+
}
909+
return strings.TrimSpace(hwid)
910+
}
911+
845912

846913
func subscriptionIconCandidates(subURL string, h http.Header) []string {
847914
parsed, err := url.Parse(subURL)
@@ -1020,7 +1087,16 @@ func discoverIconFromSubscriptionPage(client *http.Client, subURL string) string
10201087

10211088
func (a *App) fetchSubscriptionFromURL(subURL string) ([]config.ProxyEntry, int64, int64, int64, int64, string, error) {
10221089
client := &http.Client{Timeout: 15 * time.Second}
1023-
resp, err := client.Get(subURL)
1090+
req, err := http.NewRequest(http.MethodGet, subURL, nil)
1091+
if err != nil {
1092+
return nil, 0, 0, 0, 0, "", fmt.Errorf("creating subscription request: %w", err)
1093+
}
1094+
req.Header.Set("User-Agent", fmt.Sprintf("ResultProxyPC/%s", productVersionFromWailsJSON()))
1095+
if hwid := a.subscriptionHWID(); hwid != "" {
1096+
req.Header.Set("x-hwid", hwid)
1097+
}
1098+
1099+
resp, err := client.Do(req)
10241100
if err != nil {
10251101
return nil, 0, 0, 0, 0, "", fmt.Errorf("fetching subscription: %w", err)
10261102
}
@@ -1033,16 +1109,12 @@ func (a *App) fetchSubscriptionFromURL(subURL string) ([]config.ProxyEntry, int6
10331109
up, down, tot, exp := parseSubscriptionUserInfoHeader(resp.Header.Get("Subscription-Userinfo"))
10341110
iconURL := resolveSubscriptionIcon(client, subURL, resp.Header)
10351111

1036-
bodyBytes := make([]byte, 0, 1024*64)
1037-
buf := make([]byte, 4096)
1038-
for {
1039-
n, readErr := resp.Body.Read(buf)
1040-
if n > 0 {
1041-
bodyBytes = append(bodyBytes, buf[:n]...)
1042-
}
1043-
if readErr != nil {
1044-
break
1045-
}
1112+
bodyBytes, err := io.ReadAll(resp.Body)
1113+
if err != nil {
1114+
return nil, up, down, tot, exp, iconURL, fmt.Errorf("reading subscription body: %w", err)
1115+
}
1116+
if strings.TrimSpace(strings.TrimPrefix(string(bodyBytes), "\uFEFF")) == "" {
1117+
return nil, up, down, tot, exp, iconURL, subscriptionEmptyBodyError(resp.Header)
10461118
}
10471119

10481120
entries, err := proxy.ParseSubscriptionBody(string(bodyBytes))

app_subscription_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package main
2+
3+
import (
4+
"encoding/base64"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestFetchSubscriptionFromURLSendsHWIDAndParsesEntries(t *testing.T) {
12+
oldProvider := stableHWIDProvider
13+
stableHWIDProvider = func(_ string) (string, error) {
14+
return "unit-hwid-123", nil
15+
}
16+
defer func() {
17+
stableHWIDProvider = oldProvider
18+
}()
19+
20+
var seenHWID string
21+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
if seenHWID == "" && strings.TrimSpace(r.Header.Get("x-hwid")) != "" {
23+
seenHWID = strings.TrimSpace(r.Header.Get("x-hwid"))
24+
}
25+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
26+
_, _ = w.Write([]byte("vless://af815621-b245-4149-89da-dd184cfc4b3d@example.com:443?type=tcp&security=none#Node"))
27+
}))
28+
defer ts.Close()
29+
30+
app := NewApp()
31+
entries, _, _, _, _, _, err := app.fetchSubscriptionFromURL(ts.URL)
32+
if err != nil {
33+
t.Fatalf("unexpected error: %v", err)
34+
}
35+
if seenHWID != "unit-hwid-123" {
36+
t.Fatalf("x-hwid header mismatch: %q", seenHWID)
37+
}
38+
if len(entries) != 1 {
39+
t.Fatalf("expected 1 entry, got %d", len(entries))
40+
}
41+
if entries[0].Type != "VLESS" {
42+
t.Fatalf("expected VLESS, got %s", entries[0].Type)
43+
}
44+
}
45+
46+
func TestFetchSubscriptionFromURLEmptyBodyReturnsHWIDDiagnostic(t *testing.T) {
47+
oldProvider := stableHWIDProvider
48+
stableHWIDProvider = func(_ string) (string, error) {
49+
return "unit-hwid-limit", nil
50+
}
51+
defer func() {
52+
stableHWIDProvider = oldProvider
53+
}()
54+
55+
announce := "Лимит устройств для подписки"
56+
title := "V2RayTun [test]"
57+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
59+
w.Header().Set("X-Hwid-Limit", "true")
60+
w.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(announce)))
61+
w.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(title)))
62+
w.Header().Set("Support-Url", "https://example.com/support")
63+
w.WriteHeader(http.StatusOK)
64+
}))
65+
defer ts.Close()
66+
67+
app := NewApp()
68+
_, _, _, _, _, _, err := app.fetchSubscriptionFromURL(ts.URL)
69+
if err == nil {
70+
t.Fatal("expected error")
71+
}
72+
got := err.Error()
73+
if !strings.Contains(got, "достигнут лимит устройств") {
74+
t.Fatalf("unexpected error text: %s", got)
75+
}
76+
if !strings.Contains(got, announce) {
77+
t.Fatalf("announce text not found: %s", got)
78+
}
79+
if !strings.Contains(got, title) {
80+
t.Fatalf("profile title not found: %s", got)
81+
}
82+
if !strings.Contains(got, "https://example.com/support") {
83+
t.Fatalf("support url not found: %s", got)
84+
}
85+
}

build/windows/installer/wails_tools.nsh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
!define INFO_PRODUCTNAME "ResultV"
1515
!endif
1616
!ifndef INFO_PRODUCTVERSION
17-
!define INFO_PRODUCTVERSION "3.0.1"
17+
!define INFO_PRODUCTVERSION "3.0.2"
1818
!endif
1919
!ifndef INFO_COPYRIGHT
2020
!define INFO_COPYRIGHT "Copyright........."

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "resultproxy",
33
"private": true,
4-
"version": "3.0.1",
4+
"version": "3.0.2",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

frontend/package.json.md5

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c846d03bd33d50f59be0d1d0c926b34f
1+
9da03058eaa8e3b097fc7a9936a34aa7

frontend/src/hooks/useAppConfig.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const useAppConfig = (addLog) => {
3939
theme: "dark",
4040
localPort: 0,
4141
listenLan: false,
42+
dnsServers: [],
4243
});
4344
const [showProtocolModal, setShowProtocolModal] = useState(false);
4445
const [platform, setPlatform] = useState("windows");
@@ -276,6 +277,27 @@ export const useAppConfig = (addLog) => {
276277
setSettings(nextSettings);
277278
persistSettings(nextSettings).catch(console.error);
278279
wailsAPI.toggleAdBlock(value).catch(err => console.error("Ad block error:", err));
280+
} else if (key === "dnsServers") {
281+
const normalized = Array.isArray(value)
282+
? value
283+
.map((v) => String(v || "").trim())
284+
.filter(Boolean)
285+
.filter((v, idx, arr) => arr.indexOf(v) === idx)
286+
: [];
287+
const nextSettings = { ...settings, [key]: normalized };
288+
setSettings(nextSettings);
289+
try {
290+
await persistSettings(nextSettings);
291+
const status = await wailsAPI.getStatus();
292+
if (status?.currentProxy) {
293+
await wailsAPI.applyMode(nextSettings.mode || "proxy");
294+
}
295+
} catch (err) {
296+
console.error("DNS settings error:", err);
297+
const rollbackSettings = { ...settings, [key]: previousValue };
298+
setSettings(rollbackSettings);
299+
await persistSettings(rollbackSettings).catch(console.error);
300+
}
279301
} else {
280302
const nextSettings = { ...settings, [key]: value };
281303
setSettings(nextSettings);

0 commit comments

Comments
 (0)