From 1975c1ceff60fd43cf66033a20be79b2bdc19978 Mon Sep 17 00:00:00 2001 From: nikit3s Date: Sat, 26 Sep 2026 14:41:04 +0300 Subject: [PATCH] Create glass launcher prototype --- .gitignore | 15 + App.cs | 672 +++++++++++++++++++++++++ Import.cs | 734 +++++++++++++++++++++++++++ README.md | 23 + SbLauncher.cs | 934 ++++++++++++++++++++++++++++++++++ Ui.xaml | 937 ++++++++++++++++++++++++++++++++++ ViewModels.cs | 1057 +++++++++++++++++++++++++++++++++++++++ app.ico | Bin 0 -> 381038 bytes app.manifest | 31 ++ build.cmd | 134 +++++ local.example.json | 20 + logo.png | Bin 0 -> 219676 bytes preview/glass-dark.png | Bin 0 -> 406185 bytes preview/glass-light.png | Bin 0 -> 406021 bytes profiles.example.json | 48 ++ templates/direct.json | 165 ++++++ templates/wg.json | 164 ++++++ templates/wgonly.json | 132 +++++ tests/ImportSmoke.cs | 121 +++++ tests/ProfileSmoke.cs | 53 ++ tests/UiStress.cs | 54 ++ tools/MakeIcon.cs | 287 +++++++++++ 22 files changed, 5581 insertions(+) create mode 100644 .gitignore create mode 100644 App.cs create mode 100644 Import.cs create mode 100644 README.md create mode 100644 SbLauncher.cs create mode 100644 Ui.xaml create mode 100644 ViewModels.cs create mode 100644 app.ico create mode 100644 app.manifest create mode 100644 build.cmd create mode 100644 local.example.json create mode 100644 logo.png create mode 100644 preview/glass-dark.png create mode 100644 preview/glass-light.png create mode 100644 profiles.example.json create mode 100644 templates/direct.json create mode 100644 templates/wg.json create mode 100644 templates/wgonly.json create mode 100644 tests/ImportSmoke.cs create mode 100644 tests/ProfileSmoke.cs create mode 100644 tests/UiStress.cs create mode 100644 tools/MakeIcon.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed6d54a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ── секреты и параметры конкретной машины ──────────────────────────── +# private_key, local_address, имя сетевого адаптера. Никогда не коммитим. +local.json +profiles.json + +# ── рантайм ────────────────────────────────────────────────────────── +# сюда лаунчер генерирует config.json перед запуском +run/ +# sb.exe (36 МБ) и doc.srs — бинарники, лежат рядом на боевой машине +bin/ +doc/ + +# ── результат сборки ───────────────────────────────────────────────── +*.exe +*.pdb diff --git a/App.cs b/App.cs new file mode 100644 index 0000000..14a7404 --- /dev/null +++ b/App.cs @@ -0,0 +1,672 @@ +// 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.Interop; +using System.Windows.Markup; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; + +namespace SbLauncher +{ + // Ask the Windows compositor to round the opaque, borderless window. + // Unlike per-pixel transparency, this keeps the fast normal WPF render path. + public static class WindowCorners + { + [DllImport("dwmapi.dll", PreserveSig = true)] + static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + public static void Enable(Window window) + { + window.SourceInitialized += delegate + { + try + { + int preference = 2; // DWMWCP_ROUND + DwmSetWindowAttribute(new WindowInteropHelper(window).Handle, + 33, ref preference, sizeof(int)); // DWMWA_WINDOW_CORNER_PREFERENCE + } + catch (DllNotFoundException) { } // Windows before DWM corner support + catch (EntryPointNotFoundException) { } + }; + } + } + + public static class Theme + { + public const string Dark = "dark"; + public const string Light = "light"; + + public static string DetectSystem() + { + try + { + object v = Microsoft.Win32.Registry.GetValue( + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "AppsUseLightTheme", null); + if (v is int) return ((int)v) == 0 ? Dark : Light; + } + catch { } + return Dark; + } + + // Палитра кладётся СРАЗУ В ДВА места, и это не избыточность: + // + // * w.Resources — чтобы {DynamicResource} в разметке гарантированно + // получил уведомление об изменении. Окно всегда слушает свой + // собственный словарь. Уведомления от Application.Resources доходят + // только до окон, зарегистрированных в Application.Current, а окно + // попадает туда лишь если Application существовал в момент его + // создания. Промах по этому условию даёт фон null и полностью + // прозрачное окно (при AllowsTransparency="True"). + // + // * Application.Resources — оттуда Theme.Res() берёт смысловые цвета + // для статуса, пингов и подсказок; у ViewModel ссылки на окно нет. + public static void Apply(Window w, string theme) + { + string key = (theme == Light) ? "Theme.Light" : "Theme.Dark"; + ResourceDictionary src = w.Resources[key] as ResourceDictionary; + if (src == null) return; + + foreach (object k in src.Keys) + { + object v = src[k]; + w.Resources[k] = v; + if (Application.Current != null) Application.Current.Resources[k] = v; + } + } + + // Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в + // палитре, поэтому берём их из ресурсов, а не из зашитых констант. + public static Brush Res(string key) + { + try + { + if (Application.Current != null) + { + object o = Application.Current.TryFindResource(key); + if (o is Brush) return (Brush)o; + } + } + catch { } + return Brushes.Gray; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ТРЕЙ + // + // Крестик не закрывает приложение, а прячет окно: sing-box продолжает + // работать. Реальный выход — только через меню трея, и он останавливает + // туннель. Имена типов WinForms пишем полностью: NotifyIcon соседствует + // с WPF, где есть свои Application/MessageBox. + // ═════════════════════════════════════════════════════════════════════ + public class Tray : IDisposable + { + readonly System.Windows.Forms.NotifyIcon _ni; + readonly System.Windows.Forms.ToolStripMenuItem _miToggle; + readonly Window _w; + readonly MainVm _vm; + bool _balloonShown; + + public Tray(Window w, MainVm vm) + { + _w = w; _vm = vm; + + _ni = new System.Windows.Forms.NotifyIcon(); + _ni.Icon = LoadIcon(); + _ni.Text = "sb launcher"; + _ni.Visible = true; + _ni.DoubleClick += delegate { Restore(); }; + + System.Windows.Forms.ContextMenuStrip m = new System.Windows.Forms.ContextMenuStrip(); + m.Items.Add("Показать", null, delegate { Restore(); }); + _miToggle = new System.Windows.Forms.ToolStripMenuItem("Подключить", null, + delegate { if (_vm.ConnectCommand.CanExecute(null)) _vm.ConnectCommand.Execute(null); }); + m.Items.Add(_miToggle); + m.Items.Add(new System.Windows.Forms.ToolStripSeparator()); + m.Items.Add("Выход", null, delegate { ExitApp(); }); + m.Opening += delegate { _miToggle.Text = _vm.ConnectText; }; + _ni.ContextMenuStrip = m; + } + + static System.Drawing.Icon LoadIcon() + { + try + { + Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico"); + if (s != null) + using (s) + return new System.Drawing.Icon(s, System.Windows.Forms.SystemInformation.SmallIconSize); + } + catch { } + try { return System.Drawing.Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location); } + catch { return System.Drawing.SystemIcons.Application; } + } + + public void HideToTray() + { + _w.Hide(); + if (!_balloonShown) + { + _balloonShown = true; + _ni.ShowBalloonTip(3000, "sb launcher", + _vm.IsConnected + ? "Свернулся в трей — туннель продолжает работать." + : "Свернулся в трей.", + System.Windows.Forms.ToolTipIcon.Info); + } + } + + public void Restore() + { + Action a = delegate + { + _w.Show(); + if (_w.WindowState == WindowState.Minimized) _w.WindowState = WindowState.Normal; + _w.Activate(); + _w.Topmost = true; _w.Topmost = false; + _w.Focus(); + }; + if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a); + } + + public void ExitApp() + { + Action a = delegate + { + _vm.DisposeTimers(); + Dispose(); + Application.Current.Shutdown(); + }; + if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a); + } + + public void Dispose() + { + try { _ni.Visible = false; _ni.Dispose(); } catch { } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ТОЧКА ВХОДА + // ═════════════════════════════════════════════════════════════════════ + public class App + { + [STAThread] + public static int Main(string[] argv) + { + Paths paths = new Paths(ResolveRoot(argv)); + + if (Array.IndexOf(argv, "--selftest") >= 0) return SelfTest(paths); + + int thIdx = Array.IndexOf(argv, "--theme"); + string themeArg = (thIdx >= 0 && thIdx + 1 < argv.Length) ? argv[thIdx + 1] : null; + + int shotIdx = Array.IndexOf(argv, "--screenshot"); + if (shotIdx >= 0 && shotIdx + 1 < argv.Length) + return Screenshot(argv[shotIdx + 1], paths, themeArg, + Array.IndexOf(argv, "--settings") >= 0); + + // Одна копия за раз: два sing-box одновременно сломают маршруты. + // Вторая копия не ругается, а поднимает окно уже запущенной — + // иначе после сворачивания в трей ярлык выглядел бы сломанным. + // Namespace именно Local\: Global\ требует SeCreateGlobalPrivilege, + // которого у неэлевированного процесса нет. + // Проверку делаем ПОСЛЕ служебных режимов: --selftest и + // --screenshot не должны занимать блокировку. + bool isNew; + Mutex mtx = new Mutex(true, "Local\\SbLauncherGlass_SingleInstance", out isNew); + EventWaitHandle showSignal = new EventWaitHandle( + false, EventResetMode.AutoReset, "Local\\SbLauncherGlass_Show"); + if (!isNew) + { + showSignal.Set(); + return 0; + } + + // ВНИМАНИЕ: Application создаётся ПЕРВЫМ, до разбора окна. + // Window регистрируется в Application.Current в своём конструкторе. + // Если Application ещё нет, окно в этот список не попадает, и + // уведомление об изменении Application.Resources до него не доходит: + // палитра применяется, TryFindResource её находит, а вот + // {DynamicResource} остаётся неразрешённым — фон становится null, + // и при AllowsTransparency="True" окно выглядит полностью прозрачным. + Application app = new Application(); + app.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + Window w; + try { w = (Window)XamlReader.Parse(LoadXaml()); } + catch (Exception ex) + { + MessageBox.Show("Не разобран Ui.xaml:\n\n" + ex.Message, "SbLauncher", + MessageBoxButton.OK, MessageBoxImage.Error); + return 1; + } + + MainVm vm = new MainVm(); + w.DataContext = vm; + vm.Load(paths); + WindowCorners.Enable(w); + + vm.ThemeApplier = delegate(string t) { Theme.Apply(w, t); }; + string th = themeArg != null ? themeArg : vm.Local.Theme; + if (string.IsNullOrEmpty(th)) th = Theme.DetectSystem(); + vm.SetTheme(th); + + Tray tray = new Tray(w, vm); + Wire(w, vm, tray); + + w.Closing += delegate(object s, CancelEventArgs e) + { + e.Cancel = true; // крестик = свернуть в трей + vm.FlushLocal(); + tray.HideToTray(); + }; + app.Exit += delegate { tray.Dispose(); vm.DisposeTimers(); }; + + // фоновый слушатель: повторный запуск ярлыка поднимает это окно + Thread listener = new Thread(delegate() + { + while (true) { showSignal.WaitOne(); tray.Restore(); } + }); + listener.IsBackground = true; + listener.Start(); + + app.MainWindow = w; + w.Show(); + int rc = app.Run(); + GC.KeepAlive(mtx); + return rc; + } + + // ── --selftest ─────────────────────────────────────────────────── + // Разбирает XAML, проверяет темы и флаги, читает profiles/local и + // СОБИРАЕТ все три конфига, прогоняя каждый через sb.exe check. + // Ничего не запускает и не требует администратора. + static int SelfTest(Paths paths) + { + int problems = 0; + Window tw; + try + { + // Порядок ТОЧНО как в боевом Main: сначала Application, + // потом окно. Иначе тест не поймает регрессию с прозрачным + // окном — она проявляется только при обратном порядке. + new Application(); + tw = (Window)XamlReader.Parse(LoadXaml()); + Console.WriteLine("XAML .......... ok (" + tw.Width + "x" + tw.Height + ")"); + string[] names = { "TitleBar", "BtnTheme", "BtnMin", "BtnClose", "LogBox", + "BtnTabLog", "BtnTabSettings", "LinkBox", "BtnAddLink", + "AdapterBox" }; + foreach (string n in names) + { + bool ok = tw.FindName(n) != null; + if (!ok) problems++; + Console.WriteLine(" x:Name " + n.PadRight(16) + (ok ? "found" : "MISSING")); + } + } + catch (Exception ex) + { + Console.WriteLine("XAML .......... FAIL: " + ex.Message); + return 1; + } + + // Регрессия «прозрачное окно»: палитра применяется, но + // {DynamicResource} не разрешается, и фон остаётся null. + foreach (string th in new string[] { Theme.Dark, Theme.Light }) + { + Theme.Apply(tw, th); + Border rootBorder = tw.Content as Border; + object bg = (rootBorder != null) ? rootBorder.Background : null; + bool ok = bg != null; + if (!ok) problems++; + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "theme {0,-6} ... {1}", th, ok ? "фон " + bg : "ФОН NULL — окно будет прозрачным!")); + } + + // Флаги строятся кодом. Если Build бросит исключение, WPF + // проглотит его при вычислении биндинга и молча покажет + // текстовый бейдж — внешне похоже на «флаг просто не добавлен». + foreach (string fc in new string[] { "RU", "BU", "DE", "EE", "FI", "NL", "SE" }) + { + string res; + try { res = (Flags.Get(fc) != null) ? "ok" : "нет ветки в Build"; } + catch (Exception ex) { res = ex.GetType().Name + ": " + ex.Message; } + if (res != "ok") problems++; + Console.WriteLine(("flag " + fc).PadRight(14) + "... " + res); + } + + try + { + using (Stream ic = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico")) + { + if (ic == null) { Console.WriteLine("app.ico ....... MISSING resource"); problems++; } + else + { + IconBitmapDecoder d = new IconBitmapDecoder( + ic, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + Console.WriteLine("app.ico ....... ok, " + d.Frames.Count + " sizes"); + } + } + } + catch (Exception ex) { Console.WriteLine("app.ico ....... FAIL: " + ex.Message); problems++; } + + Console.WriteLine(); + Console.WriteLine("root .......... " + paths.Root); + Console.WriteLine("sb.exe ........ " + (File.Exists(paths.SbExe) ? "ok" : "MISSING") + " " + paths.SbExe); + Console.WriteLine("doc.srs ....... " + (File.Exists(paths.RuleSet) ? "ok" : "MISSING")); + + Profiles pr; + LocalCfg lc; + try + { + pr = Profiles.Load(paths.Profiles); + lc = LocalCfg.Load(paths.Local); + } + catch (Exception ex) + { + Console.WriteLine("profiles/local FAIL: " + ex.Message); + return 1; + } + + Console.WriteLine("profiles.json . " + pr.Servers.Count + " серверов, WG " + + pr.Wg.Server + ":" + pr.Wg.Port.ToString(CultureInfo.InvariantCulture)); + foreach (ServerCfg s in pr.Servers) + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0,-4} {1,-24} {2,-22} sni={3}", s.Code, s.Name, s.Endpoint, s.Sni)); + + Console.WriteLine("local.json .... ключ " + + (ConfigBuilder.IsWgKey(lc.PrivateKey) ? "ok" : "НЕ КОРРЕКТЕН") + + ", адрес " + (ConfigBuilder.IsCidr(lc.LocalAddress) ? "ok" : "НЕ КОРРЕКТЕН") + + ", адаптер \"" + lc.Adapter + "\" " + + (Adapters.Find(lc.Adapter) != null ? "найден" : "НЕ НАЙДЕН")); + + Console.WriteLine("адаптеры ...... "); + foreach (AdapterInfo a in Adapters.List()) + Console.WriteLine(" " + (a.IsUp ? "[up] " : "[down] ") + a.Name.PadRight(30) + a.Description); + + // Разбор ссылки — чистая функция, проверяем на синтетической. + // Порядок параметров в vless:// у разных панелей разный, поэтому + // важно, что читаем мы их по имени, а не по позиции. + string link = "vless://58863334-3b8a-4c81-9145-a18311621a7f@rus.krasavchik.org:443" + + "?type=tcp&security=reality&fp=random&sni=exomates.ru" + + "&pbk=Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14&sid=6d25" + + "&flow=xtls-rprx-vision#%D0%91%D1%83%D1%80%D1%8F%D1%82%D0%B8%D1%8F"; + ServerCfg parsed = VlessLink.Parse(link); + bool linkOk = parsed != null + && parsed.Server == "rus.krasavchik.org" + && parsed.Port == 443 + && parsed.Uuid == "58863334-3b8a-4c81-9145-a18311621a7f" + && parsed.Sni == "exomates.ru" + && parsed.PublicKey == "Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14" + && parsed.ShortId == "6d25" + && parsed.Fingerprint == "random" + && parsed.Flow == "xtls-rprx-vision" + && parsed.Name == "Бурятия"; + if (!linkOk) problems++; + Console.WriteLine("vless:// ...... " + (linkOk + ? "разобрана верно (имя из #-фрагмента: " + parsed.Name + ", код " + parsed.Code + ")" + : "РАЗБОР СЛОМАН")); + + // Второй тест: фрагмент с ?serverDescription=, без flow, с spx + string link2 = "vless://dbbb755e-cf8d-4701-9f97-37bf90cf530f@95.81.76.106:443" + + "?sid=68b1b1&type=tcp&spx=%2Fzx6XGVuN0oHb3Mm&headerType=none" + + "&sni=server.yamal-house.ru&security=reality&fp=chrome" + + "&pbk=G-IE8mHDdRX9600xdDuRm75upOoCSE3FHZN2Dj1hdWk&encryption=none" + + "#VPN_56RUS-Rostik?serverDescription=TVMxLeKEljE="; + ServerCfg p2 = VlessLink.Parse(link2); + bool link2Ok = p2 != null + && p2.Server == "95.81.76.106" + && p2.Uuid == "dbbb755e-cf8d-4701-9f97-37bf90cf530f" + && p2.Sni == "server.yamal-house.ru" + && p2.ShortId == "68b1b1" + && p2.Flow == "" + && p2.SpiderX == "/zx6XGVuN0oHb3Mm" + && p2.Name == "VPN_56RUS-Rostik"; // без ?serverDescription=... + if (!link2Ok) problems++; + Console.WriteLine("vless:// #2 ... " + (link2Ok + ? "разобрана верно (имя: " + p2.Name + ", flow пуст, spx=" + p2.SpiderX + ")" + : "РАЗБОР СЛОМАН")); + + Console.WriteLine(); + ServerCfg first = pr.Servers.Count > 0 ? pr.Servers[0] : null; + string[] modes = { ConfigBuilder.ModeWg, ConfigBuilder.ModeDirect, ConfigBuilder.ModeWgOnly }; + foreach (string mode in modes) + { + BuildResult br = ConfigBuilder.Build(paths, pr, lc, first, mode, + Path.Combine(paths.RunDir, "selftest-" + mode + ".json")); + if (!br.Ok) + { + problems++; + Console.WriteLine("build " + mode.PadRight(8) + "... FAIL: " + br.Error); + continue; + } + string verdict = "собран"; + if (File.Exists(paths.SbExe)) + { + string outp; + bool ok = SingBoxRunner.Check(paths.SbExe, br.ConfigPath, paths.Root, out outp); + if (!ok) { problems++; verdict = "sb check ОШИБКА: " + outp; } + else verdict = "собран, sb check ok"; + } + else verdict = "собран (sb.exe нет, check пропущен)"; + Console.WriteLine("build " + mode.PadRight(8) + "... " + verdict); + } + + Console.WriteLine(); + Console.WriteLine(problems == 0 + ? "ИТОГ .......... всё в порядке" + : "ИТОГ .......... проблем: " + problems); + return problems == 0 ? 0 : 1; + } + + // ── --screenshot ─────────────────────────────────────────── + static int Screenshot(string outPath, Paths paths, string themeArg, bool settingsTab) + { + try + { + new Application(); // нужен для ресурсов/шрифтов + Window w = (Window)XamlReader.Parse(LoadXaml()); + MainVm vm = new MainVm(); + w.DataContext = vm; + vm.ThemeApplier = delegate(string t) { Theme.Apply(w, t); }; + vm.SetTheme(themeArg != null ? themeArg : Theme.Dark); + vm.Load(paths); + vm.ShowSettings = settingsTab; + vm.AppendLog("демо-рендер интерфейса — sing-box не запускался"); + + ImageSource logo = LoadLogo(); + Image li = w.FindName("LogoImg") as Image; + if (li != null && logo != null) li.Source = logo; + Image statusLogo = w.FindName("StatusLogoImg") as Image; + if (statusLogo != null && logo != null) statusLogo.Source = logo; + + // немного «живых» данных, чтобы картинка была честной + int i = 0; + int[] demo = { 28, 41, 96, 214 }; + foreach (ServerVm s in vm.Servers) + { + if (s.Pingable) + { + int ms = demo[i++ % demo.Length]; + s.PingText = ms.ToString(CultureInfo.InvariantCulture) + " ms"; + s.PingKey = ms < 60 ? "Ok" : ms < 150 ? "Warn" : "Bad"; + } + else { s.PingText = "н/д"; s.PingKey = "Na"; } + } + + FrameworkElement root2 = (FrameworkElement)w.Content; + double ww = w.Width, hh = w.Height; + root2.Measure(new Size(ww, hh)); + root2.Arrange(new Rect(0, 0, ww, hh)); + root2.UpdateLayout(); + Pump(); // дать ItemsControl создать карточки + root2.UpdateLayout(); + + RenderTargetBitmap rtb = new RenderTargetBitmap( + (int)(ww * 1.5), (int)(hh * 1.5), 144, 144, PixelFormats.Pbgra32); + rtb.Render(root2); + PngBitmapEncoder enc = new PngBitmapEncoder(); + enc.Frames.Add(BitmapFrame.Create(rtb)); + using (FileStream fs = new FileStream(outPath, FileMode.Create)) + enc.Save(fs); + Console.WriteLine("saved: " + outPath); + return 0; + } + catch (Exception ex) + { + Console.WriteLine("screenshot FAIL: " + ex.GetType().Name + ": " + ex.Message); + return 1; + } + } + + static void Pump() + { + DispatcherFrame f = new DispatcherFrame(); + Application.Current.Dispatcher.BeginInvoke( + System.Windows.Threading.DispatcherPriority.ContextIdle, + new Action(delegate { f.Continue = false; })); + Dispatcher.PushFrame(f); + } + + // --root, иначе папка с exe, иначе соседняя (на случай exe в подпапке) + static string ResolveRoot(string[] argv) + { + for (int i = 0; i < argv.Length - 1; i++) + if (argv[i] == "--root") return argv[i + 1]; + + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (File.Exists(Path.Combine(exeDir, "profiles.json"))) return exeDir; + + string parent = Path.GetDirectoryName(exeDir); + if (parent != null && File.Exists(Path.Combine(parent, "profiles.json"))) return parent; + + return exeDir; + } + + static ImageSource LoadLogo() + { + try + { + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string ext = Path.Combine(exeDir, "logo.png"); + BitmapImage b = new BitmapImage(); + b.BeginInit(); + if (File.Exists(ext)) b.UriSource = new Uri(ext); + else b.StreamSource = Assembly.GetExecutingAssembly().GetManifestResourceStream("logo.png"); + b.CacheOption = BitmapCacheOption.OnLoad; + b.EndInit(); + b.Freeze(); + return b; + } + catch { return null; } + } + + // Ui.xaml рядом с exe перекрывает встроенный — можно править дизайн + // без пересборки: сохранил файл, перезапустил. + static string LoadXaml() + { + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string ext = Path.Combine(exeDir, "Ui.xaml"); + if (File.Exists(ext)) return File.ReadAllText(ext, Encoding.UTF8); + + using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream("Ui.xaml")) + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + return r.ReadToEnd(); + } + + // В runtime-XAML нельзя писать Click="..." — обработчики цепляем здесь. + static void Wire(Window w, MainVm vm, Tray tray) + { + // логотип в шапке и в Alt+Tab + ImageSource logo = LoadLogo(); + if (logo != null) + { + Image li = w.FindName("LogoImg") as Image; + if (li != null) li.Source = logo; + Image statusLogo = w.FindName("StatusLogoImg") as Image; + if (statusLogo != null) statusLogo.Source = logo; + try { w.Icon = logo; } catch { } + } + + UIElement bar = w.FindName("TitleBar") as UIElement; + if (bar != null) + bar.MouseLeftButtonDown += delegate(object s, MouseButtonEventArgs e) + { + if (e.ClickCount == 1) w.DragMove(); + }; + + Button theme = w.FindName("BtnTheme") as Button; + if (theme != null) theme.Click += delegate { vm.ToggleTheme(); }; + + Button min = w.FindName("BtnMin") as Button; + if (min != null) min.Click += delegate { w.WindowState = WindowState.Minimized; }; + + Button close = w.FindName("BtnClose") as Button; + if (close != null) close.Click += delegate { tray.HideToTray(); }; + + Button tabLog = w.FindName("BtnTabLog") as Button; + if (tabLog != null) tabLog.Click += delegate { vm.ShowSettings = false; }; + + Button tabSet = w.FindName("BtnTabSettings") as Button; + if (tabSet != null) tabSet.Click += delegate { vm.ShowSettings = true; }; + + // «добавить сервер из ссылки»: берём текст из поля и чистим его + TextBox link = w.FindName("LinkBox") as TextBox; + Button addLink = w.FindName("BtnAddLink") as Button; + if (addLink != null) + addLink.Click += async delegate + { + string text = link != null ? link.Text : null; + if (await vm.AddFromLinkAsync(text) && link != null) link.Text = ""; + }; + + // список адаптеров пересобираем при раскрытии: кабель могли + // воткнуть уже после запуска лаунчера + ComboBox ad = w.FindName("AdapterBox") as ComboBox; + if (ad != null) + ad.DropDownOpened += delegate { vm.RefreshAdapters(); }; + + // автопрокрутка лога + TextBox log = w.FindName("LogBox") as TextBox; + if (log != null) + log.TextChanged += delegate { log.ScrollToEnd(); }; + + w.Loaded += delegate { vm.PingAll(); }; + } + } +} diff --git a/Import.cs b/Import.cs new file mode 100644 index 0000000..ff95c46 --- /dev/null +++ b/Import.cs @@ -0,0 +1,734 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Web.Script.Serialization; + +namespace SbLauncher +{ + public class ImportResult + { + public readonly List Servers = new List(); + public readonly List Errors = new List(); + } + + // Turns share links and pasted subscriptions into sing-box outbounds. + // Unknown transports are rejected rather than silently changing the protocol. + public static class KeyImporter + { + public static bool Same(ServerCfg a, ServerCfg b) + { + if (!string.Equals(a.Protocol, b.Protocol, StringComparison.OrdinalIgnoreCase) || + !string.Equals(a.Server, b.Server, StringComparison.OrdinalIgnoreCase) || a.Port != b.Port) + return false; + if (a.Outbound != null && b.Outbound != null) + return Canonical(a.Outbound) == Canonical(b.Outbound); + if (a.Protocol == "vless") return string.Equals(a.Uuid, b.Uuid, StringComparison.OrdinalIgnoreCase); + return false; + } + + static string Canonical(object value) + { + Dictionary map = value as Dictionary; + if (map != null) + { + List keys = new List(map.Keys); + keys.Sort(StringComparer.Ordinal); + StringBuilder output = new StringBuilder("{"); + foreach (string key in keys) + output.Append(J.ToJson(key)).Append(':').Append(Canonical(map[key])).Append(','); + return output.Append('}').ToString(); + } + object[] items = value as object[]; + if (items != null) + { + StringBuilder output = new StringBuilder("["); + foreach (object item in items) output.Append(Canonical(item)).Append(','); + return output.Append(']').ToString(); + } + return J.ToJson(value); + } + + public static bool CheckWithSingBox(Paths paths, ServerCfg server, out string error) + { + error = ""; + if (!File.Exists(paths.SbExe)) return true; + string file = Path.Combine(paths.RunDir, "import-check-" + Guid.NewGuid().ToString("N") + ".json"); + try + { + Dictionary outbound = J.Parse(J.ToJson(server.Outbound)); + outbound["tag"] = "import-test"; + J.Write(file, "{\"outbounds\":[" + J.ToJson(outbound) + "]}"); + string output; + if (SingBoxRunner.Check(paths.SbExe, file, paths.Root, out output)) return true; + error = string.IsNullOrEmpty(output) ? "sing-box отклонил конфигурацию" : output; + Dictionary tls = J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null); + Dictionary reality = tls == null ? null : J.Obj(tls.ContainsKey("reality") ? tls["reality"] : null); + Dictionary obfs = J.Obj(outbound.ContainsKey("obfs") ? outbound["obfs"] : null); + foreach (string secret in new string[] { J.Str(outbound, "uuid", ""), J.Str(outbound, "password", ""), + J.Str(reality, "public_key", ""), J.Str(obfs, "password", "") }) + if (!string.IsNullOrEmpty(secret)) error = error.Replace(secret, "[скрыто]"); + if (error.Length > 300) error = error.Substring(0, 300) + "…"; + return false; + } + catch (Exception ex) { error = ex.Message; return false; } + finally { try { if (File.Exists(file)) File.Delete(file); } catch { } } + } + + const int MaxInput = 2000000; + static readonly Regex LinkPattern = new Regex( + @"(?i)(?:vless|vmess|trojan|ss|hysteria2|hy2|tuic|anytls)://\S+", + RegexOptions.Compiled); + + public static ImportResult Parse(string input) + { + ImportResult result = new ImportResult(); + if (string.IsNullOrWhiteSpace(input)) + { + result.Errors.Add("поле ввода пусто"); + return result; + } + if (input.Length > MaxInput) + { + result.Errors.Add("слишком большой текст подписки (максимум 2 МБ)"); + return result; + } + ParseText(input.Trim().Trim('\uFEFF'), result, 0); + if (result.Servers.Count == 0 && result.Errors.Count == 0) + result.Errors.Add("поддерживаемых ключей не найдено"); + return result; + } + + static void ParseText(string text, ImportResult result, int depth) + { + if (depth > 2) { result.Errors.Add("слишком много слоёв base64"); return; } + string trimmed = text.Trim(); + if (trimmed.StartsWith("{") || trimmed.StartsWith("[")) + { + ParseJson(trimmed, result); + return; + } + if (!trimmed.Contains("://")) + { + string decoded; + if (TryDecodeBase64(trimmed, out decoded) && decoded != trimmed && + (decoded.Contains("://") || decoded.TrimStart().StartsWith("{") || decoded.TrimStart().StartsWith("["))) + { + ParseText(decoded, result, depth + 1); + return; + } + } + + string[] lines = text.Replace("\r", "").Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith("//")) continue; + MatchCollection links = LinkPattern.Matches(line); + if (links.Count == 0) + { + if (Regex.IsMatch(line, @"(?i)^https?://\S+$")) + { + try { ParseText(FetchSubscription(line), result, depth + 1); } + catch (Exception ex) { result.Errors.Add("строка " + (i + 1) + ": " + ex.Message); } + } + else result.Errors.Add("строка " + (i + 1) + ": неизвестный формат ключа"); + continue; + } + foreach (Match match in links) + { + if (result.Servers.Count >= 1000) + { + result.Errors.Add("лимит импорта — 1000 серверов"); + return; + } + ServerCfg server; + string error; + if (TryParseOne(match.Value, out server, out error)) result.Servers.Add(server); + else result.Errors.Add("строка " + (i + 1) + ": " + error); + } + } + } + + static string FetchSubscription(string url) + { + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + if (new Uri(url).IsLoopback) request.Proxy = null; + request.Timeout = 10000; + request.ReadWriteTimeout = 10000; + request.AllowAutoRedirect = true; + request.MaximumAutomaticRedirections = 3; + request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + request.UserAgent = "SbLauncher/2"; + using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) + using (Stream stream = response.GetResponseStream()) + using (StreamReader reader = new StreamReader(stream, new UTF8Encoding(false, true), true)) + { + if (response.ContentLength > MaxInput) + throw new FormatException("подписка больше 2 МБ"); + StringBuilder body = new StringBuilder(); + char[] buffer = new char[4096]; + int count; + while ((count = reader.Read(buffer, 0, buffer.Length)) > 0) + { + body.Append(buffer, 0, count); + if (body.Length > MaxInput) throw new FormatException("подписка больше 2 МБ"); + } + return body.ToString(); + } + } + catch (FormatException) { throw; } + catch (WebException ex) { throw new FormatException("не удалось загрузить подписку (" + ex.Status + ")"); } + catch (Exception) { throw new FormatException("не удалось прочитать подписку"); } + } + + static void ParseJson(string text, ImportResult result) + { + try + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = MaxInput; + object root = ser.DeserializeObject(J.StripComments(text)); + Dictionary document = root as Dictionary; + object[] outbounds = root as object[]; + if (document != null) + { + object listed; + if (document.TryGetValue("outbounds", out listed)) outbounds = listed as object[]; + else if (document.ContainsKey("type")) outbounds = new object[] { document }; + } + if (outbounds == null) { result.Errors.Add("JSON не содержит outbounds"); return; } + foreach (object item in outbounds) + { + Dictionary outbound = J.Obj(item); + if (outbound == null) continue; + string type = J.Str(outbound, "type", "").ToLowerInvariant(); + if (!Supported(type)) continue; // direct, selector, block etc. are not keys + try + { + if (result.Servers.Count >= 1000) throw new FormatException("лимит импорта — 1000 серверов"); + result.Servers.Add(FromOutbound(outbound)); + } + catch (Exception ex) { result.Errors.Add("JSON: " + ex.Message); } + } + if (result.Servers.Count == 0 && result.Errors.Count == 0) + result.Errors.Add("в JSON нет поддерживаемых прокси-серверов"); + } + catch (Exception ex) { result.Errors.Add("не удалось прочитать JSON: " + ex.Message); } + } + + public static bool TryParseOne(string text, out ServerCfg server, out string error) + { + server = null; + error = ""; + try + { + if (string.IsNullOrWhiteSpace(text)) throw new FormatException("пустой ключ"); + string raw = text.Trim(); + int schemeEnd = raw.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd < 0) throw new FormatException("нет схемы протокола"); + string scheme = raw.Substring(0, schemeEnd).ToLowerInvariant(); + string body = raw.Substring(schemeEnd + 3); + if (!Supported(scheme)) throw new FormatException("протокол " + scheme + " пока не поддерживается"); + if (scheme == "vmess" && body.IndexOf('@') < 0) + server = ParseVmessJson(body); + else if (scheme == "ss") server = ParseShadowsocks(body); + else + { + Link link = ParseLink(body); + if (scheme == "vless") server = ParseVless(link); + else if (scheme == "vmess") server = ParseVmessUri(link); + else if (scheme == "trojan") server = ParseTrojan(link); + else if (scheme == "hysteria2" || scheme == "hy2") server = ParseHysteria2(link); + else if (scheme == "tuic") server = ParseTuic(link); + else if (scheme == "anytls") server = ParseAnyTls(link); + } + if (server == null) throw new FormatException("ключ не распознан"); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + static bool Supported(string type) + { + return type == "vless" || type == "vmess" || type == "trojan" || + type == "ss" || type == "shadowsocks" || type == "hysteria2" || + type == "hy2" || type == "tuic" || type == "anytls"; + } + + sealed class Link + { + public string User, Host, Name; + public int Port; + public Dictionary Query; + } + + static Link ParseLink(string body) + { + string name = ""; + int hash = body.IndexOf('#'); + if (hash >= 0) + { + name = Decode(body.Substring(hash + 1)); + int metadata = name.IndexOf("?serverDescription=", StringComparison.OrdinalIgnoreCase); + if (metadata >= 0) name = name.Substring(0, metadata); + body = body.Substring(0, hash); + } + string query = ""; + int question = body.IndexOf('?'); + if (question >= 0) { query = body.Substring(question + 1); body = body.Substring(0, question); } + body = body.TrimEnd('/'); + int at = body.LastIndexOf('@'); + if (at < 1) throw new FormatException("в ключе нет учётных данных или адреса сервера"); + string user = Decode(body.Substring(0, at)); + string address = body.Substring(at + 1); + string host; + int port; + ParseAddress(address, out host, out port); + return new Link { User = user, Host = host, Port = port, + Name = name.Length > 0 ? name : host, Query = Query(query) }; + } + + static void ParseAddress(string address, out string host, out int port) + { + string digits; + if (address.StartsWith("[")) + { + int close = address.IndexOf(']'); + if (close < 0 || close + 1 >= address.Length || address[close + 1] != ':') + throw new FormatException("неверный IPv6-адрес или порт"); + host = address.Substring(1, close - 1); + digits = address.Substring(close + 2); + } + else + { + int colon = address.LastIndexOf(':'); + if (colon < 1 || address.IndexOf(':') != colon) + throw new FormatException("адрес должен иметь вид host:port (IPv6 — в квадратных скобках)"); + host = address.Substring(0, colon); + digits = address.Substring(colon + 1); + } + host = Decode(host).Trim(); + if (host.Length == 0 || host.Contains(" ")) throw new FormatException("пустой или неверный адрес сервера"); + if (!int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out port) || port < 1 || port > 65535) + throw new FormatException("порт должен быть числом от 1 до 65535"); + } + + static Dictionary Query(string text) + { + Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string pair in text.Split('&')) + { + if (pair.Length == 0) continue; + int eq = pair.IndexOf('='); + string key = Decode((eq < 0 ? pair : pair.Substring(0, eq)).Replace('+', ' ')); + string value = eq < 0 ? "" : Decode(pair.Substring(eq + 1).Replace('+', ' ')); + values[key] = value; + } + return values; + } + + static string Get(Dictionary q, string key, string fallback) + { + string value; + return q.TryGetValue(key, out value) ? value : fallback; + } + + static string First(Dictionary q, string a, string b, string fallback) + { + string value = Get(q, a, ""); + return value.Length > 0 ? value : Get(q, b, fallback); + } + + static string Decode(string value) { return Uri.UnescapeDataString(value); } + + static bool TryDecodeBase64(string text, out string decoded) + { + decoded = null; + try + { + string clean = Regex.Replace(text, @"\s", "").Replace('-', '+').Replace('_', '/'); + if (clean.Length == 0 || clean.Length > MaxInput * 2) return false; + clean = clean.PadRight((clean.Length + 3) / 4 * 4, '='); + decoded = new UTF8Encoding(false, true).GetString(Convert.FromBase64String(clean)); + return true; + } + catch { return false; } + } + + static string Base64(string value) + { + string decoded; + if (!TryDecodeBase64(value, out decoded)) throw new FormatException("неверная base64-строка"); + return decoded; + } + + static Dictionary Base(string protocol, Link link) + { + Dictionary outbound = new Dictionary(); + outbound["type"] = protocol; + outbound["server"] = link.Host; + outbound["server_port"] = link.Port; + return outbound; + } + + static ServerCfg Finish(string protocol, Link link, Dictionary outbound) + { + ServerCfg server = new ServerCfg(); + server.Protocol = protocol; + server.Server = link.Host; + server.Port = link.Port; + server.Name = link.Name; + server.Code = MakeCode(server.Name); + server.Uuid = J.Str(outbound, "uuid", ""); + server.Sni = J.Str(J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null), "server_name", ""); + server.Outbound = outbound; + return server; + } + + static string MakeCode(string name) + { + StringBuilder code = new StringBuilder(); + foreach (char c in name) + { + if (char.IsLetterOrDigit(c)) code.Append(char.ToUpperInvariant(c)); + if (code.Length == 2) break; + } + return code.Length > 0 ? code.ToString() : "??"; + } + + static void AddTls(Dictionary outbound, Link link, string mode, bool defaultEnabled) + { + string security = Get(link.Query, "security", mode).ToLowerInvariant(); + if (security.Length == 0) security = defaultEnabled ? "tls" : "none"; + if (security == "none" || security == "false") return; + if (security != "tls" && security != "reality") + throw new FormatException("режим TLS " + security + " не поддерживается"); + Dictionary tls = new Dictionary(); + tls["enabled"] = true; + string sni = First(link.Query, "sni", "peer", link.Host); + if (sni.Length > 0) tls["server_name"] = sni; + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = First(link.Query, "allowInsecure", "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + string fp = Get(link.Query, "fp", security == "reality" ? "chrome" : ""); + if (fp.Length > 0 && fp != "none") + tls["utls"] = new Dictionary { { "enabled", true }, { "fingerprint", fp } }; + if (security == "reality") + { + string publicKey = First(link.Query, "pbk", "publicKey", ""); + if (publicKey.Length == 0) throw new FormatException("в Reality отсутствует public key (pbk)"); + tls["reality"] = new Dictionary { + { "enabled", true }, { "public_key", publicKey }, + { "short_id", First(link.Query, "sid", "shortId", "") } + }; + } + outbound["tls"] = tls; + } + + static void AddTransport(Dictionary outbound, Dictionary q) + { + string type = First(q, "type", "net", "tcp").ToLowerInvariant(); + if (type == "" || type == "tcp" || type == "raw") + { + string header = Get(q, "headerType", "none"); + if (header.Length > 0 && header != "none") + throw new FormatException("TCP headerType=" + header + " пока не поддерживается"); + return; + } + Dictionary transport = new Dictionary(); + if (type == "ws" || type == "websocket") + { + transport["type"] = "ws"; + string path = Get(q, "path", "/"); + Match early = Regex.Match(path, @"(?:\?|&)ed=(\d+)(?:&|$)"); + if (early.Success) + { + int ed; + if (int.TryParse(early.Groups[1].Value, out ed) && ed > 0) + { + transport["max_early_data"] = ed; + transport["early_data_header_name"] = "Sec-WebSocket-Protocol"; + } + path = path.Substring(0, early.Index).TrimEnd('?','&'); + } + transport["path"] = path.Length > 0 ? path : "/"; + string host = Get(q, "host", ""); + if (host.Length > 0) + transport["headers"] = new Dictionary { { "Host", host } }; + } + else if (type == "grpc") + { + transport["type"] = "grpc"; + transport["service_name"] = First(q, "serviceName", "path", "").TrimStart('/'); + } + else if (type == "httpupgrade") + { + transport["type"] = "httpupgrade"; + transport["path"] = Get(q, "path", "/"); + string host = Get(q, "host", ""); + if (host.Length > 0) transport["host"] = host; + } + else if (type == "http" || type == "h2") + { + transport["type"] = "http"; + transport["path"] = Get(q, "path", "/"); + string host = Get(q, "host", ""); + if (host.Length > 0) transport["host"] = host.Split(','); + } + else if (type == "quic") transport["type"] = "quic"; + else throw new FormatException("транспорт " + type + " пока не поддерживается"); + outbound["transport"] = transport; + } + + static ServerCfg ParseVless(Link link) + { + Guid id; + if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VLESS"); + string encryption = Get(link.Query, "encryption", "none"); + if (encryption.Length > 0 && encryption != "none") + throw new FormatException("шифрование VLESS " + encryption + " не поддерживается"); + Dictionary outbound = Base("vless", link); + outbound["uuid"] = link.User; + string flow = Get(link.Query, "flow", ""); + if (flow.Length > 0) + { + if (flow != "xtls-rprx-vision") throw new FormatException("flow " + flow + " не поддерживается"); + outbound["flow"] = flow; + } + string security = Get(link.Query, "security", Get(link.Query, "pbk", "").Length > 0 ? "reality" : "none"); + AddTls(outbound, link, security, false); + AddTransport(outbound, link.Query); + outbound["packet_encoding"] = First(link.Query, "packetEncoding", "packet_encoding", "xudp"); + ServerCfg result = Finish("vless", link, outbound); + result.Flow = flow; + result.PublicKey = Get(link.Query, "pbk", ""); + result.ShortId = Get(link.Query, "sid", ""); + result.Fingerprint = Get(link.Query, "fp", "chrome"); + result.SpiderX = Get(link.Query, "spx", ""); + return result; + } + + static ServerCfg ParseVmessJson(string body) + { + string decoded = Base64(body.Split('#')[0]); + Dictionary data = J.Parse(decoded); + if (data == null) throw new FormatException("VMess base64 не содержит JSON-объект"); + string address = J.Str(data, "add", ""); + int port = J.Int(data, "port", 0); + if (address.Length == 0 || port < 1 || port > 65535) throw new FormatException("VMess: неверный адрес или порт"); + Link link = new Link(); + link.User = J.Str(data, "id", ""); + link.Host = address; + link.Port = port; + link.Name = J.Str(data, "ps", address); + link.Query = new Dictionary(StringComparer.OrdinalIgnoreCase); + link.Query["type"] = J.Str(data, "net", "tcp"); + link.Query["path"] = J.Str(data, "path", ""); + link.Query["host"] = J.Str(data, "host", ""); + link.Query["sni"] = J.Str(data, "sni", ""); + link.Query["fp"] = J.Str(data, "fp", ""); + link.Query["alpn"] = J.Str(data, "alpn", ""); + string vmessTls = J.Str(data, "tls", "").ToLowerInvariant(); + if (vmessTls != "" && vmessTls != "tls" && vmessTls != "none") + throw new FormatException("VMess: режим TLS " + vmessTls + " не поддерживается"); + link.Query["security"] = vmessTls == "tls" ? "tls" : "none"; + link.Query["headerType"] = J.Str(data, "type", "none"); + link.Query["vmess_security"] = J.Str(data, "scy", "auto"); + link.Query["alter_id"] = J.Str(data, "aid", "0"); + return ParseVmessUri(link); + } + + static ServerCfg ParseVmessUri(Link link) + { + Guid id; + if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VMess"); + Dictionary outbound = Base("vmess", link); + outbound["uuid"] = link.User; + outbound["security"] = First(link.Query, "vmess_security", "encryption", "auto"); + int aid; + if (!int.TryParse(First(link.Query, "alter_id", "aid", "0"), out aid) || aid < 0) + throw new FormatException("неверный alter_id VMess"); + outbound["alter_id"] = aid; + AddTls(outbound, link, Get(link.Query, "security", "none"), false); + AddTransport(outbound, link.Query); + return Finish("vmess", link, outbound); + } + + static ServerCfg ParseTrojan(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль Trojan"); + Dictionary outbound = Base("trojan", link); + outbound["password"] = link.User; + AddTls(outbound, link, Get(link.Query, "security", "tls"), true); + AddTransport(outbound, link.Query); + return Finish("trojan", link, outbound); + } + + static ServerCfg ParseShadowsocks(string body) + { + string original = body; + int hash = body.IndexOf('#'); + string name = ""; + if (hash >= 0) { name = Decode(body.Substring(hash + 1)); body = body.Substring(0, hash); } + int queryAt = body.IndexOf('?'); + string query = ""; + if (queryAt >= 0) { query = body.Substring(queryAt + 1); body = body.Substring(0, queryAt); } + body = body.TrimEnd('/'); + if (body.IndexOf('@') < 0) body = Base64(body); + int at = body.LastIndexOf('@'); + if (at < 1) throw new FormatException("Shadowsocks: нет адреса или пароля"); + string user = body.Substring(0, at); + if (user.IndexOf(':') < 0) user = Base64(user); + else user = Decode(user); + int colon = user.IndexOf(':'); + if (colon < 1 || colon == user.Length - 1) throw new FormatException("Shadowsocks: ожидается method:password"); + string method = user.Substring(0, colon); + string password = user.Substring(colon + 1); + string host; int port; + ParseAddress(body.Substring(at + 1), out host, out port); + Link link = new Link { User = password, Host = host, Port = port, + Name = name.Length > 0 ? name : host, Query = Query(query) }; + Dictionary outbound = Base("shadowsocks", link); + outbound["method"] = method; + outbound["password"] = password; + string plugin = Get(link.Query, "plugin", ""); + if (plugin.Length > 0) + { + string[] parts = plugin.Split(new char[] {';'}, 2); + if (parts[0] != "obfs-local" && parts[0] != "v2ray-plugin") + throw new FormatException("плагин Shadowsocks " + parts[0] + " не поддерживается sing-box"); + outbound["plugin"] = parts[0]; + if (parts.Length > 1) outbound["plugin_opts"] = parts[1]; + } + return Finish("shadowsocks", link, outbound); + } + + static ServerCfg ParseHysteria2(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль Hysteria2"); + Dictionary outbound = Base("hysteria2", link); + outbound["password"] = link.User; + Dictionary tls = new Dictionary { { "enabled", true } }; + tls["server_name"] = Get(link.Query, "sni", link.Host); + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = Get(link.Query, "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + outbound["tls"] = tls; + string obfs = Get(link.Query, "obfs", ""); + if (obfs.Length > 0) + { + if (obfs != "salamander") throw new FormatException("обфускация Hysteria2 " + obfs + " не поддерживается этой версией sing-box"); + string secret = First(link.Query, "obfs-password", "obfsPassword", ""); + if (secret.Length == 0) throw new FormatException("нет obfs-password Hysteria2"); + outbound["obfs"] = new Dictionary { { "type", "salamander" }, { "password", secret } }; + } + string ports = Get(link.Query, "mport", ""); + if (ports.Length > 0) + { + outbound.Remove("server_port"); + outbound["server_ports"] = ports.Replace('-', ':').Split(','); + } + string up = Get(link.Query, "upmbps", ""), down = Get(link.Query, "downmbps", ""); + int value; + if (int.TryParse(up, out value) && value > 0) outbound["up_mbps"] = value; + if (int.TryParse(down, out value) && value > 0) outbound["down_mbps"] = value; + return Finish("hysteria2", link, outbound); + } + + static ServerCfg ParseTuic(Link link) + { + int colon = link.User.IndexOf(':'); + if (colon < 1 || colon == link.User.Length - 1) throw new FormatException("TUIC: ожидается UUID:пароль"); + string uuid = link.User.Substring(0, colon), password = link.User.Substring(colon + 1); + Guid id; + if (!Guid.TryParse(uuid, out id)) throw new FormatException("неверный UUID TUIC"); + Dictionary outbound = Base("tuic", link); + outbound["uuid"] = uuid; + outbound["password"] = password; + string cc = Get(link.Query, "congestion_control", ""); + if (cc.Length > 0) outbound["congestion_control"] = cc; + string relay = Get(link.Query, "udp_relay_mode", ""); + if (relay.Length > 0) outbound["udp_relay_mode"] = relay; + Dictionary tls = new Dictionary { { "enabled", true } }; + tls["server_name"] = Get(link.Query, "sni", link.Host); + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = Get(link.Query, "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + outbound["tls"] = tls; + return Finish("tuic", link, outbound); + } + + static ServerCfg ParseAnyTls(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль AnyTLS"); + Dictionary outbound = Base("anytls", link); + outbound["password"] = link.User; + AddTls(outbound, link, "tls", true); + if (!outbound.ContainsKey("tls")) throw new FormatException("AnyTLS требует TLS"); + return Finish("anytls", link, outbound); + } + + static ServerCfg FromOutbound(Dictionary input) + { + Dictionary outbound = J.Parse(J.ToJson(input)); + string type = J.Str(outbound, "type", "").ToLowerInvariant(); + if (type == "hy2") type = "hysteria2"; + if (type == "ss") type = "shadowsocks"; + string host = J.Str(outbound, "server", ""); + int port = J.Int(outbound, "server_port", 0); + if (host.Length == 0 || port < 1 || port > 65535) + throw new FormatException("outbound " + type + ": неверный server/server_port"); + string auth = type == "vless" || type == "vmess" || type == "tuic" ? + J.Str(outbound, "uuid", "") : J.Str(outbound, "password", ""); + if (auth.Length == 0) throw new FormatException("outbound " + type + ": нет UUID или пароля"); + string name = J.Str(outbound, "tag", host); + outbound["type"] = type; + outbound.Remove("tag"); + outbound.Remove("detour"); + Link link = new Link { Host = host, Port = port, Name = name }; + return Finish(type, link, outbound); + } + } + + public static class ProxyOutbound + { + public static Dictionary Build(ServerCfg server, string mode) + { + Dictionary outbound; + if (server.Outbound != null) outbound = J.Parse(J.ToJson(server.Outbound)); + else + { + outbound = new Dictionary(); + outbound["type"] = "vless"; + outbound["server"] = server.Server; + outbound["server_port"] = server.Port; + outbound["uuid"] = server.Uuid; + if (!string.IsNullOrEmpty(server.Flow)) outbound["flow"] = server.Flow; + outbound["tls"] = new Dictionary { + { "enabled", true }, { "server_name", server.Sni }, + { "utls", new Dictionary { { "enabled", true }, { "fingerprint", server.Fingerprint } } }, + { "reality", new Dictionary { { "enabled", true }, + { "public_key", server.PublicKey }, { "short_id", server.ShortId } } } + }; + outbound["packet_encoding"] = "xudp"; + outbound["domain_strategy"] = "ipv4_only"; + } + outbound["tag"] = "proxy-vless"; // shared route tag, independent of protocol + outbound["detour"] = mode == ConfigBuilder.ModeWg ? "proxy-wireguard" : "bound"; + return outbound; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a8b5d9 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# sb launcher · glass preview + +Отдельный тестовый репозиторий интерфейса для Windows 11. Рабочий `sb_launcher_rev2` не меняется. Это функционирующий лаунчер на WPF/.NET Framework 4.x: выбор и импорт серверов, проверка конфигурации, запуск и остановка sing-box, логи и настройки остались на месте. + +Дизайн использует полупрозрачные панели, мягкие градиенты и логотип «щит с арбузом». При подключении кольцо вокруг щита вращается по часовой стрелке; при отключении — обратно. Успешное соединение отмечает короткий световой импульс. Анимации меняют только поворот и прозрачность, без непрерывного размытия фона. + +## Запуск + +В папке уже лежат локальные `profiles.json`, `bin/sb.exe` и `doc/doc.srs` для теста; они не входят в Git. `local.json` создан с пустым приватным ключом. Заполните ключ и локальный адрес на вкладке «Настройки», затем в PowerShell: + +```powershell +cd F:\gitea\sb_launcher_glass +cmd /c build.cmd +.\SbLauncher.exe +``` + +Сборка использует `csc.exe` из установленного .NET Framework 4.x. Запуск основного exe запросит права администратора: они нужны sing-box для сетевых маршрутов. Для проверки интерфейса без запуска сети: `cmd /c build.cmd stress`. + +Для чистого клона скопируйте `profiles.example.json` в `profiles.json` и `local.example.json` в `local.json`, затем добавьте реальные серверы и ключ. Примерные адреса из `profiles.example.json` служат только для демонстрации. Профили и локальные настройки исключены из Git, поскольку могут содержать учётные данные. + +`build.cmd deploy` принимает явный путь назначения; папка рабочего лаунчера никогда не выбирается по умолчанию. + +Офлайн-превью с демонстрационными адресами: [светлая тема](preview/glass-light.png) и [тёмная тема](preview/glass-dark.png). diff --git a/SbLauncher.cs b/SbLauncher.cs new file mode 100644 index 0000000..3353056 --- /dev/null +++ b/SbLauncher.cs @@ -0,0 +1,934 @@ +// 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 +{ + // ═════════════════════════════════════════════════════════════════════ + // JSON + // + // Свои файлы (profiles.json / local.json) содержат комментарии //, + // поэтому перед разбором их снимаем. JavaScriptSerializer входит в + // .NET Framework (System.Web.Extensions.dll) — внешних библиотек нет. + // ═════════════════════════════════════════════════════════════════════ + public static class J + { + public static Dictionary Parse(string text) + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = int.MaxValue; + return ser.DeserializeObject(StripComments(text)) as Dictionary; + } + + public static Dictionary Obj(object o) + { + return o as Dictionary; + } + + public static List> Arr(object o) + { + List> list = new List>(); + object[] a = o as object[]; + if (a == null) return list; + foreach (object it in a) + { + Dictionary d = it as Dictionary; + if (d != null) list.Add(d); + } + return list; + } + + public static string Str(Dictionary d, string key, string def) + { + object v; + if (d == null || !d.TryGetValue(key, out v) || v == null) return def; + return Convert.ToString(v, CultureInfo.InvariantCulture); + } + + public static int Int(Dictionary d, string key, int def) + { + object v; + if (d == null || !d.TryGetValue(key, out v) || v == null) return def; + try { return Convert.ToInt32(v, CultureInfo.InvariantCulture); } + catch { return def; } + } + + // Экранирование значения, которое подставляется ВНУТРЬ строки JSON. + // Обязательно: ключ или SNI с кавычкой иначе сломают весь файл. + public static string Esc(string s) + { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.Length + 8); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + else sb.Append(c); + break; + } + } + return sb.ToString(); + } + + // Снимает // и /* */ с учётом строковых литералов: внутри кавычек + // слэши не трогаем, иначе разъедется "https://..." и base64-ключ. + public static string StripComments(string s) + { + StringBuilder sb = new StringBuilder(s.Length); + bool inStr = false, esc = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (inStr) + { + sb.Append(c); + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; sb.Append(c); continue; } + if (c == '/' && i + 1 < s.Length && s[i + 1] == '/') + { + while (i < s.Length && s[i] != '\n') i++; + if (i < s.Length) sb.Append('\n'); + continue; + } + if (c == '/' && i + 1 < s.Length && s[i + 1] == '*') + { + i += 2; + while (i + 1 < s.Length && !(s[i] == '*' && s[i + 1] == '/')) i++; + i++; + continue; + } + sb.Append(c); + } + return sb.ToString(); + } + + public static string ToJson(object value) + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = int.MaxValue; + return ser.Serialize(value); + } + + public static readonly Encoding Utf8NoBom = new UTF8Encoding(false); + + public static void Write(string path, string text) + { + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // A failed write must never leave half of a settings/config file. + string temp = path + ".tmp"; + File.WriteAllText(temp, text, Utf8NoBom); + try + { + if (File.Exists(path)) File.Replace(temp, path, null); + else File.Move(temp, path); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ПУТИ. Всё относительно папки с exe — это и есть портативность. + // ═════════════════════════════════════════════════════════════════════ + public class Paths + { + public string Root; + + public Paths(string root) { Root = root; } + + public string Profiles { get { return Path.Combine(Root, "profiles.json"); } } + public string Local { get { return Path.Combine(Root, "local.json"); } } + public string TemplatesDir { get { return Path.Combine(Root, "templates"); } } + public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } } + public string RuleSet { get { return Path.Combine(Root, Path.Combine("doc", "doc.srs")); } } + public string RunDir { get { return Path.Combine(Root, "run"); } } + public string RunConfig { get { return Path.Combine(RunDir, "config.json"); } } + + // sing-box на Windows понимает прямые слэши, а обратные пришлось бы + // экранировать в JSON. Поэтому путь к rule-set отдаём через "/". + public string RuleSetJson { get { return RuleSet.Replace('\\', '/'); } } + } + + // ═════════════════════════════════════════════════════════════════════ + // МОДЕЛЬ + // ═════════════════════════════════════════════════════════════════════ + public class ServerCfg + { + public string Code = ""; + public string Name = ""; + public string Server = ""; + public int Port = 443; + public string Uuid = ""; + public string Sni = ""; + public string Fingerprint = "chrome"; + public string PublicKey = ""; + public string ShortId = ""; + public string Flow = ""; // xtls-rprx-vision или пусто (без flow) + public string SpiderX = ""; // старые профили сохраняют это поле + public string Protocol = "vless"; + public Dictionary Outbound; // точная конфигурация импортированного ключа + + public string Endpoint + { + get { return Server + ":" + Port.ToString(CultureInfo.InvariantCulture); } + } + } + + public class WgCfg + { + public string Server = ""; + public int Port = 51820; + public string PeerPublicKey = ""; + public int Mtu = 1280; + } + + // profiles.json — локальная копия серверов; в тестовом репозитории не коммитится + public class Profiles + { + public WgCfg Wg = new WgCfg(); + public List Servers = new List(); + + public static Profiles Load(string path) + { + Profiles p = new Profiles(); + if (!File.Exists(path)) return p; + + Dictionary root = J.Parse(File.ReadAllText(path, Encoding.UTF8)); + if (root == null) return p; + + object wo; + if (root.TryGetValue("wireguard", out wo)) + { + Dictionary w = J.Obj(wo); + if (w != null) + { + p.Wg.Server = J.Str(w, "server", ""); + p.Wg.Port = J.Int(w, "port", 51820); + p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", ""); + p.Wg.Mtu = J.Int(w, "mtu", 1280); + } + } + + object so; + if (root.TryGetValue("servers", out so)) + foreach (Dictionary d in J.Arr(so)) + { + ServerCfg s = new ServerCfg(); + s.Code = J.Str(d, "code", ""); + s.Name = J.Str(d, "name", s.Code); + s.Server = J.Str(d, "server", ""); + s.Port = J.Int(d, "port", 443); + s.Uuid = J.Str(d, "uuid", ""); + s.Sni = J.Str(d, "sni", ""); + s.Fingerprint = J.Str(d, "fingerprint", "chrome"); + s.PublicKey = J.Str(d, "public_key", ""); + s.ShortId = J.Str(d, "short_id", ""); + s.Flow = J.Str(d, "flow", "xtls-rprx-vision"); + s.SpiderX = J.Str(d, "spider_x", ""); + s.Protocol = J.Str(d, "protocol", "vless"); + object outbound; + if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound); + if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol); + if (s.Server.Length > 0) p.Servers.Add(s); + } + return p; + } + + // Пишется только когда сервер добавили/удалили из GUI. Файл + // пересобирается целиком, поэтому свои комментарии ВНУТРИ списка + // серверов при этом теряются — шапка восстанавливается. + public void Save(string path) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("// Серверы и общие параметры этого тестового экземпляра."); + sb.AppendLine("// Храните данные серверов локально, а ключ WireGuard — в local.json."); + sb.AppendLine("{"); + sb.AppendLine(" \"wireguard\": {"); + sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\","); + sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ","); + sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\","); + sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture)); + sb.AppendLine(" },"); + sb.AppendLine(" \"servers\": ["); + for (int i = 0; i < Servers.Count; i++) + { + ServerCfg s = Servers[i]; + sb.AppendLine(" {"); + sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\","); + sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\","); + sb.AppendLine(" \"server\": \"" + J.Esc(s.Server) + "\","); + sb.AppendLine(" \"port\": " + s.Port.ToString(CultureInfo.InvariantCulture) + ","); + sb.AppendLine(" \"uuid\": \"" + J.Esc(s.Uuid) + "\","); + sb.AppendLine(" \"sni\": \"" + J.Esc(s.Sni) + "\","); + sb.AppendLine(" \"fingerprint\": \"" + J.Esc(s.Fingerprint) + "\","); + sb.AppendLine(" \"public_key\": \"" + J.Esc(s.PublicKey) + "\","); + // flow пишем ВСЕГДА (даже пустой), чтобы отличать «flow не задан» + // от «flow по умолчанию» при загрузке (обратная совместимость) + sb.AppendLine(" \"flow\": \"" + J.Esc(s.Flow) + "\","); + // spider_x пишем только когда задан + if (!string.IsNullOrEmpty(s.SpiderX)) + sb.AppendLine(" \"spider_x\": \"" + J.Esc(s.SpiderX) + "\","); + sb.AppendLine(" \"short_id\": \"" + J.Esc(s.ShortId) + "\","); + sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : "")); + if (s.Outbound != null) + sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound)); + sb.AppendLine(i == Servers.Count - 1 ? " }" : " },"); + } + sb.AppendLine(" ]"); + sb.AppendLine("}"); + J.Write(path, sb.ToString()); + } + } + + // local.json — параметры ЭТОЙ машины, в .gitignore + public class LocalCfg + { + public string PrivateKey = ""; + public string LocalAddress = ""; + public string Adapter = ""; + public string Theme = ""; // пусто -> берём тему Windows + public string Mode = "wg"; // wg | direct | wgonly + public string LastServer = ""; + + public static LocalCfg Load(string path) + { + LocalCfg c = new LocalCfg(); + if (!File.Exists(path)) return c; + Dictionary d = J.Parse(File.ReadAllText(path, Encoding.UTF8)); + if (d == null) return c; + c.PrivateKey = J.Str(d, "private_key", ""); + c.LocalAddress = J.Str(d, "local_address", ""); + c.Adapter = J.Str(d, "adapter", ""); + c.Theme = J.Str(d, "theme", ""); + c.Mode = J.Str(d, "mode", "wg"); + c.LastServer = J.Str(d, "last_server", ""); + return c; + } + + public void Save(string path) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("// Параметры ЭТОЙ машины. В .gitignore — здесь приватный ключ."); + sb.AppendLine("// Правится в GUI, руками лезть сюда не нужно."); + sb.AppendLine("{"); + sb.AppendLine(" \"private_key\": \"" + J.Esc(PrivateKey) + "\","); + sb.AppendLine(" \"local_address\": \"" + J.Esc(LocalAddress) + "\","); + sb.AppendLine(" \"adapter\": \"" + J.Esc(Adapter) + "\","); + sb.AppendLine(" \"theme\": \"" + J.Esc(Theme) + "\","); + sb.AppendLine(" \"mode\": \"" + J.Esc(Mode) + "\","); + sb.AppendLine(" \"last_server\": \"" + J.Esc(LastServer) + "\""); + sb.AppendLine("}"); + J.Write(path, sb.ToString()); + } + } + + // ═════════════════════════════════════════════════════════════════════ + // СЕТЕВЫЕ АДАПТЕРЫ + // + // bind_interface в sing-box хочет ИМЯ подключения из «Сетевых + // подключений», а не описание устройства. У Windows по умолчанию это + // «Wi-Fi» с дефисом, но адаптер часто переименован — из-за этого + // конфиг с зашитым "WiFi" на другом ПК молча не работал. + // ═════════════════════════════════════════════════════════════════════ + public class AdapterInfo + { + public string Name = ""; + public string Description = ""; + public bool IsUp; + } + + public static class Adapters + { + public static List List() + { + List list = new List(); + try + { + foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue; + // свой же TUN: привязываться к нему бессмысленно + if (ni.Name == "sing-box-tun") continue; + + AdapterInfo a = new AdapterInfo(); + a.Name = ni.Name; + a.Description = ni.Description; + a.IsUp = ni.OperationalStatus == OperationalStatus.Up; + list.Add(a); + } + } + catch { } + // сначала живые: обычно нужен именно тот, через который есть сеть + list.Sort(delegate(AdapterInfo x, AdapterInfo y) + { + if (x.IsUp != y.IsUp) return x.IsUp ? -1 : 1; + return string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + }); + return list; + } + + public static AdapterInfo Find(string name) + { + if (string.IsNullOrEmpty(name)) return null; + foreach (AdapterInfo a in List()) + if (string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) return a; + return null; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // СБОРКА КОНФИГА ИЗ ШАБЛОНА + // + // Числовые токены записаны в шаблоне в кавычках ("@@WG_PORT@@"), чтобы + // шаблон оставался валидным JSON и его можно было открыть в редакторе. + // При подстановке кавычки снимаются вместе с токеном. + // ═════════════════════════════════════════════════════════════════════ + public class BuildResult + { + public bool Ok; + public string Error; + public string ConfigPath; + } + + public static class ConfigBuilder + { + public const string ModeWg = "wg"; + public const string ModeDirect = "direct"; + public const string ModeWgOnly = "wgonly"; + + public static bool UsesWireguard(string mode) + { + return mode == ModeWg || mode == ModeWgOnly; + } + public static bool UsesProxy(string mode) + { + return mode == ModeWg || mode == ModeDirect; + } + + public static string ModeTitle(string mode) + { + if (mode == ModeDirect) return "напрямую через адаптер"; + if (mode == ModeWgOnly) return "только WireGuard"; + return "через WireGuard"; + } + + // Файл templates\.json рядом с exe перекрывает встроенный — + // тот же приём, что с Ui.xaml: правка шаблона без пересборки. + public static string LoadTemplate(Paths paths, string mode) + { + string ext = Path.Combine(paths.TemplatesDir, mode + ".json"); + if (File.Exists(ext)) return File.ReadAllText(ext, Encoding.UTF8); + + using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(mode + ".json")) + { + if (s == null) throw new FileNotFoundException("нет шаблона " + mode + ".json (ни рядом с exe, ни внутри)"); + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + return r.ReadToEnd(); + } + } + + // WireGuard-ключ — 32 байта в base64. Проверка ловит обрезанную + // вставку из буфера, которую иначе увидел бы только sing-box. + public static bool IsWgKey(string s) + { + if (string.IsNullOrEmpty(s)) return false; + try { return Convert.FromBase64String(s.Trim()).Length == 32; } + catch { return false; } + } + + public static bool IsCidr(string s) + { + if (string.IsNullOrEmpty(s)) return false; + string[] parts = s.Trim().Split('/'); + if (parts.Length != 2) return false; + IPAddress ip; + if (!IPAddress.TryParse(parts[0], out ip)) return false; + int bits; + if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out bits)) return false; + int max = ip.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; + return bits >= 0 && bits <= max; + } + + public static List Validate(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode) + { + List bad = new List(); + + if (UsesWireguard(mode)) + { + if (!IsWgKey(lc.PrivateKey)) + bad.Add("приватный ключ WireGuard пуст или не 32 байта base64"); + if (!IsCidr(lc.LocalAddress)) + bad.Add("локальный IP должен быть вида 10.10.0.19/24"); + if (string.IsNullOrEmpty(pr.Wg.Server)) + bad.Add("не задан адрес WireGuard-сервера в profiles.json"); + if (!IsWgKey(pr.Wg.PeerPublicKey)) + bad.Add("peer_public_key в profiles.json пуст или не 32 байта base64"); + } + + if (mode == ModeDirect) + { + if (string.IsNullOrEmpty(lc.Adapter)) + bad.Add("не выбран сетевой адаптер"); + else if (Adapters.Find(lc.Adapter) == null) + bad.Add("адаптера \"" + lc.Adapter + "\" нет в системе"); + } + + if (UsesProxy(mode)) + { + if (srv == null) bad.Add("не выбран сервер"); + else + { + if (string.IsNullOrEmpty(srv.Server)) bad.Add("у сервера не задан адрес"); + if (srv.Port < 1 || srv.Port > 65535) bad.Add("у сервера некорректный порт"); + if (srv.Outbound == null && string.IsNullOrEmpty(srv.Uuid)) + bad.Add("у сервера не задан uuid"); + } + } + + if (!File.Exists(paths.RuleSet)) + bad.Add("не найден " + paths.RuleSet + " (geoip-ru)"); + if (!File.Exists(paths.SbExe)) + bad.Add("не найден " + paths.SbExe); + + return bad; + } + + public static string Render(string tpl, + Dictionary strings, + Dictionary numbers) + { + // числовые — первыми: они забирают кавычки вокруг токена + foreach (KeyValuePair kv in numbers) + tpl = tpl.Replace("\"@@" + kv.Key + "@@\"", + kv.Value.ToString(CultureInfo.InvariantCulture)); + + foreach (KeyValuePair kv in strings) + tpl = tpl.Replace("@@" + kv.Key + "@@", J.Esc(kv.Value)); + + // Незакрытый токен — это опечатка в шаблоне. Молча отдать такой + // конфиг sing-box'у нельзя: ошибка будет невнятной. + Match m = Regex.Match(tpl, "@@[A-Za-z0-9_]+@@"); + if (m.Success) + throw new InvalidOperationException("в шаблоне остался неподставленный токен " + m.Value); + + return tpl; + } + + public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode) + { + return Build(paths, pr, lc, srv, mode, null); + } + + // outPath задаётся только в --selftest: там нужны все три режима + // рядом, а не один перезаписанный run\config.json. + public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, + string mode, string outPath) + { + BuildResult r = new BuildResult(); + try + { + List bad = Validate(paths, pr, lc, srv, mode); + if (bad.Count > 0) + { + r.Error = string.Join("; ", bad.ToArray()); + return r; + } + + Dictionary s = new Dictionary(); + Dictionary n = new Dictionary(); + + s["RULESET_PATH"] = paths.RuleSetJson; + + if (UsesWireguard(mode)) + { + s["WG_SERVER"] = pr.Wg.Server; + s["WG_LOCAL_ADDRESS"] = lc.LocalAddress.Trim(); + s["WG_PRIVATE_KEY"] = lc.PrivateKey.Trim(); + s["WG_PEER_PUBLIC_KEY"] = pr.Wg.PeerPublicKey.Trim(); + n["WG_PORT"] = pr.Wg.Port; + n["WG_MTU"] = pr.Wg.Mtu; + } + if (mode == ModeDirect) + s["ADAPTER"] = lc.Adapter; + + string tpl = LoadTemplate(paths, mode); + if (UsesProxy(mode)) + { + const string marker = "\"@@PROXY_OUTBOUND@@\""; + if (!tpl.Contains(marker)) + throw new InvalidOperationException("шаблон " + mode + ".json устарел: нет @@PROXY_OUTBOUND@@"); + tpl = tpl.Replace(marker, J.ToJson(ProxyOutbound.Build(srv, mode))); + } + + string body = Render(tpl, s, n); + + string head = + "// СГЕНЕРИРОВАНО SbLauncher — правки здесь затрутся." + Environment.NewLine + + "// Режим: " + mode + " (" + ModeTitle(mode) + ")" + + (UsesProxy(mode) ? ", сервер: " + srv.Code + " " + srv.Endpoint : "") + + Environment.NewLine + + "// Правьте templates\\" + mode + ".json, profiles.json или local.json." + + Environment.NewLine; + + string dest = string.IsNullOrEmpty(outPath) ? paths.RunConfig : outPath; + J.Write(dest, head + body); + r.Ok = true; + r.ConfigPath = dest; + return r; + } + catch (Exception ex) + { + r.Error = ex.Message; + return r; + } + } + } + + // Старое имя остаётся для внешних сценариев и selftest. + public static class VlessLink + { + public static ServerCfg Parse(string link) + { + ServerCfg server; + string error; + return KeyImporter.TryParseOne(link, out server, out error) && + server.Protocol == "vless" ? server : null; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ЗАПУСК sing-box + Job Object + // + // ВАЖНО: при auto_route/strict_route осиротевший sing-box оставляет + // систему со сломанным маршрутом по умолчанию. Job Object гарантирует, + // что дочерний процесс умрёт вместе с лаунчером — даже если тот убит + // через Диспетчер задач. + // ═════════════════════════════════════════════════════════════════════ + public class SingBoxRunner + { + public event Action Log; + public event Action StateChanged; + + Process _proc; + IntPtr _job = IntPtr.Zero; + + public bool IsRunning + { + get + { + Process p = _proc; + if (p == null) return false; + try { return !p.HasExited; } + catch (InvalidOperationException) { return false; } + } + } + + static void ApplyEnv(ProcessStartInfo psi) + { + // те же переменные, что были в startN.bat: конфиги пользуются + // wireguard-outbound'ом, который в sing-box 1.12 объявлен + // устаревшим и без этого флага не поднимается + psi.EnvironmentVariables["ENABLE_DEPRECATED_WIREGUARD_OUTBOUND"] = "true"; + psi.EnvironmentVariables["ENABLE_DEPRECATED_TUN_ADDRESS_X"] = "true"; + psi.EnvironmentVariables["ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS"] = "true"; + } + + // sb.exe check — синхронно, до запуска. Ошибку конфига лучше увидеть + // в логе лаунчера, чем ловить её сломанными маршрутами. + public static bool Check(string sbExe, string cfgPath, string workDir, out string output) + { + output = ""; + StringBuilder sb = new StringBuilder(); + try + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = sbExe; + psi.Arguments = "check -c \"" + cfgPath + "\""; + psi.WorkingDirectory = workDir; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.StandardOutputEncoding = Encoding.UTF8; + psi.StandardErrorEncoding = Encoding.UTF8; + ApplyEnv(psi); + + using (Process p = new Process()) + { + p.StartInfo = psi; + DataReceivedEventHandler grab = delegate(object s, DataReceivedEventArgs e) + { + if (e.Data != null) lock (sb) sb.AppendLine(e.Data); + }; + p.OutputDataReceived += grab; + p.ErrorDataReceived += grab; + p.Start(); + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + if (!p.WaitForExit(10000)) + { + try { p.Kill(); } catch { } + output = "sb.exe check не ответил за 10 секунд"; + return false; + } + p.WaitForExit(); // finish asynchronous stdout/stderr callbacks + lock (sb) output = sb.ToString().Trim(); + return p.ExitCode == 0; + } + } + catch (Exception ex) + { + output = ex.Message; + return false; + } + } + + public void Start(string sbExe, string cfgPath, string workDir) + { + if (IsRunning) return; + EnsureJob(); + + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = sbExe; + psi.Arguments = "run -c \"" + cfgPath + "\""; + psi.WorkingDirectory = workDir; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.StandardOutputEncoding = Encoding.UTF8; + psi.StandardErrorEncoding = Encoding.UTF8; + ApplyEnv(psi); + + Process p = new Process(); + p.StartInfo = psi; + p.EnableRaisingEvents = true; + p.OutputDataReceived += OnOut; + p.ErrorDataReceived += OnOut; + p.Exited += delegate + { + int code; + string result = "?"; + try { code = p.ExitCode; result = code.ToString(CultureInfo.InvariantCulture); } + catch { } + Emit("— sing-box завершился (код " + result + ")"); + if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false); + }; + + Emit("> " + psi.FileName + " " + psi.Arguments); + try + { + p.Start(); + if (!AssignProcessToJobObject(_job, p.Handle)) + { + int error = Marshal.GetLastWin32Error(); + try { p.Kill(); } catch { } + throw new System.ComponentModel.Win32Exception(error, "Не удалось привязать sing-box к Job Object"); + } + _proc = p; + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + if (StateChanged != null) StateChanged(true); + } + catch + { + if (object.ReferenceEquals(_proc, p)) _proc = null; + try { if (!p.HasExited) p.Kill(); } catch { } + p.Dispose(); + throw; + } + } + + public void Stop() + { + Process p = _proc; + if (p == null) return; + bool running; + try { running = !p.HasExited; } + catch { return; } + if (!running) return; + Emit("— останавливаю sing-box…"); + try + { + p.CloseMainWindow(); + if (!p.WaitForExit(2500)) p.Kill(); + p.WaitForExit(3000); + } + catch (Exception ex) { Emit("! " + ex.Message); } + if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false); + } + + void OnOut(object s, DataReceivedEventArgs e) + { + if (e.Data != null) Emit(e.Data); + } + + void Emit(string s) + { + if (Log != null) Log(s); + } + + // ── Job Object ─────────────────────────────────────────────────── + void EnsureJob() + { + if (_job != IntPtr.Zero) return; + _job = CreateJobObject(IntPtr.Zero, null); + if (_job == IntPtr.Zero) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Не удалось создать Job Object"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr ptr = Marshal.AllocHGlobal(len); + Marshal.StructureToPtr(info, ptr, false); + try + { + if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len)) + { + int error = Marshal.GetLastWin32Error(); + CloseHandle(_job); + _job = IntPtr.Zero; + throw new System.ComponentModel.Win32Exception(error, "Не удалось настроить Job Object"); + } + } + finally { Marshal.FreeHGlobal(ptr); } + } + + const int JobObjectExtendedLimitInformation = 9; + const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr a, string lpName); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr info, uint cb); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + [StructLayout(LayoutKind.Sequential)] + struct IO_COUNTERS + { + public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount; + public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount; + } + [StructLayout(LayoutKind.Sequential)] + struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit, PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + [StructLayout(LayoutKind.Sequential)] + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ПИНГ + // + // ICMP не годится: сервер 132.243.202.111 на echo-request не отвечает + // (проверено). Меряем время TCP-коннекта до server:port — это и ближе + // к реальности, и работает всегда, пока порт открыт. + // ═════════════════════════════════════════════════════════════════════ + public static class Pinger + { + public static async Task TcpLatencyAsync(string host, int port, int timeoutMs) + { + if (string.IsNullOrEmpty(host)) return -1; + Stopwatch sw = Stopwatch.StartNew(); + TcpClient c = new TcpClient(); + try + { + Task connect = c.ConnectAsync(host, port); + Task done = await Task.WhenAny(connect, Task.Delay(timeoutMs)); + if (done != connect || !c.Connected) return -1; + sw.Stop(); + return (int)sw.ElapsedMilliseconds; + } + catch { return -1; } + finally { try { c.Close(); } catch { } } + } + + // Внешний IP — уходит через TUN, то есть показывает реальную точку выхода + public static async Task ExitIpAsync() + { + try + { + using (WebClient w = new WebClient()) + { + w.Headers.Add("User-Agent", "sb-launcher"); + Task fetch = w.DownloadStringTaskAsync("https://api4.ipify.org"); + if (await Task.WhenAny(fetch, Task.Delay(5000)) != fetch) + { + w.CancelAsync(); + return null; + } + return (await fetch).Trim(); + } + } + catch { return null; } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ИНФРАСТРУКТУРА ПРИВЯЗОК + // ═════════════════════════════════════════════════════════════════════ +} diff --git a/Ui.xaml b/Ui.xaml new file mode 100644 index 0000000..addf02f --- /dev/null +++ b/Ui.xaml @@ -0,0 +1,937 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.55 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.16 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +