Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdc30ec2a |
@@ -2,6 +2,9 @@
|
||||
# private_key, local_address, имя сетевого адаптера. Никогда не коммитим.
|
||||
local.json
|
||||
profiles.json
|
||||
profiles.legacy-backup.json
|
||||
profiles.json.tmp
|
||||
local.json.tmp
|
||||
|
||||
# локальные ярлыки Windows
|
||||
*.lnk
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SbLauncher — GUI для sing-box.
|
||||
//
|
||||
// ПРИНЦИП (изменён по сравнению с прошлой версией):
|
||||
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
|
||||
// Источник правды — profiles.base.json (базовые серверы), profiles.json
|
||||
// (личные серверы) и local.json (ключ,
|
||||
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
|
||||
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
|
||||
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
|
||||
@@ -71,6 +72,7 @@ namespace SbLauncher
|
||||
{
|
||||
public const string Dark = "dark";
|
||||
public const string Light = "light";
|
||||
static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(460);
|
||||
|
||||
public static string DetectSystem()
|
||||
{
|
||||
@@ -103,12 +105,92 @@ namespace SbLauncher
|
||||
ResourceDictionary src = w.Resources[key] as ResourceDictionary;
|
||||
if (src == null) return;
|
||||
|
||||
// Первый кадр и служебные рендеры сразу получают конечную тему.
|
||||
bool animate = w.IsLoaded && SystemParameters.ClientAreaAnimation;
|
||||
foreach (object k in src.Keys)
|
||||
{
|
||||
object v = src[k];
|
||||
Brush target = v as Brush;
|
||||
if (target != null)
|
||||
v = TransitionBrush(w.Resources[k] as Brush, target, animate);
|
||||
w.Resources[k] = v;
|
||||
if (Application.Current != null) Application.Current.Resources[k] = v;
|
||||
}
|
||||
UpdateIcon(w, "ThemeSun", theme != Light, -100, animate);
|
||||
UpdateIcon(w, "ThemeMoon", theme == Light, 90, animate);
|
||||
}
|
||||
|
||||
static Brush TransitionBrush(Brush current, Brush target, bool animate)
|
||||
{
|
||||
// Исходные словари остаются неизменными. Новый переход начинается
|
||||
// с видимого цвета, даже если предыдущий ещё не успел завершиться.
|
||||
Brush next = target.Clone();
|
||||
if (!animate || current == null) return next;
|
||||
|
||||
SolidColorBrush fromSolid = current as SolidColorBrush;
|
||||
SolidColorBrush toSolid = next as SolidColorBrush;
|
||||
GradientBrush fromGradient = current as GradientBrush;
|
||||
GradientBrush toGradient = next as GradientBrush;
|
||||
if (fromSolid != null && toSolid != null)
|
||||
AnimateColor(toSolid, SolidColorBrush.ColorProperty, fromSolid.Color, toSolid.Color);
|
||||
else if (fromGradient != null && toGradient != null &&
|
||||
current.GetType() == target.GetType() &&
|
||||
fromGradient.GradientStops.Count == toGradient.GradientStops.Count)
|
||||
{
|
||||
for (int i = 0; i < toGradient.GradientStops.Count; i++)
|
||||
{
|
||||
GradientStop from = fromGradient.GradientStops[i];
|
||||
GradientStop to = toGradient.GradientStops[i];
|
||||
AnimateColor(to, GradientStop.ColorProperty, from.Color, to.Color);
|
||||
AnimateDouble(to, GradientStop.OffsetProperty, from.Offset, to.Offset, true);
|
||||
}
|
||||
}
|
||||
AnimateDouble(next, Brush.OpacityProperty, current.Opacity, target.Opacity, true);
|
||||
return next;
|
||||
}
|
||||
|
||||
static void AnimateColor(Animatable target, DependencyProperty property, Color from, Color to)
|
||||
{
|
||||
if (from == to) return;
|
||||
// WPF активирует clock на следующем кадре: до него тоже должен
|
||||
// оставаться исходный цвет, иначе при клике видна вспышка цели.
|
||||
target.SetValue(property, from);
|
||||
var animation = new ColorAnimation(from, to, TransitionDuration);
|
||||
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
|
||||
animation.FillBehavior = FillBehavior.HoldEnd;
|
||||
target.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
|
||||
}
|
||||
|
||||
static void AnimateDouble(DependencyObject target, DependencyProperty property,
|
||||
double from, double to, bool animate)
|
||||
{
|
||||
var animated = (IAnimatable)target;
|
||||
animated.BeginAnimation(property, null);
|
||||
bool transition = animate && Math.Abs(from - to) >= 0.001;
|
||||
target.SetValue(property, transition ? from : to);
|
||||
if (!transition) return;
|
||||
var animation = new DoubleAnimation(from, to, TransitionDuration);
|
||||
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
|
||||
// SnapshotAndReplace keeps one clock per property. Holding the
|
||||
// endpoint avoids callbacks from an interrupted transition.
|
||||
animation.FillBehavior = FillBehavior.HoldEnd;
|
||||
animated.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
|
||||
}
|
||||
|
||||
static void UpdateIcon(Window window, string name, bool visible, double hiddenAngle, bool animate)
|
||||
{
|
||||
FrameworkElement icon = window.FindName(name) as FrameworkElement;
|
||||
if (icon == null) return;
|
||||
TransformGroup transforms = icon.RenderTransform as TransformGroup;
|
||||
if (transforms == null || transforms.Children.Count != 2) return;
|
||||
if (transforms.IsFrozen) icon.RenderTransform = transforms = transforms.Clone();
|
||||
var scale = transforms.Children[0] as ScaleTransform;
|
||||
var rotation = transforms.Children[1] as RotateTransform;
|
||||
if (scale == null || rotation == null) return;
|
||||
AnimateDouble(icon, UIElement.OpacityProperty, icon.Opacity, visible ? 1 : 0, animate);
|
||||
AnimateDouble(scale, ScaleTransform.ScaleXProperty, scale.ScaleX, visible ? 1 : 0.4, animate);
|
||||
AnimateDouble(scale, ScaleTransform.ScaleYProperty, scale.ScaleY, visible ? 1 : 0.4, animate);
|
||||
AnimateDouble(rotation, RotateTransform.AngleProperty, rotation.Angle, visible ? 0 : hiddenAngle, animate);
|
||||
}
|
||||
|
||||
// Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в
|
||||
@@ -534,6 +616,7 @@ namespace SbLauncher
|
||||
Border selection = w.FindName("ServerSelectionWindow") as Border;
|
||||
if (serverList != null && serverHost != null && selection != null)
|
||||
MoveServerSelection(serverList, serverHost, selection, false);
|
||||
UpdateTabs(w, vm, false);
|
||||
root2.UpdateLayout();
|
||||
|
||||
RenderTargetBitmap rtb = new RenderTargetBitmap(
|
||||
@@ -569,10 +652,12 @@ namespace SbLauncher
|
||||
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;
|
||||
if (File.Exists(Path.Combine(exeDir, "profiles.json")) ||
|
||||
File.Exists(Path.Combine(exeDir, "profiles.base.json"))) return exeDir;
|
||||
|
||||
string parent = Path.GetDirectoryName(exeDir);
|
||||
if (parent != null && File.Exists(Path.Combine(parent, "profiles.json"))) return parent;
|
||||
if (parent != null && (File.Exists(Path.Combine(parent, "profiles.json")) ||
|
||||
File.Exists(Path.Combine(parent, "profiles.base.json")))) return parent;
|
||||
|
||||
return exeDir;
|
||||
}
|
||||
@@ -665,9 +750,83 @@ namespace SbLauncher
|
||||
|
||||
WireSwitch(w);
|
||||
WireServerSelection(w);
|
||||
WireTabs(w, vm);
|
||||
w.Loaded += delegate { vm.PingAll(); };
|
||||
}
|
||||
|
||||
static void WireTabs(Window w, MainVm vm)
|
||||
{
|
||||
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
|
||||
if (host == null) return;
|
||||
|
||||
w.Loaded += delegate { UpdateTabs(w, vm, false); };
|
||||
host.SizeChanged += delegate { UpdateTabs(w, vm, false); };
|
||||
PropertyChangedEventHandler changed = delegate(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == "ShowSettings") UpdateTabs(w, vm, w.IsLoaded);
|
||||
};
|
||||
vm.PropertyChanged += changed;
|
||||
w.Closed += delegate { vm.PropertyChanged -= changed; };
|
||||
}
|
||||
|
||||
static void UpdateTabs(Window w, MainVm vm, bool animate)
|
||||
{
|
||||
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
|
||||
FrameworkElement selector = w.FindName("TabSelectorHost") as FrameworkElement;
|
||||
Border highlight = w.FindName("TabSelectionWindow") as Border;
|
||||
Border log = w.FindName("LogPane") as Border;
|
||||
Border settings = w.FindName("SettingsPane") as Border;
|
||||
Button selected = w.FindName(vm.ShowSettings ? "BtnTabSettings" : "BtnTabLog") as Button;
|
||||
if (host == null || selector == null || highlight == null || log == null ||
|
||||
settings == null || selected == null || host.ActualWidth <= 0) return;
|
||||
|
||||
bool showSettings = vm.ShowSettings;
|
||||
Border inactive = showSettings ? log : settings;
|
||||
// Уходящая панель остаётся видимой до конца движения, но её поля
|
||||
// уже не должны получать клики или фокус с клавиатуры.
|
||||
if (inactive.IsKeyboardFocusWithin) selected.Focus();
|
||||
log.IsHitTestVisible = !showSettings;
|
||||
settings.IsHitTestVisible = showSettings;
|
||||
KeyboardNavigation.SetTabNavigation(log, showSettings ? KeyboardNavigationMode.None : KeyboardNavigationMode.Continue);
|
||||
KeyboardNavigation.SetTabNavigation(settings, showSettings ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
|
||||
log.Visibility = settings.Visibility = Visibility.Visible;
|
||||
|
||||
double travel = host.ActualWidth + 16;
|
||||
SlideTabElement(log, showSettings ? -travel : 0, animate,
|
||||
delegate { if (vm.ShowSettings) log.Visibility = Visibility.Hidden; });
|
||||
SlideTabElement(settings, showSettings ? 0 : travel, animate,
|
||||
delegate { if (!vm.ShowSettings) settings.Visibility = Visibility.Hidden; });
|
||||
double targetX = selected.TransformToAncestor(selector).Transform(new Point()).X;
|
||||
SlideTabElement(highlight, targetX, animate, null);
|
||||
}
|
||||
|
||||
static void SlideTabElement(FrameworkElement element, double targetX, bool animate, Action completed)
|
||||
{
|
||||
TranslateTransform shift = element.RenderTransform as TranslateTransform;
|
||||
if (shift == null) element.RenderTransform = shift = new TranslateTransform();
|
||||
else if (shift.IsFrozen) element.RenderTransform = shift = shift.Clone();
|
||||
|
||||
double fromX = shift.X;
|
||||
shift.BeginAnimation(TranslateTransform.XProperty, null);
|
||||
shift.X = fromX;
|
||||
if (!animate || Math.Abs(fromX - targetX) < 0.1)
|
||||
{
|
||||
shift.X = targetX;
|
||||
if (completed != null) completed();
|
||||
return;
|
||||
}
|
||||
|
||||
DoubleAnimation slide = new DoubleAnimation(fromX, targetX, TimeSpan.FromMilliseconds(520));
|
||||
slide.EasingFunction = new BackEase { Amplitude = 0.22, EasingMode = EasingMode.EaseOut };
|
||||
slide.Completed += delegate
|
||||
{
|
||||
shift.X = targetX;
|
||||
shift.BeginAnimation(TranslateTransform.XProperty, null);
|
||||
if (completed != null) completed();
|
||||
};
|
||||
shift.BeginAnimation(TranslateTransform.XProperty, slide);
|
||||
}
|
||||
|
||||
static void WireSwitch(Window w)
|
||||
{
|
||||
ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton;
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
|
||||
Пока нет записей.
|
||||
|
||||
## 0.1.2 — 2026-10-10
|
||||
|
||||
- Вкладки «Лог» и «Настройки» получили скользящую стеклянную подсветку и плавный переход между панелями с лёгкой пружинной отдачей.
|
||||
- Тёмная тема заменена выбранной палитрой «Графит»: нейтральные серые поверхности, мягкие границы стекла и синий акцент.
|
||||
- Светлая тема заменена выбранной палитрой «Серебро»: нейтральные серебристые поверхности, белые карточки и графитовый акцент.
|
||||
- Переключение темы плавно меняет цвета интерфейса и переводит солнце в луну с вращением, масштабированием и затуханием; повторное нажатие продолжает переход из текущего состояния.
|
||||
- В релиз включены базовые серверы из текущего рабочего списка, sing-box и файл правил: после распаковки достаточно ввести собственные ключ и IP WireGuard.
|
||||
- Базовый каталог `profiles.base.json` отделён от личных серверов в `profiles.json` и настроек компьютера в `local.json`. Обновление каталога сохраняет личные серверы, ключ, адрес, адаптер и выбранный сервер.
|
||||
- Удаление базового сервера сохраняется как персональное скрытие; сервер не возвращается после обновления. Старые списки автоматически мигрируют с резервной копией.
|
||||
- В процедуру релиза включены проверки обновления профилей и переходов темы; в архив добавлена инструкция по передаче приложения и обновлению без потери настроек.
|
||||
|
||||
## 0.1.1 — 2026-09-27
|
||||
|
||||
- Название окна и значка в трее приведено к `SbLauncher`; в шапке показывается версия сборки.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Передача SbLauncher коллеге
|
||||
|
||||
## Первый запуск
|
||||
|
||||
1. Передайте `SbLauncher-v0.1.2-win-x64.zip` и файл `.zip.sha256`
|
||||
либо ссылку на релиз. Если репозиторий закрыт, для скачивания по ссылке
|
||||
нужен доступ к нему; сам ZIP можно передать напрямую.
|
||||
2. Коллега распаковывает ZIP в постоянную папку, например
|
||||
`C:\Tools\SbLauncher`. Windows 10/11 x64 и .NET Framework 4.x обязательны.
|
||||
Устанавливать Visual Studio, собирать исходники или копировать примеры
|
||||
конфигурации не нужно: `bin\sb.exe`, `doc\doc.srs` и базовые серверы уже
|
||||
входят в архив. В комплекте используется sing-box `1.12.19`, `windows/amd64`.
|
||||
3. Запускает `SbLauncher.exe` и подтверждает запрос администратора,
|
||||
необходимый для TUN и сетевых маршрутов.
|
||||
4. На вкладке «Настройки» вводит **свой** приватный ключ WireGuard и
|
||||
**свой** локальный IP в формате CIDR, например `10.10.0.2/24`.
|
||||
Эти данные должны быть выданы и зарегистрированы для него на сервере
|
||||
WireGuard. В режиме `direct` вместо WireGuard выбирает свой сетевой адаптер.
|
||||
5. Выбирает базовый сервер и подключается. Дополнительные личные серверы
|
||||
добавляет через поле импорта. При ошибке смотрит сообщение на вкладке «Лог».
|
||||
|
||||
Лаунчер автоматически сохраняет введённые настройки. Ваш приватный ключ,
|
||||
локальный IP, сетевой адаптер и личные серверы в архив не попадают.
|
||||
|
||||
## Как устроены файлы
|
||||
|
||||
| Файл | Содержимое | При обновлении |
|
||||
| --- | --- | --- |
|
||||
| `profiles.base.json` | Базовые серверы для всех и общие параметры WireGuard-сервера | Заменяется версией из релиза |
|
||||
| `profiles.json` | Только личные серверы пользователя и список скрытых базовых серверов | Сохраняется |
|
||||
| `local.json` | Приватный ключ, локальный IP WireGuard, адаптер, тема и выбранный сервер | Сохраняется |
|
||||
| `profiles.legacy-backup.json` | Резервная копия старого списка при переходе с v0.1.1 | Сохраняется, в релиз не входит |
|
||||
| `templates/*.json` | Общие правила подключения | Обновляются из релиза |
|
||||
| `run/config.json` | Сгенерированный конфиг sing-box | Создаётся при подключении |
|
||||
|
||||
Базовые и личные серверы отображаются вместе. Добавление личного сервера
|
||||
не меняет базовый каталог. Удаление базового сервера скрывает его для этого
|
||||
пользователя, и обновление не возвращает его в список.
|
||||
|
||||
При первом запуске поверх v0.1.1 общий список `profiles.json` автоматически
|
||||
разделяется: совпадающие базовые записи берутся из релиза, личные и изменённые
|
||||
записи сохраняются в личном файле. Перед преобразованием создаётся резервная
|
||||
копия `profiles.legacy-backup.json`. `local.json` сохраняет настройки пользователя.
|
||||
|
||||
## Обновление без потери настроек
|
||||
|
||||
1. Остановите подключение и закройте программу через меню значка в трее.
|
||||
2. Для резервной копии сохраните `local.json` и `profiles.json`.
|
||||
3. Распакуйте новый релиз **поверх той же папки**, согласившись на замену
|
||||
файлов программы, базового каталога и шаблонов.
|
||||
4. Запустите лаунчер и проверьте версию в шапке окна.
|
||||
|
||||
ZIP намеренно не содержит `local.json`, `profiles.json` и резервную копию
|
||||
старых профилей, поэтому распаковка не заменяет их. Базовые серверы и правила
|
||||
обновятся, а личные серверы, ключ и IP останутся. Если вы редактировали
|
||||
шаблоны вручную, сохраните и сравните их перед заменой.
|
||||
|
||||
При установке в новую папку перенесите туда только свои `local.json` и
|
||||
`profiles.json` из прежней установки. Не удаляйте старую папку, пока не
|
||||
проверили запуск и сохранённые настройки.
|
||||
|
||||
## Проверка архива
|
||||
|
||||
В PowerShell рядом с ZIP и `.sha256`:
|
||||
|
||||
```powershell
|
||||
$archive = '.\SbLauncher-v0.1.2-win-x64.zip'
|
||||
$expected = ((Get-Content ($archive + '.sha256') -Raw).Trim() -split '\s+')[0]
|
||||
$actual = (Get-FileHash $archive -Algorithm SHA256).Hash
|
||||
if ($actual -ine $expected) { throw 'Контрольная сумма ZIP не совпала.' }
|
||||
'Контрольная сумма совпала.'
|
||||
```
|
||||
|
||||
## Подготовка следующих релизов
|
||||
|
||||
Меняйте общий каталог `profiles.base.json`, сохраняя `id` уже существующих
|
||||
серверов даже при смене их адресов или ключей. В нём разрешены общие данные
|
||||
доступа и публичные параметры WireGuard-сервера; персональных
|
||||
`private_key` и `local_address` в нём быть не должно.
|
||||
|
||||
Не копируйте личный `profiles.json` в релиз и не формируйте из него базовый
|
||||
каталог автоматически: в личном файле могут появиться серверы, которые
|
||||
пользователь не собирался передавать другим. `release.ps1` берёт серверы
|
||||
только из явно подготовленного `profiles.base.json`.
|
||||
@@ -1,95 +1,106 @@
|
||||
# SbLauncher
|
||||
|
||||
Лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11 x64 с
|
||||
.NET Framework 4.x. Поддерживает импорт и выбор серверов, три режима
|
||||
подключения (`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку
|
||||
sing-box, логи и светлую/тёмную тему.
|
||||
|
||||
Это отдельный прототип интерфейса; рабочая копия `sb_launcher_rev2` не меняется.
|
||||
Приложение запрашивает права администратора для настройки сетевых маршрутов.
|
||||
Портативный лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11
|
||||
x64 с .NET Framework 4.x. Поддерживает импорт серверов, три режима подключения
|
||||
(`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку sing-box,
|
||||
логи и светлую/тёмную тему. Для TUN и сетевых маршрутов запрашивает права
|
||||
администратора.
|
||||
|
||||

|
||||
|
||||
[Тёмная тема](preview/glass-dark.png)
|
||||
|
||||
## Подготовка
|
||||
## Установка из релиза
|
||||
|
||||
Для сборки нужен `csc.exe` из установленного .NET Framework 4.x. `build.cmd`
|
||||
использует `%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`; Visual Studio
|
||||
и .NET SDK не требуются.
|
||||
Распакуйте `SbLauncher-v0.1.2-win-x64.zip` в постоянную папку и запустите
|
||||
`SbLauncher.exe`. В архив уже включены sing-box, файл правил и базовые серверы.
|
||||
На вкладке «Настройки» задайте свой приватный ключ и локальный IP WireGuard;
|
||||
для режима `direct` выберите свой сетевой адаптер. Программа автоматически
|
||||
сохранит настройки.
|
||||
|
||||
Для подключения дополнительно нужны совместимый исполняемый файл sing-box и
|
||||
файл правил. Они хранятся только локально и в репозиторий или релизный архив
|
||||
не входят:
|
||||
Передача коллеге, проверка контрольной суммы и обновление — в
|
||||
[HANDOFF.md](HANDOFF.md).
|
||||
|
||||
```text
|
||||
bin/sb.exe
|
||||
doc/doc.srs
|
||||
```
|
||||
## Базовые серверы и личные данные
|
||||
|
||||
Из чистого клона создайте локальные настройки и профили:
|
||||
- `profiles.base.json` содержит общий каталог серверов и публичные параметры
|
||||
WireGuard-сервера. Он входит в релиз и обновляется вместе с программой.
|
||||
- `profiles.json` содержит личные серверы, добавленные пользователем, и список
|
||||
скрытых базовых серверов. Он исключён из Git и релиза.
|
||||
- `local.json` хранит приватный ключ, локальный IP WireGuard, сетевой адаптер,
|
||||
тему и выбор сервера. Он исключён из Git и релиза.
|
||||
|
||||
При обновлении закройте программу через трей и распакуйте ZIP поверх той же
|
||||
папки. Личные файлы сохранятся, а базовые серверы обновятся. Для перехода с
|
||||
v0.1.1 старый общий список автоматически преобразуется в личный слой с
|
||||
резервной копией `profiles.legacy-backup.json`.
|
||||
|
||||
`run/config.json` генерируется при подключении. Постоянные изменения правил
|
||||
вносите в `templates/*.json`, а базовых серверов — в `profiles.base.json`.
|
||||
Для новых базовых серверов задавайте уникальный `id`; у существующих его
|
||||
сохраняйте. Личный `profiles.json` никогда не копируйте в релизный каталог.
|
||||
|
||||
## Сборка из исходников
|
||||
|
||||
Нужен `csc.exe` из установленного .NET Framework 4.x. `build.cmd` использует
|
||||
`%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`; Visual Studio и
|
||||
.NET SDK не требуются.
|
||||
|
||||
В чистом клоне для подключения и проверки конфигов дополнительно разместите
|
||||
совместимый `bin/sb.exe` и файл правил `doc/doc.srs`. Бинарники не хранятся
|
||||
в Git, но включаются в готовый релизный ZIP. Текущий комплект использует
|
||||
sing-box `1.12.19`, `windows/amd64`.
|
||||
|
||||
```powershell
|
||||
Copy-Item .\local.example.json .\local.json
|
||||
Copy-Item .\profiles.example.json .\profiles.json
|
||||
cmd /c build.cmd
|
||||
.\SbLauncher.exe
|
||||
```
|
||||
|
||||
Демонстрационные адреса из `profiles.example.json` не подключаются. Добавьте
|
||||
свои серверы через импорт или в `profiles.json`, затем укажите приватный ключ
|
||||
WireGuard, адрес туннеля и сетевой адаптер на вкладке «Настройки». Секреты в
|
||||
`local.json`, а также серверы в `profiles.json` исключены из Git. Не добавляйте
|
||||
их в коммиты и релизные архивы.
|
||||
Базовые серверы загрузятся из `profiles.base.json`, личные файлы создаются
|
||||
программой при сохранении настроек или импорте. `profiles.example.json`
|
||||
содержит демонстрационные адреса для тестирования старого формата и не
|
||||
используется для первой установки из релиза.
|
||||
|
||||
Готовый ZIP из релиза распаковывается аналогично: поместите рядом с лаунчером
|
||||
`bin/sb.exe` и `doc/doc.srs`, скопируйте два файла `.example.json` без слова
|
||||
`.example` в имени и заполните свои данные. Сборка из исходников для ZIP не нужна.
|
||||
|
||||
## Сборка и проверки
|
||||
## Проверки
|
||||
|
||||
```powershell
|
||||
cmd /c build.cmd # SbLauncher.exe
|
||||
cmd /c build.cmd importtest # импорт ссылок
|
||||
cmd /c build.cmd profiletest # сохранение профилей
|
||||
cmd /c build.cmd importtest # форматы импорта и конфиги sing-box
|
||||
cmd /c build.cmd profiletest # импорт и сохранение профилей
|
||||
cmd /c build.cmd updatetest # обновление базы и сохранение личных данных
|
||||
cmd /c build.cmd stress # нагрузка интерфейса
|
||||
cmd /c build.cmd themetest # анимация темы и быстрые переключения
|
||||
```
|
||||
|
||||
`build.cmd selftest` проверяет локальную конфигурацию и может использовать
|
||||
текущие профили. `build.cmd deploy "путь"` копирует сборку в явно указанную
|
||||
тестовую папку; локальный `local.json` в ней не перезаписывается.
|
||||
`build.cmd selftest` проверяет конфигурацию и может читать текущие профили.
|
||||
`build.cmd deploy "путь"` копирует программу, базовый каталог и шаблоны в
|
||||
явно указанную тестовую папку; `local.json` и личный `profiles.json` не заменяет.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Основная ветка — `main`. Разработку фиксируйте коммитами в Git. Для очередного
|
||||
релиза обновите [CHANGELOG.md](CHANGELOG.md), три версии в `Version.cs` и номер
|
||||
`assemblyIdentity` в `app.manifest`; используйте формат `MAJOR.MINOR.PATCH`.
|
||||
После этого, например для версии `0.1.1`:
|
||||
Основная ветка — `main`. Обновите [CHANGELOG.md](CHANGELOG.md), три версии
|
||||
в `Version.cs` и номер `assemblyIdentity` в `app.manifest`. Перед коммитом
|
||||
проверьте список файлов: личных настроек, серверов и резервных копий
|
||||
в нём быть не должно. Для версии `0.1.2`:
|
||||
|
||||
```powershell
|
||||
git status
|
||||
git add -A
|
||||
git diff --cached --stat
|
||||
git commit -m "Release v0.1.1"
|
||||
.\release.ps1 -Version 0.1.1
|
||||
git tag -a v0.1.1 -m "SbLauncher v0.1.1"
|
||||
```
|
||||
|
||||
Перед коммитом проверьте список добавленных файлов: в нём должны быть только
|
||||
изменения для этого релиза. `release.ps1` требует чистую рабочую копию, запускает сборку и три
|
||||
проверки, сверяет версию EXE и собирает `dist/SbLauncher-vX.Y.Z-win-x64.zip`
|
||||
с SHA-256 в соседнем `.sha256`. Архив содержит только приложение, шаблоны,
|
||||
примеры конфигурации и документацию. Сборка идёт через `build.cmd staged`,
|
||||
поэтому открытый лаунчер не блокирует выпуск. Для пробного запуска на незакоммиченных
|
||||
изменениях есть `-AllowDirty`; архив получает суффикс `-dev` с хешем коммита.
|
||||
|
||||
При настроенном удалённом репозитории отправьте ветку и тег:
|
||||
|
||||
```powershell
|
||||
git commit -m "Release v0.1.2"
|
||||
.\release.ps1 -Version 0.1.2
|
||||
git tag -a v0.1.2 -m "SbLauncher v0.1.2"
|
||||
git push origin main
|
||||
git push origin v0.1.1
|
||||
git push origin v0.1.2
|
||||
```
|
||||
|
||||
Затем создайте релиз `v0.1.1` в GitHub/Gitea, вставьте заметки из
|
||||
`CHANGELOG.md` и прикрепите ZIP и `.sha256`. Если `origin` ещё не настроен,
|
||||
сначала добавьте адрес своего удалённого репозитория.
|
||||
`release.ps1` требует чистый Git-коммит, собирает приложение и запускает
|
||||
пять проверок. Сверяет версию EXE, запрещает персональные поля WireGuard в
|
||||
базовом каталоге и собирает `dist/SbLauncher-vX.Y.Z-win-x64.zip` с SHA-256
|
||||
в соседнем `.sha256`. Архив содержит приложение, sing-box, файл правил,
|
||||
базовые серверы, шаблоны, примеры и документацию. Личные файлы в него не входят.
|
||||
Сборка идёт через `build.cmd staged`, поэтому открытый лаунчер не блокирует
|
||||
выпуск. `-AllowDirty` предназначен для пробной сборки: архив получает
|
||||
суффикс `-dev` и не должен публиковаться как стабильный релиз.
|
||||
|
||||
Создайте релиз `v0.1.2` в GitHub/Gitea, вставьте заметки из `CHANGELOG.md`
|
||||
и прикрепите ZIP и `.sha256`.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SbLauncher — GUI для sing-box.
|
||||
//
|
||||
// ПРИНЦИП (изменён по сравнению с прошлой версией):
|
||||
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
|
||||
// Источник правды — profiles.base.json (базовые серверы), profiles.json
|
||||
// (личные серверы) и local.json (ключ,
|
||||
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
|
||||
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
|
||||
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
|
||||
@@ -187,6 +188,7 @@ namespace SbLauncher
|
||||
public Paths(string root) { Root = root; }
|
||||
|
||||
public string Profiles { get { return Path.Combine(Root, "profiles.json"); } }
|
||||
public string BaseProfiles { get { return Path.Combine(Root, "profiles.base.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")); } }
|
||||
@@ -217,6 +219,7 @@ namespace SbLauncher
|
||||
public string SpiderX = ""; // старые профили сохраняют это поле
|
||||
public string Protocol = "vless";
|
||||
public Dictionary<string, object> Outbound; // точная конфигурация импортированного ключа
|
||||
public string BaseId = ""; // стабильный id только у серверов из релиза
|
||||
|
||||
public string Endpoint
|
||||
{
|
||||
@@ -232,19 +235,115 @@ namespace SbLauncher
|
||||
public int Mtu = 1280;
|
||||
}
|
||||
|
||||
// profiles.json — локальная копия серверов; в тестовом репозитории не коммитится
|
||||
// Релиз обновляет profiles.base.json. profiles.json хранит только личные
|
||||
// серверы и скрытые базовые id; local.json независимо хранит ключ и адрес.
|
||||
public class Profiles
|
||||
{
|
||||
public WgCfg Wg = new WgCfg();
|
||||
public List<ServerCfg> Servers = new List<ServerCfg>();
|
||||
public HashSet<string> HiddenBaseServers = new HashSet<string>(StringComparer.Ordinal);
|
||||
public bool Layered;
|
||||
public bool NeedsMigration;
|
||||
bool _wgOverride;
|
||||
|
||||
public static Profiles Load(string path)
|
||||
{
|
||||
Profiles personal = ReadFile(path, false);
|
||||
string basePath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.base.json");
|
||||
if (!string.Equals(Path.GetFileName(path), "profiles.json", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(basePath)) return personal;
|
||||
|
||||
Profiles baseline = ReadFile(basePath, true);
|
||||
Profiles merged = new Profiles();
|
||||
merged.Layered = true;
|
||||
merged.NeedsMigration = File.Exists(path) && !personal.Layered;
|
||||
merged.HiddenBaseServers.UnionWith(personal.HiddenBaseServers);
|
||||
merged._wgOverride = personal._wgOverride && (personal.Layered || !SameWg(personal.Wg, baseline.Wg));
|
||||
merged.Wg = merged._wgOverride ? personal.Wg : baseline.Wg;
|
||||
|
||||
// Старый общий список превращается в личный слой без дубликатов.
|
||||
// Непохожие на базовые записи сохраняем, включая старые настройки.
|
||||
List<ServerCfg> own = new List<ServerCfg>();
|
||||
foreach (ServerCfg server in personal.Servers)
|
||||
{
|
||||
bool sharedLegacy = false;
|
||||
if (!personal.Layered)
|
||||
foreach (ServerCfg shared in baseline.Servers)
|
||||
if (SameProfile(shared, server)) { sharedLegacy = true; break; }
|
||||
if (!sharedLegacy) own.Add(server);
|
||||
}
|
||||
// Личные коды не меняем при появлении базового сервера с тем же кодом.
|
||||
List<ServerCfg> reserved = new List<ServerCfg>(own);
|
||||
foreach (ServerCfg shared in baseline.Servers)
|
||||
{
|
||||
if (merged.HiddenBaseServers.Contains(shared.BaseId)) continue;
|
||||
GiveUniqueCode(shared, reserved);
|
||||
reserved.Add(shared);
|
||||
merged.Servers.Add(shared);
|
||||
}
|
||||
merged.Servers.AddRange(own);
|
||||
return merged;
|
||||
}
|
||||
|
||||
static bool SameWg(WgCfg a, WgCfg b)
|
||||
{
|
||||
return a.Server == b.Server && a.Port == b.Port &&
|
||||
a.PeerPublicKey == b.PeerPublicKey && a.Mtu == b.Mtu;
|
||||
}
|
||||
|
||||
static bool SameProfile(ServerCfg a, ServerCfg b)
|
||||
{
|
||||
// Compare complete connection settings: a customized SNI/transport
|
||||
// with the same endpoint and UUID must remain a personal profile.
|
||||
return KeyImporter.Same(
|
||||
new ServerCfg { Protocol = a.Protocol, Server = a.Server, Port = a.Port,
|
||||
Outbound = ProxyOutbound.Build(a, ConfigBuilder.ModeDirect) },
|
||||
new ServerCfg { Protocol = b.Protocol, Server = b.Server, Port = b.Port,
|
||||
Outbound = ProxyOutbound.Build(b, ConfigBuilder.ModeDirect) });
|
||||
}
|
||||
|
||||
public static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all)
|
||||
{
|
||||
string original = server.Code;
|
||||
int number = 2;
|
||||
while (all.Exists(delegate(ServerCfg existing) {
|
||||
return string.Equals(existing.Code, server.Code, StringComparison.OrdinalIgnoreCase);
|
||||
})) server.Code = original + (number++).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public Profiles Copy()
|
||||
{
|
||||
Profiles next = new Profiles();
|
||||
next.Wg = Wg;
|
||||
next.Servers.AddRange(Servers);
|
||||
next.HiddenBaseServers.UnionWith(HiddenBaseServers);
|
||||
next.Layered = Layered;
|
||||
next.NeedsMigration = NeedsMigration;
|
||||
next._wgOverride = _wgOverride;
|
||||
return next;
|
||||
}
|
||||
|
||||
public void Remove(ServerCfg server)
|
||||
{
|
||||
Servers.Remove(server);
|
||||
if (server.BaseId.Length > 0) HiddenBaseServers.Add(server.BaseId);
|
||||
}
|
||||
|
||||
static Profiles ReadFile(string path, bool isBase)
|
||||
{
|
||||
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;
|
||||
p.Layered = J.Int(root, "schema_version", 0) >= 2;
|
||||
object hidden;
|
||||
if (root.TryGetValue("hidden_base_servers", out hidden))
|
||||
{
|
||||
object[] ids = hidden as object[];
|
||||
if (ids != null) foreach (object id in ids)
|
||||
if (id is string) p.HiddenBaseServers.Add((string)id);
|
||||
}
|
||||
|
||||
object wo;
|
||||
if (root.TryGetValue("wireguard", out wo))
|
||||
@@ -252,6 +351,7 @@ namespace SbLauncher
|
||||
Dictionary<string, object> w = J.Obj(wo);
|
||||
if (w != null)
|
||||
{
|
||||
p._wgOverride = true;
|
||||
p.Wg.Server = J.Str(w, "server", "");
|
||||
p.Wg.Port = J.Int(w, "port", 51820);
|
||||
p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", "");
|
||||
@@ -276,6 +376,12 @@ namespace SbLauncher
|
||||
s.Flow = J.Str(d, "flow", "xtls-rprx-vision");
|
||||
s.SpiderX = J.Str(d, "spider_x", "");
|
||||
s.Protocol = J.Str(d, "protocol", "vless");
|
||||
if (isBase)
|
||||
{
|
||||
s.BaseId = J.Str(d, "id", s.Code);
|
||||
if (s.BaseId.Length == 0 || p.Servers.Exists(delegate(ServerCfg existing) { return existing.BaseId == s.BaseId; }))
|
||||
throw new FormatException("У базовых серверов должны быть уникальные непустые id.");
|
||||
}
|
||||
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);
|
||||
@@ -284,25 +390,43 @@ namespace SbLauncher
|
||||
return p;
|
||||
}
|
||||
|
||||
// Пишется только когда сервер добавили/удалили из GUI. Файл
|
||||
// Пишется при миграции или добавлении/удалении сервера из 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++)
|
||||
if (NeedsMigration && File.Exists(path))
|
||||
{
|
||||
ServerCfg s = Servers[i];
|
||||
string backup = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.legacy-backup.json");
|
||||
if (!File.Exists(backup)) File.Copy(path, backup);
|
||||
}
|
||||
List<ServerCfg> stored = Layered
|
||||
? Servers.FindAll(delegate(ServerCfg server) { return server.BaseId.Length == 0; })
|
||||
: Servers;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("// Личные серверы и настройки списка. Не включайте этот файл в релиз.");
|
||||
sb.AppendLine("// Базовые серверы — profiles.base.json; ключ и адрес — local.json.");
|
||||
sb.AppendLine("{");
|
||||
if (Layered)
|
||||
{
|
||||
sb.AppendLine(" \"schema_version\": 2,");
|
||||
List<string> hidden = new List<string>(HiddenBaseServers);
|
||||
hidden.Sort(StringComparer.Ordinal);
|
||||
sb.AppendLine(" \"hidden_base_servers\": " + J.ToJson(hidden.ToArray()) + ",");
|
||||
}
|
||||
if (!Layered || _wgOverride)
|
||||
{
|
||||
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 < stored.Count; i++)
|
||||
{
|
||||
ServerCfg s = stored[i];
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\",");
|
||||
sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\",");
|
||||
@@ -322,11 +446,12 @@ namespace SbLauncher
|
||||
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(i == stored.Count - 1 ? " }" : " },");
|
||||
}
|
||||
sb.AppendLine(" ]");
|
||||
sb.AppendLine("}");
|
||||
J.Write(path, sb.ToString());
|
||||
NeedsMigration = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,97 +23,97 @@
|
||||
|
||||
<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"/>
|
||||
<GradientStop Color="#101113" Offset="0"/>
|
||||
<GradientStop Color="#17191D" Offset="0.5"/>
|
||||
<GradientStop Color="#1C1F25" 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"/>
|
||||
<SolidColorBrush x:Key="Panel" Color="#DD1B1E23"/>
|
||||
<SolidColorBrush x:Key="Card" Color="#C0252931"/>
|
||||
<SolidColorBrush x:Key="CardHover" Color="#D7333944"/>
|
||||
<SolidColorBrush x:Key="CardSel" Color="#D2354052"/>
|
||||
<SolidColorBrush x:Key="Stroke" Color="#3AADB7C6"/>
|
||||
<SolidColorBrush x:Key="GlassEdge" Color="#45C3CFDF"/>
|
||||
<SolidColorBrush x:Key="Text" Color="#F1F3F6"/>
|
||||
<SolidColorBrush x:Key="Muted" Color="#A5AFBD"/>
|
||||
<SolidColorBrush x:Key="Accent" Color="#78A9FF"/>
|
||||
<SolidColorBrush x:Key="AccentHover" Color="#98BEFF"/>
|
||||
<SolidColorBrush x:Key="AccentDim" Color="#5385DC"/>
|
||||
<SolidColorBrush x:Key="OnAccent" Color="#101820"/>
|
||||
<SolidColorBrush x:Key="SoftBg" Color="#AA282D36"/>
|
||||
<SolidColorBrush x:Key="SoftHover" Color="#CE353D49"/>
|
||||
<SolidColorBrush x:Key="SwitchTrack" Color="#FF363C46"/>
|
||||
<SolidColorBrush x:Key="SwitchThumb" Color="#F4F6FA"/>
|
||||
<SolidColorBrush x:Key="BadgeBg" Color="#FF303844"/>
|
||||
<SolidColorBrush x:Key="PingBg" Color="#A6191D24"/>
|
||||
<SolidColorBrush x:Key="LogBg" Color="#D214171C"/>
|
||||
<SolidColorBrush x:Key="LogText" Color="#BECBD9"/>
|
||||
<SolidColorBrush x:Key="ScrollTrack" Color="#182D3540"/>
|
||||
<SolidColorBrush x:Key="ScrollThumb" Color="#706A778B"/>
|
||||
<SolidColorBrush x:Key="ScrollThumbHover" Color="#ACA7B4C7"/>
|
||||
<SolidColorBrush x:Key="HeroRing" Color="#948BA4C9"/>
|
||||
<SolidColorBrush x:Key="HeroRingDim" Color="#3D93AACA"/>
|
||||
<RadialGradientBrush x:Key="GlowA">
|
||||
<GradientStop Color="#98759CFF" Offset="0"/>
|
||||
<GradientStop Color="#00759CFF" Offset="1"/>
|
||||
<GradientStop Color="#28687890" Offset="0"/>
|
||||
<GradientStop Color="#00687890" Offset="1"/>
|
||||
</RadialGradientBrush>
|
||||
<RadialGradientBrush x:Key="GlowB">
|
||||
<GradientStop Color="#6652C8DC" Offset="0"/>
|
||||
<GradientStop Color="#0052C8DC" Offset="1"/>
|
||||
<GradientStop Color="#1C4B6F9C" Offset="0"/>
|
||||
<GradientStop Color="#004B6F9C" 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>
|
||||
<SolidColorBrush x:Key="Ok" Color="#6BDBAE"/>
|
||||
<SolidColorBrush x:Key="Warn" Color="#E8BF78"/>
|
||||
<SolidColorBrush x:Key="Bad" Color="#F28794"/>
|
||||
<SolidColorBrush x:Key="Idle" Color="#A5AFBD"/>
|
||||
<SolidColorBrush x:Key="Na" Color="#85918E"/>
|
||||
<sys:Double x:Key="ShadowOpacity">0.35</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"/>
|
||||
<GradientStop Color="#F3F4F6" Offset="0"/>
|
||||
<GradientStop Color="#E8EBEF" Offset="0.5"/>
|
||||
<GradientStop Color="#EFF1F4" 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="Panel" Color="#EBF8F9FB"/>
|
||||
<SolidColorBrush x:Key="Card" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="CardHover" Color="#F7F9FC"/>
|
||||
<SolidColorBrush x:Key="CardSel" Color="#E3EAF3"/>
|
||||
<SolidColorBrush x:Key="Stroke" Color="#CDD3DC"/>
|
||||
<SolidColorBrush x:Key="GlassEdge" Color="#D5DAE2"/>
|
||||
<SolidColorBrush x:Key="Text" Color="#242B35"/>
|
||||
<SolidColorBrush x:Key="Muted" Color="#606C7B"/>
|
||||
<SolidColorBrush x:Key="Accent" Color="#405B80"/>
|
||||
<SolidColorBrush x:Key="AccentHover" Color="#4E6D96"/>
|
||||
<SolidColorBrush x:Key="AccentDim" Color="#344C6E"/>
|
||||
<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="SoftBg" Color="#E9EDF2"/>
|
||||
<SolidColorBrush x:Key="SoftHover" Color="#F6F8FB"/>
|
||||
<SolidColorBrush x:Key="SwitchTrack" Color="#C5CDD7"/>
|
||||
<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"/>
|
||||
<SolidColorBrush x:Key="BadgeBg" Color="#E0E6EE"/>
|
||||
<SolidColorBrush x:Key="PingBg" Color="#EDF1F5"/>
|
||||
<SolidColorBrush x:Key="LogBg" Color="#F8F9FB"/>
|
||||
<SolidColorBrush x:Key="LogText" Color="#49586B"/>
|
||||
<SolidColorBrush x:Key="ScrollTrack" Color="#18758499"/>
|
||||
<SolidColorBrush x:Key="ScrollThumb" Color="#8895A6"/>
|
||||
<SolidColorBrush x:Key="ScrollThumbHover" Color="#687A90"/>
|
||||
<SolidColorBrush x:Key="HeroRing" Color="#905F7591"/>
|
||||
<SolidColorBrush x:Key="HeroRingDim" Color="#407D8FA6"/>
|
||||
<RadialGradientBrush x:Key="GlowA">
|
||||
<GradientStop Color="#AA8EC2FF" Offset="0"/>
|
||||
<GradientStop Color="#008EC2FF" Offset="1"/>
|
||||
<GradientStop Color="#127689A2" Offset="0"/>
|
||||
<GradientStop Color="#007689A2" Offset="1"/>
|
||||
</RadialGradientBrush>
|
||||
<RadialGradientBrush x:Key="GlowB">
|
||||
<GradientStop Color="#99B2A5FF" Offset="0"/>
|
||||
<GradientStop Color="#00B2A5FF" Offset="1"/>
|
||||
<GradientStop Color="#108398B4" Offset="0"/>
|
||||
<GradientStop Color="#008398B4" 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>
|
||||
<SolidColorBrush x:Key="Ok" Color="#207553"/>
|
||||
<SolidColorBrush x:Key="Warn" Color="#95620D"/>
|
||||
<SolidColorBrush x:Key="Bad" Color="#BD3848"/>
|
||||
<SolidColorBrush x:Key="Idle" Color="#606C7B"/>
|
||||
<SolidColorBrush x:Key="Na" Color="#606C7B"/>
|
||||
<sys:Double x:Key="ShadowOpacity">0.10</sys:Double>
|
||||
</ResourceDictionary>
|
||||
|
||||
<!-- Одинаковая прокрутка во всех ScrollViewer/ListBox/TextBox. -->
|
||||
@@ -570,12 +570,14 @@
|
||||
<Style x:Key="Tab" TargetType="Button">
|
||||
<Setter Property="FontSize" Value="10.5"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Width" Value="112"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="Transparent" Padding="0,2,20,8">
|
||||
<ContentPresenter/>
|
||||
<Border Background="Transparent" CornerRadius="10">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
@@ -646,7 +648,22 @@
|
||||
VerticalAlignment="Center" Margin="0,0,12,0">
|
||||
<Button x:Name="BtnTheme" Style="{StaticResource Chrome}"
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="14"
|
||||
Content="{Binding ThemeGlyph}" ToolTip="Тема: светлая / тёмная"/>
|
||||
ToolTip="{Binding ThemeAction}" AutomationProperties.Name="{Binding ThemeAction}">
|
||||
<Grid Width="22" Height="22" IsHitTestVisible="False">
|
||||
<TextBlock x:Name="ThemeSun" Text="" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<TransformGroup><ScaleTransform/><RotateTransform/></TransformGroup>
|
||||
</TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
<TextBlock x:Name="ThemeMoon" Text="" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" Opacity="0" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<TransformGroup><ScaleTransform ScaleX="0.4" ScaleY="0.4"/><RotateTransform Angle="90"/></TransformGroup>
|
||||
</TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button x:Name="BtnMin" Style="{StaticResource Chrome}" Content="–"/>
|
||||
<Button x:Name="BtnClose" Style="{StaticResource Chrome}" Content="✕"/>
|
||||
</StackPanel>
|
||||
@@ -884,22 +901,33 @@
|
||||
</Border>
|
||||
|
||||
<!-- вкладки -->
|
||||
<Grid Grid.Row="2" Margin="6,14,4,3">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button x:Name="BtnTabLog" Style="{StaticResource Tab}" Content="ЛОГ"
|
||||
Foreground="{Binding TabLogBrush}"/>
|
||||
<Button x:Name="BtnTabSettings" Style="{StaticResource Tab}" Content="НАСТРОЙКИ"
|
||||
Foreground="{Binding TabSettingsBrush}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border Grid.Row="2" Margin="0,12,0,10" Padding="3"
|
||||
HorizontalAlignment="Left" CornerRadius="14"
|
||||
Background="{DynamicResource SoftBg}"
|
||||
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1">
|
||||
<Grid x:Name="TabSelectorHost">
|
||||
<Border x:Name="TabSelectionWindow" Width="112" HorizontalAlignment="Left"
|
||||
CornerRadius="10" BorderBrush="{DynamicResource Accent}" BorderThickness="1">
|
||||
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
|
||||
<Border CornerRadius="9" Background="{DynamicResource Accent}" Opacity="0.16"/>
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="BtnTabLog" Style="{StaticResource Tab}" Content="ЛОГ"
|
||||
Foreground="{Binding TabLogBrush}"/>
|
||||
<Button x:Name="BtnTabSettings" Style="{StaticResource Tab}" Content="НАСТРОЙКИ"
|
||||
Foreground="{Binding TabSettingsBrush}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- содержимое вкладки: два слоя, видим всегда ровно один -->
|
||||
<Grid Grid.Row="3">
|
||||
<!-- Панели скользят в пределах этой области и сохраняют своё содержимое. -->
|
||||
<Grid x:Name="TabContentHost" Grid.Row="3" ClipToBounds="True">
|
||||
|
||||
<!-- ── лог ─────────────────────────────────────────────── -->
|
||||
<Border Visibility="{Binding LogVis}"
|
||||
<Border x:Name="LogPane"
|
||||
CornerRadius="21" Background="{DynamicResource LogBg}"
|
||||
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="4">
|
||||
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
|
||||
<TextBox x:Name="LogBox" Text="{Binding LogText, Mode=OneWay}"
|
||||
IsReadOnly="True" BorderThickness="0" Background="Transparent"
|
||||
Foreground="{Binding LogBrush}" SelectionBrush="{DynamicResource Accent}"
|
||||
@@ -910,9 +938,10 @@
|
||||
</Border>
|
||||
|
||||
<!-- ── настройки ───────────────────────────────────────── -->
|
||||
<Border Visibility="{Binding SettingsVis}"
|
||||
<Border x:Name="SettingsPane" Visibility="Hidden"
|
||||
CornerRadius="21" Background="{DynamicResource Panel}"
|
||||
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="22,18">
|
||||
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Reflection;
|
||||
|
||||
// Обновляйте вместе с CHANGELOG.md перед выпуском новой версии.
|
||||
[assembly: AssemblyVersion("0.1.1.0")]
|
||||
[assembly: AssemblyFileVersion("0.1.1.0")]
|
||||
[assembly: AssemblyInformationalVersion("0.1.1")]
|
||||
[assembly: AssemblyVersion("0.1.2.0")]
|
||||
[assembly: AssemblyFileVersion("0.1.2.0")]
|
||||
[assembly: AssemblyInformationalVersion("0.1.2")]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SbLauncher — GUI для sing-box.
|
||||
//
|
||||
// ПРИНЦИП (изменён по сравнению с прошлой версией):
|
||||
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
|
||||
// Источник правды — profiles.base.json (базовые серверы), profiles.json
|
||||
// (личные серверы) и local.json (ключ,
|
||||
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
|
||||
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
|
||||
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
|
||||
@@ -338,13 +339,12 @@ namespace SbLauncher
|
||||
get { return _showSettings; }
|
||||
set
|
||||
{
|
||||
if (_showSettings == value) return;
|
||||
_showSettings = value;
|
||||
Raise(); Raise("LogVis"); Raise("SettingsVis");
|
||||
Raise();
|
||||
Raise("TabLogBrush"); Raise("TabSettingsBrush");
|
||||
}
|
||||
}
|
||||
public Visibility LogVis { get { return _showSettings ? Visibility.Collapsed : Visibility.Visible; } }
|
||||
public Visibility SettingsVis { get { return _showSettings ? Visibility.Visible : Visibility.Collapsed; } }
|
||||
public Brush TabLogBrush { get { return Theme.Res(_showSettings ? "Muted" : "Accent"); } }
|
||||
public Brush TabSettingsBrush { get { return Theme.Res(_showSettings ? "Accent" : "Muted"); } }
|
||||
|
||||
@@ -478,15 +478,14 @@ namespace SbLauncher
|
||||
// ── тема ─────────────────────────────────────────────────────────
|
||||
public string ThemeName { get { return _theme; } }
|
||||
|
||||
// показываем действие, а не состояние: в тёмной теме — солнце
|
||||
// (E706 Brightness), в светлой — луна (E708 QuietHours)
|
||||
public string ThemeGlyph { get { return _theme == Theme.Light ? "" : ""; } }
|
||||
// Иконки в XAML показывают действие: в тёмной теме — солнце, в светлой — луна.
|
||||
public string ThemeAction { get { return _theme == Theme.Light ? "Включить тёмную тему" : "Включить светлую тему"; } }
|
||||
|
||||
public void SetTheme(string t)
|
||||
{
|
||||
_theme = (t == Theme.Light) ? Theme.Light : Theme.Dark;
|
||||
if (ThemeApplier != null) ThemeApplier(_theme);
|
||||
Raise("ThemeName"); Raise("ThemeGlyph");
|
||||
Raise("ThemeName"); Raise("ThemeAction");
|
||||
Raise("StatusBrush");
|
||||
Raise("LogBrush");
|
||||
Raise("TabLogBrush"); Raise("TabSettingsBrush");
|
||||
@@ -533,7 +532,8 @@ namespace SbLauncher
|
||||
Raise();
|
||||
if (_sel != null)
|
||||
{
|
||||
Local.LastServer = _sel.IsWgOnly ? "*wg" : _sel.Code;
|
||||
Local.LastServer = _sel.IsWgOnly ? "*wg" :
|
||||
(_sel.Cfg.BaseId.Length > 0 ? "base:" + _sel.Cfg.BaseId : _sel.Code);
|
||||
Local.Mode = CurrentMode;
|
||||
SaveLocal();
|
||||
}
|
||||
@@ -546,6 +546,7 @@ namespace SbLauncher
|
||||
{
|
||||
P = paths;
|
||||
Prof = Profiles.Load(P.Profiles);
|
||||
if (Prof.NeedsMigration) Prof.Save(P.Profiles);
|
||||
Local = LocalCfg.Load(P.Local);
|
||||
_wg = Local.Mode != ConfigBuilder.ModeDirect;
|
||||
|
||||
@@ -568,7 +569,7 @@ namespace SbLauncher
|
||||
RefreshAdapters();
|
||||
|
||||
AppendLog("серверов: " + Prof.Servers.Count + " корень: " + P.Root);
|
||||
if (!File.Exists(P.Profiles)) AppendLog("! нет " + P.Profiles);
|
||||
if (!File.Exists(P.Profiles) && !File.Exists(P.BaseProfiles)) AppendLog("! нет списка серверов");
|
||||
if (!File.Exists(P.Local)) AppendLog("! нет local.json — задайте ключ и адрес во вкладке «настройки»");
|
||||
if (!File.Exists(P.SbExe)) AppendLog("! нет " + P.SbExe);
|
||||
if (!File.Exists(P.RuleSet)) AppendLog("! нет " + P.RuleSet);
|
||||
@@ -578,7 +579,8 @@ namespace SbLauncher
|
||||
foreach (ServerVm vm in Servers)
|
||||
{
|
||||
bool match = Local.LastServer == "*wg" ? vm.IsWgOnly
|
||||
: (!vm.IsWgOnly && vm.Code == Local.LastServer);
|
||||
: (!vm.IsWgOnly && (vm.Code == Local.LastServer ||
|
||||
(vm.Cfg.BaseId.Length > 0 && "base:" + vm.Cfg.BaseId == Local.LastServer)));
|
||||
if (match) { want = vm; break; }
|
||||
}
|
||||
if (want == null)
|
||||
@@ -836,10 +838,7 @@ namespace SbLauncher
|
||||
|
||||
Profiles CopyProfiles()
|
||||
{
|
||||
Profiles next = new Profiles();
|
||||
next.Wg = Prof.Wg;
|
||||
next.Servers.AddRange(Prof.Servers);
|
||||
return next;
|
||||
return Prof.Copy();
|
||||
}
|
||||
|
||||
sealed class ImportWork
|
||||
@@ -852,17 +851,7 @@ namespace SbLauncher
|
||||
|
||||
static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all)
|
||||
{
|
||||
string code = server.Code;
|
||||
int number = 2;
|
||||
bool used = true;
|
||||
while (used)
|
||||
{
|
||||
used = false;
|
||||
foreach (ServerCfg existing in all)
|
||||
if (string.Equals(existing.Code, code, StringComparison.OrdinalIgnoreCase))
|
||||
{ used = true; code = server.Code + (number++).ToString(CultureInfo.InvariantCulture); break; }
|
||||
}
|
||||
server.Code = code;
|
||||
Profiles.GiveUniqueCode(server, all);
|
||||
}
|
||||
|
||||
public async Task<bool> AddFromLinkAsync(string link)
|
||||
@@ -954,7 +943,8 @@ namespace SbLauncher
|
||||
}
|
||||
|
||||
string msg = "Вы действительно хотите удалить сервер \"" + vm.Title + "\" (" +
|
||||
vm.Code + " / " + vm.Cfg.Endpoint + ")?\n\nСервер будет удален из profiles.json.";
|
||||
vm.Code + " / " + vm.Cfg.Endpoint + ")?\n\n" +
|
||||
(vm.Cfg.BaseId.Length > 0 ? "Базовый сервер будет скрыт и после обновления." : "Личный сервер будет удалён.");
|
||||
if (MessageBox.Show(msg, "Подтверждение удаления", MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question) != MessageBoxResult.Yes) return;
|
||||
|
||||
@@ -962,7 +952,7 @@ namespace SbLauncher
|
||||
try
|
||||
{
|
||||
Profiles next = CopyProfiles();
|
||||
next.Servers.Remove(vm.Cfg);
|
||||
next.Remove(vm.Cfg);
|
||||
await Task.Run(delegate { next.Save(P.Profiles); });
|
||||
Prof = next;
|
||||
int index = Servers.IndexOf(vm);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.1.0" name="SbLauncher"/>
|
||||
<assemblyIdentity version="0.1.2.0" name="SbLauncher"/>
|
||||
|
||||
<!-- TUN + auto_route требуют администратора.
|
||||
Цена: UAC при каждом запуске и автозапуск только через Планировщик
|
||||
|
||||
@@ -38,6 +38,8 @@ 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"=="themetest" goto themetest
|
||||
if /i "%~1"=="updatetest" goto updatetest
|
||||
if /i "%~1"=="profiletest" goto profiletest
|
||||
if /i "%~1"=="importtest" goto importtest
|
||||
if /i "%~1"=="staged" set "APP_OUT=SbLauncher.updated.exe"
|
||||
@@ -69,15 +71,10 @@ if not exist "%DST%" (
|
||||
echo [*] Deploying to %DST%
|
||||
copy /y SbLauncher.exe "%DST%\" >nul
|
||||
copy /y Ui.xaml "%DST%\" >nul
|
||||
copy /y profiles.json "%DST%\" >nul
|
||||
copy /y profiles.base.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
|
||||
)
|
||||
rem profiles.json and local.json are user data and never deployed.
|
||||
echo [+] Deployed. bin\sb.exe and doc\doc.srs must already be there.
|
||||
goto end
|
||||
|
||||
@@ -108,6 +105,15 @@ if errorlevel 1 goto failed
|
||||
if errorlevel 1 goto failed
|
||||
goto end
|
||||
|
||||
:themetest
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs
|
||||
if errorlevel 1 goto failed
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:ThemeTransitions.exe /reference:SbLauncher.selftest.exe %REFS% tests\ThemeTransitions.cs
|
||||
if errorlevel 1 goto failed
|
||||
"%CD%\ThemeTransitions.exe"
|
||||
if errorlevel 1 goto failed
|
||||
goto end
|
||||
|
||||
:profiletest
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs
|
||||
if errorlevel 1 goto failed
|
||||
@@ -117,6 +123,15 @@ if errorlevel 1 goto failed
|
||||
if errorlevel 1 goto failed
|
||||
goto end
|
||||
|
||||
:updatetest
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs
|
||||
if errorlevel 1 goto failed
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:ProfileUpdates.exe /reference:SbLauncher.selftest.exe %REFS% tests\ProfileUpdates.cs
|
||||
if errorlevel 1 goto failed
|
||||
"%CD%\ProfileUpdates.exe"
|
||||
if errorlevel 1 goto failed
|
||||
goto end
|
||||
|
||||
:importtest
|
||||
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs
|
||||
if errorlevel 1 goto failed
|
||||
|
||||
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib"><SolidColorBrush x:Key="Card">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="GlassEdge">#FFD5DAE2</SolidColorBrush><LinearGradientBrush StartPoint="0,0" EndPoint="1,1" x:Key="Bg"><LinearGradientBrush.GradientStops><GradientStop Color="#FFF3F4F6" Offset="0" /><GradientStop Color="#FFE8EBEF" Offset="0.5" /><GradientStop Color="#FFEFF1F4" Offset="1" /></LinearGradientBrush.GradientStops></LinearGradientBrush><SolidColorBrush x:Key="ScrollTrack">#18758499</SolidColorBrush><SolidColorBrush x:Key="ScrollThumbHover">#FF687A90</SolidColorBrush><SolidColorBrush x:Key="Bad">#FFBD3848</SolidColorBrush><SolidColorBrush x:Key="CardHover">#FFF7F9FC</SolidColorBrush><SolidColorBrush x:Key="HeroRing">#905F7591</SolidColorBrush><SolidColorBrush x:Key="LogText">#FF49586B</SolidColorBrush><SolidColorBrush x:Key="Ok">#FF207553</SolidColorBrush><SolidColorBrush x:Key="LogBg">#FFF8F9FB</SolidColorBrush><SolidColorBrush x:Key="Warn">#FF95620D</SolidColorBrush><SolidColorBrush x:Key="SwitchTrack">#FFC5CDD7</SolidColorBrush><SolidColorBrush x:Key="Muted">#FF606C7B</SolidColorBrush><RadialGradientBrush x:Key="GlowB"><RadialGradientBrush.GradientStops><GradientStop Color="#108398B4" Offset="0" /><GradientStop Color="#008398B4" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush><SolidColorBrush x:Key="SoftBg">#FFE9EDF2</SolidColorBrush><SolidColorBrush x:Key="SwitchThumb">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="SoftHover">#FFF6F8FB</SolidColorBrush><SolidColorBrush x:Key="CardSel">#FFE3EAF3</SolidColorBrush><SolidColorBrush x:Key="AccentHover">#FF4E6D96</SolidColorBrush><SolidColorBrush x:Key="Stroke">#FFCDD3DC</SolidColorBrush><SolidColorBrush x:Key="Text">#FF242B35</SolidColorBrush><SolidColorBrush x:Key="Panel">#EBF8F9FB</SolidColorBrush><SolidColorBrush x:Key="ScrollThumb">#FF8895A6</SolidColorBrush><SolidColorBrush x:Key="AccentDim">#FF344C6E</SolidColorBrush><SolidColorBrush x:Key="Na">#FF606C7B</SolidColorBrush><SolidColorBrush x:Key="Accent">#FF405B80</SolidColorBrush><SolidColorBrush x:Key="PingBg">#FFEDF1F5</SolidColorBrush><SolidColorBrush x:Key="OnAccent">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRingDim">#407D8FA6</SolidColorBrush><s:Double x:Key="ShadowOpacity">0.1</s:Double><SolidColorBrush x:Key="Idle">#FF606C7B</SolidColorBrush><SolidColorBrush x:Key="BadgeBg">#FFE0E6EE</SolidColorBrush><RadialGradientBrush x:Key="GlowA"><RadialGradientBrush.GradientStops><GradientStop Color="#127689A2" Offset="0" /><GradientStop Color="#007689A2" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush></ResourceDictionary>
|
||||
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 263 KiB |
@@ -0,0 +1 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib"><SolidColorBrush x:Key="Card">#EFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="GlassEdge">#FFE8F0FA</SolidColorBrush><LinearGradientBrush StartPoint="0,0" EndPoint="1,1" x:Key="Bg"><LinearGradientBrush.GradientStops><GradientStop Color="#FFEDF5FF" Offset="0" /><GradientStop Color="#FFDDEAF9" Offset="0.5" /><GradientStop Color="#FFEAF0FD" Offset="1" /></LinearGradientBrush.GradientStops></LinearGradientBrush><SolidColorBrush x:Key="ScrollTrack">#207A9BCA</SolidColorBrush><SolidColorBrush x:Key="ScrollThumbHover">#FF577CA8</SolidColorBrush><SolidColorBrush x:Key="Bad">#FFBD3848</SolidColorBrush><SolidColorBrush x:Key="CardHover">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRing">#AC6593D3</SolidColorBrush><SolidColorBrush x:Key="LogText">#FF405E82</SolidColorBrush><SolidColorBrush x:Key="Ok">#FF207553</SolidColorBrush><SolidColorBrush x:Key="LogBg">#EAF9FCFF</SolidColorBrush><SolidColorBrush x:Key="Warn">#FF95620D</SolidColorBrush><SolidColorBrush x:Key="SwitchTrack">#FFA9BED8</SolidColorBrush><SolidColorBrush x:Key="Muted">#FF536A86</SolidColorBrush><RadialGradientBrush x:Key="GlowB"><RadialGradientBrush.GradientStops><GradientStop Color="#247F91D6" Offset="0" /><GradientStop Color="#007F91D6" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush><SolidColorBrush x:Key="SoftBg">#DBF7FBFF</SolidColorBrush><SolidColorBrush x:Key="SwitchThumb">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="SoftHover">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="CardSel">#FFDCEAFF</SolidColorBrush><SolidColorBrush x:Key="AccentHover">#FF3A7CE1</SolidColorBrush><SolidColorBrush x:Key="Stroke">#FFC4D3E6</SolidColorBrush><SolidColorBrush x:Key="Text">#FF1F3351</SolidColorBrush><SolidColorBrush x:Key="Panel">#D9F9FCFF</SolidColorBrush><SolidColorBrush x:Key="ScrollThumb">#FF829CBD</SolidColorBrush><SolidColorBrush x:Key="AccentDim">#FF245ABD</SolidColorBrush><SolidColorBrush x:Key="Na">#FF536A86</SolidColorBrush><SolidColorBrush x:Key="Accent">#FF2E6CCC</SolidColorBrush><SolidColorBrush x:Key="PingBg">#FFF4F8FE</SolidColorBrush><SolidColorBrush x:Key="OnAccent">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRingDim">#406C99D8</SolidColorBrush><s:Double x:Key="ShadowOpacity">0.1</s:Double><SolidColorBrush x:Key="Idle">#FF536A86</SolidColorBrush><SolidColorBrush x:Key="BadgeBg">#FFDCE8F8</SolidColorBrush><RadialGradientBrush x:Key="GlowA"><RadialGradientBrush.GradientStops><GradientStop Color="#387FAFEE" Offset="0" /><GradientStop Color="#007FAFEE" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush></ResourceDictionary>
|
||||
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 238 KiB |
@@ -0,0 +1 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib"><SolidColorBrush x:Key="Card">#FFFFFDFA</SolidColorBrush><SolidColorBrush x:Key="GlassEdge">#FFE4DACD</SolidColorBrush><LinearGradientBrush StartPoint="0,0" EndPoint="1,1" x:Key="Bg"><LinearGradientBrush.GradientStops><GradientStop Color="#FFF7F3EC" Offset="0" /><GradientStop Color="#FFEDE5D9" Offset="0.5" /><GradientStop Color="#FFF5EFE6" Offset="1" /></LinearGradientBrush.GradientStops></LinearGradientBrush><SolidColorBrush x:Key="ScrollTrack">#209A866C</SolidColorBrush><SolidColorBrush x:Key="ScrollThumbHover">#FF857359</SolidColorBrush><SolidColorBrush x:Key="Bad">#FFBD3848</SolidColorBrush><SolidColorBrush x:Key="CardHover">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRing">#9CA18765</SolidColorBrush><SolidColorBrush x:Key="LogText">#FF6B5A46</SolidColorBrush><SolidColorBrush x:Key="Ok">#FF207553</SolidColorBrush><SolidColorBrush x:Key="LogBg">#FFFCFAF5</SolidColorBrush><SolidColorBrush x:Key="Warn">#FF95620D</SolidColorBrush><SolidColorBrush x:Key="SwitchTrack">#FFCBBEAC</SolidColorBrush><SolidColorBrush x:Key="Muted">#FF746858</SolidColorBrush><RadialGradientBrush x:Key="GlowB"><RadialGradientBrush.GradientStops><GradientStop Color="#16C8AE80" Offset="0" /><GradientStop Color="#00C8AE80" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush><SolidColorBrush x:Key="SoftBg">#FFF0E9DF</SolidColorBrush><SolidColorBrush x:Key="SwitchThumb">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="SoftHover">#FFFFFCF7</SolidColorBrush><SolidColorBrush x:Key="CardSel">#FFECE0CF</SolidColorBrush><SolidColorBrush x:Key="AccentHover">#FF9A7049</SolidColorBrush><SolidColorBrush x:Key="Stroke">#FFD9CEBE</SolidColorBrush><SolidColorBrush x:Key="Text">#FF352F28</SolidColorBrush><SolidColorBrush x:Key="Panel">#EDFBF8F2</SolidColorBrush><SolidColorBrush x:Key="ScrollThumb">#FFA69680</SolidColorBrush><SolidColorBrush x:Key="AccentDim">#FF714D2C</SolidColorBrush><SolidColorBrush x:Key="Na">#FF746858</SolidColorBrush><SolidColorBrush x:Key="Accent">#FF89603B</SolidColorBrush><SolidColorBrush x:Key="PingBg">#FFF5F0E8</SolidColorBrush><SolidColorBrush x:Key="OnAccent">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRingDim">#40AE9370</SolidColorBrush><s:Double x:Key="ShadowOpacity">0.1</s:Double><SolidColorBrush x:Key="Idle">#FF746858</SolidColorBrush><SolidColorBrush x:Key="BadgeBg">#FFEDE2D2</SolidColorBrush><RadialGradientBrush x:Key="GlowA"><RadialGradientBrush.GradientStops><GradientStop Color="#209E875F" Offset="0" /><GradientStop Color="#009E875F" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush></ResourceDictionary>
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 247 KiB |
@@ -0,0 +1 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib"><SolidColorBrush x:Key="Card">#FFFDFEFC</SolidColorBrush><SolidColorBrush x:Key="GlassEdge">#FFD5E1D7</SolidColorBrush><LinearGradientBrush StartPoint="0,0" EndPoint="1,1" x:Key="Bg"><LinearGradientBrush.GradientStops><GradientStop Color="#FFF0F5F1" Offset="0" /><GradientStop Color="#FFDFE9E2" Offset="0.5" /><GradientStop Color="#FFEBF1E9" Offset="1" /></LinearGradientBrush.GradientStops></LinearGradientBrush><SolidColorBrush x:Key="ScrollTrack">#20829A8A</SolidColorBrush><SolidColorBrush x:Key="ScrollThumbHover">#FF608570</SolidColorBrush><SolidColorBrush x:Key="Bad">#FFBD3848</SolidColorBrush><SolidColorBrush x:Key="CardHover">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRing">#9C729B83</SolidColorBrush><SolidColorBrush x:Key="LogText">#FF486757</SolidColorBrush><SolidColorBrush x:Key="Ok">#FF207553</SolidColorBrush><SolidColorBrush x:Key="LogBg">#FFF7FBF7</SolidColorBrush><SolidColorBrush x:Key="Warn">#FF95620D</SolidColorBrush><SolidColorBrush x:Key="SwitchTrack">#FFB5C9BC</SolidColorBrush><SolidColorBrush x:Key="Muted">#FF5C7064</SolidColorBrush><RadialGradientBrush x:Key="GlowB"><RadialGradientBrush.GradientStops><GradientStop Color="#2084A26C" Offset="0" /><GradientStop Color="#0084A26C" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush><SolidColorBrush x:Key="SoftBg">#FFE5EEE7</SolidColorBrush><SolidColorBrush x:Key="SwitchThumb">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="SoftHover">#FFF6FCF7</SolidColorBrush><SolidColorBrush x:Key="CardSel">#FFDDECE2</SolidColorBrush><SolidColorBrush x:Key="AccentHover">#FF46846C</SolidColorBrush><SolidColorBrush x:Key="Stroke">#FFC5D4CB</SolidColorBrush><SolidColorBrush x:Key="Text">#FF25372F</SolidColorBrush><SolidColorBrush x:Key="Panel">#E8F7FBF7</SolidColorBrush><SolidColorBrush x:Key="ScrollThumb">#FF869E8E</SolidColorBrush><SolidColorBrush x:Key="AccentDim">#FF295C49</SolidColorBrush><SolidColorBrush x:Key="Na">#FF5C7064</SolidColorBrush><SolidColorBrush x:Key="Accent">#FF37715B</SolidColorBrush><SolidColorBrush x:Key="PingBg">#FFEDF4EE</SolidColorBrush><SolidColorBrush x:Key="OnAccent">#FFFFFFFF</SolidColorBrush><SolidColorBrush x:Key="HeroRingDim">#407FA78D</SolidColorBrush><s:Double x:Key="ShadowOpacity">0.1</s:Double><SolidColorBrush x:Key="Idle">#FF5C7064</SolidColorBrush><SolidColorBrush x:Key="BadgeBg">#FFDCE9DF</SolidColorBrush><RadialGradientBrush x:Key="GlowA"><RadialGradientBrush.GradientStops><GradientStop Color="#207DAF93" Offset="0" /><GradientStop Color="#007DAF93" Offset="1" /></RadialGradientBrush.GradientStops></RadialGradientBrush></ResourceDictionary>
|
||||
|
After Width: | Height: | Size: 678 KiB |
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"wireguard": {
|
||||
"server": "10.13.213.230",
|
||||
"port": 51820,
|
||||
"peer_public_key": "9HtCros3QcKqxDelI/LSYL1GAO6jt+YlSxQsvzE2nHk=",
|
||||
"mtu": 1280
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"id": "builtin-fi",
|
||||
"code": "FI",
|
||||
"name": "Finland",
|
||||
"server": "132.243.202.111",
|
||||
"port": 443,
|
||||
"uuid": "9abfc5b3-1aa4-4b03-bb9e-031ff89bd898",
|
||||
"sni": "www.asus.com",
|
||||
"fingerprint": "chrome",
|
||||
"public_key": "2YJaw09FMAXLOmtKL6hMFy7qHqnDjek91o5LkAfcyl4",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"short_id": "7aa1a249dc4c792a",
|
||||
"protocol": "vless"
|
||||
},
|
||||
{
|
||||
"id": "builtin-ee",
|
||||
"code": "EE",
|
||||
"name": "Estonia",
|
||||
"server": "5.183.190.30",
|
||||
"port": 443,
|
||||
"uuid": "6ab95aee-4406-46b6-96b2-6c8ba3fd42a7",
|
||||
"sni": "back2russia.net",
|
||||
"fingerprint": "chrome",
|
||||
"public_key": "ALXeVL8_LagCkzs0enk0dh98D9S_Vo2BTdDPVQiWAlw",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"short_id": "6b",
|
||||
"protocol": "vless"
|
||||
},
|
||||
{
|
||||
"id": "builtin-de",
|
||||
"code": "DE",
|
||||
"name": "Germany",
|
||||
"server": "88.210.63.115",
|
||||
"port": 443,
|
||||
"uuid": "20e62cd5-48d5-4758-b76d-60de59a74458",
|
||||
"sni": "www.bing.com",
|
||||
"fingerprint": "chrome",
|
||||
"public_key": "jvOOdwW8mEvYUvCyd3m1lqoD0IsB0MWi0eNxmCUcRl0",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"short_id": "c6435384cd9c96e4",
|
||||
"protocol": "vless"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,14 @@ try {
|
||||
}
|
||||
|
||||
$tag = 'v' + $Version
|
||||
$baseProfiles = Join-Path $root 'profiles.base.json'
|
||||
if (-not (Test-Path -LiteralPath $baseProfiles)) { throw 'Нет каталога базовых серверов profiles.base.json.' }
|
||||
if ((Get-Content -LiteralPath $baseProfiles -Raw) -match '"(?:private_key|local_address)"\s*:') {
|
||||
throw 'Базовый каталог содержит персональные поля WireGuard.'
|
||||
}
|
||||
foreach ($runtimeFile in @('bin\sb.exe', 'doc\doc.srs')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $root $runtimeFile))) { throw "Нет обязательного файла $runtimeFile." }
|
||||
}
|
||||
& git show-ref --verify --quiet ("refs/tags/" + $tag)
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$tagCommit = (& git rev-list -n 1 $tag).Trim()
|
||||
@@ -40,9 +48,13 @@ try {
|
||||
$stressRoot = Join-Path $dist ('stress-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $stressRoot | Out-Null
|
||||
$previousStressRoot = $env:SB_STRESS_ROOT
|
||||
$previousTemp = $env:TEMP
|
||||
$previousTmp = $env:TMP
|
||||
$env:SB_STRESS_ROOT = $stressRoot
|
||||
$env:TEMP = $stressRoot
|
||||
$env:TMP = $stressRoot
|
||||
try {
|
||||
foreach ($mode in @('staged', 'importtest', 'profiletest', 'stress')) {
|
||||
foreach ($mode in @('staged', 'importtest', 'profiletest', 'updatetest', 'stress', 'themetest')) {
|
||||
Write-Host "[*] build.cmd $mode"
|
||||
& $env:ComSpec /d /c build.cmd $mode
|
||||
if ($LASTEXITCODE -ne 0) { throw "Сборка или проверка $mode завершилась с ошибкой." }
|
||||
@@ -50,6 +62,8 @@ try {
|
||||
}
|
||||
finally {
|
||||
$env:SB_STRESS_ROOT = $previousStressRoot
|
||||
$env:TEMP = $previousTemp
|
||||
$env:TMP = $previousTmp
|
||||
$stressFull = [IO.Path]::GetFullPath($stressRoot)
|
||||
$distFull = [IO.Path]::GetFullPath($dist).TrimEnd('\') + '\'
|
||||
if ($stressFull.StartsWith($distFull, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
@@ -75,10 +89,15 @@ try {
|
||||
New-Item -ItemType Directory -Path $stage | Out-Null
|
||||
try {
|
||||
Copy-Item -LiteralPath $exe -Destination (Join-Path $stage 'SbLauncher.exe')
|
||||
foreach ($name in @('Ui.xaml', 'README.md', 'CHANGELOG.md',
|
||||
'profiles.example.json', 'local.example.json')) {
|
||||
foreach ($name in @('Ui.xaml', 'README.md', 'CHANGELOG.md', 'HANDOFF.md',
|
||||
'profiles.base.json', 'profiles.example.json', 'local.example.json')) {
|
||||
Copy-Item -LiteralPath (Join-Path $root $name) -Destination $stage
|
||||
}
|
||||
foreach ($runtimeDir in @('bin', 'doc')) {
|
||||
New-Item -ItemType Directory -Path (Join-Path $stage $runtimeDir) | Out-Null
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $root 'bin\sb.exe') -Destination (Join-Path $stage 'bin\sb.exe')
|
||||
Copy-Item -LiteralPath (Join-Path $root 'doc\doc.srs') -Destination (Join-Path $stage 'doc\doc.srs')
|
||||
$templates = Join-Path $stage 'templates'
|
||||
New-Item -ItemType Directory -Path $templates | Out-Null
|
||||
foreach ($name in @('wg.json', 'direct.json', 'wgonly.json')) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using SbLauncher;
|
||||
|
||||
// Real first-run/import/save/restart/update paths using synthetic credentials.
|
||||
public static class ProfileUpdates
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "SbLauncherUpdates-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
|
||||
MainVm vm = null;
|
||||
try
|
||||
{
|
||||
var a = Shared("a", "AA", "127.0.0.1", "00000000-0000-4000-8000-000000000001");
|
||||
var b = Shared("b", "BB", "127.0.0.1", "00000000-0000-4000-8000-000000000002");
|
||||
WriteBase(root, "198.51.100.10", a, b);
|
||||
Paths paths = new Paths(root);
|
||||
vm = new MainVm();
|
||||
vm.Load(paths);
|
||||
Require(vm.Prof.Servers.Count == 2 && !File.Exists(paths.Profiles), "Fresh install loads shared servers without personal profiles");
|
||||
vm.PrivateKey = Convert.ToBase64String(Enumerable.Range(1, 32).Select(delegate(int n) { return (byte)n; }).ToArray());
|
||||
vm.LocalAddress = "10.10.0.2/24";
|
||||
vm.Adapter = "Personal adapter";
|
||||
Require(Wait(vm.AddFromLinkAsync("trojan://personal-secret@127.0.0.2:443?sni=example.com#Personal")), "UI import adds a personal server");
|
||||
var own = vm.Prof.Servers.Single(delegate(ServerCfg server) { return server.BaseId.Length == 0; });
|
||||
string ownCode = own.Code;
|
||||
vm.Selected = vm.Servers.Single(delegate(ServerVm card) { return card.Cfg != null && card.Cfg.BaseId == "b"; });
|
||||
vm.FlushLocal();
|
||||
string localBefore = File.ReadAllText(paths.Local);
|
||||
string personalBefore = File.ReadAllText(paths.Profiles);
|
||||
Require(!personalBefore.Contains("00000000-0000-4000-8000-000000000001") &&
|
||||
!personalBefore.Contains("00000000-0000-4000-8000-000000000002"), "Saving personal servers excludes all shared credentials");
|
||||
vm.DisposeTimers();
|
||||
vm = null;
|
||||
|
||||
Profiles removed = Profiles.Load(paths.Profiles);
|
||||
removed.Remove(removed.Servers.Single(delegate(ServerCfg server) { return server.BaseId == "a"; }));
|
||||
removed.Save(paths.Profiles);
|
||||
string personalHidden = File.ReadAllText(paths.Profiles);
|
||||
// Simulate replacement of files shipped by the next release.
|
||||
var changedA = Shared("a", "AA", "127.0.0.3", "00000000-0000-4000-8000-000000000003");
|
||||
var changedB = Shared("b", "BB", "127.0.0.4", "00000000-0000-4000-8000-000000000004");
|
||||
var c = Shared("c", ownCode, "127.0.0.5", "00000000-0000-4000-8000-000000000005");
|
||||
WriteBase(root, "198.51.100.20", changedA, changedB, c);
|
||||
Require(File.ReadAllText(paths.Local) == localBefore && File.ReadAllText(paths.Profiles) == personalHidden,
|
||||
"Replacing the release catalog does not write either personal file");
|
||||
vm = new MainVm();
|
||||
vm.Load(paths);
|
||||
Require(vm.Prof.Servers.Count == 3 && vm.Prof.Wg.Server == "198.51.100.20", "Updated shared catalog and WireGuard endpoint load");
|
||||
Require(vm.Prof.Servers.All(delegate(ServerCfg server) { return server.BaseId != "a"; }), "Hidden shared server stays hidden after its credentials change");
|
||||
Require(vm.Prof.Servers.Single(delegate(ServerCfg server) { return server.BaseId == "b"; }).Server == "127.0.0.4", "Existing shared server receives updated configuration");
|
||||
Require(vm.Prof.Servers.Single(delegate(ServerCfg server) { return server.BaseId.Length == 0; }).Code == ownCode,
|
||||
"Personal server survives a shared code collision");
|
||||
Require(vm.Prof.Servers.Select(delegate(ServerCfg server) { return server.Code; }).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 3,
|
||||
"Merged server codes are unique");
|
||||
Require(vm.Selected.Cfg.BaseId == "b", "Shared selection is restored by stable id");
|
||||
vm.FlushLocal();
|
||||
Require(File.ReadAllText(paths.Local) == localBefore, "WireGuard key, address, adapter and selection survive restart after update");
|
||||
vm.DisposeTimers();
|
||||
vm = null;
|
||||
|
||||
// Upgrade a v0.1.1 full list with one personal server and settings.
|
||||
string legacyRoot = Path.Combine(root, "legacy");
|
||||
Directory.CreateDirectory(legacyRoot);
|
||||
WriteBase(legacyRoot, "198.51.100.10", a, b);
|
||||
Profiles legacy = new Profiles();
|
||||
legacy.Wg.Server = "198.51.100.10";
|
||||
legacy.Wg.PeerPublicKey = "shared-public-key";
|
||||
legacy.Servers.Add(new ServerCfg { Code = "AA", Server = "127.0.0.1", Uuid = "00000000-0000-4000-8000-000000000001" });
|
||||
legacy.Servers.Add(new ServerCfg { Code = "BB", Server = "127.0.0.1", Uuid = "00000000-0000-4000-8000-000000000002" });
|
||||
legacy.Servers.Add(own);
|
||||
string legacyPath = Path.Combine(legacyRoot, "profiles.json");
|
||||
legacy.Save(legacyPath);
|
||||
File.WriteAllText(Path.Combine(legacyRoot, "local.json"), localBefore);
|
||||
string legacyBefore = File.ReadAllText(legacyPath);
|
||||
vm = new MainVm();
|
||||
vm.Load(new Paths(legacyRoot));
|
||||
Require(vm.Prof.Servers.Count == 3 && !vm.Prof.NeedsMigration, "Legacy list migrates without losing personal servers or duplicating shared servers");
|
||||
Require(File.ReadAllText(Path.Combine(legacyRoot, "profiles.legacy-backup.json")) == legacyBefore,
|
||||
"Migration preserves a complete legacy backup");
|
||||
Require(!File.ReadAllText(legacyPath).Contains("00000000-0000-4000-8000-000000000001"), "Migration stores only personal servers");
|
||||
vm.DisposeTimers();
|
||||
vm = null;
|
||||
WriteBase(legacyRoot, "198.51.100.20", changedB);
|
||||
Profiles upgraded = Profiles.Load(legacyPath);
|
||||
Require(upgraded.Servers.Count == 2 && upgraded.Wg.Server == "198.51.100.20", "Migrated installations receive later shared updates and removals");
|
||||
Require(File.ReadAllText(Path.Combine(legacyRoot, "local.json")) == localBefore, "Legacy upgrade preserves the personal WireGuard settings");
|
||||
string customRoot = Path.Combine(root, "custom");
|
||||
Directory.CreateDirectory(customRoot);
|
||||
WriteBase(customRoot, "198.51.100.10", a);
|
||||
Profiles custom = new Profiles();
|
||||
custom.Servers.Add(new ServerCfg { Code = "AA", Server = "127.0.0.1",
|
||||
Uuid = "00000000-0000-4000-8000-000000000001", Sni = "custom.example.com" });
|
||||
custom.Save(Path.Combine(customRoot, "profiles.json"));
|
||||
Require(Profiles.Load(Path.Combine(customRoot, "profiles.json")).Servers.Count == 2,
|
||||
"Legacy custom connection settings are preserved even with a matching endpoint and UUID");
|
||||
upgraded.Remove(upgraded.Servers.Single(delegate(ServerCfg server) { return server.BaseId.Length == 0; }));
|
||||
upgraded.Save(legacyPath);
|
||||
Require(Profiles.Load(legacyPath).Servers.Count == 1, "Deleted personal server does not return on restart");
|
||||
Console.WriteLine("Profile update checks passed.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
|
||||
finally
|
||||
{
|
||||
if (vm != null) vm.DisposeTimers();
|
||||
app.Shutdown();
|
||||
string full = Path.GetFullPath(root);
|
||||
string temp = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
if (full.StartsWith(temp, StringComparison.OrdinalIgnoreCase)) Directory.Delete(full, true);
|
||||
}
|
||||
}
|
||||
|
||||
static Dictionary<string, object> Shared(string id, string code, string host, string uuid)
|
||||
{
|
||||
return new Dictionary<string, object> {
|
||||
{ "id", id }, { "code", code }, { "name", code }, { "server", host },
|
||||
{ "port", 443 }, { "uuid", uuid }, { "protocol", "vless" }, { "flow", "" }
|
||||
};
|
||||
}
|
||||
|
||||
static void WriteBase(string root, string wgHost, params Dictionary<string, object>[] servers)
|
||||
{
|
||||
J.Write(Path.Combine(root, "profiles.base.json"), J.ToJson(new Dictionary<string, object> {
|
||||
{ "wireguard", new Dictionary<string, object> { { "server", wgHost }, { "peer_public_key", "shared-public-key" } } },
|
||||
{ "servers", servers }
|
||||
}));
|
||||
}
|
||||
|
||||
static bool Wait(Task<bool> task)
|
||||
{
|
||||
var frame = new DispatcherFrame();
|
||||
var started = DateTime.UtcNow;
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
|
||||
timer.Tick += delegate {
|
||||
if (task.IsCompleted || (DateTime.UtcNow - started).TotalSeconds > 10) { timer.Stop(); frame.Continue = false; }
|
||||
};
|
||||
timer.Start();
|
||||
Dispatcher.PushFrame(frame);
|
||||
return task.IsCompleted && task.Result;
|
||||
}
|
||||
|
||||
static void Require(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException(message);
|
||||
Console.WriteLine("PASS " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Markup;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using SbLauncher;
|
||||
|
||||
// Exercises real WPF animation clocks, including interruption and settling.
|
||||
// Synthetic view model only: no local settings, network, or sing-box process.
|
||||
public static class ThemeTransitions
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
|
||||
Window window = null;
|
||||
var vm = new MainVm();
|
||||
try
|
||||
{
|
||||
string root = args.Length > 0 ? args[0] : AppDomain.CurrentDomain.BaseDirectory;
|
||||
window = (Window)XamlReader.Parse(File.ReadAllText(Path.Combine(root, "Ui.xaml")));
|
||||
window.DataContext = vm;
|
||||
vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); };
|
||||
vm.SetTheme(Theme.Dark);
|
||||
var dark = (ResourceDictionary)window.Resources["Theme.Dark"];
|
||||
var light = (ResourceDictionary)window.Resources["Theme.Light"];
|
||||
var sun = (TextBlock)window.FindName("ThemeSun");
|
||||
var moon = (TextBlock)window.FindName("ThemeMoon");
|
||||
Color darkText = ((SolidColorBrush)dark["Text"]).Color;
|
||||
Color lightText = ((SolidColorBrush)light["Text"]).Color;
|
||||
Require(SameColor(TextColor(window), darkText) && sun.Opacity == 1 && moon.Opacity == 0,
|
||||
"Startup is immediate and shows the correct icon");
|
||||
vm.SetTheme(Theme.Light);
|
||||
Require(SameColor(TextColor(window), lightText) && sun.Opacity == 0 && moon.Opacity == 1,
|
||||
"Unloaded screenshot rendering is immediate in both themes");
|
||||
vm.SetTheme(Theme.Dark);
|
||||
|
||||
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
window.Left = -20000;
|
||||
window.Top = -20000;
|
||||
window.ShowInTaskbar = false;
|
||||
window.ShowActivated = false;
|
||||
window.Show();
|
||||
PumpFor(100);
|
||||
vm.SetTheme(Theme.Light);
|
||||
Require(object.ReferenceEquals(window.Resources["Muted"], Application.Current.Resources["Muted"]),
|
||||
"Semantic colors and XAML share the transition brushes");
|
||||
if (SystemParameters.ClientAreaAnimation)
|
||||
{
|
||||
Require(SameColor(TextColor(window), darkText), "No initial palette flash");
|
||||
PumpFor(170);
|
||||
Color halfway = TextColor(window);
|
||||
Require(!SameColor(halfway, darkText) && !SameColor(halfway, lightText), "Text colors interpolate");
|
||||
Require(sun.Opacity > 0 && sun.Opacity < 1 && moon.Opacity > 0 && moon.Opacity < 1,
|
||||
"Both icons participate in the crossfade");
|
||||
var sunTransform = (TransformGroup)sun.RenderTransform;
|
||||
Require(((RotateTransform)sunTransform.Children[1]).Angle != 0 &&
|
||||
((ScaleTransform)sunTransform.Children[0]).ScaleX < 1,
|
||||
"Outgoing icon rotates and scales");
|
||||
var background = (GradientBrush)window.Resources["Bg"];
|
||||
Color middleStop = background.GradientStops[0].Color;
|
||||
double sunOpacity = sun.Opacity;
|
||||
vm.SetTheme(Theme.Dark);
|
||||
Require(SameColor(TextColor(window), halfway) &&
|
||||
SameColor(((GradientBrush)window.Resources["Bg"]).GradientStops[0].Color, middleStop) &&
|
||||
Math.Abs(sun.Opacity - sunOpacity) < 0.001,
|
||||
"Reversal continues from current colors and icon position");
|
||||
}
|
||||
else Require(SameColor(TextColor(window), lightText) && moon.Opacity == 1,
|
||||
"Windows reduced animation preference is respected");
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
vm.SetTheme(i % 2 == 0 ? Theme.Light : Theme.Dark);
|
||||
PumpFor(35);
|
||||
}
|
||||
vm.SetTheme(Theme.Light);
|
||||
PumpFor(650);
|
||||
Require(SameColor(TextColor(window), lightText) && sun.Opacity == 0 && moon.Opacity == 1,
|
||||
"Latest request wins after rapid switching");
|
||||
Require(vm.ThemeAction == "Включить тёмную тему", "Accessible action matches the destination");
|
||||
CheckPalette(window, light);
|
||||
vm.SetTheme(Theme.Dark);
|
||||
PumpFor(650);
|
||||
Require(SameColor(TextColor(window), darkText) && sun.Opacity == 1 && moon.Opacity == 0,
|
||||
"Reverse transition reaches its exact final state");
|
||||
CheckPalette(window, dark);
|
||||
Require(((SolidColorBrush)dark["Text"]).Color == darkText &&
|
||||
((SolidColorBrush)light["Text"]).Color == lightText &&
|
||||
!((SolidColorBrush)dark["Text"]).HasAnimatedProperties &&
|
||||
!((SolidColorBrush)light["Text"]).HasAnimatedProperties,
|
||||
"Source palettes are unchanged");
|
||||
Console.WriteLine("Theme transition checks passed.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
|
||||
finally
|
||||
{
|
||||
vm.DisposeTimers();
|
||||
if (window != null) window.Close();
|
||||
app.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
static Color TextColor(Window window) { return ((SolidColorBrush)window.Resources["Text"]).Color; }
|
||||
|
||||
// ColorAnimation interpolates in scRGB; compare the rendered 8-bit channels
|
||||
// rather than exact floating-point equality with XAML's sRGB representation.
|
||||
static bool SameColor(Color a, Color b) { return a.A == b.A && a.R == b.R && a.G == b.G && a.B == b.B; }
|
||||
|
||||
static void CheckPalette(Window window, ResourceDictionary expected)
|
||||
{
|
||||
foreach (object key in expected.Keys)
|
||||
{
|
||||
var solid = expected[key] as SolidColorBrush;
|
||||
if (solid != null)
|
||||
Require(SameColor(((SolidColorBrush)window.Resources[key]).Color, solid.Color), "Final color: " + key);
|
||||
var gradient = expected[key] as GradientBrush;
|
||||
if (gradient != null)
|
||||
{
|
||||
var actual = (GradientBrush)window.Resources[key];
|
||||
for (int i = 0; i < gradient.GradientStops.Count; i++)
|
||||
Require(SameColor(actual.GradientStops[i].Color, gradient.GradientStops[i].Color) &&
|
||||
actual.GradientStops[i].Offset == gradient.GradientStops[i].Offset,
|
||||
"Final gradient: " + key + " / " + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void PumpFor(int milliseconds)
|
||||
{
|
||||
var frame = new DispatcherFrame();
|
||||
var timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(milliseconds) };
|
||||
timer.Tick += delegate { timer.Stop(); frame.Continue = false; };
|
||||
timer.Start();
|
||||
Dispatcher.PushFrame(frame);
|
||||
}
|
||||
|
||||
static void Require(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException(message);
|
||||
Console.WriteLine("PASS " + message);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
@@ -26,6 +27,9 @@ public static class UiStress
|
||||
vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); };
|
||||
vm.Load(new Paths(dataRoot));
|
||||
vm.SetTheme(Theme.Dark);
|
||||
// Подключаем переходы вкладок без сетевых действий основного Wire.
|
||||
typeof(SbLauncher.App).GetMethod("WireTabs", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
.Invoke(null, new object[] { window, vm });
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
long previous = 0, longest = 0;
|
||||
int ticks = 0;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Preview-only renderer. Uses real WPF controls and synthetic data; no app settings are loaded.
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Markup;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using SbLauncher;
|
||||
|
||||
public static class RenderLightThemes
|
||||
{
|
||||
sealed class Variant
|
||||
{
|
||||
public string Id, Title, Description;
|
||||
public string[] Background;
|
||||
public string Colors;
|
||||
public Variant(string id, string title, string description, string[] background, string colors)
|
||||
{ Id = id; Title = title; Description = description; Background = background; Colors = colors; }
|
||||
}
|
||||
|
||||
static readonly Variant[] Variants = {
|
||||
new Variant("01-silver", "01 Серебро", "Нейтральный фон · графитовый акцент",
|
||||
new[] { "#F3F4F6", "#E8EBEF", "#EFF1F4" },
|
||||
"Panel=#EBF8F9FB;Card=#FFFFFFFF;CardHover=#FFF7F9FC;CardSel=#FFE3EAF3;Stroke=#FFCDD3DC;GlassEdge=#FFD5DAE2;Text=#242B35;Muted=#606C7B;Accent=#405B80;AccentHover=#4E6D96;AccentDim=#344C6E;OnAccent=#FFFFFF;SoftBg=#FFE9EDF2;SoftHover=#FFF6F8FB;SwitchTrack=#C5CDD7;SwitchThumb=#FFFFFF;BadgeBg=#E0E6EE;PingBg=#EDF1F5;LogBg=#F8F9FB;LogText=#49586B;ScrollTrack=#18758499;ScrollThumb=#8895A6;ScrollThumbHover=#687A90;HeroRing=#905F7591;HeroRingDim=#407D8FA6;GlowA=#127689A2;GlowB=#108398B4"),
|
||||
new Variant("02-frost", "02 Лёд", "Холодное стекло · синий акцент",
|
||||
new[] { "#EDF5FF", "#DDEAF9", "#EAF0FD" },
|
||||
"Panel=#D9F9FCFF;Card=#EFFFFFFF;CardHover=#FFFFFFFF;CardSel=#FFDCEAFF;Stroke=#FFC4D3E6;GlassEdge=#FFE8F0FA;Text=#1F3351;Muted=#536A86;Accent=#2E6CCC;AccentHover=#3A7CE1;AccentDim=#245ABD;OnAccent=#FFFFFF;SoftBg=#DBF7FBFF;SoftHover=#FFFFFFFF;SwitchTrack=#A9BED8;SwitchThumb=#FFFFFF;BadgeBg=#DCE8F8;PingBg=#F4F8FE;LogBg=#EAF9FCFF;LogText=#405E82;ScrollTrack=#207A9BCA;ScrollThumb=#829CBD;ScrollThumbHover=#577CA8;HeroRing=#AC6593D3;HeroRingDim=#406C99D8;GlowA=#387FAFEE;GlowB=#247F91D6"),
|
||||
new Variant("03-linen", "03 Лён", "Тёплый молочный фон · бронзовый акцент",
|
||||
new[] { "#F7F3EC", "#EDE5D9", "#F5EFE6" },
|
||||
"Panel=#EDFBF8F2;Card=#FFFFFDFA;CardHover=#FFFFFFFF;CardSel=#FFECE0CF;Stroke=#FFD9CEBE;GlassEdge=#FFE4DACD;Text=#352F28;Muted=#746858;Accent=#89603B;AccentHover=#9A7049;AccentDim=#714D2C;OnAccent=#FFFFFF;SoftBg=#FFF0E9DF;SoftHover=#FFFFFCF7;SwitchTrack=#CBBEAC;SwitchThumb=#FFFFFF;BadgeBg=#EDE2D2;PingBg=#F5F0E8;LogBg=#FCFAF5;LogText=#6B5A46;ScrollTrack=#209A866C;ScrollThumb=#A69680;ScrollThumbHover=#857359;HeroRing=#9CA18765;HeroRingDim=#40AE9370;GlowA=#209E875F;GlowB=#16C8AE80"),
|
||||
new Variant("04-sage", "04 Шалфей", "Серо-зелёный фон · хвойный акцент",
|
||||
new[] { "#F0F5F1", "#DFE9E2", "#EBF1E9" },
|
||||
"Panel=#E8F7FBF7;Card=#FFFDFEFC;CardHover=#FFFFFFFF;CardSel=#FFDDECE2;Stroke=#FFC5D4CB;GlassEdge=#FFD5E1D7;Text=#25372F;Muted=#5C7064;Accent=#37715B;AccentHover=#46846C;AccentDim=#295C49;OnAccent=#FFFFFF;SoftBg=#FFE5EEE7;SoftHover=#FFF6FCF7;SwitchTrack=#B5C9BC;SwitchThumb=#FFFFFF;BadgeBg=#DCE9DF;PingBg=#EDF4EE;LogBg=#F7FBF7;LogText=#486757;ScrollTrack=#20829A8A;ScrollThumb=#869E8E;ScrollThumbHover=#608570;HeroRing=#9C729B83;HeroRingDim=#407FA78D;GlowA=#207DAF93;GlowB=#2084A26C")
|
||||
};
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string source = Path.GetFullPath(args[0]);
|
||||
string output = Path.GetFullPath(args[1]);
|
||||
Directory.CreateDirectory(output);
|
||||
var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
|
||||
var screenshots = new BitmapSource[Variants.Length];
|
||||
for (int i = 0; i < Variants.Length; i++)
|
||||
{
|
||||
Variant variant = Variants[i];
|
||||
var window = (Window)XamlReader.Parse(File.ReadAllText(Path.Combine(source, "Ui.xaml")));
|
||||
var palette = (ResourceDictionary)window.Resources["Theme.Light"];
|
||||
var background = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 1) };
|
||||
for (int j = 0; j < 3; j++) background.GradientStops.Add(new GradientStop(Color(variant.Background[j]), j * 0.5));
|
||||
palette["Bg"] = background;
|
||||
foreach (string token in variant.Colors.Split(';'))
|
||||
{
|
||||
string[] pair = token.Split('=');
|
||||
if (!palette.Contains(pair[0])) throw new InvalidOperationException("Unknown token: " + pair[0]);
|
||||
if (pair[0].StartsWith("Glow"))
|
||||
{
|
||||
Color center = Color(pair[1]);
|
||||
palette[pair[0]] = new RadialGradientBrush(center, System.Windows.Media.Color.FromArgb(0, center.R, center.G, center.B));
|
||||
}
|
||||
else palette[pair[0]] = new SolidColorBrush(Color(pair[1]));
|
||||
}
|
||||
palette["Ok"] = new SolidColorBrush(Color("#207553"));
|
||||
palette["Warn"] = new SolidColorBrush(Color("#95620D"));
|
||||
palette["Bad"] = new SolidColorBrush(Color("#BD3848"));
|
||||
palette["Idle"] = palette["Muted"];
|
||||
palette["Na"] = palette["Muted"];
|
||||
palette["ShadowOpacity"] = 0.10;
|
||||
File.WriteAllText(Path.Combine(output, variant.Id + ".xaml"), XamlWriter.Save(palette));
|
||||
|
||||
MainVm vm = CreateDemo();
|
||||
window.DataContext = vm;
|
||||
vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); };
|
||||
vm.SetTheme(Theme.Light);
|
||||
((Image)window.FindName("LogoImg")).Source = new BitmapImage(new Uri(Path.Combine(source, "logo.png")));
|
||||
var root = (FrameworkElement)window.Content;
|
||||
root.Measure(new Size(window.Width, window.Height));
|
||||
root.Arrange(new Rect(0, 0, window.Width, window.Height));
|
||||
root.UpdateLayout();
|
||||
Pump();
|
||||
root.UpdateLayout();
|
||||
Call("UpdateSwitch", window.FindName("WireguardSwitch"), false);
|
||||
Call("MoveServerSelection", window.FindName("ServerList"), window.FindName("ServerListHost"), window.FindName("ServerSelectionWindow"), false);
|
||||
vm.ShowSettings = true;
|
||||
Call("UpdateTabs", window, vm, false);
|
||||
root.UpdateLayout();
|
||||
screenshots[i] = Render(root, 1120, 790, 1.5);
|
||||
Save(screenshots[i], Path.Combine(output, variant.Id + ".png"));
|
||||
vm.ShowSettings = false;
|
||||
Call("UpdateTabs", window, vm, false);
|
||||
root.UpdateLayout();
|
||||
Save(Render(root, 1120, 790, 1.5), Path.Combine(output, variant.Id + "-log.png"));
|
||||
vm.DisposeTimers();
|
||||
window.Close();
|
||||
Console.WriteLine("Rendered " + variant.Id);
|
||||
}
|
||||
Board(screenshots, Path.Combine(output, "comparison.png"));
|
||||
app.Shutdown();
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
|
||||
}
|
||||
|
||||
static MainVm CreateDemo()
|
||||
{
|
||||
var vm = new MainVm();
|
||||
string[] codes = { "FI", "EE", "DE" };
|
||||
string[] titles = { "Finland", "Estonia", "Germany" };
|
||||
string[] pings = { "28 ms", "41 ms", "96 ms" };
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
string host = codes[i].ToLowerInvariant() + ".example.com";
|
||||
vm.Servers.Add(new ServerVm {
|
||||
Code = codes[i], Title = titles[i], Subtitle = "VLESS · " + host + ":443",
|
||||
PingText = pings[i], PingKey = i == 2 ? "Warn" : "Ok",
|
||||
Cfg = new ServerCfg { Code = codes[i], Name = titles[i], Server = host, Port = 443, Protocol = "vless" }
|
||||
});
|
||||
}
|
||||
vm.Servers.Add(new ServerVm { Code = "RU", Title = "Только WireGuard", IsWgOnly = true,
|
||||
Subtitle = "выход внутри РФ · 198.51.100.10", PingText = "н/д", PingKey = "Na" });
|
||||
vm.Selected = vm.Servers[0];
|
||||
vm.LogText = "12:00:00 демонстрация интерфейса — sing-box не запускался";
|
||||
return vm;
|
||||
}
|
||||
|
||||
static void Call(string method, params object[] args)
|
||||
{ typeof(SbLauncher.App).GetMethod(method, BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, args); }
|
||||
|
||||
static void Pump()
|
||||
{
|
||||
var frame = new DispatcherFrame();
|
||||
Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { frame.Continue = false; }));
|
||||
Dispatcher.PushFrame(frame);
|
||||
}
|
||||
|
||||
static Color Color(string hex) { return (Color)ColorConverter.ConvertFromString(hex); }
|
||||
|
||||
static BitmapSource Render(Visual visual, int width, int height, double scale)
|
||||
{
|
||||
var bitmap = new RenderTargetBitmap((int)(width * scale), (int)(height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
static void Save(BitmapSource bitmap, string path)
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using (var stream = File.Create(path)) encoder.Save(stream);
|
||||
}
|
||||
|
||||
static void Board(BitmapSource[] screenshots, string path)
|
||||
{
|
||||
var visual = new DrawingVisual();
|
||||
RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.HighQuality);
|
||||
using (DrawingContext dc = visual.RenderOpen())
|
||||
{
|
||||
dc.DrawRectangle(new SolidColorBrush(Color("#E5E7EB")), null, new Rect(0, 0, 2336, 1840));
|
||||
for (int i = 0; i < screenshots.Length; i++)
|
||||
{
|
||||
double x = 32 + (i % 2) * 1152;
|
||||
double y = 28 + (i / 2) * 902;
|
||||
DrawText(dc, Variants[i].Title, x, y, 30, "#29323D", true);
|
||||
DrawText(dc, Variants[i].Description, x, y + 43, 21, "#566373", false);
|
||||
dc.DrawImage(screenshots[i], new Rect(x, y + 83, 1120, 790));
|
||||
}
|
||||
}
|
||||
Save(Render(visual, 2336, 1840, 1), path);
|
||||
}
|
||||
|
||||
static void DrawText(DrawingContext dc, string text, double x, double y, double size, string color, bool bold)
|
||||
{
|
||||
var face = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, bold ? FontWeights.SemiBold : FontWeights.Normal, FontStretches.Normal);
|
||||
dc.DrawText(new FormattedText(text, CultureInfo.GetCultureInfo("ru-RU"), FlowDirection.LeftToRight,
|
||||
face, size, new SolidColorBrush(Color(color))), new Point(x, y));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sourceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$framework = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319'
|
||||
$buildDir = Join-Path $sourceRoot 'run\light-theme-preview'
|
||||
$outputDir = Join-Path $sourceRoot 'preview\light-variants'
|
||||
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
|
||||
$renderExe = Join-Path $buildDir 'RenderLightThemes.exe'
|
||||
$references = @('WPF\PresentationFramework.dll', 'WPF\PresentationCore.dll', 'WPF\WindowsBase.dll', 'System.Xaml.dll', 'System.dll', 'System.Core.dll', 'System.Windows.Forms.dll', 'System.Drawing.dll', 'System.Web.Extensions.dll') | ForEach-Object { '/reference:' + (Join-Path $framework $_) }
|
||||
$sources = @('Version.cs', 'SbLauncher.cs', 'ViewModels.cs', 'App.cs', 'Import.cs', 'tools\RenderLightThemes.cs') | ForEach-Object { Join-Path $sourceRoot $_ }
|
||||
& (Join-Path $framework 'csc.exe') /nologo /target:exe /platform:x64 /main:RenderLightThemes "/out:$renderExe" @references @sources
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Preview compilation failed.' }
|
||||
& $renderExe $sourceRoot $outputDir
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Preview rendering failed.' }
|
||||