Create glass launcher prototype
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# ── секреты и параметры конкретной машины ────────────────────────────
|
||||||
|
# private_key, local_address, имя сетевого адаптера. Никогда не коммитим.
|
||||||
|
local.json
|
||||||
|
profiles.json
|
||||||
|
|
||||||
|
# ── рантайм ──────────────────────────────────────────────────────────
|
||||||
|
# сюда лаунчер генерирует config.json перед запуском
|
||||||
|
run/
|
||||||
|
# sb.exe (36 МБ) и doc.srs — бинарники, лежат рядом на боевой машине
|
||||||
|
bin/
|
||||||
|
doc/
|
||||||
|
|
||||||
|
# ── результат сборки ─────────────────────────────────────────────────
|
||||||
|
*.exe
|
||||||
|
*.pdb
|
||||||
@@ -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(); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════
|
||||||
|
// ИНФРАСТРУКТУРА ПРИВЯЗОК
|
||||||
|
// ═════════════════════════════════════════════════════════════════════
|
||||||
|
}
|
||||||
@@ -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="–"/>
|
||||||
|
<Button x:Name="BtnClose" Style="{StaticResource Chrome}" Content="✕"/>
|
||||||
|
</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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 397 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user