// 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(); }; } } }