// SbLauncher — GUI для sing-box. // // ПРИНЦИП (изменён по сравнению с прошлой версией): // Источник правды — profiles.base.json (базовые серверы), 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 _exec; readonly Func _can; public RelayCommand(Action exec) : this(exec, null) { } public RelayCommand(Action exec, Func 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 Cache = new Dictionary(); 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 _pendingLog = new ConcurrentQueue(); readonly object _logGate = new object(); static readonly Regex AnsiEscape = new Regex(@"\x1B\[[0-9;]*[A-Za-z]", RegexOptions.Compiled); int _pendingLogCount, _adapterRefreshVersion, _ipVersion; bool _connecting, _checking, _pinging, _editingProfiles; List _adapterSnapshot = new List(); public ObservableCollection Servers { get; private set; } public ObservableCollection 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 ThemeApplier; public MainVm() { Servers = new ObservableCollection(); AdapterNames = new ObservableCollection(); _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(); }); ClearLogCommand = new RelayCommand(delegate { ClearLog(); }); 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; } public ICommand ClearLogCommand { get; private set; } 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 VersionText { get { Assembly assembly = Assembly.GetExecutingAssembly(); AssemblyInformationalVersionAttribute version = (AssemblyInformationalVersionAttribute)Attribute.GetCustomAttribute( assembly, typeof(AssemblyInformationalVersionAttribute)); return "v" + (version != null ? version.InformationalVersion : assembly.GetName().Version.ToString(3)); } } 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(); Raise("ConnectionInProgress"); } } public bool ConnectionInProgress { get { return _connectionPhase == "connecting" || _connectionPhase == "disconnecting"; } } public bool IsConnected { get { return _runner.IsRunning; } } public string LogText { get { return _log; } set { _log = value; Raise(); } } public Brush LogBrush { get { return Theme.Res("LogText"); } } 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 { if (_showSettings == value) return; _showSettings = value; Raise(); Raise("TabLogBrush"); Raise("TabSettingsBrush"); } } 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 found = await Task.Run(delegate { return Adapters.List(); }); if (request != _adapterRefreshVersion) return; _adapterSnapshot = found; List names = new List(); 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; } } // Иконки в XAML показывают действие: в тёмной теме — солнце, в светлой — луна. public string ThemeAction { 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("ThemeAction"); Raise("StatusBrush"); Raise("LogBrush"); 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.Cfg.BaseId.Length > 0 ? "base:" + _sel.Cfg.BaseId : _sel.Code); Local.Mode = CurrentMode; SaveLocal(); } UpdateStatusIdle(); } } // ── загрузка ───────────────────────────────────────────────────── public void Load(Paths paths) { P = paths; Prof = Profiles.Load(P.Profiles); if (Prof.NeedsMigration) Prof.Save(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) && !File.Exists(P.BaseProfiles)) AppendLog("! нет списка серверов"); 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 || (vm.Cfg.BaseId.Length > 0 && "base:" + vm.Cfg.BaseId == 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 tasks = new List(); 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() { return Prof.Copy(); } sealed class ImportWork { public Profiles Next; public readonly List Added = new List(); public readonly List Errors = new List(); public int Duplicates; } static void GiveUniqueCode(ServerCfg server, List all) { Profiles.GiveUniqueCode(server, all); } public async Task 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" + (vm.Cfg.BaseId.Length > 0 ? "Базовый сервер будет скрыт и после обновления." : "Личный сервер будет удалён."); if (MessageBox.Show(msg, "Подтверждение удаления", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) return; _editingProfiles = true; try { Profiles next = CopyProfiles(); next.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) { if (line == null) line = ""; if (line.IndexOf('\x1b') >= 0) line = AnsiEscape.Replace(line, ""); string stamped = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + " " + line; lock (_logGate) { _pendingLog.Enqueue(stamped); if (++_pendingLogCount > 2000) { string discarded; if (_pendingLog.TryDequeue(out discarded)) --_pendingLogCount; } } } public void ClearLog() { lock (_logGate) { string discarded; while (_pendingLog.TryDequeue(out discarded)) { } _pendingLogCount = 0; } LogText = ""; } void FlushLog() { StringBuilder batch = new StringBuilder(); string line; int n = 0; lock (_logGate) { while (n++ < 200 && _pendingLog.TryDequeue(out line)) { --_pendingLogCount; if (batch.Length > 0) batch.AppendLine(); batch.Append(line); } } 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% — при портативном использовании он оставался на старой // машине, поэтому переехал к остальным локальным параметрам). // ═════════════════════════════════════════════════════════════════════ }