Files
SbLauncher/App.cs
T
2026-09-26 21:51:08 +03:00

663 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(); };
}
}
}