Files
SbLauncher/ViewModels.cs
T

1083 lines
50 KiB
C#
Raw Normal View History

2026-09-26 14:41:04 +03:00
// SbLauncher — GUI для sing-box.
//
// ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
// тот же private_key в пяти файлах не требуется.
//
// ПОРТАТИВНОСТЬ: всё ищется рядом с exe. Скопировали папку — работает.
//
// Компилятор — встроенный в Windows csc.exe (C# 5). Поэтому здесь нет
// интерполяции строк ($""), ?. и expression-bodied членов. См. build.cmd.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace SbLauncher
{
public class Bindable : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void Raise([CallerMemberName] string name = null)
{
PropertyChangedEventHandler h = PropertyChanged;
if (h != null) h(this, new PropertyChangedEventArgs(name));
}
}
public class RelayCommand : ICommand
{
readonly Action<object> _exec;
readonly Func<object, bool> _can;
public RelayCommand(Action<object> exec) : this(exec, null) { }
public RelayCommand(Action<object> exec, Func<object, bool> can) { _exec = exec; _can = can; }
public bool CanExecute(object p) { return _can == null || _can(p); }
public void Execute(object p) { _exec(p); }
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
// ═════════════════════════════════════════════════════════════════════
// ФЛАГИ
//
// Рисуем векторно: emoji-флаги (🇫🇮) в Windows не отображаются —
// Segoe UI Emoji показывает вместо них две буквы в рамках.
// Чтобы добавить страну, допишите case в Build().
// ═════════════════════════════════════════════════════════════════════
public static class Flags
{
static readonly Dictionary<string, ImageSource> Cache = new Dictionary<string, ImageSource>();
public static ImageSource Get(string code)
{
if (string.IsNullOrEmpty(code)) return null;
string c = code.ToUpperInvariant();
ImageSource img;
if (Cache.TryGetValue(c, out img)) return img;
img = Build(c);
Cache[c] = img;
return img;
}
const double W = 3, H = 2;
static void Rect(DrawingGroup g, double x, double y, double w, double h, string hex)
{
g.Children.Add(new GeometryDrawing(
new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)),
null, new RectangleGeometry(new Rect(x, y, w, h))));
}
static void Band(DrawingGroup g, int i, string hex) { Rect(g, 0, i * H / 3, W, H / 3, hex); }
// Соёмбо с флага Бурятии: сверху пламя, под ним солнце, снизу
// полумесяц. Рисуем упрощённо — в карточке эмблема занимает ~6x9 px.
// Geometry.Parse разбирает числа по InvariantCulture, так что точка
// как разделитель безопасна и на русской локали.
static void Soyombo(DrawingGroup g, double cx, string hex)
{
Brush b = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
Geometry flame = Geometry.Parse(
"M 0,-0.300 C 0.040,-0.210 0.062,-0.180 0.058,-0.120 " +
"L 0.088,-0.165 C 0.107,-0.090 0.085,-0.030 0.046,-0.008 " +
"L -0.046,-0.008 C -0.085,-0.030 -0.107,-0.090 -0.088,-0.165 " +
"L -0.058,-0.120 C -0.062,-0.180 -0.040,-0.210 0,-0.300 Z");
// Geometry.Parse отдаёт ЗАМОРОЖЕННЫЙ StreamGeometry: присвоить ему
// Transform нельзя (InvalidOperationException). Поэтому сдвигаем не
// саму геометрию, а обёрточную DrawingGroup — она изменяемая.
DrawingGroup fg = new DrawingGroup();
fg.Children.Add(new GeometryDrawing(b, null, flame));
fg.Transform = new TranslateTransform(cx, 0.40);
g.Children.Add(fg);
g.Children.Add(new GeometryDrawing(b, null,
new EllipseGeometry(new Point(cx, 0.520), 0.085, 0.085)));
g.Children.Add(new GeometryDrawing(b, null, new CombinedGeometry(
GeometryCombineMode.Exclude,
new EllipseGeometry(new Point(cx, 0.800), 0.145, 0.145),
new EllipseGeometry(new Point(cx, 0.745), 0.128, 0.128))));
}
static ImageSource Build(string c)
{
DrawingGroup g = new DrawingGroup();
switch (c)
{
case "DE": Band(g, 0, "#000000"); Band(g, 1, "#DD0000"); Band(g, 2, "#FFCE00"); break;
case "EE": Band(g, 0, "#0072CE"); Band(g, 1, "#000000"); Band(g, 2, "#FFFFFF"); break;
case "RU": Band(g, 0, "#FFFFFF"); Band(g, 1, "#0039A6"); Band(g, 2, "#D52B1E"); break;
case "FI": // белый с синим крестом
Rect(g, 0, 0, W, H, "#FFFFFF");
Rect(g, 0, 0.73, W, 0.54, "#003580");
Rect(g, 0.82, 0, 0.54, H, "#003580");
break;
case "NL": Band(g, 0, "#AE1C28"); Band(g, 1, "#FFFFFF"); Band(g, 2, "#21468B"); break;
case "SE":
Rect(g, 0, 0, W, H, "#006AA7");
Rect(g, 0, 0.79, W, 0.42, "#FECC00");
Rect(g, 0.86, 0, 0.42, H, "#FECC00");
break;
case "BU": // Бурятия
Rect(g, 0, 0, W, 1.0, "#0066B3"); // синий — 1/2 высоты
Rect(g, 0, 1.0, W, 0.5, "#FFFFFF"); // белый — 1/4
Rect(g, 0, 1.5, W, 0.5, "#FFD200"); // жёлтый — 1/4
Soyombo(g, 0.55, "#FFD200"); // в древковой части
break;
default: return null; // покажется текстовый бейдж
}
// Тонкая внутренняя обводка: без неё белые полосы (EE, FI, RU)
// сливаются с фоном карточки. Цвет НЕЙТРАЛЬНО-СЕРЫЙ, а не белый:
// флаги кэшируются и не перерисовываются при смене темы.
g.Children.Add(new GeometryDrawing(null,
new Pen(new SolidColorBrush(Color.FromArgb(95, 125, 135, 150)), 0.16),
new RectangleGeometry(new Rect(0, 0, W, H), 0.22, 0.22)));
g.ClipGeometry = new RectangleGeometry(new Rect(0, 0, W, H), 0.22, 0.22);
DrawingImage img = new DrawingImage(g);
img.Freeze();
return img;
}
}
// ═════════════════════════════════════════════════════════════════════
// Карточка в списке серверов
// ═════════════════════════════════════════════════════════════════════
public class ServerVm : Bindable
{
public ServerCfg Cfg; // null у карточки «только WireGuard»
public bool IsWgOnly;
string _code = "", _title = "", _sub = "", _pingText = "—";
bool _selected;
string _pingKey = "Na";
public string Code
{
get { return _code; }
set { _code = value; Raise(); Raise("Badge"); Raise("Flag"); }
}
// null -> в карточке покажется текстовый бейдж вместо флага
public ImageSource Flag { get { return Flags.Get(_code); } }
// в квадратик 34px влезает 2–3 символа, не больше
public string Badge
{
get
{
if (IsWgOnly) return "WG";
if (string.IsNullOrEmpty(_code)) return "?";
if (_code.Length <= 3) return _code.ToUpperInvariant();
return _code.Substring(0, 2).ToUpperInvariant();
}
}
public string Title { get { return _title; } set { _title = value; Raise(); } }
public string Subtitle { get { return _sub; } set { _sub = value; Raise(); } }
public string PingText { get { return _pingText; } set { _pingText = value; Raise(); } }
// Храним не кисть, а ключ палитры — иначе при смене темы в карточках
// остались бы цвета старой темы.
public string PingKey { set { _pingKey = value; Raise("PingBrush"); } }
public Brush PingBrush { get { return Theme.Res(_pingKey); } }
public void RefreshBrushes() { Raise("PingBrush"); }
public bool IsSelected { get { return _selected; } set { _selected = value; Raise(); } }
public ICommand SelectCommand { get; set; }
public ICommand DeleteCommand { get; set; }
// Пинговать имеет смысл только vless-сервер: у «только WireGuard»
// пир — приватный 10.x, снаружи недостижим по определению.
public bool Pingable
{
get { return !IsWgOnly && Cfg != null && Cfg.Server.Length > 0 &&
Cfg.Protocol != "hysteria2" && Cfg.Protocol != "tuic"; }
}
}
// ═════════════════════════════════════════════════════════════════════
// ViewModel
// ═════════════════════════════════════════════════════════════════════
public class MainVm : Bindable
{
public Paths P;
public Profiles Prof = new Profiles();
public LocalCfg Local = new LocalCfg();
readonly SingBoxRunner _runner = new SingBoxRunner();
readonly DispatcherTimer _saveTimer = new DispatcherTimer();
readonly DispatcherTimer _logTimer = new DispatcherTimer();
readonly ConcurrentQueue<string> _pendingLog = new ConcurrentQueue<string>();
2026-09-26 21:49:44 +03:00
readonly object _logGate = new object();
static readonly Regex AnsiEscape = new Regex(@"\x1B\[[0-9;]*[A-Za-z]", RegexOptions.Compiled);
2026-09-26 14:41:04 +03:00
int _pendingLogCount, _adapterRefreshVersion, _ipVersion;
bool _connecting, _checking, _pinging, _editingProfiles;
List<AdapterInfo> _adapterSnapshot = new List<AdapterInfo>();
public ObservableCollection<ServerVm> Servers { get; private set; }
public ObservableCollection<string> AdapterNames { get; private set; }
ServerVm _sel;
ServerCfg _activeServer;
bool _wg = true, _busy, _showSettings;
string _statusTitle = "Отключено", _statusSub = "выберите сервер";
string _connectText = "Подключить";
string _connectionPhase = "idle";
string _log = "";
string _importStatus = "Поддерживаются VLESS, VMess, Trojan, Shadowsocks, Hysteria2, TUIC и AnyTLS";
string _statusKey = "Idle";
string _theme = Theme.Dark;
// ставится из App: собственно применение палитры к окну
public Action<string> ThemeApplier;
public MainVm()
{
Servers = new ObservableCollection<ServerVm>();
AdapterNames = new ObservableCollection<string>();
_saveTimer.Interval = TimeSpan.FromMilliseconds(600);
_saveTimer.Tick += delegate { FlushLocal(); };
_logTimer.Interval = TimeSpan.FromMilliseconds(100);
_logTimer.Tick += delegate { FlushLog(); };
_logTimer.Start();
ConnectCommand = new RelayCommand(delegate { ToggleConnect(); });
2026-09-26 21:49:44 +03:00
ClearLogCommand = new RelayCommand(delegate { ClearLog(); });
2026-09-26 14:41:04 +03:00
PingAllCommand = new RelayCommand(delegate { PingAll(); });
KillStrayCommand = new RelayCommand(delegate { KillStray(); });
OpenFolderCommand = new RelayCommand(delegate { OpenFolder(); });
OpenConfigCommand = new RelayCommand(delegate { OpenConfig(); });
CheckConfigCommand = new RelayCommand(delegate { CheckConfig(); });
RefreshAdaptersCommand = new RelayCommand(delegate { RefreshAdapters(); });
AddFromLinkCommand = new RelayCommand(async delegate(object o) { await AddFromLinkAsync(o as string); });
DeleteSelectedCommand = new RelayCommand(delegate { DeleteServer(Selected); });
_runner.Log += delegate(string s) { AppendLog(s); };
_runner.StateChanged += delegate(bool on) { OnRunnerState(on); };
}
public ICommand ConnectCommand { get; private set; }
2026-09-26 21:49:44 +03:00
public ICommand ClearLogCommand { get; private set; }
2026-09-26 14:41:04 +03:00
public ICommand PingAllCommand { get; private set; }
public ICommand KillStrayCommand { get; private set; }
public ICommand OpenFolderCommand { get; private set; }
public ICommand OpenConfigCommand { get; private set; }
public ICommand CheckConfigCommand { get; private set; }
public ICommand RefreshAdaptersCommand { get; private set; }
public ICommand AddFromLinkCommand { get; private set; }
public ICommand DeleteSelectedCommand { get; private set; }
// ── статус ───────────────────────────────────────────────────────
public string StatusTitle { get { return _statusTitle; } set { _statusTitle = value; Raise(); } }
public string StatusSub { get { return _statusSub; } set { _statusSub = value; Raise(); } }
public string StatusKey { set { _statusKey = value; Raise("StatusBrush"); } }
public Brush StatusBrush { get { return Theme.Res(_statusKey); } }
public string ConnectText { get { return _connectText; } set { _connectText = value; Raise(); } }
// Лёгкая анимация в XAML зависит только от фазы, без таймеров на UI-потоке.
public string ConnectionPhase { get { return _connectionPhase; } set { _connectionPhase = value; Raise(); } }
public bool IsConnected { get { return _runner.IsRunning; } }
public string LogText { get { return _log; } set { _log = value; Raise(); } }
2026-09-26 21:49:44 +03:00
public Brush LogBrush { get { return Theme.Res("LogText"); } }
2026-09-26 14:41:04 +03:00
public string ImportStatus { get { return _importStatus; } set { _importStatus = value; Raise(); } }
public bool IsBusy { get { return _busy; } set { _busy = value; Raise(); } }
// ── вкладки «лог / настройки» ────────────────────────────────────
public bool ShowSettings
{
get { return _showSettings; }
set
{
_showSettings = value;
Raise(); Raise("LogVis"); Raise("SettingsVis");
Raise("TabLogBrush"); Raise("TabSettingsBrush");
}
}
public Visibility LogVis { get { return _showSettings ? Visibility.Collapsed : Visibility.Visible; } }
public Visibility SettingsVis { get { return _showSettings ? Visibility.Visible : Visibility.Collapsed; } }
public Brush TabLogBrush { get { return Theme.Res(_showSettings ? "Muted" : "Accent"); } }
public Brush TabSettingsBrush { get { return Theme.Res(_showSettings ? "Accent" : "Muted"); } }
// ── настройки WireGuard (пишутся в local.json) ───────────────────
public string PrivateKey
{
get { return Local.PrivateKey; }
set
{
Local.PrivateKey = (value != null ? value.Trim() : "");
Raise(); Raise("PrivateKeyHint"); Raise("PrivateKeyHintBrush");
SaveLocal();
}
}
public string PrivateKeyHint
{
get
{
if (string.IsNullOrEmpty(Local.PrivateKey)) return "не задан";
return ConfigBuilder.IsWgKey(Local.PrivateKey)
? "ключ корректный (32 байта base64)"
: "! это не похоже на ключ WireGuard — 32 байта base64, обычно 44 символа с = на конце";
}
}
public Brush PrivateKeyHintBrush
{
get
{
if (string.IsNullOrEmpty(Local.PrivateKey)) return Theme.Res("Muted");
return Theme.Res(ConfigBuilder.IsWgKey(Local.PrivateKey) ? "Ok" : "Bad");
}
}
public string LocalAddress
{
get { return Local.LocalAddress; }
set
{
Local.LocalAddress = (value != null ? value.Trim() : "");
Raise(); Raise("LocalAddressHint"); Raise("LocalAddressHintBrush");
SaveLocal();
}
}
public string LocalAddressHint
{
get
{
if (string.IsNullOrEmpty(Local.LocalAddress)) return "не задан";
return ConfigBuilder.IsCidr(Local.LocalAddress)
? "адрес корректный"
: "! ожидается вид 10.10.0.19/24";
}
}
public Brush LocalAddressHintBrush
{
get
{
if (string.IsNullOrEmpty(Local.LocalAddress)) return Theme.Res("Muted");
return Theme.Res(ConfigBuilder.IsCidr(Local.LocalAddress) ? "Ok" : "Bad");
}
}
// ── сетевой адаптер ──────────────────────────────────────────────
public string Adapter
{
get { return Local.Adapter; }
set
{
Local.Adapter = (value != null ? value.Trim() : "");
Raise(); Raise("AdapterHint"); Raise("AdapterHintBrush");
SaveLocal();
UpdateStatusIdle();
}
}
public string AdapterHint
{
get
{
if (string.IsNullOrEmpty(Local.Adapter)) return "не выбран — нужен только в режиме «напрямую»";
AdapterInfo a = FindCachedAdapter(Local.Adapter);
if (a == null) return "! адаптера с таким именем в системе нет";
return a.IsUp ? "есть связь · " + a.Description : "адаптер найден, но связи нет";
}
}
public Brush AdapterHintBrush
{
get
{
if (string.IsNullOrEmpty(Local.Adapter)) return Theme.Res("Muted");
AdapterInfo a = FindCachedAdapter(Local.Adapter);
if (a == null) return Theme.Res("Bad");
return Theme.Res(a.IsUp ? "Ok" : "Warn");
}
}
AdapterInfo FindCachedAdapter(string name)
{
foreach (AdapterInfo a in _adapterSnapshot)
if (string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) return a;
return null;
}
// Enumeration may block in Windows drivers. Only the result touches WPF.
public async void RefreshAdapters()
{
int request = ++_adapterRefreshVersion;
try
{
List<AdapterInfo> found = await Task.Run(delegate { return Adapters.List(); });
if (request != _adapterRefreshVersion) return;
_adapterSnapshot = found;
List<string> names = new List<string>();
foreach (AdapterInfo a in found) names.Add(a.Name);
bool same = names.Count == AdapterNames.Count;
if (same)
for (int i = 0; i < names.Count; i++)
if (names[i] != AdapterNames[i]) { same = false; break; }
if (!same)
{
string keep = Local.Adapter;
AdapterNames.Clear();
foreach (string n in names) AdapterNames.Add(n);
Local.Adapter = keep;
Raise("Adapter");
}
Raise("AdapterHint"); Raise("AdapterHintBrush");
}
catch (Exception ex) { AppendLog("! список адаптеров: " + ex.Message); }
}
// ── тема ─────────────────────────────────────────────────────────
public string ThemeName { get { return _theme; } }
// показываем действие, а не состояние: в тёмной теме — солнце
// (E706 Brightness), в светлой — луна (E708 QuietHours)
public string ThemeGlyph { get { return _theme == Theme.Light ? "" : ""; } }
public void SetTheme(string t)
{
_theme = (t == Theme.Light) ? Theme.Light : Theme.Dark;
if (ThemeApplier != null) ThemeApplier(_theme);
Raise("ThemeName"); Raise("ThemeGlyph");
Raise("StatusBrush");
2026-09-26 21:49:44 +03:00
Raise("LogBrush");
2026-09-26 14:41:04 +03:00
Raise("TabLogBrush"); Raise("TabSettingsBrush");
Raise("PrivateKeyHintBrush"); Raise("LocalAddressHintBrush"); Raise("AdapterHintBrush");
foreach (ServerVm s in Servers) s.RefreshBrushes();
}
public void ToggleTheme()
{
SetTheme(_theme == Theme.Dark ? Theme.Light : Theme.Dark);
Local.Theme = _theme;
SaveLocal();
}
// ── режим ────────────────────────────────────────────────────────
public bool WireguardOn
{
get { return _wg; }
set
{
_wg = value; Raise();
if (Selected != null && !Selected.IsWgOnly) { Local.Mode = CurrentMode; SaveLocal(); }
UpdateStatusIdle();
}
}
public string CurrentMode
{
get
{
if (Selected != null && Selected.IsWgOnly) return ConfigBuilder.ModeWgOnly;
return _wg ? ConfigBuilder.ModeWg : ConfigBuilder.ModeDirect;
}
}
public ServerVm Selected
{
get { return _sel; }
set
{
if (_sel != null) _sel.IsSelected = false;
_sel = value;
if (_sel != null) _sel.IsSelected = true;
Raise();
if (_sel != null)
{
Local.LastServer = _sel.IsWgOnly ? "*wg" : _sel.Code;
Local.Mode = CurrentMode;
SaveLocal();
}
UpdateStatusIdle();
}
}
// ── загрузка ─────────────────────────────────────────────────────
public void Load(Paths paths)
{
P = paths;
Prof = Profiles.Load(P.Profiles);
Local = LocalCfg.Load(P.Local);
_wg = Local.Mode != ConfigBuilder.ModeDirect;
Servers.Clear();
foreach (ServerCfg c in Prof.Servers) Servers.Add(CardFor(c));
// Бывший conf0: не папка, а режим. Карточка синтетическая.
ServerVm wgv = new ServerVm();
wgv.IsWgOnly = true;
wgv.Code = "RU";
wgv.Title = "Только WireGuard";
wgv.Subtitle = Prof.Wg.Server.Length > 0
? "выход внутри РФ · " + Prof.Wg.Server
: "WireGuard не настроен";
wgv.PingText = "н/д";
wgv.SelectCommand = new RelayCommand(delegate(object o) { Selected = (ServerVm)o; });
wgv.DeleteCommand = new RelayCommand(delegate(object o) { DeleteServer(wgv); });
Servers.Add(wgv);
RefreshAdapters();
AppendLog("серверов: " + Prof.Servers.Count + " корень: " + P.Root);
if (!File.Exists(P.Profiles)) AppendLog("! нет " + P.Profiles);
if (!File.Exists(P.Local)) AppendLog("! нет local.json — задайте ключ и адрес во вкладке «настройки»");
if (!File.Exists(P.SbExe)) AppendLog("! нет " + P.SbExe);
if (!File.Exists(P.RuleSet)) AppendLog("! нет " + P.RuleSet);
// восстановить выбор
ServerVm want = null;
foreach (ServerVm vm in Servers)
{
bool match = Local.LastServer == "*wg" ? vm.IsWgOnly
: (!vm.IsWgOnly && vm.Code == Local.LastServer);
if (match) { want = vm; break; }
}
if (want == null)
foreach (ServerVm vm in Servers) if (!vm.IsWgOnly) { want = vm; break; }
Selected = want != null ? want : (Servers.Count > 0 ? Servers[0] : null);
}
public void SaveLocal()
{
if (P == null) return;
_saveTimer.Stop();
_saveTimer.Start();
}
public void FlushLocal()
{
_saveTimer.Stop();
if (P == null) return;
try { Local.Save(P.Local); }
catch (Exception ex) { AppendLog("! не сохранился local.json: " + ex.Message); }
}
LocalCfg SnapshotLocal()
{
return new LocalCfg {
PrivateKey = Local.PrivateKey, LocalAddress = Local.LocalAddress,
Adapter = Local.Adapter, Theme = Local.Theme,
Mode = Local.Mode, LastServer = Local.LastServer
};
}
void UpdateStatusIdle()
{
if (_runner.IsRunning) return;
if (Selected == null)
{
StatusTitle = "Отключено";
StatusSub = (Servers.Count == 0) ? "список серверов пуст" : "выберите сервер";
StatusKey = "Idle";
return;
}
string mode = CurrentMode;
string s = ConfigBuilder.ModeTitle(mode);
if (mode == ConfigBuilder.ModeDirect) s += " «" + Local.Adapter + "»";
if (Selected.Cfg != null) s = Selected.Cfg.Endpoint + " · " + s;
StatusSub = s;
}
// ── подключение ──────────────────────────────────────────────────
async void ToggleConnect()
{
if (_connecting || _checking) return;
if (_runner.IsRunning)
{
_connecting = true;
IsBusy = true;
ConnectionPhase = "disconnecting";
StatusTitle = "Отключение…";
ConnectText = "Отключаем…";
// Остановка идёт в фоне. Короткое минимальное время даёт
// обратному вращению отобразиться, не блокируя окно.
try {
await Task.WhenAll(Task.Run(delegate { _runner.Stop(); }),
Task.Delay(400));
}
catch (Exception ex) { AppendLog("! остановка: " + ex.Message); }
finally {
_connecting = false; IsBusy = false;
if (_runner.IsRunning) {
ConnectionPhase = "connected";
StatusTitle = "Подключено";
ConnectText = "Отключить";
} else if (ConnectionPhase == "disconnecting") {
ConnectionPhase = "idle";
StatusTitle = "Отключено";
ConnectText = "Подключить";
UpdateStatusIdle();
}
}
return;
}
if (_editingProfiles) { AppendLog("! дождитесь сохранения списка серверов"); return; }
if (Selected == null) { AppendLog("! не выбран сервер"); return; }
_connecting = true;
IsBusy = true;
ConnectionPhase = "connecting";
ConnectText = "Подключаем…";
string mode = CurrentMode;
ServerCfg srv = Selected.Cfg;
LocalCfg local = SnapshotLocal();
StatusTitle = "Подключение…";
StatusKey = "Warn";
try
{
BuildResult br = await Task.Run(delegate { return ConfigBuilder.Build(P, Prof, local, srv, mode); });
if (!br.Ok)
{
AppendLog("! конфиг не собран: " + br.Error);
Fail("Не собрался конфиг");
return;
}
AppendLog("конфиг собран (" + mode + ") → " + br.ConfigPath);
string outp = null;
bool ok = await Task.Run(delegate
{
string o;
bool result = SingBoxRunner.Check(P.SbExe, br.ConfigPath, P.Root, out o);
outp = o;
return result;
});
if (!ok)
{
AppendLog("! sb.exe check забраковал конфиг: " + outp);
Fail("Конфиг не прошёл проверку");
return;
}
if (!string.IsNullOrEmpty(outp)) AppendLog(outp);
_activeServer = srv;
await Task.Run(delegate { _runner.Start(P.SbExe, br.ConfigPath, P.Root); });
}
catch (Exception ex) { _activeServer = null; AppendLog("! " + ex.Message); Fail("Ошибка"); }
finally { _connecting = false; IsBusy = false; }
}
void Fail(string title)
{
ConnectionPhase = "error";
StatusTitle = title;
StatusKey = "Bad";
ConnectText = "Подключить";
ShowSettings = true; // ошибка почти всегда в параметрах
}
void OnRunnerState(bool on)
{
Action ui = delegate
{
if (on)
{
ConnectionPhase = "connected";
StatusTitle = "Подключено";
StatusKey = "Ok";
ConnectText = "Отключить";
ShowExitIp();
}
else
{
_activeServer = null;
++_ipVersion;
if (_connecting && ConnectionPhase == "disconnecting") return;
ConnectionPhase = "idle";
StatusTitle = "Отключено";
StatusKey = "Idle";
ConnectText = "Подключить";
UpdateStatusIdle();
}
};
if (Application.Current != null) Application.Current.Dispatcher.BeginInvoke(ui);
else ui();
}
async void ShowExitIp()
{
int request = ++_ipVersion;
await Task.Delay(1500);
string ip = await Pinger.ExitIpAsync();
if (request == _ipVersion && _runner.IsRunning && ip != null)
StatusSub = "внешний IP: " + ip;
}
// ── проверка конфига без подключения ─────────────────────────────
public async void CheckConfig()
{
if (_connecting || _checking || _editingProfiles) return;
if (Selected == null) { AppendLog("! не выбран сервер"); return; }
_checking = true;
IsBusy = true;
string mode = CurrentMode;
ServerCfg server = Selected.Cfg;
LocalCfg local = SnapshotLocal();
try
{
BuildResult br = await Task.Run(delegate { return ConfigBuilder.Build(P, Prof, local, server, mode); });
if (!br.Ok) { AppendLog("! " + br.Error); return; }
string outp = null;
bool ok = await Task.Run(delegate
{
string o;
bool result = SingBoxRunner.Check(P.SbExe, br.ConfigPath, P.Root, out o);
outp = o;
return result;
});
AppendLog(ok ? "проверка (" + mode + "): конфиг корректен"
: "проверка (" + mode + "): ОШИБКА");
if (!string.IsNullOrEmpty(outp)) AppendLog(outp);
}
catch (Exception ex) { AppendLog("! проверка: " + ex.Message); }
finally { _checking = false; IsBusy = false; }
}
// ── пинг ─────────────────────────────────────────────────────────
public async void PingAll()
{
if (_pinging) return;
_pinging = true;
try
{
List<Task> tasks = new List<Task>();
using (SemaphoreSlim limit = new SemaphoreSlim(8))
{
foreach (ServerVm vm in Servers)
{
if (!vm.Pingable) { vm.PingText = "н/д"; vm.PingKey = "Na"; continue; }
vm.PingText = "…";
tasks.Add(PingOne(vm, limit));
}
await Task.WhenAll(tasks);
}
}
catch (Exception ex) { AppendLog("! пинг: " + ex.Message); }
finally { _pinging = false; }
}
async Task PingOne(ServerVm vm, SemaphoreSlim limit)
{
await limit.WaitAsync();
try
{
int ms = await Pinger.TcpLatencyAsync(vm.Cfg.Server, vm.Cfg.Port, 3000);
if (ms < 0) { vm.PingText = "—"; vm.PingKey = "Bad"; }
else
{
vm.PingText = ms.ToString(CultureInfo.InvariantCulture) + " ms";
vm.PingKey = ms < 60 ? "Ok" : ms < 150 ? "Warn" : "Bad";
}
}
finally { limit.Release(); }
}
// ── серверы ──────────────────────────────────────────────────────
// Save a snapshot off the UI thread, then publish it in one UI update.
ServerVm CardFor(ServerCfg c)
{
ServerVm vm = new ServerVm();
vm.Cfg = c;
vm.Code = c.Code;
vm.Title = c.Name.Length > 0 ? c.Name : c.Code;
vm.Subtitle = c.Protocol.ToUpperInvariant() + " · " + c.Endpoint +
(c.Sni.Length > 0 ? " · SNI " + c.Sni : "");
vm.SelectCommand = new RelayCommand(delegate(object o) { Selected = (ServerVm)o; });
vm.DeleteCommand = new RelayCommand(delegate(object o) { DeleteServer(vm); });
return vm;
}
Profiles CopyProfiles()
{
Profiles next = new Profiles();
next.Wg = Prof.Wg;
next.Servers.AddRange(Prof.Servers);
return next;
}
sealed class ImportWork
{
public Profiles Next;
public readonly List<ServerCfg> Added = new List<ServerCfg>();
public readonly List<string> Errors = new List<string>();
public int Duplicates;
}
static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all)
{
string code = server.Code;
int number = 2;
bool used = true;
while (used)
{
used = false;
foreach (ServerCfg existing in all)
if (string.Equals(existing.Code, code, StringComparison.OrdinalIgnoreCase))
{ used = true; code = server.Code + (number++).ToString(CultureInfo.InvariantCulture); break; }
}
server.Code = code;
}
public async Task<bool> AddFromLinkAsync(string link)
{
if (_editingProfiles || _connecting || _checking) return false;
_editingProfiles = true;
ImportStatus = "Импорт: разбираю и проверяю ключи…";
try
{
Profiles next = CopyProfiles();
ImportWork work = await Task.Run(delegate
{
ImportWork report = new ImportWork();
report.Next = next;
ImportResult parsed = KeyImporter.Parse(link);
report.Errors.AddRange(parsed.Errors);
foreach (ServerCfg server in parsed.Servers)
{
bool duplicate = false;
foreach (ServerCfg existing in next.Servers)
if (KeyImporter.Same(existing, server)) { duplicate = true; break; }
if (duplicate) { report.Duplicates++; continue; }
string validation;
if (!KeyImporter.CheckWithSingBox(P, server, out validation))
{
report.Errors.Add(server.Protocol.ToUpperInvariant() + " «" + server.Name + "»: " + validation);
continue;
}
GiveUniqueCode(server, next.Servers);
next.Servers.Add(server);
report.Added.Add(server);
}
if (report.Added.Count > 0) next.Save(P.Profiles);
return report;
});
if (work.Added.Count > 0)
{
Prof = work.Next;
ServerVm selected = null;
foreach (ServerCfg server in work.Added)
{
ServerVm card = CardFor(server);
Servers.Insert(Servers.Count > 0 ? Servers.Count - 1 : 0, card);
if (selected == null) selected = card;
}
Selected = selected;
if (Servers.Count <= 25) PingAll();
}
ImportStatus = "Добавлено: " + work.Added.Count +
" · повторов: " + work.Duplicates + " · ошибок: " + work.Errors.Count;
if (work.Added.Count > 0 && Servers.Count > 25)
ImportStatus += " · пинг запускается кнопкой «пинг»";
if (work.Errors.Count > 0)
{
string first = work.Errors[0];
ImportStatus += " · " + (first.Length > 180 ? first.Substring(0, 180) + "…" : first);
}
AppendLog("импорт: " + ImportStatus);
for (int i = 0; i < work.Errors.Count && i < 10; i++)
AppendLog("! " + work.Errors[i]);
if (work.Errors.Count > 10)
AppendLog("! и ещё ошибок: " + (work.Errors.Count - 10));
return work.Added.Count > 0 && work.Errors.Count == 0;
}
catch (Exception ex)
{
ImportStatus = "Импорт не завершён — см. лог";
AppendLog("! импорт: " + ex.Message);
return false;
}
finally { _editingProfiles = false; }
}
public async void DeleteServer(ServerVm vm)
{
if (vm == null || _editingProfiles || _connecting || _checking) return;
if (vm.IsWgOnly || vm.Cfg == null)
{
MessageBox.Show("Режим «Только WireGuard» является встроенным и не может быть удалён.",
"Удаление сервера", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
if (_runner.IsRunning && vm.Cfg == _activeServer)
{
MessageBox.Show("Нельзя удалить сервер, к которому сейчас установлено активное подключение.\nСначала отключитесь.",
"Удаление сервера", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
string msg = "Вы действительно хотите удалить сервер \"" + vm.Title + "\" (" +
vm.Code + " / " + vm.Cfg.Endpoint + ")?\n\nСервер будет удален из profiles.json.";
if (MessageBox.Show(msg, "Подтверждение удаления", MessageBoxButton.YesNo,
MessageBoxImage.Question) != MessageBoxResult.Yes) return;
_editingProfiles = true;
try
{
Profiles next = CopyProfiles();
next.Servers.Remove(vm.Cfg);
await Task.Run(delegate { next.Save(P.Profiles); });
Prof = next;
int index = Servers.IndexOf(vm);
Servers.Remove(vm);
if (Selected == vm)
Selected = Servers.Count == 0 ? null : Servers[Math.Min(index, Servers.Count - 1)];
UpdateStatusIdle();
AppendLog("Сервер \"" + vm.Title + "\" (" + vm.Code + ") удалён из profiles.json.");
}
catch (Exception ex)
{
MessageBox.Show("Не удалось сохранить profiles.json:\n" + ex.Message,
"Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
AppendLog("! Ошибка при сохранении profiles.json: " + ex.Message);
}
finally { _editingProfiles = false; }
}
// ── сервис ───────────────────────────────────────────────────────
// Именно то, что раньше делалось вручную: taskkill /f /im sb.exe.
// Свой процесс не трогаем — его останавливает кнопка «Отключить».
async void KillStray()
{
if (_runner.IsRunning || _connecting || _checking)
{
AppendLog("! сначала отключите текущее подключение");
return;
}
int killed = 0;
try
{
killed = await Task.Run(delegate
{
int count = 0;
foreach (Process p in Process.GetProcessesByName("sb"))
{
try { p.Kill(); p.WaitForExit(3000); count++; }
catch (Exception ex) { AppendLog("! " + p.Id + ": " + ex.Message); }
finally { p.Dispose(); }
}
return count;
});
}
catch (Exception ex) { AppendLog("! поиск sb.exe: " + ex.Message); }
AppendLog(killed > 0 ? "прибито процессов sb.exe: " + killed : "зависших sb.exe не найдено");
}
void OpenFolder()
{
try { Process.Start("explorer.exe", "\"" + P.Root + "\""); }
catch (Exception ex) { AppendLog("! " + ex.Message); }
}
void OpenConfig()
{
if (!File.Exists(P.RunConfig))
{
AppendLog("! конфиг ещё не собирался — нажмите «проверить конфиг»");
return;
}
try { Process.Start("notepad.exe", "\"" + P.RunConfig + "\""); }
catch (Exception ex) { AppendLog("! " + ex.Message); }
}
public void ShutdownRunner() { _runner.Stop(); }
public void AppendLog(string line)
{
2026-09-26 21:49:44 +03:00
if (line == null) line = "";
if (line.IndexOf('\x1b') >= 0) line = AnsiEscape.Replace(line, "");
2026-09-26 14:41:04 +03:00
string stamped = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + " " + line;
2026-09-26 21:49:44 +03:00
lock (_logGate)
{
_pendingLog.Enqueue(stamped);
if (++_pendingLogCount > 2000)
{
string discarded;
if (_pendingLog.TryDequeue(out discarded)) --_pendingLogCount;
}
}
}
public void ClearLog()
{
lock (_logGate)
2026-09-26 14:41:04 +03:00
{
string discarded;
2026-09-26 21:49:44 +03:00
while (_pendingLog.TryDequeue(out discarded)) { }
_pendingLogCount = 0;
2026-09-26 14:41:04 +03:00
}
2026-09-26 21:49:44 +03:00
LogText = "";
2026-09-26 14:41:04 +03:00
}
void FlushLog()
{
StringBuilder batch = new StringBuilder();
string line;
int n = 0;
2026-09-26 21:49:44 +03:00
lock (_logGate)
2026-09-26 14:41:04 +03:00
{
2026-09-26 21:49:44 +03:00
while (n++ < 200 && _pendingLog.TryDequeue(out line))
{
--_pendingLogCount;
if (batch.Length > 0) batch.AppendLine();
batch.Append(line);
}
2026-09-26 14:41:04 +03:00
}
if (batch.Length == 0) return;
string old = LogText;
string combined = old.Length == 0 ? batch.ToString() : old + Environment.NewLine + batch;
if (combined.Length > 50000)
{
int cut = combined.IndexOf('\n', combined.Length - 40000);
combined = cut >= 0 ? combined.Substring(cut + 1) : combined.Substring(combined.Length - 40000);
}
LogText = combined;
}
public void DisposeTimers()
{
FlushLocal();
_saveTimer.Stop();
_logTimer.Stop();
}
}
// ═════════════════════════════════════════════════════════════════════
// ТЕМА
//
// Палитры лежат в Ui.xaml (Theme.Dark / Theme.Light). Здесь мы копируем
// выбранную в Application.Resources — интерфейс ссылается на токены через
// DynamicResource, поэтому подхватывает смену на лету.
// Выбор запоминается в local.json (раньше был отдельный settings.ini
// в %APPDATA% — при портативном использовании он оставался на старой
// машине, поэтому переехал к остальным локальным параметрам).
// ═════════════════════════════════════════════════════════════════════
}