1083 lines
50 KiB
C#
1083 lines
50 KiB
C#
// 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>();
|
|
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<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(); });
|
|
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 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(); } }
|
|
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
|
|
{
|
|
_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");
|
|
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.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)
|
|
{
|
|
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% — при портативном использовании он оставался на старой
|
|
// машине, поэтому переехал к остальным локальным параметрам).
|
|
// ═════════════════════════════════════════════════════════════════════
|
|
}
|