Files
SbLauncher/App.cs
T
2026-10-10 14:58:04 +03:00

949 lines
47 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.base.json (базовые серверы), 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.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
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";
static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(460);
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;
// Первый кадр и служебные рендеры сразу получают конечную тему.
bool animate = w.IsLoaded && SystemParameters.ClientAreaAnimation;
foreach (object k in src.Keys)
{
object v = src[k];
Brush target = v as Brush;
if (target != null)
v = TransitionBrush(w.Resources[k] as Brush, target, animate);
w.Resources[k] = v;
if (Application.Current != null) Application.Current.Resources[k] = v;
}
UpdateIcon(w, "ThemeSun", theme != Light, -100, animate);
UpdateIcon(w, "ThemeMoon", theme == Light, 90, animate);
}
static Brush TransitionBrush(Brush current, Brush target, bool animate)
{
// Исходные словари остаются неизменными. Новый переход начинается
// с видимого цвета, даже если предыдущий ещё не успел завершиться.
Brush next = target.Clone();
if (!animate || current == null) return next;
SolidColorBrush fromSolid = current as SolidColorBrush;
SolidColorBrush toSolid = next as SolidColorBrush;
GradientBrush fromGradient = current as GradientBrush;
GradientBrush toGradient = next as GradientBrush;
if (fromSolid != null && toSolid != null)
AnimateColor(toSolid, SolidColorBrush.ColorProperty, fromSolid.Color, toSolid.Color);
else if (fromGradient != null && toGradient != null &&
current.GetType() == target.GetType() &&
fromGradient.GradientStops.Count == toGradient.GradientStops.Count)
{
for (int i = 0; i < toGradient.GradientStops.Count; i++)
{
GradientStop from = fromGradient.GradientStops[i];
GradientStop to = toGradient.GradientStops[i];
AnimateColor(to, GradientStop.ColorProperty, from.Color, to.Color);
AnimateDouble(to, GradientStop.OffsetProperty, from.Offset, to.Offset, true);
}
}
AnimateDouble(next, Brush.OpacityProperty, current.Opacity, target.Opacity, true);
return next;
}
static void AnimateColor(Animatable target, DependencyProperty property, Color from, Color to)
{
if (from == to) return;
// WPF активирует clock на следующем кадре: до него тоже должен
// оставаться исходный цвет, иначе при клике видна вспышка цели.
target.SetValue(property, from);
var animation = new ColorAnimation(from, to, TransitionDuration);
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
animation.FillBehavior = FillBehavior.HoldEnd;
target.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
}
static void AnimateDouble(DependencyObject target, DependencyProperty property,
double from, double to, bool animate)
{
var animated = (IAnimatable)target;
animated.BeginAnimation(property, null);
bool transition = animate && Math.Abs(from - to) >= 0.001;
target.SetValue(property, transition ? from : to);
if (!transition) return;
var animation = new DoubleAnimation(from, to, TransitionDuration);
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
// SnapshotAndReplace keeps one clock per property. Holding the
// endpoint avoids callbacks from an interrupted transition.
animation.FillBehavior = FillBehavior.HoldEnd;
animated.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
}
static void UpdateIcon(Window window, string name, bool visible, double hiddenAngle, bool animate)
{
FrameworkElement icon = window.FindName(name) as FrameworkElement;
if (icon == null) return;
TransformGroup transforms = icon.RenderTransform as TransformGroup;
if (transforms == null || transforms.Children.Count != 2) return;
if (transforms.IsFrozen) icon.RenderTransform = transforms = transforms.Clone();
var scale = transforms.Children[0] as ScaleTransform;
var rotation = transforms.Children[1] as RotateTransform;
if (scale == null || rotation == null) return;
AnimateDouble(icon, UIElement.OpacityProperty, icon.Opacity, visible ? 1 : 0, animate);
AnimateDouble(scale, ScaleTransform.ScaleXProperty, scale.ScaleX, visible ? 1 : 0.4, animate);
AnimateDouble(scale, ScaleTransform.ScaleYProperty, scale.ScaleY, visible ? 1 : 0.4, animate);
AnimateDouble(rotation, RotateTransform.AngleProperty, rotation.Angle, visible ? 0 : hiddenAngle, animate);
}
// Смысловые цвета (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 = "SbLauncher";
_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.ClearLog();
vm.AppendLog("демонстрация интерфейса — sing-box не запускался");
ImageSource logo = LoadLogo();
Image li = w.FindName("LogoImg") as Image;
if (li != null && logo != null) li.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();
ToggleButton modeSwitch = w.FindName("WireguardSwitch") as ToggleButton;
if (modeSwitch != null) UpdateSwitch(modeSwitch, false);
ListBox serverList = w.FindName("ServerList") as ListBox;
FrameworkElement serverHost = w.FindName("ServerListHost") as FrameworkElement;
Border selection = w.FindName("ServerSelectionWindow") as Border;
if (serverList != null && serverHost != null && selection != null)
MoveServerSelection(serverList, serverHost, selection, false);
UpdateTabs(w, vm, false);
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")) ||
File.Exists(Path.Combine(exeDir, "profiles.base.json"))) return exeDir;
string parent = Path.GetDirectoryName(exeDir);
if (parent != null && (File.Exists(Path.Combine(parent, "profiles.json")) ||
File.Exists(Path.Combine(parent, "profiles.base.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;
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(); };
WireSwitch(w);
WireServerSelection(w);
WireTabs(w, vm);
w.Loaded += delegate { vm.PingAll(); };
}
static void WireTabs(Window w, MainVm vm)
{
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
if (host == null) return;
w.Loaded += delegate { UpdateTabs(w, vm, false); };
host.SizeChanged += delegate { UpdateTabs(w, vm, false); };
PropertyChangedEventHandler changed = delegate(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == "ShowSettings") UpdateTabs(w, vm, w.IsLoaded);
};
vm.PropertyChanged += changed;
w.Closed += delegate { vm.PropertyChanged -= changed; };
}
static void UpdateTabs(Window w, MainVm vm, bool animate)
{
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
FrameworkElement selector = w.FindName("TabSelectorHost") as FrameworkElement;
Border highlight = w.FindName("TabSelectionWindow") as Border;
Border log = w.FindName("LogPane") as Border;
Border settings = w.FindName("SettingsPane") as Border;
Button selected = w.FindName(vm.ShowSettings ? "BtnTabSettings" : "BtnTabLog") as Button;
if (host == null || selector == null || highlight == null || log == null ||
settings == null || selected == null || host.ActualWidth <= 0) return;
bool showSettings = vm.ShowSettings;
Border inactive = showSettings ? log : settings;
// Уходящая панель остаётся видимой до конца движения, но её поля
// уже не должны получать клики или фокус с клавиатуры.
if (inactive.IsKeyboardFocusWithin) selected.Focus();
log.IsHitTestVisible = !showSettings;
settings.IsHitTestVisible = showSettings;
KeyboardNavigation.SetTabNavigation(log, showSettings ? KeyboardNavigationMode.None : KeyboardNavigationMode.Continue);
KeyboardNavigation.SetTabNavigation(settings, showSettings ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
log.Visibility = settings.Visibility = Visibility.Visible;
double travel = host.ActualWidth + 16;
SlideTabElement(log, showSettings ? -travel : 0, animate,
delegate { if (vm.ShowSettings) log.Visibility = Visibility.Hidden; });
SlideTabElement(settings, showSettings ? 0 : travel, animate,
delegate { if (!vm.ShowSettings) settings.Visibility = Visibility.Hidden; });
double targetX = selected.TransformToAncestor(selector).Transform(new Point()).X;
SlideTabElement(highlight, targetX, animate, null);
}
static void SlideTabElement(FrameworkElement element, double targetX, bool animate, Action completed)
{
TranslateTransform shift = element.RenderTransform as TranslateTransform;
if (shift == null) element.RenderTransform = shift = new TranslateTransform();
else if (shift.IsFrozen) element.RenderTransform = shift = shift.Clone();
double fromX = shift.X;
shift.BeginAnimation(TranslateTransform.XProperty, null);
shift.X = fromX;
if (!animate || Math.Abs(fromX - targetX) < 0.1)
{
shift.X = targetX;
if (completed != null) completed();
return;
}
DoubleAnimation slide = new DoubleAnimation(fromX, targetX, TimeSpan.FromMilliseconds(520));
slide.EasingFunction = new BackEase { Amplitude = 0.22, EasingMode = EasingMode.EaseOut };
slide.Completed += delegate
{
shift.X = targetX;
shift.BeginAnimation(TranslateTransform.XProperty, null);
if (completed != null) completed();
};
shift.BeginAnimation(TranslateTransform.XProperty, slide);
}
static void WireSwitch(Window w)
{
ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton;
if (toggle == null) return;
// При загрузке положение выставляется сразу; последующие изменения
// идут от текущего положения, поэтому быстрые клики не дают скачка.
toggle.Loaded += delegate { UpdateSwitch(toggle, false); };
toggle.Checked += delegate { UpdateSwitch(toggle, true); };
toggle.Unchecked += delegate { UpdateSwitch(toggle, true); };
}
static void UpdateSwitch(ToggleButton toggle, bool animate)
{
if (!toggle.IsLoaded && animate) return;
toggle.ApplyTemplate();
Border active = toggle.Template.FindName("ActiveTrack", toggle) as Border;
System.Windows.Shapes.Ellipse thumb =
toggle.Template.FindName("Thumb", toggle) as System.Windows.Shapes.Ellipse;
TranslateTransform shift = thumb != null ? thumb.RenderTransform as TranslateTransform : null;
if (active == null || shift == null) return;
if (shift.IsFrozen)
{
shift = shift.Clone();
thumb.RenderTransform = shift;
}
double targetX = toggle.IsChecked == true ? 22 : 0;
double targetOpacity = toggle.IsChecked == true ? 1 : 0;
double fromX = shift.X, fromOpacity = active.Opacity;
shift.BeginAnimation(TranslateTransform.XProperty, null);
active.BeginAnimation(UIElement.OpacityProperty, null);
shift.X = targetX;
active.Opacity = targetOpacity;
if (!animate) return;
DoubleAnimation slide = new DoubleAnimation(fromX, targetX,
TimeSpan.FromMilliseconds(430));
slide.EasingFunction = new BackEase { Amplitude = 0.28, EasingMode = EasingMode.EaseOut };
slide.FillBehavior = FillBehavior.Stop;
shift.BeginAnimation(TranslateTransform.XProperty, slide);
DoubleAnimation color = new DoubleAnimation(fromOpacity, targetOpacity,
TimeSpan.FromMilliseconds(300));
color.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut };
color.FillBehavior = FillBehavior.Stop;
active.BeginAnimation(UIElement.OpacityProperty, color);
}
static void WireServerSelection(Window w)
{
ListBox list = w.FindName("ServerList") as ListBox;
FrameworkElement host = w.FindName("ServerListHost") as FrameworkElement;
Border highlight = w.FindName("ServerSelectionWindow") as Border;
if (list == null || host == null || highlight == null) return;
bool selectionPending = false;
int selectionRevision = 0;
list.Loaded += delegate { MoveServerSelection(list, host, highlight, false); };
list.SelectionChanged += delegate
{
selectionPending = true;
int revision = ++selectionRevision;
if (list.SelectedItem != null) list.ScrollIntoView(list.SelectedItem);
list.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(delegate
{
if (revision != selectionRevision) return;
try
{
list.UpdateLayout();
MoveServerSelection(list, host, highlight, true);
}
finally { selectionPending = false; }
}));
};
list.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(
delegate { if (!selectionPending) MoveServerSelection(list, host, highlight, false); }));
list.SizeChanged += delegate { if (!selectionPending) MoveServerSelection(list, host, highlight, false); };
}
static void MoveServerSelection(ListBox list, FrameworkElement host,
Border highlight, bool animate)
{
ListBoxItem item = list.ItemContainerGenerator.ContainerFromItem(list.SelectedItem) as ListBoxItem;
if (item == null || item.ActualHeight <= 10)
{
highlight.Visibility = Visibility.Collapsed;
return;
}
Point p;
try { p = item.TransformToAncestor(host).Transform(new Point(0, 0)); }
catch (InvalidOperationException) { return; }
double height = item.ActualHeight - 10; // промежуток между карточками
if (p.Y + height < 0 || p.Y > host.ActualHeight)
{
highlight.Visibility = Visibility.Collapsed;
return;
}
bool wasVisible = highlight.Visibility == Visibility.Visible;
double fromY = Canvas.GetTop(highlight);
if (double.IsNaN(fromY)) fromY = p.Y;
highlight.BeginAnimation(Canvas.TopProperty, null);
Canvas.SetLeft(highlight, p.X);
Canvas.SetTop(highlight, p.Y);
highlight.Width = item.ActualWidth;
highlight.Height = height;
highlight.Visibility = Visibility.Visible;
if (!animate || !wasVisible || Math.Abs(fromY - p.Y) < 0.5) return;
DoubleAnimation slide = new DoubleAnimation(fromY, p.Y,
TimeSpan.FromMilliseconds(520));
slide.EasingFunction = new BackEase { Amplitude = 0.32, EasingMode = EasingMode.EaseOut };
slide.FillBehavior = FillBehavior.Stop;
highlight.BeginAnimation(Canvas.TopProperty, slide);
}
}
}