Files
SbLauncher/App.cs
T

663 lines
32 KiB
C#
Raw Permalink Normal View History

2026-09-26 14:41:04 +03:00
// SbLauncher — GUI для sing-box.
//
// ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
// тот же private_key в пяти файлах не требуется.
//
// ПОРТАТИВНОСТЬ: всё ищется рядом с exe. Скопировали папку — работает.
//
// Компилятор — встроенный в Windows csc.exe (C# 5). Поэтому здесь нет
// интерполяции строк ($""), ?. и expression-bodied членов. См. build.cmd.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.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;
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();
}
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 <png> ───────────────────────────────────────────
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(); };
}
}
}