790 lines
38 KiB
C#
790 lines
38 KiB
C#
// 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.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";
|
||
|
||
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 = "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);
|
||
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;
|
||
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);
|
||
w.Loaded += delegate { vm.PingAll(); };
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|