Create glass launcher prototype

This commit is contained in:
nikit3s
2026-09-26 14:42:06 +03:00
commit 1975c1ceff
22 changed files with 5581 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# ── секреты и параметры конкретной машины ────────────────────────────
# private_key, local_address, имя сетевого адаптера. Никогда не коммитим.
local.json
profiles.json
# ── рантайм ──────────────────────────────────────────────────────────
# сюда лаунчер генерирует config.json перед запуском
run/
# sb.exe (36 МБ) и doc.srs — бинарники, лежат рядом на боевой машине
bin/
doc/
# ── результат сборки ─────────────────────────────────────────────────
*.exe
*.pdb
+672
View File
@@ -0,0 +1,672 @@
// SbLauncher — GUI для sing-box.
//
// ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
// тот же private_key в пяти файлах не требуется.
//
// ПОРТАТИВНОСТЬ: всё ищется рядом с exe. Скопировали папку — работает.
//
// Компилятор — встроенный в Windows csc.exe (C# 5). Поэтому здесь нет
// интерполяции строк ($""), ?. и expression-bodied членов. См. build.cmd.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace SbLauncher
{
// Ask the Windows compositor to round the opaque, borderless window.
// Unlike per-pixel transparency, this keeps the fast normal WPF render path.
public static class WindowCorners
{
[DllImport("dwmapi.dll", PreserveSig = true)]
static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
public static void Enable(Window window)
{
window.SourceInitialized += delegate
{
try
{
int preference = 2; // DWMWCP_ROUND
DwmSetWindowAttribute(new WindowInteropHelper(window).Handle,
33, ref preference, sizeof(int)); // DWMWA_WINDOW_CORNER_PREFERENCE
}
catch (DllNotFoundException) { } // Windows before DWM corner support
catch (EntryPointNotFoundException) { }
};
}
}
public static class Theme
{
public const string Dark = "dark";
public const string Light = "light";
public static string DetectSystem()
{
try
{
object v = Microsoft.Win32.Registry.GetValue(
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
"AppsUseLightTheme", null);
if (v is int) return ((int)v) == 0 ? Dark : Light;
}
catch { }
return Dark;
}
// Палитра кладётся СРАЗУ В ДВА места, и это не избыточность:
//
// * w.Resources — чтобы {DynamicResource} в разметке гарантированно
// получил уведомление об изменении. Окно всегда слушает свой
// собственный словарь. Уведомления от Application.Resources доходят
// только до окон, зарегистрированных в Application.Current, а окно
// попадает туда лишь если Application существовал в момент его
// создания. Промах по этому условию даёт фон null и полностью
// прозрачное окно (при AllowsTransparency="True").
//
// * Application.Resources — оттуда Theme.Res() берёт смысловые цвета
// для статуса, пингов и подсказок; у ViewModel ссылки на окно нет.
public static void Apply(Window w, string theme)
{
string key = (theme == Light) ? "Theme.Light" : "Theme.Dark";
ResourceDictionary src = w.Resources[key] as ResourceDictionary;
if (src == null) return;
foreach (object k in src.Keys)
{
object v = src[k];
w.Resources[k] = v;
if (Application.Current != null) Application.Current.Resources[k] = v;
}
}
// Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в
// палитре, поэтому берём их из ресурсов, а не из зашитых констант.
public static Brush Res(string key)
{
try
{
if (Application.Current != null)
{
object o = Application.Current.TryFindResource(key);
if (o is Brush) return (Brush)o;
}
}
catch { }
return Brushes.Gray;
}
}
// ═════════════════════════════════════════════════════════════════════
// ТРЕЙ
//
// Крестик не закрывает приложение, а прячет окно: sing-box продолжает
// работать. Реальный выход — только через меню трея, и он останавливает
// туннель. Имена типов WinForms пишем полностью: NotifyIcon соседствует
// с WPF, где есть свои Application/MessageBox.
// ═════════════════════════════════════════════════════════════════════
public class Tray : IDisposable
{
readonly System.Windows.Forms.NotifyIcon _ni;
readonly System.Windows.Forms.ToolStripMenuItem _miToggle;
readonly Window _w;
readonly MainVm _vm;
bool _balloonShown;
public Tray(Window w, MainVm vm)
{
_w = w; _vm = vm;
_ni = new System.Windows.Forms.NotifyIcon();
_ni.Icon = LoadIcon();
_ni.Text = "sb launcher";
_ni.Visible = true;
_ni.DoubleClick += delegate { Restore(); };
System.Windows.Forms.ContextMenuStrip m = new System.Windows.Forms.ContextMenuStrip();
m.Items.Add("Показать", null, delegate { Restore(); });
_miToggle = new System.Windows.Forms.ToolStripMenuItem("Подключить", null,
delegate { if (_vm.ConnectCommand.CanExecute(null)) _vm.ConnectCommand.Execute(null); });
m.Items.Add(_miToggle);
m.Items.Add(new System.Windows.Forms.ToolStripSeparator());
m.Items.Add("Выход", null, delegate { ExitApp(); });
m.Opening += delegate { _miToggle.Text = _vm.ConnectText; };
_ni.ContextMenuStrip = m;
}
static System.Drawing.Icon LoadIcon()
{
try
{
Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico");
if (s != null)
using (s)
return new System.Drawing.Icon(s, System.Windows.Forms.SystemInformation.SmallIconSize);
}
catch { }
try { return System.Drawing.Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location); }
catch { return System.Drawing.SystemIcons.Application; }
}
public void HideToTray()
{
_w.Hide();
if (!_balloonShown)
{
_balloonShown = true;
_ni.ShowBalloonTip(3000, "sb launcher",
_vm.IsConnected
? "Свернулся в трей — туннель продолжает работать."
: "Свернулся в трей.",
System.Windows.Forms.ToolTipIcon.Info);
}
}
public void Restore()
{
Action a = delegate
{
_w.Show();
if (_w.WindowState == WindowState.Minimized) _w.WindowState = WindowState.Normal;
_w.Activate();
_w.Topmost = true; _w.Topmost = false;
_w.Focus();
};
if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a);
}
public void ExitApp()
{
Action a = delegate
{
_vm.DisposeTimers();
Dispose();
Application.Current.Shutdown();
};
if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a);
}
public void Dispose()
{
try { _ni.Visible = false; _ni.Dispose(); } catch { }
}
}
// ═════════════════════════════════════════════════════════════════════
// ТОЧКА ВХОДА
// ═════════════════════════════════════════════════════════════════════
public class App
{
[STAThread]
public static int Main(string[] argv)
{
Paths paths = new Paths(ResolveRoot(argv));
if (Array.IndexOf(argv, "--selftest") >= 0) return SelfTest(paths);
int thIdx = Array.IndexOf(argv, "--theme");
string themeArg = (thIdx >= 0 && thIdx + 1 < argv.Length) ? argv[thIdx + 1] : null;
int shotIdx = Array.IndexOf(argv, "--screenshot");
if (shotIdx >= 0 && shotIdx + 1 < argv.Length)
return Screenshot(argv[shotIdx + 1], paths, themeArg,
Array.IndexOf(argv, "--settings") >= 0);
// Одна копия за раз: два sing-box одновременно сломают маршруты.
// Вторая копия не ругается, а поднимает окно уже запущенной —
// иначе после сворачивания в трей ярлык выглядел бы сломанным.
// Namespace именно Local\: Global\ требует SeCreateGlobalPrivilege,
// которого у неэлевированного процесса нет.
// Проверку делаем ПОСЛЕ служебных режимов: --selftest и
// --screenshot не должны занимать блокировку.
bool isNew;
Mutex mtx = new Mutex(true, "Local\\SbLauncherGlass_SingleInstance", out isNew);
EventWaitHandle showSignal = new EventWaitHandle(
false, EventResetMode.AutoReset, "Local\\SbLauncherGlass_Show");
if (!isNew)
{
showSignal.Set();
return 0;
}
// ВНИМАНИЕ: Application создаётся ПЕРВЫМ, до разбора окна.
// Window регистрируется в Application.Current в своём конструкторе.
// Если Application ещё нет, окно в этот список не попадает, и
// уведомление об изменении Application.Resources до него не доходит:
// палитра применяется, TryFindResource её находит, а вот
// {DynamicResource} остаётся неразрешённым — фон становится null,
// и при AllowsTransparency="True" окно выглядит полностью прозрачным.
Application app = new Application();
app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
Window w;
try { w = (Window)XamlReader.Parse(LoadXaml()); }
catch (Exception ex)
{
MessageBox.Show("Не разобран Ui.xaml:\n\n" + ex.Message, "SbLauncher",
MessageBoxButton.OK, MessageBoxImage.Error);
return 1;
}
MainVm vm = new MainVm();
w.DataContext = vm;
vm.Load(paths);
WindowCorners.Enable(w);
vm.ThemeApplier = delegate(string t) { Theme.Apply(w, t); };
string th = themeArg != null ? themeArg : vm.Local.Theme;
if (string.IsNullOrEmpty(th)) th = Theme.DetectSystem();
vm.SetTheme(th);
Tray tray = new Tray(w, vm);
Wire(w, vm, tray);
w.Closing += delegate(object s, CancelEventArgs e)
{
e.Cancel = true; // крестик = свернуть в трей
vm.FlushLocal();
tray.HideToTray();
};
app.Exit += delegate { tray.Dispose(); vm.DisposeTimers(); };
// фоновый слушатель: повторный запуск ярлыка поднимает это окно
Thread listener = new Thread(delegate()
{
while (true) { showSignal.WaitOne(); tray.Restore(); }
});
listener.IsBackground = true;
listener.Start();
app.MainWindow = w;
w.Show();
int rc = app.Run();
GC.KeepAlive(mtx);
return rc;
}
// ── --selftest ───────────────────────────────────────────────────
// Разбирает XAML, проверяет темы и флаги, читает profiles/local и
// СОБИРАЕТ все три конфига, прогоняя каждый через sb.exe check.
// Ничего не запускает и не требует администратора.
static int SelfTest(Paths paths)
{
int problems = 0;
Window tw;
try
{
// Порядок ТОЧНО как в боевом Main: сначала Application,
// потом окно. Иначе тест не поймает регрессию с прозрачным
// окном — она проявляется только при обратном порядке.
new Application();
tw = (Window)XamlReader.Parse(LoadXaml());
Console.WriteLine("XAML .......... ok (" + tw.Width + "x" + tw.Height + ")");
string[] names = { "TitleBar", "BtnTheme", "BtnMin", "BtnClose", "LogBox",
"BtnTabLog", "BtnTabSettings", "LinkBox", "BtnAddLink",
"AdapterBox" };
foreach (string n in names)
{
bool ok = tw.FindName(n) != null;
if (!ok) problems++;
Console.WriteLine(" x:Name " + n.PadRight(16) + (ok ? "found" : "MISSING"));
}
}
catch (Exception ex)
{
Console.WriteLine("XAML .......... FAIL: " + ex.Message);
return 1;
}
// Регрессия «прозрачное окно»: палитра применяется, но
// {DynamicResource} не разрешается, и фон остаётся null.
foreach (string th in new string[] { Theme.Dark, Theme.Light })
{
Theme.Apply(tw, th);
Border rootBorder = tw.Content as Border;
object bg = (rootBorder != null) ? rootBorder.Background : null;
bool ok = bg != null;
if (!ok) problems++;
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"theme {0,-6} ... {1}", th, ok ? "фон " + bg : "ФОН NULL — окно будет прозрачным!"));
}
// Флаги строятся кодом. Если Build бросит исключение, WPF
// проглотит его при вычислении биндинга и молча покажет
// текстовый бейдж — внешне похоже на «флаг просто не добавлен».
foreach (string fc in new string[] { "RU", "BU", "DE", "EE", "FI", "NL", "SE" })
{
string res;
try { res = (Flags.Get(fc) != null) ? "ok" : "нет ветки в Build"; }
catch (Exception ex) { res = ex.GetType().Name + ": " + ex.Message; }
if (res != "ok") problems++;
Console.WriteLine(("flag " + fc).PadRight(14) + "... " + res);
}
try
{
using (Stream ic = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico"))
{
if (ic == null) { Console.WriteLine("app.ico ....... MISSING resource"); problems++; }
else
{
IconBitmapDecoder d = new IconBitmapDecoder(
ic, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
Console.WriteLine("app.ico ....... ok, " + d.Frames.Count + " sizes");
}
}
}
catch (Exception ex) { Console.WriteLine("app.ico ....... FAIL: " + ex.Message); problems++; }
Console.WriteLine();
Console.WriteLine("root .......... " + paths.Root);
Console.WriteLine("sb.exe ........ " + (File.Exists(paths.SbExe) ? "ok" : "MISSING") + " " + paths.SbExe);
Console.WriteLine("doc.srs ....... " + (File.Exists(paths.RuleSet) ? "ok" : "MISSING"));
Profiles pr;
LocalCfg lc;
try
{
pr = Profiles.Load(paths.Profiles);
lc = LocalCfg.Load(paths.Local);
}
catch (Exception ex)
{
Console.WriteLine("profiles/local FAIL: " + ex.Message);
return 1;
}
Console.WriteLine("profiles.json . " + pr.Servers.Count + " серверов, WG " +
pr.Wg.Server + ":" + pr.Wg.Port.ToString(CultureInfo.InvariantCulture));
foreach (ServerCfg s in pr.Servers)
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" {0,-4} {1,-24} {2,-22} sni={3}", s.Code, s.Name, s.Endpoint, s.Sni));
Console.WriteLine("local.json .... ключ " +
(ConfigBuilder.IsWgKey(lc.PrivateKey) ? "ok" : "НЕ КОРРЕКТЕН") +
", адрес " + (ConfigBuilder.IsCidr(lc.LocalAddress) ? "ok" : "НЕ КОРРЕКТЕН") +
", адаптер \"" + lc.Adapter + "\" " +
(Adapters.Find(lc.Adapter) != null ? "найден" : "НЕ НАЙДЕН"));
Console.WriteLine("адаптеры ...... ");
foreach (AdapterInfo a in Adapters.List())
Console.WriteLine(" " + (a.IsUp ? "[up] " : "[down] ") + a.Name.PadRight(30) + a.Description);
// Разбор ссылки — чистая функция, проверяем на синтетической.
// Порядок параметров в vless:// у разных панелей разный, поэтому
// важно, что читаем мы их по имени, а не по позиции.
string link = "vless://58863334-3b8a-4c81-9145-a18311621a7f@rus.krasavchik.org:443" +
"?type=tcp&security=reality&fp=random&sni=exomates.ru" +
"&pbk=Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14&sid=6d25" +
"&flow=xtls-rprx-vision#%D0%91%D1%83%D1%80%D1%8F%D1%82%D0%B8%D1%8F";
ServerCfg parsed = VlessLink.Parse(link);
bool linkOk = parsed != null
&& parsed.Server == "rus.krasavchik.org"
&& parsed.Port == 443
&& parsed.Uuid == "58863334-3b8a-4c81-9145-a18311621a7f"
&& parsed.Sni == "exomates.ru"
&& parsed.PublicKey == "Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14"
&& parsed.ShortId == "6d25"
&& parsed.Fingerprint == "random"
&& parsed.Flow == "xtls-rprx-vision"
&& parsed.Name == "Бурятия";
if (!linkOk) problems++;
Console.WriteLine("vless:// ...... " + (linkOk
? "разобрана верно (имя из #-фрагмента: " + parsed.Name + ", код " + parsed.Code + ")"
: "РАЗБОР СЛОМАН"));
// Второй тест: фрагмент с ?serverDescription=, без flow, с spx
string link2 = "vless://dbbb755e-cf8d-4701-9f97-37bf90cf530f@95.81.76.106:443" +
"?sid=68b1b1&type=tcp&spx=%2Fzx6XGVuN0oHb3Mm&headerType=none" +
"&sni=server.yamal-house.ru&security=reality&fp=chrome" +
"&pbk=G-IE8mHDdRX9600xdDuRm75upOoCSE3FHZN2Dj1hdWk&encryption=none" +
"#VPN_56RUS-Rostik?serverDescription=TVMxLeKEljE=";
ServerCfg p2 = VlessLink.Parse(link2);
bool link2Ok = p2 != null
&& p2.Server == "95.81.76.106"
&& p2.Uuid == "dbbb755e-cf8d-4701-9f97-37bf90cf530f"
&& p2.Sni == "server.yamal-house.ru"
&& p2.ShortId == "68b1b1"
&& p2.Flow == ""
&& p2.SpiderX == "/zx6XGVuN0oHb3Mm"
&& p2.Name == "VPN_56RUS-Rostik"; // без ?serverDescription=...
if (!link2Ok) problems++;
Console.WriteLine("vless:// #2 ... " + (link2Ok
? "разобрана верно (имя: " + p2.Name + ", flow пуст, spx=" + p2.SpiderX + ")"
: "РАЗБОР СЛОМАН"));
Console.WriteLine();
ServerCfg first = pr.Servers.Count > 0 ? pr.Servers[0] : null;
string[] modes = { ConfigBuilder.ModeWg, ConfigBuilder.ModeDirect, ConfigBuilder.ModeWgOnly };
foreach (string mode in modes)
{
BuildResult br = ConfigBuilder.Build(paths, pr, lc, first, mode,
Path.Combine(paths.RunDir, "selftest-" + mode + ".json"));
if (!br.Ok)
{
problems++;
Console.WriteLine("build " + mode.PadRight(8) + "... FAIL: " + br.Error);
continue;
}
string verdict = "собран";
if (File.Exists(paths.SbExe))
{
string outp;
bool ok = SingBoxRunner.Check(paths.SbExe, br.ConfigPath, paths.Root, out outp);
if (!ok) { problems++; verdict = "sb check ОШИБКА: " + outp; }
else verdict = "собран, sb check ok";
}
else verdict = "собран (sb.exe нет, check пропущен)";
Console.WriteLine("build " + mode.PadRight(8) + "... " + verdict);
}
Console.WriteLine();
Console.WriteLine(problems == 0
? "ИТОГ .......... всё в порядке"
: "ИТОГ .......... проблем: " + problems);
return problems == 0 ? 0 : 1;
}
// ── --screenshot <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(); };
}
}
}
+734
View File
@@ -0,0 +1,734 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
namespace SbLauncher
{
public class ImportResult
{
public readonly List<ServerCfg> Servers = new List<ServerCfg>();
public readonly List<string> Errors = new List<string>();
}
// Turns share links and pasted subscriptions into sing-box outbounds.
// Unknown transports are rejected rather than silently changing the protocol.
public static class KeyImporter
{
public static bool Same(ServerCfg a, ServerCfg b)
{
if (!string.Equals(a.Protocol, b.Protocol, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(a.Server, b.Server, StringComparison.OrdinalIgnoreCase) || a.Port != b.Port)
return false;
if (a.Outbound != null && b.Outbound != null)
return Canonical(a.Outbound) == Canonical(b.Outbound);
if (a.Protocol == "vless") return string.Equals(a.Uuid, b.Uuid, StringComparison.OrdinalIgnoreCase);
return false;
}
static string Canonical(object value)
{
Dictionary<string, object> map = value as Dictionary<string, object>;
if (map != null)
{
List<string> keys = new List<string>(map.Keys);
keys.Sort(StringComparer.Ordinal);
StringBuilder output = new StringBuilder("{");
foreach (string key in keys)
output.Append(J.ToJson(key)).Append(':').Append(Canonical(map[key])).Append(',');
return output.Append('}').ToString();
}
object[] items = value as object[];
if (items != null)
{
StringBuilder output = new StringBuilder("[");
foreach (object item in items) output.Append(Canonical(item)).Append(',');
return output.Append(']').ToString();
}
return J.ToJson(value);
}
public static bool CheckWithSingBox(Paths paths, ServerCfg server, out string error)
{
error = "";
if (!File.Exists(paths.SbExe)) return true;
string file = Path.Combine(paths.RunDir, "import-check-" + Guid.NewGuid().ToString("N") + ".json");
try
{
Dictionary<string, object> outbound = J.Parse(J.ToJson(server.Outbound));
outbound["tag"] = "import-test";
J.Write(file, "{\"outbounds\":[" + J.ToJson(outbound) + "]}");
string output;
if (SingBoxRunner.Check(paths.SbExe, file, paths.Root, out output)) return true;
error = string.IsNullOrEmpty(output) ? "sing-box отклонил конфигурацию" : output;
Dictionary<string, object> tls = J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null);
Dictionary<string, object> reality = tls == null ? null : J.Obj(tls.ContainsKey("reality") ? tls["reality"] : null);
Dictionary<string, object> obfs = J.Obj(outbound.ContainsKey("obfs") ? outbound["obfs"] : null);
foreach (string secret in new string[] { J.Str(outbound, "uuid", ""), J.Str(outbound, "password", ""),
J.Str(reality, "public_key", ""), J.Str(obfs, "password", "") })
if (!string.IsNullOrEmpty(secret)) error = error.Replace(secret, "[скрыто]");
if (error.Length > 300) error = error.Substring(0, 300) + "…";
return false;
}
catch (Exception ex) { error = ex.Message; return false; }
finally { try { if (File.Exists(file)) File.Delete(file); } catch { } }
}
const int MaxInput = 2000000;
static readonly Regex LinkPattern = new Regex(
@"(?i)(?:vless|vmess|trojan|ss|hysteria2|hy2|tuic|anytls)://\S+",
RegexOptions.Compiled);
public static ImportResult Parse(string input)
{
ImportResult result = new ImportResult();
if (string.IsNullOrWhiteSpace(input))
{
result.Errors.Add("поле ввода пусто");
return result;
}
if (input.Length > MaxInput)
{
result.Errors.Add("слишком большой текст подписки (максимум 2 МБ)");
return result;
}
ParseText(input.Trim().Trim('\uFEFF'), result, 0);
if (result.Servers.Count == 0 && result.Errors.Count == 0)
result.Errors.Add("поддерживаемых ключей не найдено");
return result;
}
static void ParseText(string text, ImportResult result, int depth)
{
if (depth > 2) { result.Errors.Add("слишком много слоёв base64"); return; }
string trimmed = text.Trim();
if (trimmed.StartsWith("{") || trimmed.StartsWith("["))
{
ParseJson(trimmed, result);
return;
}
if (!trimmed.Contains("://"))
{
string decoded;
if (TryDecodeBase64(trimmed, out decoded) && decoded != trimmed &&
(decoded.Contains("://") || decoded.TrimStart().StartsWith("{") || decoded.TrimStart().StartsWith("[")))
{
ParseText(decoded, result, depth + 1);
return;
}
}
string[] lines = text.Replace("\r", "").Split('\n');
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i].Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith("//")) continue;
MatchCollection links = LinkPattern.Matches(line);
if (links.Count == 0)
{
if (Regex.IsMatch(line, @"(?i)^https?://\S+$"))
{
try { ParseText(FetchSubscription(line), result, depth + 1); }
catch (Exception ex) { result.Errors.Add("строка " + (i + 1) + ": " + ex.Message); }
}
else result.Errors.Add("строка " + (i + 1) + ": неизвестный формат ключа");
continue;
}
foreach (Match match in links)
{
if (result.Servers.Count >= 1000)
{
result.Errors.Add("лимит импорта — 1000 серверов");
return;
}
ServerCfg server;
string error;
if (TryParseOne(match.Value, out server, out error)) result.Servers.Add(server);
else result.Errors.Add("строка " + (i + 1) + ": " + error);
}
}
}
static string FetchSubscription(string url)
{
try
{
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
if (new Uri(url).IsLoopback) request.Proxy = null;
request.Timeout = 10000;
request.ReadWriteTimeout = 10000;
request.AllowAutoRedirect = true;
request.MaximumAutomaticRedirections = 3;
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
request.UserAgent = "SbLauncher/2";
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
using (Stream stream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(stream, new UTF8Encoding(false, true), true))
{
if (response.ContentLength > MaxInput)
throw new FormatException("подписка больше 2 МБ");
StringBuilder body = new StringBuilder();
char[] buffer = new char[4096];
int count;
while ((count = reader.Read(buffer, 0, buffer.Length)) > 0)
{
body.Append(buffer, 0, count);
if (body.Length > MaxInput) throw new FormatException("подписка больше 2 МБ");
}
return body.ToString();
}
}
catch (FormatException) { throw; }
catch (WebException ex) { throw new FormatException("не удалось загрузить подписку (" + ex.Status + ")"); }
catch (Exception) { throw new FormatException("не удалось прочитать подписку"); }
}
static void ParseJson(string text, ImportResult result)
{
try
{
JavaScriptSerializer ser = new JavaScriptSerializer();
ser.MaxJsonLength = MaxInput;
object root = ser.DeserializeObject(J.StripComments(text));
Dictionary<string, object> document = root as Dictionary<string, object>;
object[] outbounds = root as object[];
if (document != null)
{
object listed;
if (document.TryGetValue("outbounds", out listed)) outbounds = listed as object[];
else if (document.ContainsKey("type")) outbounds = new object[] { document };
}
if (outbounds == null) { result.Errors.Add("JSON не содержит outbounds"); return; }
foreach (object item in outbounds)
{
Dictionary<string, object> outbound = J.Obj(item);
if (outbound == null) continue;
string type = J.Str(outbound, "type", "").ToLowerInvariant();
if (!Supported(type)) continue; // direct, selector, block etc. are not keys
try
{
if (result.Servers.Count >= 1000) throw new FormatException("лимит импорта — 1000 серверов");
result.Servers.Add(FromOutbound(outbound));
}
catch (Exception ex) { result.Errors.Add("JSON: " + ex.Message); }
}
if (result.Servers.Count == 0 && result.Errors.Count == 0)
result.Errors.Add("в JSON нет поддерживаемых прокси-серверов");
}
catch (Exception ex) { result.Errors.Add("не удалось прочитать JSON: " + ex.Message); }
}
public static bool TryParseOne(string text, out ServerCfg server, out string error)
{
server = null;
error = "";
try
{
if (string.IsNullOrWhiteSpace(text)) throw new FormatException("пустой ключ");
string raw = text.Trim();
int schemeEnd = raw.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd < 0) throw new FormatException("нет схемы протокола");
string scheme = raw.Substring(0, schemeEnd).ToLowerInvariant();
string body = raw.Substring(schemeEnd + 3);
if (!Supported(scheme)) throw new FormatException("протокол " + scheme + " пока не поддерживается");
if (scheme == "vmess" && body.IndexOf('@') < 0)
server = ParseVmessJson(body);
else if (scheme == "ss") server = ParseShadowsocks(body);
else
{
Link link = ParseLink(body);
if (scheme == "vless") server = ParseVless(link);
else if (scheme == "vmess") server = ParseVmessUri(link);
else if (scheme == "trojan") server = ParseTrojan(link);
else if (scheme == "hysteria2" || scheme == "hy2") server = ParseHysteria2(link);
else if (scheme == "tuic") server = ParseTuic(link);
else if (scheme == "anytls") server = ParseAnyTls(link);
}
if (server == null) throw new FormatException("ключ не распознан");
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
static bool Supported(string type)
{
return type == "vless" || type == "vmess" || type == "trojan" ||
type == "ss" || type == "shadowsocks" || type == "hysteria2" ||
type == "hy2" || type == "tuic" || type == "anytls";
}
sealed class Link
{
public string User, Host, Name;
public int Port;
public Dictionary<string, string> Query;
}
static Link ParseLink(string body)
{
string name = "";
int hash = body.IndexOf('#');
if (hash >= 0)
{
name = Decode(body.Substring(hash + 1));
int metadata = name.IndexOf("?serverDescription=", StringComparison.OrdinalIgnoreCase);
if (metadata >= 0) name = name.Substring(0, metadata);
body = body.Substring(0, hash);
}
string query = "";
int question = body.IndexOf('?');
if (question >= 0) { query = body.Substring(question + 1); body = body.Substring(0, question); }
body = body.TrimEnd('/');
int at = body.LastIndexOf('@');
if (at < 1) throw new FormatException("в ключе нет учётных данных или адреса сервера");
string user = Decode(body.Substring(0, at));
string address = body.Substring(at + 1);
string host;
int port;
ParseAddress(address, out host, out port);
return new Link { User = user, Host = host, Port = port,
Name = name.Length > 0 ? name : host, Query = Query(query) };
}
static void ParseAddress(string address, out string host, out int port)
{
string digits;
if (address.StartsWith("["))
{
int close = address.IndexOf(']');
if (close < 0 || close + 1 >= address.Length || address[close + 1] != ':')
throw new FormatException("неверный IPv6-адрес или порт");
host = address.Substring(1, close - 1);
digits = address.Substring(close + 2);
}
else
{
int colon = address.LastIndexOf(':');
if (colon < 1 || address.IndexOf(':') != colon)
throw new FormatException("адрес должен иметь вид host:port (IPv6 — в квадратных скобках)");
host = address.Substring(0, colon);
digits = address.Substring(colon + 1);
}
host = Decode(host).Trim();
if (host.Length == 0 || host.Contains(" ")) throw new FormatException("пустой или неверный адрес сервера");
if (!int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out port) || port < 1 || port > 65535)
throw new FormatException("порт должен быть числом от 1 до 65535");
}
static Dictionary<string, string> Query(string text)
{
Dictionary<string, string> values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string pair in text.Split('&'))
{
if (pair.Length == 0) continue;
int eq = pair.IndexOf('=');
string key = Decode((eq < 0 ? pair : pair.Substring(0, eq)).Replace('+', ' '));
string value = eq < 0 ? "" : Decode(pair.Substring(eq + 1).Replace('+', ' '));
values[key] = value;
}
return values;
}
static string Get(Dictionary<string, string> q, string key, string fallback)
{
string value;
return q.TryGetValue(key, out value) ? value : fallback;
}
static string First(Dictionary<string, string> q, string a, string b, string fallback)
{
string value = Get(q, a, "");
return value.Length > 0 ? value : Get(q, b, fallback);
}
static string Decode(string value) { return Uri.UnescapeDataString(value); }
static bool TryDecodeBase64(string text, out string decoded)
{
decoded = null;
try
{
string clean = Regex.Replace(text, @"\s", "").Replace('-', '+').Replace('_', '/');
if (clean.Length == 0 || clean.Length > MaxInput * 2) return false;
clean = clean.PadRight((clean.Length + 3) / 4 * 4, '=');
decoded = new UTF8Encoding(false, true).GetString(Convert.FromBase64String(clean));
return true;
}
catch { return false; }
}
static string Base64(string value)
{
string decoded;
if (!TryDecodeBase64(value, out decoded)) throw new FormatException("неверная base64-строка");
return decoded;
}
static Dictionary<string, object> Base(string protocol, Link link)
{
Dictionary<string, object> outbound = new Dictionary<string, object>();
outbound["type"] = protocol;
outbound["server"] = link.Host;
outbound["server_port"] = link.Port;
return outbound;
}
static ServerCfg Finish(string protocol, Link link, Dictionary<string, object> outbound)
{
ServerCfg server = new ServerCfg();
server.Protocol = protocol;
server.Server = link.Host;
server.Port = link.Port;
server.Name = link.Name;
server.Code = MakeCode(server.Name);
server.Uuid = J.Str(outbound, "uuid", "");
server.Sni = J.Str(J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null), "server_name", "");
server.Outbound = outbound;
return server;
}
static string MakeCode(string name)
{
StringBuilder code = new StringBuilder();
foreach (char c in name)
{
if (char.IsLetterOrDigit(c)) code.Append(char.ToUpperInvariant(c));
if (code.Length == 2) break;
}
return code.Length > 0 ? code.ToString() : "??";
}
static void AddTls(Dictionary<string, object> outbound, Link link, string mode, bool defaultEnabled)
{
string security = Get(link.Query, "security", mode).ToLowerInvariant();
if (security.Length == 0) security = defaultEnabled ? "tls" : "none";
if (security == "none" || security == "false") return;
if (security != "tls" && security != "reality")
throw new FormatException("режим TLS " + security + " не поддерживается");
Dictionary<string, object> tls = new Dictionary<string, object>();
tls["enabled"] = true;
string sni = First(link.Query, "sni", "peer", link.Host);
if (sni.Length > 0) tls["server_name"] = sni;
string alpn = Get(link.Query, "alpn", "");
if (alpn.Length > 0) tls["alpn"] = alpn.Split(',');
string insecure = First(link.Query, "allowInsecure", "insecure", "");
if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true;
string fp = Get(link.Query, "fp", security == "reality" ? "chrome" : "");
if (fp.Length > 0 && fp != "none")
tls["utls"] = new Dictionary<string, object> { { "enabled", true }, { "fingerprint", fp } };
if (security == "reality")
{
string publicKey = First(link.Query, "pbk", "publicKey", "");
if (publicKey.Length == 0) throw new FormatException("в Reality отсутствует public key (pbk)");
tls["reality"] = new Dictionary<string, object> {
{ "enabled", true }, { "public_key", publicKey },
{ "short_id", First(link.Query, "sid", "shortId", "") }
};
}
outbound["tls"] = tls;
}
static void AddTransport(Dictionary<string, object> outbound, Dictionary<string, string> q)
{
string type = First(q, "type", "net", "tcp").ToLowerInvariant();
if (type == "" || type == "tcp" || type == "raw")
{
string header = Get(q, "headerType", "none");
if (header.Length > 0 && header != "none")
throw new FormatException("TCP headerType=" + header + " пока не поддерживается");
return;
}
Dictionary<string, object> transport = new Dictionary<string, object>();
if (type == "ws" || type == "websocket")
{
transport["type"] = "ws";
string path = Get(q, "path", "/");
Match early = Regex.Match(path, @"(?:\?|&)ed=(\d+)(?:&|$)");
if (early.Success)
{
int ed;
if (int.TryParse(early.Groups[1].Value, out ed) && ed > 0)
{
transport["max_early_data"] = ed;
transport["early_data_header_name"] = "Sec-WebSocket-Protocol";
}
path = path.Substring(0, early.Index).TrimEnd('?','&');
}
transport["path"] = path.Length > 0 ? path : "/";
string host = Get(q, "host", "");
if (host.Length > 0)
transport["headers"] = new Dictionary<string, object> { { "Host", host } };
}
else if (type == "grpc")
{
transport["type"] = "grpc";
transport["service_name"] = First(q, "serviceName", "path", "").TrimStart('/');
}
else if (type == "httpupgrade")
{
transport["type"] = "httpupgrade";
transport["path"] = Get(q, "path", "/");
string host = Get(q, "host", "");
if (host.Length > 0) transport["host"] = host;
}
else if (type == "http" || type == "h2")
{
transport["type"] = "http";
transport["path"] = Get(q, "path", "/");
string host = Get(q, "host", "");
if (host.Length > 0) transport["host"] = host.Split(',');
}
else if (type == "quic") transport["type"] = "quic";
else throw new FormatException("транспорт " + type + " пока не поддерживается");
outbound["transport"] = transport;
}
static ServerCfg ParseVless(Link link)
{
Guid id;
if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VLESS");
string encryption = Get(link.Query, "encryption", "none");
if (encryption.Length > 0 && encryption != "none")
throw new FormatException("шифрование VLESS " + encryption + " не поддерживается");
Dictionary<string, object> outbound = Base("vless", link);
outbound["uuid"] = link.User;
string flow = Get(link.Query, "flow", "");
if (flow.Length > 0)
{
if (flow != "xtls-rprx-vision") throw new FormatException("flow " + flow + " не поддерживается");
outbound["flow"] = flow;
}
string security = Get(link.Query, "security", Get(link.Query, "pbk", "").Length > 0 ? "reality" : "none");
AddTls(outbound, link, security, false);
AddTransport(outbound, link.Query);
outbound["packet_encoding"] = First(link.Query, "packetEncoding", "packet_encoding", "xudp");
ServerCfg result = Finish("vless", link, outbound);
result.Flow = flow;
result.PublicKey = Get(link.Query, "pbk", "");
result.ShortId = Get(link.Query, "sid", "");
result.Fingerprint = Get(link.Query, "fp", "chrome");
result.SpiderX = Get(link.Query, "spx", "");
return result;
}
static ServerCfg ParseVmessJson(string body)
{
string decoded = Base64(body.Split('#')[0]);
Dictionary<string, object> data = J.Parse(decoded);
if (data == null) throw new FormatException("VMess base64 не содержит JSON-объект");
string address = J.Str(data, "add", "");
int port = J.Int(data, "port", 0);
if (address.Length == 0 || port < 1 || port > 65535) throw new FormatException("VMess: неверный адрес или порт");
Link link = new Link();
link.User = J.Str(data, "id", "");
link.Host = address;
link.Port = port;
link.Name = J.Str(data, "ps", address);
link.Query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
link.Query["type"] = J.Str(data, "net", "tcp");
link.Query["path"] = J.Str(data, "path", "");
link.Query["host"] = J.Str(data, "host", "");
link.Query["sni"] = J.Str(data, "sni", "");
link.Query["fp"] = J.Str(data, "fp", "");
link.Query["alpn"] = J.Str(data, "alpn", "");
string vmessTls = J.Str(data, "tls", "").ToLowerInvariant();
if (vmessTls != "" && vmessTls != "tls" && vmessTls != "none")
throw new FormatException("VMess: режим TLS " + vmessTls + " не поддерживается");
link.Query["security"] = vmessTls == "tls" ? "tls" : "none";
link.Query["headerType"] = J.Str(data, "type", "none");
link.Query["vmess_security"] = J.Str(data, "scy", "auto");
link.Query["alter_id"] = J.Str(data, "aid", "0");
return ParseVmessUri(link);
}
static ServerCfg ParseVmessUri(Link link)
{
Guid id;
if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VMess");
Dictionary<string, object> outbound = Base("vmess", link);
outbound["uuid"] = link.User;
outbound["security"] = First(link.Query, "vmess_security", "encryption", "auto");
int aid;
if (!int.TryParse(First(link.Query, "alter_id", "aid", "0"), out aid) || aid < 0)
throw new FormatException("неверный alter_id VMess");
outbound["alter_id"] = aid;
AddTls(outbound, link, Get(link.Query, "security", "none"), false);
AddTransport(outbound, link.Query);
return Finish("vmess", link, outbound);
}
static ServerCfg ParseTrojan(Link link)
{
if (link.User.Length == 0) throw new FormatException("пустой пароль Trojan");
Dictionary<string, object> outbound = Base("trojan", link);
outbound["password"] = link.User;
AddTls(outbound, link, Get(link.Query, "security", "tls"), true);
AddTransport(outbound, link.Query);
return Finish("trojan", link, outbound);
}
static ServerCfg ParseShadowsocks(string body)
{
string original = body;
int hash = body.IndexOf('#');
string name = "";
if (hash >= 0) { name = Decode(body.Substring(hash + 1)); body = body.Substring(0, hash); }
int queryAt = body.IndexOf('?');
string query = "";
if (queryAt >= 0) { query = body.Substring(queryAt + 1); body = body.Substring(0, queryAt); }
body = body.TrimEnd('/');
if (body.IndexOf('@') < 0) body = Base64(body);
int at = body.LastIndexOf('@');
if (at < 1) throw new FormatException("Shadowsocks: нет адреса или пароля");
string user = body.Substring(0, at);
if (user.IndexOf(':') < 0) user = Base64(user);
else user = Decode(user);
int colon = user.IndexOf(':');
if (colon < 1 || colon == user.Length - 1) throw new FormatException("Shadowsocks: ожидается method:password");
string method = user.Substring(0, colon);
string password = user.Substring(colon + 1);
string host; int port;
ParseAddress(body.Substring(at + 1), out host, out port);
Link link = new Link { User = password, Host = host, Port = port,
Name = name.Length > 0 ? name : host, Query = Query(query) };
Dictionary<string, object> outbound = Base("shadowsocks", link);
outbound["method"] = method;
outbound["password"] = password;
string plugin = Get(link.Query, "plugin", "");
if (plugin.Length > 0)
{
string[] parts = plugin.Split(new char[] {';'}, 2);
if (parts[0] != "obfs-local" && parts[0] != "v2ray-plugin")
throw new FormatException("плагин Shadowsocks " + parts[0] + " не поддерживается sing-box");
outbound["plugin"] = parts[0];
if (parts.Length > 1) outbound["plugin_opts"] = parts[1];
}
return Finish("shadowsocks", link, outbound);
}
static ServerCfg ParseHysteria2(Link link)
{
if (link.User.Length == 0) throw new FormatException("пустой пароль Hysteria2");
Dictionary<string, object> outbound = Base("hysteria2", link);
outbound["password"] = link.User;
Dictionary<string, object> tls = new Dictionary<string, object> { { "enabled", true } };
tls["server_name"] = Get(link.Query, "sni", link.Host);
string alpn = Get(link.Query, "alpn", "");
if (alpn.Length > 0) tls["alpn"] = alpn.Split(',');
string insecure = Get(link.Query, "insecure", "");
if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true;
outbound["tls"] = tls;
string obfs = Get(link.Query, "obfs", "");
if (obfs.Length > 0)
{
if (obfs != "salamander") throw new FormatException("обфускация Hysteria2 " + obfs + " не поддерживается этой версией sing-box");
string secret = First(link.Query, "obfs-password", "obfsPassword", "");
if (secret.Length == 0) throw new FormatException("нет obfs-password Hysteria2");
outbound["obfs"] = new Dictionary<string, object> { { "type", "salamander" }, { "password", secret } };
}
string ports = Get(link.Query, "mport", "");
if (ports.Length > 0)
{
outbound.Remove("server_port");
outbound["server_ports"] = ports.Replace('-', ':').Split(',');
}
string up = Get(link.Query, "upmbps", ""), down = Get(link.Query, "downmbps", "");
int value;
if (int.TryParse(up, out value) && value > 0) outbound["up_mbps"] = value;
if (int.TryParse(down, out value) && value > 0) outbound["down_mbps"] = value;
return Finish("hysteria2", link, outbound);
}
static ServerCfg ParseTuic(Link link)
{
int colon = link.User.IndexOf(':');
if (colon < 1 || colon == link.User.Length - 1) throw new FormatException("TUIC: ожидается UUID:пароль");
string uuid = link.User.Substring(0, colon), password = link.User.Substring(colon + 1);
Guid id;
if (!Guid.TryParse(uuid, out id)) throw new FormatException("неверный UUID TUIC");
Dictionary<string, object> outbound = Base("tuic", link);
outbound["uuid"] = uuid;
outbound["password"] = password;
string cc = Get(link.Query, "congestion_control", "");
if (cc.Length > 0) outbound["congestion_control"] = cc;
string relay = Get(link.Query, "udp_relay_mode", "");
if (relay.Length > 0) outbound["udp_relay_mode"] = relay;
Dictionary<string, object> tls = new Dictionary<string, object> { { "enabled", true } };
tls["server_name"] = Get(link.Query, "sni", link.Host);
string alpn = Get(link.Query, "alpn", "");
if (alpn.Length > 0) tls["alpn"] = alpn.Split(',');
string insecure = Get(link.Query, "insecure", "");
if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true;
outbound["tls"] = tls;
return Finish("tuic", link, outbound);
}
static ServerCfg ParseAnyTls(Link link)
{
if (link.User.Length == 0) throw new FormatException("пустой пароль AnyTLS");
Dictionary<string, object> outbound = Base("anytls", link);
outbound["password"] = link.User;
AddTls(outbound, link, "tls", true);
if (!outbound.ContainsKey("tls")) throw new FormatException("AnyTLS требует TLS");
return Finish("anytls", link, outbound);
}
static ServerCfg FromOutbound(Dictionary<string, object> input)
{
Dictionary<string, object> outbound = J.Parse(J.ToJson(input));
string type = J.Str(outbound, "type", "").ToLowerInvariant();
if (type == "hy2") type = "hysteria2";
if (type == "ss") type = "shadowsocks";
string host = J.Str(outbound, "server", "");
int port = J.Int(outbound, "server_port", 0);
if (host.Length == 0 || port < 1 || port > 65535)
throw new FormatException("outbound " + type + ": неверный server/server_port");
string auth = type == "vless" || type == "vmess" || type == "tuic" ?
J.Str(outbound, "uuid", "") : J.Str(outbound, "password", "");
if (auth.Length == 0) throw new FormatException("outbound " + type + ": нет UUID или пароля");
string name = J.Str(outbound, "tag", host);
outbound["type"] = type;
outbound.Remove("tag");
outbound.Remove("detour");
Link link = new Link { Host = host, Port = port, Name = name };
return Finish(type, link, outbound);
}
}
public static class ProxyOutbound
{
public static Dictionary<string, object> Build(ServerCfg server, string mode)
{
Dictionary<string, object> outbound;
if (server.Outbound != null) outbound = J.Parse(J.ToJson(server.Outbound));
else
{
outbound = new Dictionary<string, object>();
outbound["type"] = "vless";
outbound["server"] = server.Server;
outbound["server_port"] = server.Port;
outbound["uuid"] = server.Uuid;
if (!string.IsNullOrEmpty(server.Flow)) outbound["flow"] = server.Flow;
outbound["tls"] = new Dictionary<string, object> {
{ "enabled", true }, { "server_name", server.Sni },
{ "utls", new Dictionary<string, object> { { "enabled", true }, { "fingerprint", server.Fingerprint } } },
{ "reality", new Dictionary<string, object> { { "enabled", true },
{ "public_key", server.PublicKey }, { "short_id", server.ShortId } } }
};
outbound["packet_encoding"] = "xudp";
outbound["domain_strategy"] = "ipv4_only";
}
outbound["tag"] = "proxy-vless"; // shared route tag, independent of protocol
outbound["detour"] = mode == ConfigBuilder.ModeWg ? "proxy-wireguard" : "bound";
return outbound;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# sb launcher · glass preview
Отдельный тестовый репозиторий интерфейса для Windows 11. Рабочий `sb_launcher_rev2` не меняется. Это функционирующий лаунчер на WPF/.NET Framework 4.x: выбор и импорт серверов, проверка конфигурации, запуск и остановка sing-box, логи и настройки остались на месте.
Дизайн использует полупрозрачные панели, мягкие градиенты и логотип «щит с арбузом». При подключении кольцо вокруг щита вращается по часовой стрелке; при отключении — обратно. Успешное соединение отмечает короткий световой импульс. Анимации меняют только поворот и прозрачность, без непрерывного размытия фона.
## Запуск
В папке уже лежат локальные `profiles.json`, `bin/sb.exe` и `doc/doc.srs` для теста; они не входят в Git. `local.json` создан с пустым приватным ключом. Заполните ключ и локальный адрес на вкладке «Настройки», затем в PowerShell:
```powershell
cd F:\gitea\sb_launcher_glass
cmd /c build.cmd
.\SbLauncher.exe
```
Сборка использует `csc.exe` из установленного .NET Framework 4.x. Запуск основного exe запросит права администратора: они нужны sing-box для сетевых маршрутов. Для проверки интерфейса без запуска сети: `cmd /c build.cmd stress`.
Для чистого клона скопируйте `profiles.example.json` в `profiles.json` и `local.example.json` в `local.json`, затем добавьте реальные серверы и ключ. Примерные адреса из `profiles.example.json` служат только для демонстрации. Профили и локальные настройки исключены из Git, поскольку могут содержать учётные данные.
`build.cmd deploy` принимает явный путь назначения; папка рабочего лаунчера никогда не выбирается по умолчанию.
Офлайн-превью с демонстрационными адресами: [светлая тема](preview/glass-light.png) и [тёмная тема](preview/glass-dark.png).
+934
View File
@@ -0,0 +1,934 @@
// 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.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace SbLauncher
{
// ═════════════════════════════════════════════════════════════════════
// JSON
//
// Свои файлы (profiles.json / local.json) содержат комментарии //,
// поэтому перед разбором их снимаем. JavaScriptSerializer входит в
// .NET Framework (System.Web.Extensions.dll) — внешних библиотек нет.
// ═════════════════════════════════════════════════════════════════════
public static class J
{
public static Dictionary<string, object> Parse(string text)
{
JavaScriptSerializer ser = new JavaScriptSerializer();
ser.MaxJsonLength = int.MaxValue;
return ser.DeserializeObject(StripComments(text)) as Dictionary<string, object>;
}
public static Dictionary<string, object> Obj(object o)
{
return o as Dictionary<string, object>;
}
public static List<Dictionary<string, object>> Arr(object o)
{
List<Dictionary<string, object>> list = new List<Dictionary<string, object>>();
object[] a = o as object[];
if (a == null) return list;
foreach (object it in a)
{
Dictionary<string, object> d = it as Dictionary<string, object>;
if (d != null) list.Add(d);
}
return list;
}
public static string Str(Dictionary<string, object> d, string key, string def)
{
object v;
if (d == null || !d.TryGetValue(key, out v) || v == null) return def;
return Convert.ToString(v, CultureInfo.InvariantCulture);
}
public static int Int(Dictionary<string, object> d, string key, int def)
{
object v;
if (d == null || !d.TryGetValue(key, out v) || v == null) return def;
try { return Convert.ToInt32(v, CultureInfo.InvariantCulture); }
catch { return def; }
}
// Экранирование значения, которое подставляется ВНУТРЬ строки JSON.
// Обязательно: ключ или SNI с кавычкой иначе сломают весь файл.
public static string Esc(string s)
{
if (s == null) return "";
StringBuilder sb = new StringBuilder(s.Length + 8);
foreach (char c in s)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
else sb.Append(c);
break;
}
}
return sb.ToString();
}
// Снимает // и /* */ с учётом строковых литералов: внутри кавычек
// слэши не трогаем, иначе разъедется "https://..." и base64-ключ.
public static string StripComments(string s)
{
StringBuilder sb = new StringBuilder(s.Length);
bool inStr = false, esc = false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (inStr)
{
sb.Append(c);
if (esc) esc = false;
else if (c == '\\') esc = true;
else if (c == '"') inStr = false;
continue;
}
if (c == '"') { inStr = true; sb.Append(c); continue; }
if (c == '/' && i + 1 < s.Length && s[i + 1] == '/')
{
while (i < s.Length && s[i] != '\n') i++;
if (i < s.Length) sb.Append('\n');
continue;
}
if (c == '/' && i + 1 < s.Length && s[i + 1] == '*')
{
i += 2;
while (i + 1 < s.Length && !(s[i] == '*' && s[i + 1] == '/')) i++;
i++;
continue;
}
sb.Append(c);
}
return sb.ToString();
}
public static string ToJson(object value)
{
JavaScriptSerializer ser = new JavaScriptSerializer();
ser.MaxJsonLength = int.MaxValue;
return ser.Serialize(value);
}
public static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
public static void Write(string path, string text)
{
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// A failed write must never leave half of a settings/config file.
string temp = path + ".tmp";
File.WriteAllText(temp, text, Utf8NoBom);
try
{
if (File.Exists(path)) File.Replace(temp, path, null);
else File.Move(temp, path);
}
finally { if (File.Exists(temp)) File.Delete(temp); }
}
}
// ═════════════════════════════════════════════════════════════════════
// ПУТИ. Всё относительно папки с exe — это и есть портативность.
// ═════════════════════════════════════════════════════════════════════
public class Paths
{
public string Root;
public Paths(string root) { Root = root; }
public string Profiles { get { return Path.Combine(Root, "profiles.json"); } }
public string Local { get { return Path.Combine(Root, "local.json"); } }
public string TemplatesDir { get { return Path.Combine(Root, "templates"); } }
public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } }
public string RuleSet { get { return Path.Combine(Root, Path.Combine("doc", "doc.srs")); } }
public string RunDir { get { return Path.Combine(Root, "run"); } }
public string RunConfig { get { return Path.Combine(RunDir, "config.json"); } }
// sing-box на Windows понимает прямые слэши, а обратные пришлось бы
// экранировать в JSON. Поэтому путь к rule-set отдаём через "/".
public string RuleSetJson { get { return RuleSet.Replace('\\', '/'); } }
}
// ═════════════════════════════════════════════════════════════════════
// МОДЕЛЬ
// ═════════════════════════════════════════════════════════════════════
public class ServerCfg
{
public string Code = "";
public string Name = "";
public string Server = "";
public int Port = 443;
public string Uuid = "";
public string Sni = "";
public string Fingerprint = "chrome";
public string PublicKey = "";
public string ShortId = "";
public string Flow = ""; // xtls-rprx-vision или пусто (без flow)
public string SpiderX = ""; // старые профили сохраняют это поле
public string Protocol = "vless";
public Dictionary<string, object> Outbound; // точная конфигурация импортированного ключа
public string Endpoint
{
get { return Server + ":" + Port.ToString(CultureInfo.InvariantCulture); }
}
}
public class WgCfg
{
public string Server = "";
public int Port = 51820;
public string PeerPublicKey = "";
public int Mtu = 1280;
}
// profiles.json — локальная копия серверов; в тестовом репозитории не коммитится
public class Profiles
{
public WgCfg Wg = new WgCfg();
public List<ServerCfg> Servers = new List<ServerCfg>();
public static Profiles Load(string path)
{
Profiles p = new Profiles();
if (!File.Exists(path)) return p;
Dictionary<string, object> root = J.Parse(File.ReadAllText(path, Encoding.UTF8));
if (root == null) return p;
object wo;
if (root.TryGetValue("wireguard", out wo))
{
Dictionary<string, object> w = J.Obj(wo);
if (w != null)
{
p.Wg.Server = J.Str(w, "server", "");
p.Wg.Port = J.Int(w, "port", 51820);
p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", "");
p.Wg.Mtu = J.Int(w, "mtu", 1280);
}
}
object so;
if (root.TryGetValue("servers", out so))
foreach (Dictionary<string, object> d in J.Arr(so))
{
ServerCfg s = new ServerCfg();
s.Code = J.Str(d, "code", "");
s.Name = J.Str(d, "name", s.Code);
s.Server = J.Str(d, "server", "");
s.Port = J.Int(d, "port", 443);
s.Uuid = J.Str(d, "uuid", "");
s.Sni = J.Str(d, "sni", "");
s.Fingerprint = J.Str(d, "fingerprint", "chrome");
s.PublicKey = J.Str(d, "public_key", "");
s.ShortId = J.Str(d, "short_id", "");
s.Flow = J.Str(d, "flow", "xtls-rprx-vision");
s.SpiderX = J.Str(d, "spider_x", "");
s.Protocol = J.Str(d, "protocol", "vless");
object outbound;
if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound);
if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol);
if (s.Server.Length > 0) p.Servers.Add(s);
}
return p;
}
// Пишется только когда сервер добавили/удалили из GUI. Файл
// пересобирается целиком, поэтому свои комментарии ВНУТРИ списка
// серверов при этом теряются — шапка восстанавливается.
public void Save(string path)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine("// Серверы и общие параметры этого тестового экземпляра.");
sb.AppendLine("// Храните данные серверов локально, а ключ WireGuard — в local.json.");
sb.AppendLine("{");
sb.AppendLine(" \"wireguard\": {");
sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\",");
sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ",");
sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\",");
sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture));
sb.AppendLine(" },");
sb.AppendLine(" \"servers\": [");
for (int i = 0; i < Servers.Count; i++)
{
ServerCfg s = Servers[i];
sb.AppendLine(" {");
sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\",");
sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\",");
sb.AppendLine(" \"server\": \"" + J.Esc(s.Server) + "\",");
sb.AppendLine(" \"port\": " + s.Port.ToString(CultureInfo.InvariantCulture) + ",");
sb.AppendLine(" \"uuid\": \"" + J.Esc(s.Uuid) + "\",");
sb.AppendLine(" \"sni\": \"" + J.Esc(s.Sni) + "\",");
sb.AppendLine(" \"fingerprint\": \"" + J.Esc(s.Fingerprint) + "\",");
sb.AppendLine(" \"public_key\": \"" + J.Esc(s.PublicKey) + "\",");
// flow пишем ВСЕГДА (даже пустой), чтобы отличать «flow не задан»
// от «flow по умолчанию» при загрузке (обратная совместимость)
sb.AppendLine(" \"flow\": \"" + J.Esc(s.Flow) + "\",");
// spider_x пишем только когда задан
if (!string.IsNullOrEmpty(s.SpiderX))
sb.AppendLine(" \"spider_x\": \"" + J.Esc(s.SpiderX) + "\",");
sb.AppendLine(" \"short_id\": \"" + J.Esc(s.ShortId) + "\",");
sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : ""));
if (s.Outbound != null)
sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound));
sb.AppendLine(i == Servers.Count - 1 ? " }" : " },");
}
sb.AppendLine(" ]");
sb.AppendLine("}");
J.Write(path, sb.ToString());
}
}
// local.json — параметры ЭТОЙ машины, в .gitignore
public class LocalCfg
{
public string PrivateKey = "";
public string LocalAddress = "";
public string Adapter = "";
public string Theme = ""; // пусто -> берём тему Windows
public string Mode = "wg"; // wg | direct | wgonly
public string LastServer = "";
public static LocalCfg Load(string path)
{
LocalCfg c = new LocalCfg();
if (!File.Exists(path)) return c;
Dictionary<string, object> d = J.Parse(File.ReadAllText(path, Encoding.UTF8));
if (d == null) return c;
c.PrivateKey = J.Str(d, "private_key", "");
c.LocalAddress = J.Str(d, "local_address", "");
c.Adapter = J.Str(d, "adapter", "");
c.Theme = J.Str(d, "theme", "");
c.Mode = J.Str(d, "mode", "wg");
c.LastServer = J.Str(d, "last_server", "");
return c;
}
public void Save(string path)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine("// Параметры ЭТОЙ машины. В .gitignore — здесь приватный ключ.");
sb.AppendLine("// Правится в GUI, руками лезть сюда не нужно.");
sb.AppendLine("{");
sb.AppendLine(" \"private_key\": \"" + J.Esc(PrivateKey) + "\",");
sb.AppendLine(" \"local_address\": \"" + J.Esc(LocalAddress) + "\",");
sb.AppendLine(" \"adapter\": \"" + J.Esc(Adapter) + "\",");
sb.AppendLine(" \"theme\": \"" + J.Esc(Theme) + "\",");
sb.AppendLine(" \"mode\": \"" + J.Esc(Mode) + "\",");
sb.AppendLine(" \"last_server\": \"" + J.Esc(LastServer) + "\"");
sb.AppendLine("}");
J.Write(path, sb.ToString());
}
}
// ═════════════════════════════════════════════════════════════════════
// СЕТЕВЫЕ АДАПТЕРЫ
//
// bind_interface в sing-box хочет ИМЯ подключения из «Сетевых
// подключений», а не описание устройства. У Windows по умолчанию это
// «Wi-Fi» с дефисом, но адаптер часто переименован — из-за этого
// конфиг с зашитым "WiFi" на другом ПК молча не работал.
// ═════════════════════════════════════════════════════════════════════
public class AdapterInfo
{
public string Name = "";
public string Description = "";
public bool IsUp;
}
public static class Adapters
{
public static List<AdapterInfo> List()
{
List<AdapterInfo> list = new List<AdapterInfo>();
try
{
foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
// свой же TUN: привязываться к нему бессмысленно
if (ni.Name == "sing-box-tun") continue;
AdapterInfo a = new AdapterInfo();
a.Name = ni.Name;
a.Description = ni.Description;
a.IsUp = ni.OperationalStatus == OperationalStatus.Up;
list.Add(a);
}
}
catch { }
// сначала живые: обычно нужен именно тот, через который есть сеть
list.Sort(delegate(AdapterInfo x, AdapterInfo y)
{
if (x.IsUp != y.IsUp) return x.IsUp ? -1 : 1;
return string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
});
return list;
}
public static AdapterInfo Find(string name)
{
if (string.IsNullOrEmpty(name)) return null;
foreach (AdapterInfo a in List())
if (string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) return a;
return null;
}
}
// ═════════════════════════════════════════════════════════════════════
// СБОРКА КОНФИГА ИЗ ШАБЛОНА
//
// Числовые токены записаны в шаблоне в кавычках ("@@WG_PORT@@"), чтобы
// шаблон оставался валидным JSON и его можно было открыть в редакторе.
// При подстановке кавычки снимаются вместе с токеном.
// ═════════════════════════════════════════════════════════════════════
public class BuildResult
{
public bool Ok;
public string Error;
public string ConfigPath;
}
public static class ConfigBuilder
{
public const string ModeWg = "wg";
public const string ModeDirect = "direct";
public const string ModeWgOnly = "wgonly";
public static bool UsesWireguard(string mode)
{
return mode == ModeWg || mode == ModeWgOnly;
}
public static bool UsesProxy(string mode)
{
return mode == ModeWg || mode == ModeDirect;
}
public static string ModeTitle(string mode)
{
if (mode == ModeDirect) return "напрямую через адаптер";
if (mode == ModeWgOnly) return "только WireGuard";
return "через WireGuard";
}
// Файл templates\<mode>.json рядом с exe перекрывает встроенный —
// тот же приём, что с Ui.xaml: правка шаблона без пересборки.
public static string LoadTemplate(Paths paths, string mode)
{
string ext = Path.Combine(paths.TemplatesDir, mode + ".json");
if (File.Exists(ext)) return File.ReadAllText(ext, Encoding.UTF8);
using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(mode + ".json"))
{
if (s == null) throw new FileNotFoundException("нет шаблона " + mode + ".json (ни рядом с exe, ни внутри)");
using (StreamReader r = new StreamReader(s, Encoding.UTF8))
return r.ReadToEnd();
}
}
// WireGuard-ключ — 32 байта в base64. Проверка ловит обрезанную
// вставку из буфера, которую иначе увидел бы только sing-box.
public static bool IsWgKey(string s)
{
if (string.IsNullOrEmpty(s)) return false;
try { return Convert.FromBase64String(s.Trim()).Length == 32; }
catch { return false; }
}
public static bool IsCidr(string s)
{
if (string.IsNullOrEmpty(s)) return false;
string[] parts = s.Trim().Split('/');
if (parts.Length != 2) return false;
IPAddress ip;
if (!IPAddress.TryParse(parts[0], out ip)) return false;
int bits;
if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out bits)) return false;
int max = ip.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
return bits >= 0 && bits <= max;
}
public static List<string> Validate(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode)
{
List<string> bad = new List<string>();
if (UsesWireguard(mode))
{
if (!IsWgKey(lc.PrivateKey))
bad.Add("приватный ключ WireGuard пуст или не 32 байта base64");
if (!IsCidr(lc.LocalAddress))
bad.Add("локальный IP должен быть вида 10.10.0.19/24");
if (string.IsNullOrEmpty(pr.Wg.Server))
bad.Add("не задан адрес WireGuard-сервера в profiles.json");
if (!IsWgKey(pr.Wg.PeerPublicKey))
bad.Add("peer_public_key в profiles.json пуст или не 32 байта base64");
}
if (mode == ModeDirect)
{
if (string.IsNullOrEmpty(lc.Adapter))
bad.Add("не выбран сетевой адаптер");
else if (Adapters.Find(lc.Adapter) == null)
bad.Add("адаптера \"" + lc.Adapter + "\" нет в системе");
}
if (UsesProxy(mode))
{
if (srv == null) bad.Add("не выбран сервер");
else
{
if (string.IsNullOrEmpty(srv.Server)) bad.Add("у сервера не задан адрес");
if (srv.Port < 1 || srv.Port > 65535) bad.Add("у сервера некорректный порт");
if (srv.Outbound == null && string.IsNullOrEmpty(srv.Uuid))
bad.Add("у сервера не задан uuid");
}
}
if (!File.Exists(paths.RuleSet))
bad.Add("не найден " + paths.RuleSet + " (geoip-ru)");
if (!File.Exists(paths.SbExe))
bad.Add("не найден " + paths.SbExe);
return bad;
}
public static string Render(string tpl,
Dictionary<string, string> strings,
Dictionary<string, int> numbers)
{
// числовые — первыми: они забирают кавычки вокруг токена
foreach (KeyValuePair<string, int> kv in numbers)
tpl = tpl.Replace("\"@@" + kv.Key + "@@\"",
kv.Value.ToString(CultureInfo.InvariantCulture));
foreach (KeyValuePair<string, string> kv in strings)
tpl = tpl.Replace("@@" + kv.Key + "@@", J.Esc(kv.Value));
// Незакрытый токен — это опечатка в шаблоне. Молча отдать такой
// конфиг sing-box'у нельзя: ошибка будет невнятной.
Match m = Regex.Match(tpl, "@@[A-Za-z0-9_]+@@");
if (m.Success)
throw new InvalidOperationException("в шаблоне остался неподставленный токен " + m.Value);
return tpl;
}
public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode)
{
return Build(paths, pr, lc, srv, mode, null);
}
// outPath задаётся только в --selftest: там нужны все три режима
// рядом, а не один перезаписанный run\config.json.
public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv,
string mode, string outPath)
{
BuildResult r = new BuildResult();
try
{
List<string> bad = Validate(paths, pr, lc, srv, mode);
if (bad.Count > 0)
{
r.Error = string.Join("; ", bad.ToArray());
return r;
}
Dictionary<string, string> s = new Dictionary<string, string>();
Dictionary<string, int> n = new Dictionary<string, int>();
s["RULESET_PATH"] = paths.RuleSetJson;
if (UsesWireguard(mode))
{
s["WG_SERVER"] = pr.Wg.Server;
s["WG_LOCAL_ADDRESS"] = lc.LocalAddress.Trim();
s["WG_PRIVATE_KEY"] = lc.PrivateKey.Trim();
s["WG_PEER_PUBLIC_KEY"] = pr.Wg.PeerPublicKey.Trim();
n["WG_PORT"] = pr.Wg.Port;
n["WG_MTU"] = pr.Wg.Mtu;
}
if (mode == ModeDirect)
s["ADAPTER"] = lc.Adapter;
string tpl = LoadTemplate(paths, mode);
if (UsesProxy(mode))
{
const string marker = "\"@@PROXY_OUTBOUND@@\"";
if (!tpl.Contains(marker))
throw new InvalidOperationException("шаблон " + mode + ".json устарел: нет @@PROXY_OUTBOUND@@");
tpl = tpl.Replace(marker, J.ToJson(ProxyOutbound.Build(srv, mode)));
}
string body = Render(tpl, s, n);
string head =
"// СГЕНЕРИРОВАНО SbLauncher — правки здесь затрутся." + Environment.NewLine +
"// Режим: " + mode + " (" + ModeTitle(mode) + ")" +
(UsesProxy(mode) ? ", сервер: " + srv.Code + " " + srv.Endpoint : "") +
Environment.NewLine +
"// Правьте templates\\" + mode + ".json, profiles.json или local.json." +
Environment.NewLine;
string dest = string.IsNullOrEmpty(outPath) ? paths.RunConfig : outPath;
J.Write(dest, head + body);
r.Ok = true;
r.ConfigPath = dest;
return r;
}
catch (Exception ex)
{
r.Error = ex.Message;
return r;
}
}
}
// Старое имя остаётся для внешних сценариев и selftest.
public static class VlessLink
{
public static ServerCfg Parse(string link)
{
ServerCfg server;
string error;
return KeyImporter.TryParseOne(link, out server, out error) &&
server.Protocol == "vless" ? server : null;
}
}
// ═════════════════════════════════════════════════════════════════════
// ЗАПУСК sing-box + Job Object
//
// ВАЖНО: при auto_route/strict_route осиротевший sing-box оставляет
// систему со сломанным маршрутом по умолчанию. Job Object гарантирует,
// что дочерний процесс умрёт вместе с лаунчером — даже если тот убит
// через Диспетчер задач.
// ═════════════════════════════════════════════════════════════════════
public class SingBoxRunner
{
public event Action<string> Log;
public event Action<bool> StateChanged;
Process _proc;
IntPtr _job = IntPtr.Zero;
public bool IsRunning
{
get
{
Process p = _proc;
if (p == null) return false;
try { return !p.HasExited; }
catch (InvalidOperationException) { return false; }
}
}
static void ApplyEnv(ProcessStartInfo psi)
{
// те же переменные, что были в startN.bat: конфиги пользуются
// wireguard-outbound'ом, который в sing-box 1.12 объявлен
// устаревшим и без этого флага не поднимается
psi.EnvironmentVariables["ENABLE_DEPRECATED_WIREGUARD_OUTBOUND"] = "true";
psi.EnvironmentVariables["ENABLE_DEPRECATED_TUN_ADDRESS_X"] = "true";
psi.EnvironmentVariables["ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS"] = "true";
}
// sb.exe check — синхронно, до запуска. Ошибку конфига лучше увидеть
// в логе лаунчера, чем ловить её сломанными маршрутами.
public static bool Check(string sbExe, string cfgPath, string workDir, out string output)
{
output = "";
StringBuilder sb = new StringBuilder();
try
{
ProcessStartInfo psi = new ProcessStartInfo();
psi.FileName = sbExe;
psi.Arguments = "check -c \"" + cfgPath + "\"";
psi.WorkingDirectory = workDir;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.StandardOutputEncoding = Encoding.UTF8;
psi.StandardErrorEncoding = Encoding.UTF8;
ApplyEnv(psi);
using (Process p = new Process())
{
p.StartInfo = psi;
DataReceivedEventHandler grab = delegate(object s, DataReceivedEventArgs e)
{
if (e.Data != null) lock (sb) sb.AppendLine(e.Data);
};
p.OutputDataReceived += grab;
p.ErrorDataReceived += grab;
p.Start();
p.BeginOutputReadLine();
p.BeginErrorReadLine();
if (!p.WaitForExit(10000))
{
try { p.Kill(); } catch { }
output = "sb.exe check не ответил за 10 секунд";
return false;
}
p.WaitForExit(); // finish asynchronous stdout/stderr callbacks
lock (sb) output = sb.ToString().Trim();
return p.ExitCode == 0;
}
}
catch (Exception ex)
{
output = ex.Message;
return false;
}
}
public void Start(string sbExe, string cfgPath, string workDir)
{
if (IsRunning) return;
EnsureJob();
ProcessStartInfo psi = new ProcessStartInfo();
psi.FileName = sbExe;
psi.Arguments = "run -c \"" + cfgPath + "\"";
psi.WorkingDirectory = workDir;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.StandardOutputEncoding = Encoding.UTF8;
psi.StandardErrorEncoding = Encoding.UTF8;
ApplyEnv(psi);
Process p = new Process();
p.StartInfo = psi;
p.EnableRaisingEvents = true;
p.OutputDataReceived += OnOut;
p.ErrorDataReceived += OnOut;
p.Exited += delegate
{
int code;
string result = "?";
try { code = p.ExitCode; result = code.ToString(CultureInfo.InvariantCulture); }
catch { }
Emit("— sing-box завершился (код " + result + ")");
if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false);
};
Emit("> " + psi.FileName + " " + psi.Arguments);
try
{
p.Start();
if (!AssignProcessToJobObject(_job, p.Handle))
{
int error = Marshal.GetLastWin32Error();
try { p.Kill(); } catch { }
throw new System.ComponentModel.Win32Exception(error, "Не удалось привязать sing-box к Job Object");
}
_proc = p;
p.BeginOutputReadLine();
p.BeginErrorReadLine();
if (StateChanged != null) StateChanged(true);
}
catch
{
if (object.ReferenceEquals(_proc, p)) _proc = null;
try { if (!p.HasExited) p.Kill(); } catch { }
p.Dispose();
throw;
}
}
public void Stop()
{
Process p = _proc;
if (p == null) return;
bool running;
try { running = !p.HasExited; }
catch { return; }
if (!running) return;
Emit("— останавливаю sing-box…");
try
{
p.CloseMainWindow();
if (!p.WaitForExit(2500)) p.Kill();
p.WaitForExit(3000);
}
catch (Exception ex) { Emit("! " + ex.Message); }
if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false);
}
void OnOut(object s, DataReceivedEventArgs e)
{
if (e.Data != null) Emit(e.Data);
}
void Emit(string s)
{
if (Log != null) Log(s);
}
// ── Job Object ───────────────────────────────────────────────────
void EnsureJob()
{
if (_job != IntPtr.Zero) return;
_job = CreateJobObject(IntPtr.Zero, null);
if (_job == IntPtr.Zero)
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Не удалось создать Job Object");
JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
IntPtr ptr = Marshal.AllocHGlobal(len);
Marshal.StructureToPtr(info, ptr, false);
try
{
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
{
int error = Marshal.GetLastWin32Error();
CloseHandle(_job);
_job = IntPtr.Zero;
throw new System.ComponentModel.Win32Exception(error, "Не удалось настроить Job Object");
}
}
finally { Marshal.FreeHGlobal(ptr); }
}
const int JobObjectExtendedLimitInformation = 9;
const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr a, string lpName);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr info, uint cb);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr handle);
[StructLayout(LayoutKind.Sequential)]
struct IO_COUNTERS
{
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
}
}
// ═════════════════════════════════════════════════════════════════════
// ПИНГ
//
// ICMP не годится: сервер 132.243.202.111 на echo-request не отвечает
// (проверено). Меряем время TCP-коннекта до server:port — это и ближе
// к реальности, и работает всегда, пока порт открыт.
// ═════════════════════════════════════════════════════════════════════
public static class Pinger
{
public static async Task<int> TcpLatencyAsync(string host, int port, int timeoutMs)
{
if (string.IsNullOrEmpty(host)) return -1;
Stopwatch sw = Stopwatch.StartNew();
TcpClient c = new TcpClient();
try
{
Task connect = c.ConnectAsync(host, port);
Task done = await Task.WhenAny(connect, Task.Delay(timeoutMs));
if (done != connect || !c.Connected) return -1;
sw.Stop();
return (int)sw.ElapsedMilliseconds;
}
catch { return -1; }
finally { try { c.Close(); } catch { } }
}
// Внешний IP — уходит через TUN, то есть показывает реальную точку выхода
public static async Task<string> ExitIpAsync()
{
try
{
using (WebClient w = new WebClient())
{
w.Headers.Add("User-Agent", "sb-launcher");
Task<string> fetch = w.DownloadStringTaskAsync("https://api4.ipify.org");
if (await Task.WhenAny(fetch, Task.Delay(5000)) != fetch)
{
w.CancelAsync();
return null;
}
return (await fetch).Trim();
}
}
catch { return null; }
}
}
// ═════════════════════════════════════════════════════════════════════
// ИНФРАСТРУКТУРА ПРИВЯЗОК
// ═════════════════════════════════════════════════════════════════════
}
+937
View File
@@ -0,0 +1,937 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="sb launcher · glass preview" Width="1120" Height="790"
WindowStyle="None" AllowsTransparency="False" Background="{DynamicResource Bg}"
ResizeMode="NoResize" WindowStartupLocation="CenterScreen"
TextOptions.TextFormattingMode="Display"
FontFamily="Segoe UI" UseLayoutRounding="True" SnapsToDevicePixels="True">
<!--
ТЕМЫ.
Ниже два словаря — Theme.Dark и Theme.Light. Приложение при старте копирует
выбранный в Application.Resources, а весь интерфейс ссылается на токены
через {DynamicResource}. Поэтому смена темы происходит вживую.
ВАЖНО: именно DynamicResource, а не StaticResource — последний берёт
значение один раз при разборе XAML и на подмену палитры не реагирует.
Чтобы добавить цвет: заведите ключ в ОБОИХ словарях и ссылайтесь на него.
-->
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary x:Key="Theme.Dark">
<LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#101B36" Offset="0"/>
<GradientStop Color="#1C315C" Offset="0.54"/>
<GradientStop Color="#323B72" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="Panel" Color="#B51D2D50"/>
<SolidColorBrush x:Key="Card" Color="#8D38527E"/>
<SolidColorBrush x:Key="CardHover" Color="#B24A6490"/>
<SolidColorBrush x:Key="CardSel" Color="#BE5274AA"/>
<SolidColorBrush x:Key="Stroke" Color="#77C4DAFF"/>
<SolidColorBrush x:Key="GlassEdge" Color="#A6E6F3FF"/>
<SolidColorBrush x:Key="Text" Color="#F5F9FF"/>
<SolidColorBrush x:Key="Muted" Color="#B9CBE6"/>
<SolidColorBrush x:Key="Accent" Color="#6B9FFF"/>
<SolidColorBrush x:Key="AccentHover" Color="#8AB6FF"/>
<SolidColorBrush x:Key="AccentDim" Color="#416DC5"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
<SolidColorBrush x:Key="SoftBg" Color="#703F5B83"/>
<SolidColorBrush x:Key="SoftHover" Color="#A452709B"/>
<SolidColorBrush x:Key="SwitchTrack" Color="#66869CC1"/>
<SolidColorBrush x:Key="SwitchThumb" Color="#F0F7FF"/>
<SolidColorBrush x:Key="BadgeBg" Color="#82536F9A"/>
<SolidColorBrush x:Key="PingBg" Color="#7B21375E"/>
<SolidColorBrush x:Key="LogBg" Color="#A0182B4F"/>
<SolidColorBrush x:Key="LogText" Color="#D7E5F8"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#3223375A"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#9DAFC9EA"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#D5DDECFF"/>
<SolidColorBrush x:Key="HeroRing" Color="#B2BBDDFF"/>
<SolidColorBrush x:Key="HeroRingDim" Color="#55A6C8FF"/>
<RadialGradientBrush x:Key="GlowA">
<GradientStop Color="#98759CFF" Offset="0"/>
<GradientStop Color="#00759CFF" Offset="1"/>
</RadialGradientBrush>
<RadialGradientBrush x:Key="GlowB">
<GradientStop Color="#6652C8DC" Offset="0"/>
<GradientStop Color="#0052C8DC" Offset="1"/>
</RadialGradientBrush>
<!-- смысловые цвета: их же читает код для статуса и пинга -->
<SolidColorBrush x:Key="Ok" Color="#34D399"/>
<SolidColorBrush x:Key="Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Bad" Color="#F87171"/>
<SolidColorBrush x:Key="Idle" Color="#8A94A6"/>
<SolidColorBrush x:Key="Na" Color="#6B7280"/>
<sys:Double x:Key="ShadowOpacity">0.55</sys:Double>
</ResourceDictionary>
<ResourceDictionary x:Key="Theme.Light">
<LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#DDEEFF" Offset="0"/>
<GradientStop Color="#BCD6FB" Offset="0.54"/>
<GradientStop Color="#E2E7FA" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="Panel" Color="#B9F8FBFF"/>
<SolidColorBrush x:Key="Card" Color="#9CFFFFFF"/>
<SolidColorBrush x:Key="CardHover" Color="#D8FFFFFF"/>
<SolidColorBrush x:Key="CardSel" Color="#D7DFEDFF"/>
<SolidColorBrush x:Key="Stroke" Color="#A7FFFFFF"/>
<SolidColorBrush x:Key="GlassEdge" Color="#EFFFFFFF"/>
<SolidColorBrush x:Key="Text" Color="#173052"/>
<SolidColorBrush x:Key="Muted" Color="#577190"/>
<SolidColorBrush x:Key="Accent" Color="#347CF2"/>
<SolidColorBrush x:Key="AccentHover" Color="#5291F8"/>
<SolidColorBrush x:Key="AccentDim" Color="#2466CB"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
<SolidColorBrush x:Key="SoftBg" Color="#AFFFFFFF"/>
<SolidColorBrush x:Key="SoftHover" Color="#E5FFFFFF"/>
<SolidColorBrush x:Key="SwitchTrack" Color="#8CA7BEDD"/>
<SolidColorBrush x:Key="SwitchThumb" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BadgeBg" Color="#BDDFEAFA"/>
<SolidColorBrush x:Key="PingBg" Color="#9AFFFFFF"/>
<SolidColorBrush x:Key="LogBg" Color="#B6F5F9FF"/>
<SolidColorBrush x:Key="LogText" Color="#365476"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#34FFFFFF"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#8D8FA6C5"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#B37490B5"/>
<SolidColorBrush x:Key="HeroRing" Color="#C84483EF"/>
<SolidColorBrush x:Key="HeroRingDim" Color="#665896E7"/>
<RadialGradientBrush x:Key="GlowA">
<GradientStop Color="#AA8EC2FF" Offset="0"/>
<GradientStop Color="#008EC2FF" Offset="1"/>
</RadialGradientBrush>
<RadialGradientBrush x:Key="GlowB">
<GradientStop Color="#99B2A5FF" Offset="0"/>
<GradientStop Color="#00B2A5FF" Offset="1"/>
</RadialGradientBrush>
<SolidColorBrush x:Key="Ok" Color="#0E9F6E"/>
<SolidColorBrush x:Key="Warn" Color="#B4790B"/>
<SolidColorBrush x:Key="Bad" Color="#DC2626"/>
<SolidColorBrush x:Key="Idle" Color="#7A8699"/>
<SolidColorBrush x:Key="Na" Color="#9AA3B2"/>
<sys:Double x:Key="ShadowOpacity">0.16</sys:Double>
</ResourceDictionary>
<!-- Одинаковая прокрутка во всех ScrollViewer/ListBox/TextBox. -->
<Style x:Key="ScrollPageButton" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollGrip" TargetType="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Grip" Margin="2,1" CornerRadius="4"
Background="{DynamicResource ScrollThumb}"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Grip" Property="Background" Value="{DynamicResource ScrollThumbHover}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Grip" Property="Background" Value="{DynamicResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="VerticalScroll" TargetType="ScrollBar">
<Border Background="{DynamicResource ScrollTrack}" Width="10">
<Track x:Name="PART_Track" IsDirectionReversed="True"
Orientation="Vertical" ViewportSize="{TemplateBinding ViewportSize}"
Minimum="{TemplateBinding Minimum}" Maximum="{TemplateBinding Maximum}"
Value="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageUpCommand"/>
</Track.DecreaseRepeatButton>
<Track.Thumb><Thumb Style="{StaticResource ScrollGrip}"/></Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageDownCommand"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
<ControlTemplate x:Key="HorizontalScroll" TargetType="ScrollBar">
<Border Background="{DynamicResource ScrollTrack}" Height="10">
<Track x:Name="PART_Track" Orientation="Horizontal"
ViewportSize="{TemplateBinding ViewportSize}"
Minimum="{TemplateBinding Minimum}" Maximum="{TemplateBinding Maximum}"
Value="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageLeftCommand"/>
</Track.DecreaseRepeatButton>
<Track.Thumb><Thumb Style="{StaticResource ScrollGrip}"/></Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageRightCommand"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="10"/>
<Setter Property="Template" Value="{StaticResource VerticalScroll}"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="10"/>
<Setter Property="Template" Value="{StaticResource HorizontalScroll}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ─── кнопка-таблетка (основное действие) ────────────────────────── -->
<Style x:Key="Pill" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="24" Background="{DynamicResource Accent}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1"
Padding="30,13">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource AccentHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource AccentDim}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── вторичная кнопка ───────────────────────────────────────────── -->
<Style x:Key="Soft" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="12" Background="{DynamicResource SoftBg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="14,8">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource SoftHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── кнопка опасного действия (удаление) ────────────────────────── -->
<Style x:Key="SoftDanger" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="12" Background="{DynamicResource SoftBg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="14,8">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource SoftHover}"/>
<Setter TargetName="B" Property="BorderBrush" Value="{DynamicResource Bad}"/>
<Setter Property="Foreground" Value="{DynamicResource Bad}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── контекстное меню ───────────────────────────────────────────── -->
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border CornerRadius="8" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="4">
<Border.Effect>
<DropShadowEffect BlurRadius="14" ShadowDepth="3" Direction="270"
Opacity="{DynamicResource ShadowOpacity}" Color="Black"/>
</Border.Effect>
<StackPanel IsItemsHost="True"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="B" CornerRadius="6" Background="Transparent" Padding="12,7">
<ContentPresenter ContentSource="Header" HorizontalAlignment="Left" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource SoftHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── кнопки заголовка ───────────────────────────────────────────── -->
<Style x:Key="Chrome" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Width" Value="40"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="B" CornerRadius="7" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource SoftHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── переключатель ──────────────────────────────────────────────── -->
<Style x:Key="Switch" TargetType="ToggleButton">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid Width="46" Height="26">
<Border x:Name="Track" CornerRadius="13" Background="{DynamicResource SwitchTrack}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1"/>
<Ellipse x:Name="Thumb" Width="18" Height="18" Fill="{DynamicResource SwitchThumb}"
HorizontalAlignment="Left" Margin="4,0,0,0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="{DynamicResource Accent}"/>
<Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter TargetName="Thumb" Property="Fill" Value="{DynamicResource OnAccent}"/>
<Setter TargetName="Thumb" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="Thumb" Property="Margin" Value="0,0,4,0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── карточка сервера ───────────────────────────────────────────── -->
<DataTemplate x:Key="ServerCard">
<Border x:Name="Card" CornerRadius="16" Background="{DynamicResource Card}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="0,0,0,10">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Удалить сервер" Command="{Binding DeleteCommand}"/>
</ContextMenu>
</Border.ContextMenu>
<Button Command="{Binding SelectCommand}" CommandParameter="{Binding}"
Cursor="Hand" HorizontalContentAlignment="Stretch">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="14,14">
<ContentPresenter/>
</Border>
</ControlTemplate>
</Button.Template>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="42"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- флаг страны; для незнакомого кода — текстовый бейдж -->
<Grid Grid.Column="0" Width="34" HorizontalAlignment="Left">
<Image Source="{Binding Flag}" Width="34" Height="23" Stretch="Fill"
RenderOptions.BitmapScalingMode="HighQuality"/>
<Border x:Name="TextBadge" Width="34" Height="34" CornerRadius="9"
Background="{DynamicResource BadgeBg}" Visibility="Collapsed">
<TextBlock Text="{Binding Badge}" Foreground="{DynamicResource Text}"
FontSize="12" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="10,0,0,0">
<TextBlock Text="{Binding Title}" Foreground="{DynamicResource Text}"
FontSize="13.5" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" Foreground="{DynamicResource Muted}"
FontSize="11" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Border Grid.Column="2" CornerRadius="6" Padding="7,3"
Background="{DynamicResource PingBg}" VerticalAlignment="Center">
<TextBlock Text="{Binding PingText}" Foreground="{Binding PingBrush}"
FontSize="11" FontWeight="SemiBold"/>
</Border>
</Grid>
</Button>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Flag}" Value="{x:Null}">
<Setter TargetName="TextBadge" Property="Visibility" Value="Visible"/>
</DataTrigger>
<Trigger SourceName="Card" Property="IsMouseOver" Value="True">
<Setter TargetName="Card" Property="Background" Value="{DynamicResource CardHover}"/>
</Trigger>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter TargetName="Card" Property="Background" Value="{DynamicResource CardSel}"/>
<Setter TargetName="Card" Property="BorderBrush" Value="{DynamicResource Accent}"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- ─── поле ввода ─────────────────────────────────────────────────
Consolas: значения здесь — base64-ключ и IP-адрес, в
моноширинном их глазом проверять заметно легче. -->
<Style x:Key="Field" TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Text}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource Accent}"/>
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="B" CornerRadius="8" Background="{DynamicResource SoftBg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
Padding="10,7">
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Stretch"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource SoftHover}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="B" Property="BorderBrush" Value="{DynamicResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── элемент выпадающего списка ─────────────────────────────── -->
<Style x:Key="ComboItem" TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="B" Background="Transparent" Padding="10,7" CornerRadius="6">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource CardHover}"/>
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource CardSel}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── редактируемый выпадающий список ────────────────────────────
Шаблон целиком, а не только цвета: штатный ComboBox рисуется
темой Windows и в тёмной палитре выглядит белым пятном.
Обязательные части — PART_EditableTextBox и PART_Popup.
Редактируемый он намеренно: если адаптера нет в списке
(отключён, виртуальный), имя можно вписать руками. -->
<Style x:Key="FieldCombo" TargetType="ComboBox">
<Setter Property="IsEditable" Value="True"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource ComboItem}"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="B" CornerRadius="8" Background="{DynamicResource SoftBg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1"/>
<Grid Margin="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="PART_EditableTextBox" Grid.Column="0"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource Text}"
CaretBrush="{DynamicResource Text}"
SelectionBrush="{DynamicResource Accent}"
Padding="9,6" VerticalContentAlignment="Center"/>
<ToggleButton Grid.Column="1" Focusable="False" ClickMode="Press" Cursor="Hand"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent">
<Path Data="M 0,0 L 8,0 L 4,5 Z" Fill="{DynamicResource Muted}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid>
<Popup x:Name="PART_Popup" Placement="Bottom" AllowsTransparency="True"
IsOpen="{TemplateBinding IsDropDownOpen}" Focusable="False"
PopupAnimation="Fade">
<Border Background="{DynamicResource Panel}" CornerRadius="8"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
Margin="0,4,0,0" Padding="4"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}">
<ScrollViewer MaxHeight="220" VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="B" Property="BorderBrush" Value="{DynamicResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── переключатель вкладок «лог / настройки» ─────────────────
Цвет активной вкладки приходит из ViewModel ключом палитры,
поэтому переживает смену темы. -->
<Style x:Key="Tab" TargetType="Button">
<Setter Property="FontSize" Value="10.5"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="0,2,20,8">
<ContentPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── подпись поля ───────────────────────────────────────────── -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="FontSize" Value="12"/>
<!-- Top, а не Center: под полем идёт подсказка, и по центру
всей колонки подпись уезжала бы ниже самого поля. -->
<Setter Property="VerticalAlignment" Value="Top"/>
<Setter Property="Margin" Value="0,9,12,0"/>
</Style>
<!-- ─── заголовок группы ───────────────────────────────────────── -->
<Style x:Key="GroupLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="FontSize" Value="10.5"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<!-- ─── подсказка под полем ────────────────────────────────────── -->
<Style x:Key="Hint" TargetType="TextBlock">
<Setter Property="FontSize" Value="10.5"/>
<Setter Property="Margin" Value="0,4,0,0"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</ResourceDictionary>
</Window.Resources>
<!-- ══════════════════════════════════════════════════════════════════ -->
<Border Background="{DynamicResource Bg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1">
<Grid ClipToBounds="True">
<Grid.RowDefinitions>
<RowDefinition Height="62"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Цветовые пятна статичны: плавный «стеклянный» вид без дорогого blur. -->
<Canvas Grid.RowSpan="2" IsHitTestVisible="False" ClipToBounds="True">
<Ellipse Width="720" Height="720" Fill="{DynamicResource GlowA}"
Canvas.Left="-240" Canvas.Top="-320"/>
<Ellipse Width="740" Height="740" Fill="{DynamicResource GlowB}"
Canvas.Left="570" Canvas.Top="300"/>
</Canvas>
<!-- ─── заголовок ─────────────────────────────────────────────── -->
<Grid x:Name="TitleBar" Grid.Row="0" Background="Transparent">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="23,0,0,0">
<Image x:Name="LogoImg" Width="31" Height="31" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="sb launcher" Foreground="{DynamicResource Text}"
FontSize="15" FontWeight="SemiBold" Margin="11,0,0,0"
VerticalAlignment="Center"/>
<Border Background="{DynamicResource SoftBg}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" CornerRadius="10" Margin="12,0,0,0" Padding="8,3">
<TextBlock Text="glass preview" Foreground="{DynamicResource Muted}"
FontSize="10" VerticalAlignment="Center"/>
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="0,0,12,0">
<Button x:Name="BtnTheme" Style="{StaticResource Chrome}"
FontFamily="Segoe MDL2 Assets" FontSize="14"
Content="{Binding ThemeGlyph}" ToolTip="Тема: светлая / тёмная"/>
<Button x:Name="BtnMin" Style="{StaticResource Chrome}" Content="&#x2013;"/>
<Button x:Name="BtnClose" Style="{StaticResource Chrome}" Content="&#x2715;"/>
</StackPanel>
</Grid>
<!-- ─── тело ──────────────────────────────────────────────────── -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="335"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- левая колонка: серверы -->
<Border Grid.Column="0" Background="{DynamicResource Panel}" CornerRadius="22"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1"
Margin="16,6,0,16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="20,20,16,16">
<TextBlock Text="СЕРВЕРЫ" Foreground="{DynamicResource Muted}"
FontSize="11" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Style="{StaticResource Soft}" Content="пинг"
Command="{Binding PingAllCommand}" HorizontalAlignment="Right"
Padding="10,4"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Servers}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
ItemTemplate="{StaticResource ServerCard}"
Background="Transparent" BorderThickness="0"
Margin="12,0,12,14" Padding="0,0,4,0"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.CanContentScroll="True"
VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Recycling">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter ContentSource="Content"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Grid>
</Border>
<!-- правая колонка -->
<Grid Grid.Column="1" Margin="18,6,18,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- статус -->
<RowDefinition Height="Auto"/> <!-- режим -->
<RowDefinition Height="Auto"/> <!-- вкладки -->
<RowDefinition Height="*"/> <!-- лог/настройки-->
</Grid.RowDefinitions>
<!-- статус -->
<Border Grid.Row="0" CornerRadius="24" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="23,22">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="78"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="66" Height="66" VerticalAlignment="Center">
<Ellipse Fill="{DynamicResource SoftBg}" Stroke="{DynamicResource GlassEdge}"
StrokeThickness="1"/>
<Ellipse Width="62" Height="62" Stroke="{DynamicResource HeroRingDim}"
StrokeThickness="2" Margin="2"/>
<!-- После удачного подключения — один спокойный световой импульс. -->
<Ellipse Width="56" Height="56" Fill="{DynamicResource Ok}"
RenderTransformOrigin="0.5,0.5" IsHitTestVisible="False">
<Ellipse.RenderTransform><ScaleTransform/></Ellipse.RenderTransform>
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Opacity" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ConnectionPhase}" Value="connected">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="0.36" To="0" Duration="0:0:0.65"/>
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
From="0.8" To="1.2" Duration="0:0:0.65"/>
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
From="0.8" To="1.2" Duration="0:0:0.65"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Ellipse Width="62" Height="62" Stroke="{DynamicResource HeroRing}"
StrokeThickness="3" StrokeDashArray="2 5" Margin="2"
RenderTransformOrigin="0.5,0.5">
<Ellipse.RenderTransform><RotateTransform/></Ellipse.RenderTransform>
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Opacity" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ConnectionPhase}" Value="connecting">
<Setter Property="Opacity" Value="1"/>
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="ConnectOrbit">
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
From="0" To="360" Duration="0:0:2"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions><StopStoryboard BeginStoryboardName="ConnectOrbit"/></DataTrigger.ExitActions>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionPhase}" Value="disconnecting">
<Setter Property="Opacity" Value="1"/>
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="DisconnectOrbit">
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
From="360" To="0" Duration="0:0:1.4"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions><StopStoryboard BeginStoryboardName="DisconnectOrbit"/></DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Image x:Name="StatusLogoImg" Width="38" Height="38"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,10,0">
<TextBlock Text="СОЕДИНЕНИЕ" Foreground="{DynamicResource Muted}"
FontSize="10" FontWeight="SemiBold" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding StatusBrush}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusTitle}" Foreground="{DynamicResource Text}"
FontSize="23" FontWeight="Light" Margin="9,0,0,0"/>
</StackPanel>
<TextBlock Text="{Binding StatusSub}" Foreground="{DynamicResource Muted}"
FontSize="11.5" Margin="0,5,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Style="{StaticResource Pill}" Content="{Binding ConnectText}"
Command="{Binding ConnectCommand}"
Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- режим: через WireGuard или напрямую через адаптер -->
<Border Grid.Row="1" CornerRadius="21" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1"
Padding="24,18" Margin="0,13,0,0">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="WireGuard для .ru-трафика" Foreground="{DynamicResource Text}"
FontSize="13" FontWeight="SemiBold"/>
<TextBlock Foreground="{DynamicResource Muted}" FontSize="11" Margin="0,3,0,0"
Text="вкл — VLESS поверх WireGuard · выкл — напрямую через сетевой адаптер"/>
</StackPanel>
<ToggleButton Style="{StaticResource Switch}"
IsChecked="{Binding WireguardOn, Mode=TwoWay}"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- вкладки -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="6,16,0,0">
<Button x:Name="BtnTabLog" Style="{StaticResource Tab}" Content="ЛОГ"
Foreground="{Binding TabLogBrush}"/>
<Button x:Name="BtnTabSettings" Style="{StaticResource Tab}" Content="НАСТРОЙКИ"
Foreground="{Binding TabSettingsBrush}"/>
</StackPanel>
<!-- содержимое вкладки: два слоя, видим всегда ровно один -->
<Grid Grid.Row="3">
<!-- ── лог ─────────────────────────────────────────────── -->
<Border Visibility="{Binding LogVis}"
CornerRadius="21" Background="{DynamicResource LogBg}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="4">
<TextBox x:Name="LogBox" Text="{Binding LogText, Mode=OneWay}"
IsReadOnly="True" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource LogText}" FontFamily="Consolas" FontSize="11.5"
Padding="14,10" TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"/>
</Border>
<!-- ── настройки ───────────────────────────────────────── -->
<Border Visibility="{Binding SettingsVis}"
CornerRadius="21" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="22,18">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel>
<TextBlock Style="{StaticResource GroupLabel}" Text="WIREGUARD"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="128"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Style="{StaticResource FieldLabel}"
Text="Приватный ключ"/>
<StackPanel Grid.Row="0" Grid.Column="1">
<TextBox Style="{StaticResource Field}"
Text="{Binding PrivateKey, Mode=TwoWay}"/>
<TextBlock Style="{StaticResource Hint}" Text="{Binding PrivateKeyHint}"
Foreground="{Binding PrivateKeyHintBrush}"/>
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" Style="{StaticResource FieldLabel}"
Text="Локальный IP" Margin="0,21,12,0"/>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="0,12,0,0">
<TextBox Style="{StaticResource Field}"
Text="{Binding LocalAddress, Mode=TwoWay}"/>
<TextBlock Style="{StaticResource Hint}" Text="{Binding LocalAddressHint}"
Foreground="{Binding LocalAddressHintBrush}"/>
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource GroupLabel}" Text="СЕТЕВОЙ АДАПТЕР"
Margin="0,20,0,10"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="128"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Style="{StaticResource FieldLabel}"
Text="Имя адаптера"/>
<StackPanel Grid.Column="1">
<ComboBox x:Name="AdapterBox" Style="{StaticResource FieldCombo}"
ItemsSource="{Binding AdapterNames}"
Text="{Binding Adapter, Mode=TwoWay}"/>
<TextBlock Style="{StaticResource Hint}" Text="{Binding AdapterHint}"
Foreground="{Binding AdapterHintBrush}"/>
<TextBlock Style="{StaticResource Hint}" Foreground="{DynamicResource Muted}"
Text="нужен, только когда WireGuard выключен"/>
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource GroupLabel}" Text="ДОБАВИТЬ СЕРВЕР"
Margin="0,20,0,10"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="LinkBox" Grid.Column="0" Style="{StaticResource Field}"
Height="88" AcceptsReturn="True" TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto" MaxLength="2000000"/>
<Button x:Name="BtnAddLink" Grid.Column="1" Style="{StaticResource Soft}"
Content="импортировать" Margin="8,0,0,0" VerticalAlignment="Top"/>
</Grid>
<TextBlock Style="{StaticResource Hint}" Foreground="{DynamicResource Muted}"
Text="Ссылки, текст/base64, URL подписки или sing-box JSON"/>
<TextBlock Style="{StaticResource Hint}" Foreground="{DynamicResource Muted}"
Text="{Binding ImportStatus}" TextWrapping="Wrap"/>
<WrapPanel Margin="0,20,0,0">
<Button Style="{StaticResource Soft}" Content="проверить конфиг"
Command="{Binding CheckConfigCommand}" Margin="0,0,8,8"/>
<Button Style="{StaticResource Soft}" Content="показать конфиг"
Command="{Binding OpenConfigCommand}" Margin="0,0,8,8"/>
<Button Style="{StaticResource Soft}" Content="папка"
Command="{Binding OpenFolderCommand}" Margin="0,0,8,8"/>
<Button Style="{StaticResource Soft}" Content="убить sb.exe"
Command="{Binding KillStrayCommand}" Margin="0,0,8,8"
ToolTip="Прибить зависшие процессы sb.exe, оставшиеся от прошлых запусков"/>
<Button Style="{StaticResource SoftDanger}" Content="удалить сервер"
Command="{Binding DeleteSelectedCommand}" Margin="0,0,8,8"
ToolTip="Удалить выбранный сервер из profiles.json"/>
</WrapPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Grid>
</Grid>
</Grid>
</Border>
</Window>
+1057
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.1.0.0" name="SbLauncher"/>
<!-- TUN + auto_route требуют администратора.
Цена: UAC при каждом запуске и автозапуск только через Планировщик
задач с галкой «Наивысшие права». -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<!-- без этого на HiDPI интерфейс будет мыльным -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
+134
View File
@@ -0,0 +1,134 @@
@echo off
rem ---------------------------------------------------------------------
rem Build SbLauncher with the C# compiler that ships inside Windows.
rem No .NET SDK, no Visual Studio, no internet required.
rem
rem build.cmd normal build (exe asks for admin)
rem build.cmd deploy <target> build, then copy exe/Ui/templates
rem build.cmd selftest [root] build without manifest + run --selftest
rem build.cmd staged build SbLauncher.updated.exe while app is running
rem build.cmd icon <source.png> regenerate app.ico and logo.png
rem
rem NOTE: this file is intentionally ASCII-only and CRLF. cmd.exe reads
rem .bat in the OEM codepage and breaks "goto" on LF line endings.
rem ---------------------------------------------------------------------
setlocal
cd /d "%~dp0"
set "FW=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319"
set "CSC=%FW%\csc.exe"
if not exist "%CSC%" (
echo [!] csc.exe not found at %CSC%
echo .NET Framework 4.x is required - it ships with Windows.
exit /b 1
)
set "WPF=/reference:"%FW%\WPF\PresentationFramework.dll" /reference:"%FW%\WPF\PresentationCore.dll" /reference:"%FW%\WPF\WindowsBase.dll" /reference:"%FW%\System.Xaml.dll" /reference:"%FW%\System.dll" /reference:"%FW%\System.Core.dll""
set "WINFORMS=/reference:"%FW%\System.Windows.Forms.dll" /reference:"%FW%\System.Drawing.dll""
rem System.Web.Extensions = JavaScriptSerializer, reads profiles/local json
set "JSON=/reference:"%FW%\System.Web.Extensions.dll""
set "REFS=%WPF% %WINFORMS% %JSON%"
rem Ui.xaml and the three config templates are embedded, so a bare exe
rem works on its own; a file of the same name next to the exe wins.
set "RES=/resource:Ui.xaml,Ui.xaml /resource:app.ico,app.ico /resource:logo.png,logo.png /resource:templates\wg.json,wg.json /resource:templates\direct.json,direct.json /resource:templates\wgonly.json,wgonly.json"
set "DODEPLOY="
set "APP_OUT=SbLauncher.exe"
if /i "%~1"=="icon" goto icon
if /i "%~1"=="selftest" goto selftest
if /i "%~1"=="stress" goto stress
if /i "%~1"=="profiletest" goto profiletest
if /i "%~1"=="importtest" goto importtest
if /i "%~1"=="staged" set "APP_OUT=SbLauncher.updated.exe"
if /i "%~1"=="deploy" set "DODEPLOY=1"
if not exist app.ico (
echo [!] app.ico missing - run: build.cmd icon "path\to\logo.png"
exit /b 1
)
echo [*] Building %APP_OUT% ...
"%CSC%" /nologo /target:winexe /platform:x64 /optimize+ /out:%APP_OUT% /win32manifest:app.manifest /win32icon:app.ico %RES% %REFS% SbLauncher.cs ViewModels.cs App.cs Import.cs
if errorlevel 1 goto failed
echo [+] Done: %CD%\%APP_OUT%
if defined DODEPLOY goto docopy
echo.
echo Run SbLauncher.exe here, or deploy to an explicit test target.
goto end
:docopy
set "DST=%~2"
if "%DST%"=="" (
echo [!] Specify a test target: build.cmd deploy "path"
exit /b 1
)
if not exist "%DST%" (
echo [!] target not found: %DST%
exit /b 1
)
echo [*] Deploying to %DST%
copy /y SbLauncher.exe "%DST%\" >nul
copy /y Ui.xaml "%DST%\" >nul
copy /y profiles.json "%DST%\" >nul
if not exist "%DST%\templates" mkdir "%DST%\templates"
copy /y templates\*.json "%DST%\templates\" >nul
rem local.json is deliberately NOT overwritten: it holds this machine's
rem private key and adapter name.
if not exist "%DST%\local.json" (
echo [i] %DST%\local.json missing - seeding it from local.example.json
copy /y local.example.json "%DST%\local.json" >nul
)
echo [+] Deployed. bin\sb.exe and doc\doc.srs must already be there.
goto end
:icon
if "%~2"=="" ( echo [!] usage: build.cmd icon "path\to\logo.png" & exit /b 1 )
echo [*] Building MakeIcon.exe ...
"%CSC%" /nologo /target:exe /platform:x64 /out:tools\MakeIcon.exe %WPF% tools\MakeIcon.cs
if errorlevel 1 goto failed
"%CD%\tools\MakeIcon.exe" "%~2" app.ico logo.png
if errorlevel 1 goto failed
goto end
:selftest
echo [*] Test build without manifest (so it does not trigger UAC) ...
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% SbLauncher.cs ViewModels.cs App.cs Import.cs
if errorlevel 1 goto failed
echo [+] Built. Running --selftest
echo.
if "%~2"=="" ("%CD%\SbLauncher.selftest.exe" --selftest) else ("%CD%\SbLauncher.selftest.exe" --selftest --root "%~2")
goto end
:stress
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% SbLauncher.cs ViewModels.cs App.cs Import.cs
if errorlevel 1 goto failed
"%CSC%" /nologo /target:exe /platform:x64 /out:UiStress.exe /reference:SbLauncher.selftest.exe %REFS% tests\UiStress.cs
if errorlevel 1 goto failed
"%CD%\UiStress.exe"
if errorlevel 1 goto failed
goto end
:profiletest
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% SbLauncher.cs ViewModels.cs App.cs Import.cs
if errorlevel 1 goto failed
"%CSC%" /nologo /target:exe /platform:x64 /out:ProfileSmoke.exe /reference:SbLauncher.selftest.exe %REFS% tests\ProfileSmoke.cs
if errorlevel 1 goto failed
"%CD%\ProfileSmoke.exe"
if errorlevel 1 goto failed
goto end
:importtest
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% SbLauncher.cs ViewModels.cs App.cs Import.cs
if errorlevel 1 goto failed
"%CSC%" /nologo /target:exe /platform:x64 /out:ImportSmoke.exe /reference:SbLauncher.selftest.exe %REFS% tests\ImportSmoke.cs
if errorlevel 1 goto failed
"%CD%\ImportSmoke.exe"
if errorlevel 1 goto failed
goto end
:failed
echo [!] Build failed
exit /b 1
:end
endlocal
+20
View File
@@ -0,0 +1,20 @@
// ─────────────────────────────────────────────────────────────────────
// Параметры ЭТОЙ машины. Скопируйте в local.json — он в .gitignore.
// Всё это правится в GUI, руками лезть сюда не нужно.
// Бывший params.txt + бывший %APPDATA%\SbLauncher\settings.ini.
// ─────────────────────────────────────────────────────────────────────
{
// WireGuard: приватный ключ и адрес внутри туннеля
"private_key": "",
"local_address": "",
// Имя сетевого адаптера для режима «direct».
// У Windows по умолчанию «Wi-Fi» (с дефисом) или «Ethernet».
// Точное имя: Get-NetAdapter | Select Name — или выпадающий список в GUI.
"adapter": "",
// состояние интерфейса
"theme": "light",
"mode": "wg",
"last_server": ""
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+48
View File
@@ -0,0 +1,48 @@
// Демонстрационные адреса. Они показывают интерфейс, но не подключаются.
// Для работы используйте свои серверы или импортируйте ссылки в приложении.
{
"wireguard": {
"server": "198.51.100.10",
"port": 51820,
"peer_public_key": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=",
"mtu": 1280
},
"servers": [
{
"code": "FI",
"name": "Finland",
"server": "fi.example.com",
"port": 443,
"uuid": "00000000-0000-4000-8000-000000000001",
"sni": "fi.example.com",
"protocol": "vless",
"flow": "",
"public_key": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P0A",
"short_id": ""
},
{
"code": "EE",
"name": "Estonia",
"server": "ee.example.com",
"port": 443,
"uuid": "00000000-0000-4000-8000-000000000002",
"sni": "ee.example.com",
"protocol": "vless",
"flow": "",
"public_key": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P0A",
"short_id": ""
},
{
"code": "DE",
"name": "Germany",
"server": "de.example.com",
"port": 443,
"uuid": "00000000-0000-4000-8000-000000000003",
"sni": "de.example.com",
"protocol": "vless",
"flow": "",
"public_key": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P0A",
"short_id": ""
}
]
}
+165
View File
@@ -0,0 +1,165 @@
// ─────────────────────────────────────────────────────────────────────
// Режим «direct»: WireGuard не используется вообще. VLESS идёт напрямую
// через конкретный сетевой адаптер (@@ADAPTER@@), .ru-трафик — тоже.
// Бывшие папки conf_XX_WiFi.
//
// Имя адаптера берётся из local.json и настраивается в GUI: у Windows
// по умолчанию это «Wi-Fi» с дефисом, но адаптер часто переименован.
// ─────────────────────────────────────────────────────────────────────
{
"log": {
"level": "warn",
"timestamp": true
},
"dns": {
"strategy": "ipv4_only",
"fakeip": {
"enabled": true,
"inet4_range": "198.18.0.0/15",
"inet6_range": "fc00::/18",
"include_rule": [
{
"domain_suffix": [
"gemini.google.com",
"aistudio.google.com",
"alkalimining-pa.googleapis.com",
"proactivebackend-pa.googleapis.com",
"generativelanguage.googleapis.com",
"ai.google.com",
"alkali.google.com",
"oauth2.googleapis.com",
"oauthaccountmanager.googleapis.com",
"robinfrontend-pa.googleapis.com",
"notifications-pa.googleapis.com",
"app-analytics-services.com"
],
"domain_keyword": [
"gemini"
]
}
]
},
"servers": [
{
"tag": "dns-bootstrap",
"address": "8.8.8.8",
"detour": "bound"
},
{
"tag": "dns-ru",
"address": "77.88.8.8",
"detour": "bound"
},
{
"tag": "dns-global",
"address": "https://cloudflare-dns.com/dns-query",
"detour": "proxy-vless"
},
{
"tag": "local",
"address": "local",
"detour": "direct"
}
],
"rules": [
{
"domain": [
"cloudflare-dns.com"
],
"server": "dns-bootstrap"
},
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"server": "dns-ru"
},
{
"rule_set": "geoip-ru",
"server": "dns-ru"
}
],
"final": "dns-global"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "sing-box-tun",
"address": [
"172.19.0.1/30"
],
"auto_route": true,
"strict_route": true,
"route_exclude_address": [
"10.0.0.0/8"
],
"stack": "gvisor",
// mtu здесь намеренно не задан — в этом режиме он мешал
// "mtu": 1280,
"sniff": true,
"sniff_timeout": "300ms",
"sniff_override_destination": true
}
],
"outbounds": [
{
"type": "direct",
"tag": "bound",
"bind_interface": "@@ADAPTER@@",
"domain_strategy": "ipv4_only"
},
"@@PROXY_OUTBOUND@@",
{
"type": "direct",
"tag": "direct",
"domain_strategy": "ipv4_only"
}
],
"route": {
"default_domain_resolver": "dns-bootstrap",
"rule_set": [
{
"tag": "geoip-ru",
"type": "local",
"format": "binary",
"path": "@@RULESET_PATH@@"
}
],
"rules": [
{
"protocol": "dns",
"action": "hijack-dns"
},
{
"network": "udp",
"port": 443,
"action": "reject"
},
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"outbound": "bound"
},
{
"rule_set": [
"geoip-ru"
],
"outbound": "bound"
},
{
"ip_cidr": [
"10.0.0.0/8"
],
"outbound": "direct"
}
],
"auto_detect_interface": true,
"final": "proxy-vless"
}
}
+164
View File
@@ -0,0 +1,164 @@
// ─────────────────────────────────────────────────────────────────────
// Режим «wg»: весь трафик идёт в VLESS, а VLESS — поверх WireGuard.
// .ru-домены и geoip-ru уходят в WireGuard напрямую.
// Бывшие папки conf_XX_eth.
//
// @@ТОКЕНЫ@@ подставляет лаунчер. Числовые токены записаны в кавычках,
// чтобы файл оставался валидным JSON; кавычки снимаются при подстановке.
// Всё остальное правьте руками — это обычный конфиг sing-box.
// ─────────────────────────────────────────────────────────────────────
{
"log": {
"level": "warn",
"timestamp": true
},
"dns": {
"strategy": "ipv4_only",
"fakeip": {
"enabled": true,
"inet4_range": "198.18.0.0/15",
"inet6_range": "fc00::/18",
"include_rule": [
{
"domain_suffix": [
"gemini.google.com",
"domain:gemini.google.com",
"domain:aistudio.google.com",
"domain:generativelanguage.googleapis.com",
"domain:deepmind.google",
"domain:deepmind.com",
"domain:antigravity.google",
"domain:antigravity.com",
"domain:run.app",
"domain:cloudaicompanion.googleapis.com",
"domain:accounts.google.com",
"domain:antigravity-unleash.goog",
"domain:antigravity-hub-auto-updater-974169037036.us-central1.run.app",
"domain:daily-cloudcode-pa.googleapis.com",
"domain:www.googleapis.com",
"domain:play.googleapis.com",
"domain:lh3.googleusercontent.com",
"domain:googlehosted.l.googleusercontent.com",
"domain:www.gstatic.com",
"domain:bard.google.com"
],
"domain_keyword": [
"gemini"
]
}
]
},
"servers": [
{
"tag": "dns-bootstrap",
"address": "8.8.8.8",
"detour": "proxy-wireguard"
},
{
"tag": "dns-ru",
"address": "77.88.8.8",
"detour": "proxy-wireguard"
},
{
"tag": "dns-global",
"address": "8.8.8.8",
"detour": "proxy-vless"
}
],
"rules": [
{
"domain": [
"cloudflare-dns.com"
],
"server": "dns-bootstrap"
},
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"server": "dns-ru"
},
{
"rule_set": "geoip-ru",
"server": "dns-ru"
}
],
"final": "dns-global"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "sing-box-tun",
"address": [
"172.19.0.1/30"
],
"auto_route": true,
"strict_route": true,
"stack": "gvisor",
"mtu": 1280,
"sniff": true,
"sniff_timeout": "300ms",
"sniff_override_destination": true
}
],
"outbounds": [
{
"type": "wireguard",
"tag": "proxy-wireguard",
"server": "@@WG_SERVER@@",
"server_port": "@@WG_PORT@@",
"local_address": [
"@@WG_LOCAL_ADDRESS@@"
],
"private_key": "@@WG_PRIVATE_KEY@@",
"peer_public_key": "@@WG_PEER_PUBLIC_KEY@@",
"mtu": "@@WG_MTU@@"
},
"@@PROXY_OUTBOUND@@",
{
"type": "direct",
"tag": "direct"
}
],
"route": {
"default_domain_resolver": "dns-bootstrap",
"rule_set": [
{
"tag": "geoip-ru",
"type": "local",
"format": "binary",
"path": "@@RULESET_PATH@@"
}
],
"rules": [
{
"protocol": "dns",
"action": "hijack-dns"
},
{
"network": "udp",
"port": 443,
"action": "reject"
},
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"outbound": "proxy-wireguard"
},
{
"rule_set": [
"geoip-ru"
],
"outbound": "proxy-wireguard"
}
],
"auto_detect_interface": true,
"final": "proxy-vless"
}
}
+132
View File
@@ -0,0 +1,132 @@
// ─────────────────────────────────────────────────────────────────────
// Режим «wgonly»: только WireGuard, без VLESS. Выход — внутри РФ.
// Бывшая папка conf0.
// ─────────────────────────────────────────────────────────────────────
{
"log": {
"level": "warn",
"timestamp": true
},
"dns": {
"strategy": "ipv4_only",
"servers": [
{
"tag": "dns-yandex",
"address": "https://77.88.8.8",
"detour": "proxy-wireguard"
},
{
"tag": "dns-google",
"address": "https://8.8.8.8",
"detour": "proxy-wireguard"
},
{
"tag": "dns-cloudflare",
"address": "https://1.1.1.1",
"detour": "proxy-wireguard",
"strategy": "ipv4_only"
},
{
"tag": "local",
"address": "local",
"detour": "direct"
},
{
"tag": "block",
"address": "rcode://success"
}
],
"rules": [
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"server": "dns-yandex"
},
{
"rule_set": "geoip-ru",
"server": "dns-yandex"
}
],
"final": "dns-cloudflare"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "sing-box-tun",
"address": [
"172.19.0.1/30"
],
"auto_route": true,
"strict_route": true,
"stack": "gvisor",
"mtu": 1280,
"sniff": true,
"sniff_timeout": "300ms",
"sniff_override_destination": true
}
],
"outbounds": [
{
"type": "dns",
"tag": "dns-out"
},
{
"type": "wireguard",
"tag": "proxy-wireguard",
"server": "@@WG_SERVER@@",
"server_port": "@@WG_PORT@@",
"local_address": [
"@@WG_LOCAL_ADDRESS@@"
],
"private_key": "@@WG_PRIVATE_KEY@@",
"peer_public_key": "@@WG_PEER_PUBLIC_KEY@@",
"mtu": "@@WG_MTU@@",
"domain_strategy": "ipv4_only"
},
{
"type": "direct",
"tag": "direct",
"domain_strategy": "ipv4_only"
}
],
"route": {
"rule_set": [
{
"tag": "geoip-ru",
"type": "local",
"format": "binary",
"path": "@@RULESET_PATH@@"
}
],
"rules": [
{
"protocol": "dns",
"action": "hijack-dns"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"domain_suffix": [
".ru",
".su",
".xn--p1ai"
],
"outbound": "proxy-wireguard"
},
{
"rule_set": [
"geoip-ru"
],
"outbound": "proxy-wireguard"
}
],
"auto_detect_interface": true,
"final": "proxy-wireguard"
}
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using System.Text;
using SbLauncher;
public static class ImportSmoke
{
static int failures;
static void Check(bool ok, string name)
{
Console.WriteLine((ok ? "ok " : "FAIL ") + name);
if (!ok) failures++;
}
[STAThread]
public static int Main()
{
string root = AppDomain.CurrentDomain.BaseDirectory;
Paths paths = new Paths(root);
Profiles profiles = Profiles.Load(paths.Profiles);
// Проверяем импорт на искусственных данных: тест не зависит от
// настоящего приватного ключа и сетевого адаптера пользователя.
byte[] privateBytes = new byte[32], publicBytes = new byte[32];
for (int n = 0; n < 32; n++) { privateBytes[n] = (byte)(n + 1); publicBytes[n] = (byte)(n + 33); }
LocalCfg local = new LocalCfg {
PrivateKey = Convert.ToBase64String(privateBytes),
LocalAddress = "10.10.0.2/24", Adapter = "Ethernet"
};
profiles.Wg.Server = "198.51.100.10";
profiles.Wg.PeerPublicKey = Convert.ToBase64String(publicBytes);
string id = "58863334-3b8a-4c81-9145-a18311621a7f";
string pbk = "Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14";
string vmessJson = "{\"v\":\"2\",\"ps\":\"VMess WS\",\"add\":\"example.com\",\"port\":\"443\",\"id\":\"" + id + "\",\"aid\":\"0\",\"scy\":\"auto\",\"net\":\"ws\",\"host\":\"cdn.example.com\",\"path\":\"/ws\",\"tls\":\"tls\",\"sni\":\"example.com\"}";
string[] keys = {
"vless://" + id + "@example.com:443?security=reality&pbk=" + pbk + "&sid=ab&flow=xtls-rprx-vision#Reality",
"vless://" + id + "@example.com:443?security=tls&type=ws&host=cdn.example.com&path=%2Fws&alpn=h2%2Chttp%2F1.1#WebSocket",
"vless://" + id + "@example.com:443?security=tls&type=grpc&serviceName=service#GRPC",
"vless://" + id + "@example.com:443?security=tls&type=httpupgrade&host=cdn.example.com&path=%2Fup#Upgrade",
"vless://" + id + "@example.com:80?security=none#Plain",
"vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(vmessJson)),
"trojan://test-password@example.com:443?type=grpc&serviceName=tun&sni=example.com#Trojan",
"ss://" + Convert.ToBase64String(Encoding.UTF8.GetBytes("aes-256-gcm:password")) + "@example.com:8388#SIP002",
"ss://" + Convert.ToBase64String(Encoding.UTF8.GetBytes("aes-256-gcm:password@example.com:8388")) + "#LegacySS",
"hysteria2://password@example.com:443?sni=example.com&insecure=1&obfs=salamander&obfs-password=secret#Hy2",
"tuic://" + id + ":password@example.com:443?sni=example.com&alpn=h3#TUIC",
"anytls://password@example.com:443?sni=example.com#AnyTLS"
};
for (int i = 0; i < keys.Length; i++)
{
ServerCfg server;
string error;
bool parsed = KeyImporter.TryParseOne(keys[i], out server, out error);
Check(parsed, "parse " + i + (parsed ? " " + server.Protocol : " " + error));
if (!parsed) continue;
bool valid = KeyImporter.CheckWithSingBox(paths, server, out error);
Check(valid, "outbound " + i + (valid ? "" : " " + error));
if (!valid) continue;
foreach (string mode in new string[] { ConfigBuilder.ModeWg, ConfigBuilder.ModeDirect })
{
string config = Path.Combine(paths.RunDir, "import-smoke-" + i + "-" + mode + ".json");
try
{
BuildResult built = ConfigBuilder.Build(paths, profiles, local, server, mode, config);
if (!built.Ok) { Check(false, "build " + i + " " + mode + " " + built.Error); continue; }
string output;
bool checkedOk = SingBoxRunner.Check(paths.SbExe, config, paths.Root, out output);
Check(checkedOk, "config " + i + " " + mode + (checkedOk ? "" : " " + output));
}
finally { if (File.Exists(config)) File.Delete(config); }
}
}
ImportResult batch = KeyImporter.Parse(keys[0] + "\n" + keys[6]);
Check(batch.Servers.Count == 2 && batch.Errors.Count == 0, "multiline import");
string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(keys[0] + "\n" + keys[6]));
ImportResult subscription = KeyImporter.Parse(encoded);
Check(subscription.Servers.Count == 2 && subscription.Errors.Count == 0, "base64 subscription");
TcpListener listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
bool accepted = false;
Task served = Task.Run(delegate
{
using (TcpClient client = listener.AcceptTcpClient())
using (NetworkStream stream = client.GetStream())
{
accepted = true;
byte[] endOfHeaders = { 13, 10, 13, 10 };
int matched = 0;
while (matched < 4)
{
int b = stream.ReadByte();
if (b < 0) break;
matched = b == endOfHeaders[matched] ? matched + 1 : 0;
}
byte[] body = Encoding.UTF8.GetBytes(encoded);
byte[] head = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " + body.Length + "\r\nConnection: close\r\n\r\n");
stream.Write(head, 0, head.Length);
stream.Write(body, 0, body.Length);
}
});
try
{
ImportResult remote = KeyImporter.Parse("http://127.0.0.1:" + port + "/subscription");
Check(remote.Servers.Count == 2 && remote.Errors.Count == 0, "HTTP subscription " + remote.Servers.Count + " accepted=" + accepted + " served=" + served.IsCompleted + " / " + string.Join("; ", remote.Errors.ToArray()));
served.Wait(3000);
}
finally { listener.Stop(); }
ImportResult json = KeyImporter.Parse("{\"outbounds\":[{\"type\":\"trojan\",\"tag\":\"from-json\",\"server\":\"example.com\",\"server_port\":443,\"password\":\"test\",\"tls\":{\"enabled\":true}}]}");
Check(json.Servers.Count == 1 && json.Servers[0].Protocol == "trojan", "sing-box JSON");
ImportResult sameJson = KeyImporter.Parse("{\"type\":\"trojan\",\"tls\":{\"enabled\":true},\"password\":\"test\",\"server_port\":443,\"server\":\"example.com\"}");
Check(sameJson.Servers.Count == 1 && KeyImporter.Same(json.Servers[0], sameJson.Servers[0]), "dedupe reordered JSON");
ServerCfg bad; string reason;
Check(!KeyImporter.TryParseOne("vless://" + id + "@example.com:443?security=tls&type=xhttp", out bad, out reason) && reason.Contains("транспорт"), "unsupported transport rejected");
Check(!KeyImporter.TryParseOne("vless://" + id + "@example.com:443?security=reality", out bad, out reason) && reason.Contains("public key"), "incomplete Reality rejected");
Console.WriteLine("failures: " + failures);
return failures == 0 ? 0 : 1;
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using SbLauncher;
public static class ProfileSmoke
{
[STAThread]
public static int Main()
{
string root = Path.Combine(Path.GetTempPath(), "SbLauncherRev2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var app = new Application();
app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var vm = new MainVm();
vm.Load(new Paths(root));
string vless = "vless://58863334-3b8a-4c81-9145-a18311621a7f@localhost:443?security=reality&pbk=test&sni=example.com#Smoke";
string trojan = "trojan://password@example.com:443?sni=example.com#Second";
Task<bool> added = vm.AddFromLinkAsync(vless + "\n" + trojan + "\n" + vless);
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
var started = DateTime.UtcNow;
timer.Tick += delegate
{
if (added.IsCompleted || (DateTime.UtcNow - started).TotalSeconds > 5)
{
timer.Stop();
app.Shutdown();
}
};
timer.Start();
app.Run();
vm.DisposeTimers();
Profiles stored = Profiles.Load(Path.Combine(root, "profiles.json"));
bool ok = added.IsCompleted && added.Result && stored.Servers.Count == 2 &&
vm.Servers.Count == 3 && stored.Servers[0].Outbound != null &&
stored.Servers[1].Outbound != null &&
stored.Servers[0].Protocol == "vless" && stored.Servers[1].Protocol == "trojan" &&
stored.Servers[0].Code != stored.Servers[1].Code;
Console.WriteLine(ok ? "batch import, deduplication and roundtrip: ok" :
"batch import, deduplication and roundtrip: failed");
return ok ? 0 : 1;
}
finally
{
if (root.StartsWith(Path.GetTempPath(), StringComparison.OrdinalIgnoreCase))
Directory.Delete(root, true);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Markup;
using System.Windows.Threading;
using SbLauncher;
public static class UiStress
{
[STAThread]
public static int Main()
{
var app = new Application();
app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var root = AppDomain.CurrentDomain.BaseDirectory;
var window = (Window)XamlReader.Parse(File.ReadAllText(Path.Combine(root, "Ui.xaml")));
var vm = new MainVm();
window.DataContext = vm;
vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); };
vm.Load(new Paths(root));
vm.SetTheme(Theme.Dark);
var stopwatch = Stopwatch.StartNew();
long previous = 0, longest = 0;
int ticks = 0;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
timer.Tick += delegate
{
long now = stopwatch.ElapsedMilliseconds;
if (previous != 0) longest = Math.Max(longest, now - previous);
previous = now;
ticks++;
vm.ShowSettings = !vm.ShowSettings;
vm.SetTheme(ticks % 2 == 0 ? Theme.Dark : Theme.Light);
// Оставляем каждую фазу на несколько кадров, чтобы реально
// проверить запуск и остановку Storyboard при нагрузке.
if (ticks % 10 == 0)
vm.ConnectionPhase = new[] { "connecting", "connected", "disconnecting", "idle" }[(ticks / 10) % 4];
if (now >= 4000) { timer.Stop(); app.Shutdown(); }
};
window.Show();
timer.Start();
Task.Run(delegate
{
for (int i = 0; i < 20000; i++) vm.AppendLog("stress line " + i);
});
app.Run();
vm.DisposeTimers();
Console.WriteLine("UI ticks: " + ticks + ", max interval: " + longest + " ms, log length: " + vm.LogText.Length);
return ticks >= 40 && longest < 500 && vm.LogText.Length <= 50000 ? 0 : 1;
}
}
+287
View File
@@ -0,0 +1,287 @@
// MakeIcon — из исходного PNG с логотипом делает app.ico и logo.png.
//
// MakeIcon.exe <input.png> [app.ico] [logo.png]
//
// Что делает:
// 1. оценивает цвет фона по рамке изображения;
// 2. считает альфу по расстоянию до фона и «размачивает» (un-matte) цвет,
// чтобы по краям не осталось белой каймы;
// 3. обрезает по границам непрозрачного, добавляет поля, доводит до квадрата;
// 4. пишет .ico: размеры <=48 как BMP-записи, >=64 как PNG-записи
// (так делают современные иконки, Windows 10/11 понимает и то и другое).
//
// Компилятор — тот же встроенный csc.exe (C# 5).
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
public static class MakeIcon
{
[STAThread]
public static int Main(string[] a)
{
if (a.Length < 1) { Console.WriteLine("usage: MakeIcon <input.png> [app.ico] [logo.png]"); return 2; }
string inPath = a[0];
string icoPath = a.Length > 1 ? a[1] : "app.ico";
string pngPath = a.Length > 2 ? a[2] : "logo.png";
if (!File.Exists(inPath)) { Console.WriteLine("not found: " + inPath); return 1; }
try
{
BitmapSource src = Load(inPath);
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"source ........ {0}x{1}", src.PixelWidth, src.PixelHeight));
int w = src.PixelWidth, h = src.PixelHeight;
byte[] px = new byte[w * h * 4];
src.CopyPixels(px, w * 4, 0);
byte[] bg = EstimateBackground(px, w, h);
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"background .... #{0:X2}{1:X2}{2:X2}", bg[2], bg[1], bg[0]));
Unmatte(px, w, h, bg);
int x0, y0, x1, y1;
if (!Bbox(px, w, h, out x0, out y0, out x1, out y1))
{ Console.WriteLine("nothing but background found"); return 1; }
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"logo bbox ..... {0},{1} .. {2},{3} ({4}x{5})", x0, y0, x1, y1, x1 - x0 + 1, y1 - y0 + 1));
BitmapSource cleaned = BitmapSource.Create(w, h, 96, 96, PixelFormats.Bgra32, null, px, w * 4);
BitmapSource squared = CropSquare(cleaned, x0, y0, x1, y1, 0.06);
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"squared ....... {0}x{1}", squared.PixelWidth, squared.PixelHeight));
SavePng(Resize(squared, 512), pngPath);
Console.WriteLine("wrote ......... " + pngPath);
int[] sizes = new int[] { 16, 20, 24, 32, 40, 48, 64, 128, 256 };
WriteIco(squared, sizes, icoPath);
Console.WriteLine("wrote ......... " + icoPath + " (" + new FileInfo(icoPath).Length + " bytes, "
+ sizes.Length + " sizes)");
return 0;
}
catch (Exception ex)
{
Console.WriteLine("FAIL: " + ex.GetType().Name + ": " + ex.Message);
return 1;
}
}
static BitmapSource Load(string path)
{
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read))
{
BitmapDecoder d = BitmapDecoder.Create(fs, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
FormatConvertedBitmap c = new FormatConvertedBitmap(d.Frames[0], PixelFormats.Bgra32, null, 0);
c.Freeze();
return c;
}
}
// Медиана по рамке шириной 8px — устойчива к виньетке и водяному знаку.
static byte[] EstimateBackground(byte[] px, int w, int h, int band = 8)
{
List<byte> b = new List<byte>(), g = new List<byte>(), r = new List<byte>();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (x >= band && x < w - band && y >= band && y < h - band) continue;
int i = (y * w + x) * 4;
b.Add(px[i]); g.Add(px[i + 1]); r.Add(px[i + 2]);
}
b.Sort(); g.Sort(); r.Sort();
return new byte[] { b[b.Count / 2], g[g.Count / 2], r[r.Count / 2], 255 };
}
const double T0 = 14.0; // ниже — считаем чистым фоном
const double T1 = 46.0; // выше — считаем чистым логотипом
static void Unmatte(byte[] px, int w, int h, byte[] bg)
{
for (int i = 0; i < px.Length; i += 4)
{
double db = Math.Abs(px[i] - bg[0]);
double dg = Math.Abs(px[i + 1] - bg[1]);
double dr = Math.Abs(px[i + 2] - bg[2]);
double d = Math.Max(db, Math.Max(dg, dr));
double alpha = (d - T0) / (T1 - T0);
if (alpha <= 0) { px[i] = px[i + 1] = px[i + 2] = px[i + 3] = 0; continue; }
if (alpha > 1) alpha = 1;
// O = A*F + (1-A)*B -> F = (O - (1-A)*B) / A
for (int c = 0; c < 3; c++)
{
double o = px[i + c];
double f = (o - (1 - alpha) * bg[c]) / alpha;
px[i + c] = (byte)(f < 0 ? 0 : f > 255 ? 255 : f);
}
px[i + 3] = (byte)Math.Round(alpha * 255);
}
}
// Глобальная рамка не годится: в неё попадает водяной знак в углу.
// Берём КРУПНЕЙШУЮ связную область, всё остальное затираем.
static bool Bbox(byte[] px, int w, int h, out int x0, out int y0, out int x1, out int y1)
{
x0 = 0; y0 = 0; x1 = -1; y1 = -1;
int[] label = new int[w * h];
int best = 0, bestSize = 0, bx0 = 0, by0 = 0, bx1 = 0, by1 = 0, next = 0;
int[] stack = new int[w * h];
for (int start = 0; start < w * h; start++)
{
if (label[start] != 0 || px[start * 4 + 3] <= 12) continue;
next++;
int sp = 0, size = 0;
int cx0 = w, cy0 = h, cx1 = -1, cy1 = -1;
stack[sp++] = start;
label[start] = next;
while (sp > 0)
{
int p = stack[--sp];
int py = p / w, pxx = p % w;
size++;
if (pxx < cx0) cx0 = pxx;
if (pxx > cx1) cx1 = pxx;
if (py < cy0) cy0 = py;
if (py > cy1) cy1 = py;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
int nx = pxx + dx, ny = py + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
int q = ny * w + nx;
if (label[q] != 0 || px[q * 4 + 3] <= 12) continue;
label[q] = next;
stack[sp++] = q;
}
}
if (size > bestSize) { bestSize = size; best = next; bx0 = cx0; by0 = cy0; bx1 = cx1; by1 = cy1; }
}
if (bestSize == 0) return false;
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"components .... {0}, largest {1} px", next, bestSize));
for (int i = 0; i < w * h; i++) // всё, что не логотип — в ноль
if (label[i] != best) { px[i * 4] = px[i * 4 + 1] = px[i * 4 + 2] = px[i * 4 + 3] = 0; }
x0 = bx0; y0 = by0; x1 = bx1; y1 = by1;
return true;
}
static BitmapSource CropSquare(BitmapSource src, int x0, int y0, int x1, int y1, double padFrac)
{
int bw = x1 - x0 + 1, bh = y1 - y0 + 1;
int side = (int)Math.Round(Math.Max(bw, bh) * (1 + 2 * padFrac));
int cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
// рисуем на прозрачный квадрат — так не упрёмся в границы исходника
DrawingVisual dv = new DrawingVisual();
using (DrawingContext dc = dv.RenderOpen())
{
RenderOptions.SetBitmapScalingMode(dv, BitmapScalingMode.HighQuality);
dc.DrawImage(src, new Rect(-(cx - side / 2.0), -(cy - side / 2.0), src.PixelWidth, src.PixelHeight));
}
RenderTargetBitmap rtb = new RenderTargetBitmap(side, side, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
static BitmapSource Resize(BitmapSource src, int size)
{
DrawingVisual dv = new DrawingVisual();
using (DrawingContext dc = dv.RenderOpen())
{
RenderOptions.SetBitmapScalingMode(dv, BitmapScalingMode.HighQuality);
dc.DrawImage(src, new Rect(0, 0, size, size));
}
RenderTargetBitmap rtb = new RenderTargetBitmap(size, size, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
static void SavePng(BitmapSource bmp, string path)
{
PngBitmapEncoder e = new PngBitmapEncoder();
e.Frames.Add(BitmapFrame.Create(bmp));
using (FileStream fs = new FileStream(path, FileMode.Create)) e.Save(fs);
}
static byte[] PngBytes(BitmapSource bmp)
{
PngBitmapEncoder e = new PngBitmapEncoder();
e.Frames.Add(BitmapFrame.Create(bmp));
using (MemoryStream ms = new MemoryStream()) { e.Save(ms); return ms.ToArray(); }
}
// 32-битная BMP-запись внутри ICO: BITMAPINFOHEADER + XOR(BGRA, снизу вверх) + AND-маска
static byte[] BmpBytes(BitmapSource bmp)
{
int w = bmp.PixelWidth, h = bmp.PixelHeight;
FormatConvertedBitmap c = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0);
byte[] px = new byte[w * h * 4];
c.CopyPixels(px, w * 4, 0);
int maskStride = ((w + 31) / 32) * 4;
using (MemoryStream ms = new MemoryStream())
using (BinaryWriter bw = new BinaryWriter(ms))
{
bw.Write(40); bw.Write(w); bw.Write(h * 2);
bw.Write((short)1); bw.Write((short)32);
bw.Write(0); bw.Write(w * h * 4 + maskStride * h);
bw.Write(0); bw.Write(0); bw.Write(0); bw.Write(0);
for (int y = h - 1; y >= 0; y--)
bw.Write(px, y * w * 4, w * 4);
byte[] mask = new byte[maskStride * h]; // все нули = пиксель виден
bw.Write(mask);
bw.Flush();
return ms.ToArray();
}
}
static void WriteIco(BitmapSource square, int[] sizes, string path)
{
// Все записи — BMP, включая 256. PNG внутри ICO формально разрешён
// с Vista, но System.Drawing.Icon такие кадры не выбирает (проверено:
// запрос 256 возвращал 128). Файл выходит ~350 КБ вместо ~110 — для
// иконки приложения это несущественно, зато работает везде.
List<byte[]> blobs = new List<byte[]>();
foreach (int s in sizes)
blobs.Add(BmpBytes(Resize(square, s)));
using (FileStream fs = new FileStream(path, FileMode.Create))
using (BinaryWriter bw = new BinaryWriter(fs))
{
bw.Write((short)0); bw.Write((short)1); bw.Write((short)sizes.Length);
int offset = 6 + 16 * sizes.Length;
for (int i = 0; i < sizes.Length; i++)
{
int s = sizes[i];
bw.Write((byte)(s >= 256 ? 0 : s));
bw.Write((byte)(s >= 256 ? 0 : s));
bw.Write((byte)0); bw.Write((byte)0);
bw.Write((short)1); bw.Write((short)32);
bw.Write(blobs[i].Length);
bw.Write(offset);
offset += blobs[i].Length;
}
foreach (byte[] b in blobs) bw.Write(b);
}
}
}