Release 2.7.3.3: dark theme and Amnezia vpn:// import fixes.

This commit is contained in:
M4
2026-07-29 19:49:48 +03:00
parent 041cbb1250
commit 64c097d1e7
23 changed files with 401 additions and 69 deletions
+196 -42
View File
@@ -22,6 +22,79 @@
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
--radius: 22px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
--hero-on-bg: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
--input-bg: rgba(255,255,255,.94);
--chip-bg: rgba(255,255,255,.75);
--row-bg: rgba(255,255,255,.72);
--row-active-bg: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
--modal-bg: #f5fbf8;
--modal-scrim: rgba(8, 28, 24, .42);
--soft-btn: linear-gradient(180deg, #d2efe4, #bfe6d6);
--soft-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
--danger-btn: linear-gradient(180deg, #f8d8d4, #efc4bf);
--danger-btn-hover: linear-gradient(180deg, #f3c5bf, #e8aea7);
--danger-ink: #9e2a22;
--danger-ink-hover: #7f1f19;
--meta-bg: rgba(255,255,255,.35);
--meta-ok-bg: rgba(216,243,233,.65);
--meta-err-bg: rgba(255,236,234,.75);
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
--switch-track: #c7d5cf;
--switch-knob: #fff;
--ms-good: #0a7a3e;
--ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7);
}
html[data-theme="dark"] {
--ink: #e6f4ee;
--muted: #8eaaa0;
--line: rgba(230, 244, 238, 0.12);
--accent: #2bbf8a;
--accent-deep: #7ee0b8;
--accent-soft: rgba(43, 191, 138, 0.16);
--danger: #f07167;
--ok: #2bbf8a;
--surface: rgba(18, 28, 26, 0.88);
--surface-2: rgba(28, 42, 38, 0.72);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
--page-bg:
radial-gradient(720px 400px at 6% -12%, rgba(20, 90, 70, .55) 0%, transparent 55%),
radial-gradient(560px 340px at 100% 0%, rgba(30, 55, 80, .45) 0%, transparent 50%),
radial-gradient(480px 300px at 50% 110%, rgba(18, 70, 55, .4) 0%, transparent 45%),
linear-gradient(165deg, #0c1412 0%, #121c1a 48%, #101820 100%);
--hero-bg: linear-gradient(160deg, rgba(28, 42, 38, .95), rgba(22, 34, 31, .9));
--hero-on-bg: linear-gradient(160deg, rgba(24, 70, 55, .55), rgba(28, 42, 38, .95));
--input-bg: rgba(12, 20, 18, .92);
--chip-bg: rgba(12, 20, 18, .75);
--row-bg: rgba(22, 34, 31, .78);
--row-active-bg: linear-gradient(135deg, rgba(43, 191, 138, .18), rgba(28, 42, 38, .95));
--modal-bg: #182420;
--modal-scrim: rgba(0, 0, 0, .58);
--soft-btn: linear-gradient(180deg, #1f3d34, #18332b);
--soft-btn-hover: linear-gradient(180deg, #264d42, #1d3d34);
--danger-btn: linear-gradient(180deg, #4a2a28, #3d2220);
--danger-btn-hover: linear-gradient(180deg, #5a3230, #4a2826);
--danger-ink: #f0a39c;
--danger-ink-hover: #ffc4be;
--meta-bg: rgba(12, 20, 18, .45);
--meta-ok-bg: rgba(43, 191, 138, .14);
--meta-err-bg: rgba(240, 113, 103, .14);
--update-bg: linear-gradient(135deg, rgba(43,191,138,.16), rgba(22,34,31,.85));
--shop-bg: linear-gradient(135deg, rgba(43,191,138,.12), rgba(22,34,31,.7));
--switch-track: #3a4f48;
--switch-knob: #e6f4ee;
--ms-good: #4ad69a;
--ms-mid: #e0b84a;
--focus-ring: rgba(43,191,138,.2);
--theme-btn-bg: rgba(22, 34, 31, .85);
}
* { box-sizing: border-box; }
html, body {
@@ -29,11 +102,12 @@
min-height: 100%;
font-family: Figtree, sans-serif;
color: var(--ink);
background:
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease);
color-scheme: light;
}
html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
}
body {
display: grid;
@@ -61,7 +135,7 @@
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(13, 138, 102, 0.28);
background: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; }
@@ -105,6 +179,29 @@
}
.logo svg { width: 30px; height: 30px; }
.brand-wrap { min-width: 0; flex: 1; }
.theme-btn {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--theme-btn-bg);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, border-color .15s, transform .15s;
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.theme-btn:hover {
border-color: rgba(13,138,102,.35);
transform: translateY(-1px);
}
.theme-btn svg { width: 18px; height: 18px; display: block; }
.theme-btn .icon-sun { display: none; }
.theme-btn .icon-moon { display: block; }
html[data-theme="dark"] .theme-btn .icon-sun { display: block; }
html[data-theme="dark"] .theme-btn .icon-moon { display: none; }
.brand-row {
display: flex;
align-items: baseline;
@@ -142,8 +239,7 @@
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--line);
background:
linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
background: var(--hero-bg);
padding: 16px 16px 14px;
margin-bottom: 14px;
transition: border-color .25s, box-shadow .25s, background .25s;
@@ -151,8 +247,7 @@
.hero.on {
border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background:
linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
background: var(--hero-on-bg);
}
.hero::after {
content: "";
@@ -202,7 +297,7 @@
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
background: rgba(255,255,255,.75);
background: var(--chip-bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
@@ -265,13 +360,13 @@
select, input[type="text"], textarea {
width: 100%;
border: 1px solid var(--line);
background: rgba(255,255,255,.94);
background: var(--input-bg);
border-radius: 14px;
padding: 11px 13px;
font: inherit;
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s;
transition: border-color .15s, box-shadow .15s, background .15s;
}
textarea {
min-height: 78px;
@@ -282,7 +377,7 @@
}
select:focus, input[type="text"]:focus, textarea:focus {
border-color: rgba(13,138,102,.5);
box-shadow: 0 0 0 4px rgba(13,138,102,.12);
box-shadow: 0 0 0 4px var(--focus-ring);
}
.icon-btn {
width: 44px;
@@ -291,7 +386,7 @@
place-items: center;
border-radius: 14px;
border: 1px solid rgba(8, 90, 68, 0.22);
background: linear-gradient(180deg, #d2efe4, #bfe6d6);
background: var(--soft-btn);
color: var(--accent-deep);
font-size: 1.15rem;
font-weight: 700;
@@ -300,19 +395,19 @@
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
}
.icon-btn:hover {
background: linear-gradient(180deg, #c5e9db, #aedfcb);
background: var(--soft-btn-hover);
border-color: rgba(8, 90, 68, 0.38);
color: #05553f;
color: var(--accent-deep);
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
}
.icon-btn.danger {
color: #9e2a22;
background: linear-gradient(180deg, #f8d8d4, #efc4bf);
color: var(--danger-ink);
background: var(--danger-btn);
border-color: rgba(158, 42, 34, 0.28);
}
.icon-btn.danger:hover {
background: linear-gradient(180deg, #f3c5bf, #e8aea7);
color: #7f1f19;
background: var(--danger-btn-hover);
color: var(--danger-ink-hover);
}
.icon-btn.wide {
width: auto;
@@ -374,7 +469,7 @@
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255,255,255,.6);
background: var(--surface-2);
margin-bottom: 12px;
font-weight: 600;
font-size: .9rem;
@@ -382,7 +477,7 @@
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: #c7d5cf;
position: absolute; inset: 0; background: var(--switch-track);
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
@@ -390,7 +485,7 @@
position: absolute;
width: 20px; height: 20px;
left: 3px; top: 3px;
background: #fff;
background: var(--switch-knob);
border-radius: 50%;
transition: transform .15s;
box-shadow: 0 2px 6px rgba(0,0,0,.12);
@@ -472,7 +567,7 @@
.server-actions .mini {
appearance: none;
border: 1px solid rgba(8, 90, 68, 0.22);
background: linear-gradient(180deg, #d2efe4, #bfe6d6);
background: var(--soft-btn);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 10px;
@@ -506,7 +601,7 @@
padding: 4px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,.4);
background: var(--surface-2);
}
.server-row {
display: grid;
@@ -516,7 +611,7 @@
padding: 6px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: rgba(255,255,255,.72);
background: var(--row-bg);
cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left;
@@ -526,11 +621,11 @@
}
.server-row:hover {
border-color: rgba(13,138,102,.25);
background: #fff;
background: var(--input-bg);
}
.server-row.active {
border-color: rgba(13,138,102,.4);
background: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
background: var(--row-active-bg);
box-shadow: 0 4px 14px rgba(13,138,102,.1);
}
.server-row .left { min-width: 0; }
@@ -573,8 +668,8 @@
color: var(--muted);
}
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: #0a7a3e; }
.server-row .ms.mid { color: #b8860b; }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.server-empty {
padding: 14px 10px;
@@ -600,18 +695,18 @@
min-height: 2.4em;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255,255,255,.35);
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
}
.meta.ok {
color: var(--ok);
background: rgba(216,243,233,.65);
background: var(--meta-ok-bg);
border-color: rgba(13,138,102,.18);
}
.meta.err {
color: var(--danger);
background: rgba(255,236,234,.75);
background: var(--meta-err-bg);
border-color: rgba(192,54,44,.16);
}
@@ -627,7 +722,7 @@
padding: 8px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,.6);
background: var(--row-bg);
font-size: .82rem;
}
.ping-item .ms { font-weight: 700; }
@@ -639,7 +734,7 @@
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(13, 138, 102, 0.16);
background: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
background: var(--shop-bg);
}
.shop h3 {
font-family: Sora, sans-serif;
@@ -680,7 +775,7 @@
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(8, 28, 24, .42);
background: var(--modal-scrim);
backdrop-filter: blur(4px);
display: none;
place-items: center;
@@ -690,12 +785,13 @@
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%);
background: #f5fbf8;
background: var(--modal-bg);
border-radius: 22px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
animation: rise .35s var(--ease) both;
color: var(--ink);
}
.modal h2 {
font-family: Sora, sans-serif;
@@ -715,6 +811,18 @@
.tools { grid-template-columns: 1fr; }
}
</style>
<script>
(function () {
try {
var key = "navis.theme";
var saved = localStorage.getItem(key);
var theme = saved === "dark" || saved === "light"
? saved
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (_) {}
})();
</script>
</head>
<body>
<main class="shell">
@@ -744,6 +852,15 @@
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
<button class="theme-btn" id="themeBtn" type="button" title="Тема" aria-label="Переключить тему">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15.5 3.5a8.5 8.5 0 1 0 5 14.2A7 7 0 1 1 15.5 3.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.2 5.2l1.6 1.6M17.2 17.2l1.6 1.6M18.8 5.2l-1.6 1.6M6.8 17.2l-1.6 1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</header>
<section class="hero" id="hero">
@@ -893,8 +1010,9 @@
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy">Ссылка</label>
<input id="newProxy" type="text" placeholder="vless:// · vmess:// · trojan:// · hy2 · naive · awg" />
<label class="field" for="newProxy">Ссылка / конфиг</label>
<textarea id="newProxy" rows="4" placeholder="vpn:// · awg:// · vless:// · hy2:// · naive · или текст [Interface]" style="min-height:88px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem"></textarea>
<p class="meta" id="modalErr" style="display:none;margin-top:8px;min-height:0"></p>
</div>
</div>
<div class="actions">
@@ -986,6 +1104,25 @@
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
const AUTO_BEST_KEY = "navis.autoBest";
const THEME_KEY = "navis.theme";
(function initThemeToggle() {
const btn = $("themeBtn");
if (!btn) return;
const apply = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
btn.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
};
btn.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
apply(cur === "dark" ? "light" : "dark");
});
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
btn.title = cur === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
})();
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
@@ -1384,6 +1521,8 @@
addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
const me = $("modalErr");
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
modal.classList.add("open");
$("newName").focus();
});
@@ -1391,17 +1530,32 @@
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
function sanitizeShare(s) {
return String(s || "")
.replace(/[\u200B-\u200D\u2060\uFEFF\u2066-\u2069\u200E\u200F\u202A-\u202E]/g, "")
.trim();
}
$("createNew").addEventListener("click", () => withBusy(async () => {
const me = $("modalErr");
const showModalErr = (msg) => {
if (!me) { setMeta(String(msg), "err"); return; }
me.style.display = "block";
me.className = "meta err";
me.textContent = String(msg);
};
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
const p = sanitizeShare($("newProxy").value);
if (!n) throw "Укажите название профиля";
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
$("newProxy").value = p;
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
if (me) { me.style.display = "none"; me.textContent = ""; }
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); }
} catch (e) { showModalErr(e); }
}));
async function openShop(e) {
+20
View File
@@ -4,6 +4,7 @@ import "strings"
// DetectProtocol infers protocol from a share/proxy URI or config body.
func DetectProtocol(raw string) Protocol {
raw = stripShareNoise(raw)
lower := strings.ToLower(strings.TrimSpace(raw))
switch {
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
@@ -29,3 +30,22 @@ func DetectProtocol(raw string) Protocol {
return ""
}
}
func stripShareNoise(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
var b strings.Builder
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u2066', '\u2067', '\u2068', '\u2069',
'\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
+16 -2
View File
@@ -90,7 +90,17 @@ func (c *Config) UpsertProfile(name, proxy string) error {
}
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
// The upserted profile becomes Active.
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, true)
}
// UpsertProfileKeepActive creates/updates a profile without changing Active.
func (c *Config) UpsertProfileKeepActive(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, false)
}
func (c *Config) upsertProfile(name, proxy string, proto Protocol, activate bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("имя профиля пустое")
@@ -110,7 +120,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen()
}
c.Active = name
if activate {
c.Active = name
}
return nil
}
}
@@ -121,7 +133,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
Proxy: proxy,
Listen: defaultListen(),
})
c.Active = name
if activate {
c.Active = name
}
return nil
}
+14 -3
View File
@@ -283,8 +283,13 @@ func (m *Manager) SetActiveProfile(name string) error {
func (m *Manager) SaveProfile(name, proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь")
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("имя профиля пустое")
}
connected := m.engine != nil && m.engine.Running()
if connected && name == m.cfg.Active {
return fmt.Errorf("сначала отключитесь, чтобы изменить активный профиль")
}
proxy = strings.TrimSpace(proxy)
var proto config.Protocol
@@ -296,7 +301,13 @@ func (m *Manager) SaveProfile(name, proxy string) error {
proxy = normalized
proto = detected
}
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
var err error
if connected {
err = m.cfg.UpsertProfileKeepActive(name, proxy, proto)
} else {
err = m.cfg.UpsertProfileWithProtocol(name, proxy, proto)
}
if err != nil {
return err
}
if p, err := m.cfg.ActiveProfile(); err == nil {
+1 -2
View File
@@ -2,7 +2,6 @@ package linknorm
import (
"fmt"
"strings"
"vpnclient/internal/config"
"vpnclient/internal/protocols/awg"
@@ -13,7 +12,7 @@ import (
// 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)
raw = SanitizeShareText(raw)
if raw == "" {
return "", proto, "", fmt.Errorf("пустая ссылка")
}
+35
View File
@@ -0,0 +1,35 @@
package linknorm
import (
"strings"
"unicode"
)
// SanitizeShareText strips BOM, bidi isolates and zero-width chars that break
// scheme detection (common when pasting from messengers / Amnezia share).
func SanitizeShareText(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', // zero-width / BOM
'\u2066', '\u2067', '\u2068', '\u2069', // LRI/RLI/FSI/PDI
'\u200e', '\u200f', // LRM/RLM
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e': // embedding overrides
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
if unicode.Is(unicode.Cf, r) { // other format controls
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
+42
View File
@@ -0,0 +1,42 @@
package linknorm
import "testing"
func TestSanitizeShareText_BidiWrappers(t *testing.T) {
raw := "\u2068vpn://abc\u2069"
got := SanitizeShareText(raw)
if got != "vpn://abc" {
t.Fatalf("got %q", got)
}
}
func TestNormalize_DirtyVPN(t *testing.T) {
raw := "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxLjEuMS4xClByaXZhdGVLZXkgPSBhSkpSYUFFY1RidWdJYjB2emFJcW5MQkZJQ0dqNlRkcXo1RC84OU13T0hZPQpKYyA9IDYKCltQZWVyXQpQdWJsaWNLZXkgPSBOUThJMVpPcUtxWmhGUXlxcXBuakRWc1poWXpkUk11Qm9tVXBzNWZPTTNzPQpFbmRwb2ludCA9IHJ1LmRlNGltYS51azo0MTQyMQpBbGxvd2VkSVBzID0gMC4wLjAuMC8w\u2069"
// Use full user link instead for reliable parse
raw = "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1\u2069"
u, proto, _, err := Normalize("", raw)
if err != nil {
t.Fatal(err)
}
if proto != "awg" {
t.Fatalf("proto %q", proto)
}
if u == "" || !containsFold(u, "[Interface]") {
t.Fatalf("normalized %q", u)
}
}
func containsFold(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
}
func stringIndexFold(s, sub string) int {
// simple contains for test
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+22 -2
View File
@@ -35,7 +35,7 @@ type Conf struct {
// Detect reports whether raw looks like an AWG/WireGuard config or share link.
func Detect(raw string) bool {
raw = strings.TrimSpace(raw)
raw = sanitizeShare(raw)
if raw == "" {
return false
}
@@ -58,7 +58,7 @@ func Detect(raw string) bool {
// Parse accepts a WireGuard/AWG .conf body, awg:// URI, vpn:// blob, or Amnezia JSON.
func Parse(raw string) (Conf, error) {
raw = strings.TrimSpace(raw)
raw = sanitizeShare(raw)
if raw == "" {
return Conf{}, fmt.Errorf("пустой AWG конфиг")
}
@@ -83,6 +83,26 @@ func Parse(raw string) (Conf, error) {
}
}
func sanitizeShare(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u2066', '\u2067', '\u2068', '\u2069',
'\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
func parseINI(raw string) (Conf, error) {
var c Conf
section := "interface" // treat leading keys before [Interface] as Interface
+35
View File
@@ -0,0 +1,35 @@
package awg
import (
"strings"
"testing"
)
func TestParseUserVPNLink(t *testing.T) {
raw := `vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1`
c, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if c.Endpoint != "ru.de4ima.uk:41421" {
t.Fatalf("endpoint %q", c.Endpoint)
}
if !strings.Contains(c.H1, "-") {
t.Fatalf("expected range H1, got %q", c.H1)
}
u, rem, err := NormalizeShareLink(raw)
if err != nil {
t.Fatalf("Normalize: %v", err)
}
t.Logf("remark=%q normalized_len=%d h1=%q", rem, len(u), c.H1)
// invisible unicode wrappers often come from mobile share / chat paste
dirty := "\u2068" + raw + "\u2069\u2005"
if !Detect(dirty) {
t.Fatal("Detect should accept dirty bidi-wrapped vpn://")
}
_, _, err = NormalizeShareLink(dirty)
if err != nil {
t.Fatalf("dirty NormalizeShareLink: %v", err)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "2.7.3"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 1
const BuildNumber = 3
// 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"