Release 1.6.1: fix subscription button stuck disabled and improve import parsing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -555,6 +555,7 @@
|
||||
let formHydrated = false;
|
||||
let dirty = false;
|
||||
let profiles = [];
|
||||
let metaHoldUntil = 0;
|
||||
|
||||
function markDirty() { dirty = true; }
|
||||
[
|
||||
@@ -571,6 +572,7 @@
|
||||
function setMeta(text, kind) {
|
||||
meta.textContent = text || "";
|
||||
meta.style.color = kind === "err" ? "var(--danger)" : kind === "ok" ? "var(--accent)" : "var(--muted)";
|
||||
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
|
||||
}
|
||||
|
||||
function fillProfiles(list, active) {
|
||||
@@ -640,6 +642,8 @@
|
||||
pingBtn.disabled = busy;
|
||||
updCheckBtn.disabled = busy;
|
||||
updateBtn.disabled = busy;
|
||||
subBtn.disabled = busy;
|
||||
subUrl.disabled = busy;
|
||||
btn.disabled = busy;
|
||||
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
@@ -670,7 +674,10 @@
|
||||
} else {
|
||||
detail = "Вставьте ссылку naive или hysteria2://";
|
||||
}
|
||||
if (!busy) setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
if (!busy && Date.now() > metaHoldUntil) {
|
||||
setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
metaHoldUntil = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(opts) {
|
||||
@@ -682,7 +689,7 @@
|
||||
async function withBusy(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = true; });
|
||||
paintButtonsLocked(true);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
@@ -691,11 +698,17 @@
|
||||
await refresh({ syncForm: true });
|
||||
} catch (e) {
|
||||
setMeta(String(e), "err");
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = false; });
|
||||
paintButtonsLocked(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintButtonsLocked(locked) {
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn, subUrl].forEach((b) => {
|
||||
if (b) b.disabled = locked;
|
||||
});
|
||||
}
|
||||
|
||||
profile.addEventListener("change", () => withBusy(async () => {
|
||||
await selectProfile(profile.value);
|
||||
dirty = false;
|
||||
@@ -780,15 +793,30 @@
|
||||
shopBtn.addEventListener("click", openShop);
|
||||
shopLink.addEventListener("click", openShop);
|
||||
|
||||
async function runImportSubscription() {
|
||||
const url = subUrl.value.trim();
|
||||
if (!url) {
|
||||
setMeta("Вставьте URL подписки", "err");
|
||||
return;
|
||||
}
|
||||
setMeta("Загрузка подписки…");
|
||||
const n = await importSubscription(url);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Импортировано профилей: " + n, "ok");
|
||||
}
|
||||
|
||||
subBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Загрузка подписки…");
|
||||
const n = await importSubscription(subUrl.value.trim());
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Импортировано профилей: " + n, "ok");
|
||||
await runImportSubscription();
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
subUrl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
subBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
pingBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
|
||||
@@ -404,19 +404,15 @@ func (m *Manager) SubscriptionURL() string {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
+140
-17
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -24,6 +25,11 @@ type Item struct {
|
||||
URI string
|
||||
}
|
||||
|
||||
var (
|
||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
||||
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
||||
)
|
||||
|
||||
// Fetch downloads a subscription body and parses proxy share links.
|
||||
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
@@ -39,53 +45,96 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/1.6.0")
|
||||
req.Header.Set("User-Agent", "ClashMeta/1.18.0")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("не удалось скачать подписку: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
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))
|
||||
items, err := ParseBody(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription).
|
||||
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
|
||||
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
|
||||
|
||||
candidates := []string{body}
|
||||
if decoded, err := decodeMaybeBase64(body); err == nil && decoded != "" && decoded != body {
|
||||
candidates = append(candidates, decoded)
|
||||
// some panels double-encode
|
||||
if decoded2, err2 := decodeMaybeBase64(decoded); err2 == nil && decoded2 != "" && decoded2 != decoded {
|
||||
candidates = append(candidates, decoded2)
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, text := range candidates {
|
||||
items, err := parseText(text)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
}
|
||||
|
||||
func parseText(text string) ([]Item, error) {
|
||||
lines := splitLines(text)
|
||||
// Also extract share URIs embedded in YAML/HTML/JSON.
|
||||
extracted := reShareLine.FindAllString(text, -1)
|
||||
for _, m := range extracted {
|
||||
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
lines = append(lines, block)
|
||||
}
|
||||
|
||||
out := make([]Item, 0, len(lines))
|
||||
seen := map[string]int{}
|
||||
seenURI := map[string]struct{}{}
|
||||
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
|
||||
}
|
||||
// Skip bare https:// without credentials (common noise in HTML/YAML).
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||
continue
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, dup := seenURI[normalized]; dup {
|
||||
continue
|
||||
}
|
||||
seenURI[normalized] = struct{}{}
|
||||
name := remark
|
||||
if name == "" {
|
||||
name = defaultName(detected, normalized)
|
||||
@@ -106,13 +155,81 @@ func ParseBody(body string) ([]Item, error) {
|
||||
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 naiveLinkHasAuth(line string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(line))
|
||||
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
rest := line[len(p):]
|
||||
at := strings.IndexByte(rest, '@')
|
||||
return at > 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractClashHY2(text string) []string {
|
||||
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
|
||||
lower := strings.ToLower(text)
|
||||
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := strings.Split(text, "\n- ")
|
||||
for _, chunk := range chunks {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
continue
|
||||
}
|
||||
server := yamlField(chunk, "server")
|
||||
port := yamlField(chunk, "port")
|
||||
pass := yamlField(chunk, "password")
|
||||
if pass == "" {
|
||||
pass = yamlField(chunk, "auth")
|
||||
}
|
||||
if server == "" || port == "" || pass == "" {
|
||||
continue
|
||||
}
|
||||
name := yamlField(chunk, "name")
|
||||
sni := yamlField(chunk, "sni")
|
||||
obfs := yamlField(chunk, "obfs")
|
||||
obfsPass := yamlField(chunk, "obfs-password")
|
||||
if obfsPass == "" {
|
||||
obfsPass = yamlField(chunk, "obfs_password")
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "hysteria2",
|
||||
User: url.User(pass),
|
||||
Host: server + ":" + port,
|
||||
}
|
||||
q := url.Values{}
|
||||
if sni != "" {
|
||||
q.Set("sni", sni)
|
||||
}
|
||||
if obfs != "" {
|
||||
q.Set("obfs", obfs)
|
||||
}
|
||||
if obfsPass != "" {
|
||||
q.Set("obfs-password", obfsPass)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
link := u.String()
|
||||
if name != "" {
|
||||
link += "#" + url.PathEscape(name)
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
_ = reClashHY2 // kept for possible future tightening
|
||||
return out
|
||||
}
|
||||
|
||||
func yamlField(block, key string) string {
|
||||
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
m := re.FindStringSubmatch(block)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func decodeMaybeBase64(s string) (string, error) {
|
||||
@@ -122,6 +239,9 @@ func decodeMaybeBase64(s string) (string, error) {
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("empty")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
raw, err = base64.RawStdEncoding.DecodeString(s)
|
||||
@@ -129,6 +249,9 @@ func decodeMaybeBase64(s string) (string, error) {
|
||||
if err != nil {
|
||||
raw, err = base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package subscription
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBodyPlain(t *testing.T) {
|
||||
body := `# comment
|
||||
@@ -15,3 +19,42 @@ naive+https://u:p@n.example.com#naive1
|
||||
t.Fatalf("got %d items", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBodyBase64(t *testing.T) {
|
||||
plain := "hysteria2://secret@host.example:443/?sni=host.example#HY\nhttps://user:pass@naive.example.com#NV\n"
|
||||
body := base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
items, err := ParseBody(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items: %+v", len(items), items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBodyClashYAML(t *testing.T) {
|
||||
body := `
|
||||
proxies:
|
||||
- name: "DE HY2"
|
||||
type: hysteria2
|
||||
server: de.example.com
|
||||
port: 443
|
||||
password: secret
|
||||
sni: de.example.com
|
||||
obfs: salamander
|
||||
obfs-password: obfspass
|
||||
`
|
||||
items, err := ParseBody(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("got %d", len(items))
|
||||
}
|
||||
if items[0].Protocol != "hysteria2" {
|
||||
t.Fatalf("proto %s", items[0].Protocol)
|
||||
}
|
||||
if !strings.Contains(items[0].URI, "de.example.com:443") {
|
||||
t.Fatalf("uri %s", items[0].URI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "1.6.0"
|
||||
const CurrentVersion = "1.6.1"
|
||||
|
||||
// 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"
|
||||
|
||||
Reference in New Issue
Block a user