Release v0.1.2

This commit is contained in:
nikit3s
2026-10-10 14:58:04 +03:00
parent b2ae91b5b3
commit 3cdc30ec2a
33 changed files with 1218 additions and 212 deletions
+3
View File
@@ -2,6 +2,9 @@
# private_key, local_address, имя сетевого адаптера. Никогда не коммитим. # private_key, local_address, имя сетевого адаптера. Никогда не коммитим.
local.json local.json
profiles.json profiles.json
profiles.legacy-backup.json
profiles.json.tmp
local.json.tmp
# локальные ярлыки Windows # локальные ярлыки Windows
*.lnk *.lnk
+162 -3
View File
@@ -1,7 +1,8 @@
// SbLauncher — GUI для sing-box. // SbLauncher — GUI для sing-box.
// //
// ПРИНЦИП (изменён по сравнению с прошлой версией): // ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ, // Источник правды — profiles.base.json (базовые серверы), profiles.json
// (личные серверы) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: // адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся // он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и // в run\config.json. Поэтому папок conf_* больше нет, и править один и
@@ -71,6 +72,7 @@ namespace SbLauncher
{ {
public const string Dark = "dark"; public const string Dark = "dark";
public const string Light = "light"; public const string Light = "light";
static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(460);
public static string DetectSystem() public static string DetectSystem()
{ {
@@ -103,12 +105,92 @@ namespace SbLauncher
ResourceDictionary src = w.Resources[key] as ResourceDictionary; ResourceDictionary src = w.Resources[key] as ResourceDictionary;
if (src == null) return; if (src == null) return;
// Первый кадр и служебные рендеры сразу получают конечную тему.
bool animate = w.IsLoaded && SystemParameters.ClientAreaAnimation;
foreach (object k in src.Keys) foreach (object k in src.Keys)
{ {
object v = src[k]; 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; w.Resources[k] = v;
if (Application.Current != null) Application.Current.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) тоже живут в // Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в
@@ -534,6 +616,7 @@ namespace SbLauncher
Border selection = w.FindName("ServerSelectionWindow") as Border; Border selection = w.FindName("ServerSelectionWindow") as Border;
if (serverList != null && serverHost != null && selection != null) if (serverList != null && serverHost != null && selection != null)
MoveServerSelection(serverList, serverHost, selection, false); MoveServerSelection(serverList, serverHost, selection, false);
UpdateTabs(w, vm, false);
root2.UpdateLayout(); root2.UpdateLayout();
RenderTargetBitmap rtb = new RenderTargetBitmap( RenderTargetBitmap rtb = new RenderTargetBitmap(
@@ -569,10 +652,12 @@ namespace SbLauncher
if (argv[i] == "--root") return argv[i + 1]; if (argv[i] == "--root") return argv[i + 1];
string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); 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); 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; return exeDir;
} }
@@ -665,9 +750,83 @@ namespace SbLauncher
WireSwitch(w); WireSwitch(w);
WireServerSelection(w); WireServerSelection(w);
WireTabs(w, vm);
w.Loaded += delegate { vm.PingAll(); }; 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) static void WireSwitch(Window w)
{ {
ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton; ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton;
+11
View File
@@ -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 ## 0.1.1 — 2026-09-27
- Название окна и значка в трее приведено к `SbLauncher`; в шапке показывается версия сборки. - Название окна и значка в трее приведено к `SbLauncher`; в шапке показывается версия сборки.
+84
View File
@@ -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`.
+72 -61
View File
@@ -1,95 +1,106 @@
# SbLauncher # SbLauncher
Лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11 x64 с Портативный лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11
.NET Framework 4.x. Поддерживает импорт и выбор серверов, три режима x64 с .NET Framework 4.x. Поддерживает импорт серверов, три режима подключения
подключения (`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку (`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку sing-box,
sing-box, логи и светлую/тёмную тему. логи и светлую/тёмную тему. Для TUN и сетевых маршрутов запрашивает права
администратора.
Это отдельный прототип интерфейса; рабочая копия `sb_launcher_rev2` не меняется.
Приложение запрашивает права администратора для настройки сетевых маршрутов.
![Светлая тема](preview/glass-light.png) ![Светлая тема](preview/glass-light.png)
[Тёмная тема](preview/glass-dark.png) [Тёмная тема](preview/glass-dark.png)
## Подготовка ## Установка из релиза
Для сборки нужен `csc.exe` из установленного .NET Framework 4.x. `build.cmd` Распакуйте `SbLauncher-v0.1.2-win-x64.zip` в постоянную папку и запустите
использует `%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`; Visual Studio `SbLauncher.exe`. В архив уже включены sing-box, файл правил и базовые серверы.
и .NET SDK не требуются. На вкладке «Настройки» задайте свой приватный ключ и локальный 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 ```powershell
Copy-Item .\local.example.json .\local.json
Copy-Item .\profiles.example.json .\profiles.json
cmd /c build.cmd cmd /c build.cmd
.\SbLauncher.exe .\SbLauncher.exe
``` ```
Демонстрационные адреса из `profiles.example.json` не подключаются. Добавьте Базовые серверы загрузятся из `profiles.base.json`, личные файлы создаются
свои серверы через импорт или в `profiles.json`, затем укажите приватный ключ программой при сохранении настроек или импорте. `profiles.example.json`
WireGuard, адрес туннеля и сетевой адаптер на вкладке «Настройки». Секреты в содержит демонстрационные адреса для тестирования старого формата и не
`local.json`, а также серверы в `profiles.json` исключены из Git. Не добавляйте используется для первой установки из релиза.
их в коммиты и релизные архивы.
Готовый ZIP из релиза распаковывается аналогично: поместите рядом с лаунчером ## Проверки
`bin/sb.exe` и `doc/doc.srs`, скопируйте два файла `.example.json` без слова
`.example` в имени и заполните свои данные. Сборка из исходников для ZIP не нужна.
## Сборка и проверки
```powershell ```powershell
cmd /c build.cmd # SbLauncher.exe cmd /c build.cmd importtest # форматы импорта и конфиги sing-box
cmd /c build.cmd importtest # импорт ссылок cmd /c build.cmd profiletest # импорт и сохранение профилей
cmd /c build.cmd profiletest # сохранение профилей cmd /c build.cmd updatetest # обновление базы и сохранение личных данных
cmd /c build.cmd stress # нагрузка интерфейса cmd /c build.cmd stress # нагрузка интерфейса
cmd /c build.cmd themetest # анимация темы и быстрые переключения
``` ```
`build.cmd selftest` проверяет локальную конфигурацию и может использовать `build.cmd selftest` проверяет конфигурацию и может читать текущие профили.
текущие профили. `build.cmd deploy "путь"` копирует сборку в явно указанную `build.cmd deploy "путь"` копирует программу, базовый каталог и шаблоны в
тестовую папку; локальный `local.json` в ней не перезаписывается. явно указанную тестовую папку; `local.json` и личный `profiles.json` не заменяет.
## Git и релизы ## Git и релизы
Основная ветка — `main`. Разработку фиксируйте коммитами в Git. Для очередного Основная ветка — `main`. Обновите [CHANGELOG.md](CHANGELOG.md), три версии
релиза обновите [CHANGELOG.md](CHANGELOG.md), три версии в `Version.cs` и номер в `Version.cs` и номер `assemblyIdentity` в `app.manifest`. Перед коммитом
`assemblyIdentity` в `app.manifest`; используйте формат `MAJOR.MINOR.PATCH`. проверьте список файлов: личных настроек, серверов и резервных копий
После этого, например для версии `0.1.1`: в нём быть не должно. Для версии `0.1.2`:
```powershell ```powershell
git status git status
git add -A git add -A
git diff --cached --stat git diff --cached --stat
git commit -m "Release v0.1.1" git commit -m "Release v0.1.2"
.\release.ps1 -Version 0.1.1 .\release.ps1 -Version 0.1.2
git tag -a v0.1.1 -m "SbLauncher v0.1.1" git tag -a v0.1.2 -m "SbLauncher v0.1.2"
```
Перед коммитом проверьте список добавленных файлов: в нём должны быть только
изменения для этого релиза. `release.ps1` требует чистую рабочую копию, запускает сборку и три
проверки, сверяет версию EXE и собирает `dist/SbLauncher-vX.Y.Z-win-x64.zip`
с SHA-256 в соседнем `.sha256`. Архив содержит только приложение, шаблоны,
примеры конфигурации и документацию. Сборка идёт через `build.cmd staged`,
поэтому открытый лаунчер не блокирует выпуск. Для пробного запуска на незакоммиченных
изменениях есть `-AllowDirty`; архив получает суффикс `-dev` с хешем коммита.
При настроенном удалённом репозитории отправьте ветку и тег:
```powershell
git push origin main git push origin main
git push origin v0.1.1 git push origin v0.1.2
``` ```
Затем создайте релиз `v0.1.1` в GitHub/Gitea, вставьте заметки из `release.ps1` требует чистый Git-коммит, собирает приложение и запускает
`CHANGELOG.md` и прикрепите ZIP и `.sha256`. Если `origin` ещё не настроен, пять проверок. Сверяет версию 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`.
+133 -8
View File
@@ -1,7 +1,8 @@
// SbLauncher — GUI для sing-box. // SbLauncher — GUI для sing-box.
// //
// ПРИНЦИП (изменён по сравнению с прошлой версией): // ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ, // Источник правды — profiles.base.json (базовые серверы), profiles.json
// (личные серверы) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: // адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся // он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и // в run\config.json. Поэтому папок conf_* больше нет, и править один и
@@ -187,6 +188,7 @@ namespace SbLauncher
public Paths(string root) { Root = root; } public Paths(string root) { Root = root; }
public string Profiles { get { return Path.Combine(Root, "profiles.json"); } } 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 Local { get { return Path.Combine(Root, "local.json"); } }
public string TemplatesDir { get { return Path.Combine(Root, "templates"); } } public string TemplatesDir { get { return Path.Combine(Root, "templates"); } }
public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } } public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } }
@@ -217,6 +219,7 @@ namespace SbLauncher
public string SpiderX = ""; // старые профили сохраняют это поле public string SpiderX = ""; // старые профили сохраняют это поле
public string Protocol = "vless"; public string Protocol = "vless";
public Dictionary<string, object> Outbound; // точная конфигурация импортированного ключа public Dictionary<string, object> Outbound; // точная конфигурация импортированного ключа
public string BaseId = ""; // стабильный id только у серверов из релиза
public string Endpoint public string Endpoint
{ {
@@ -232,19 +235,115 @@ namespace SbLauncher
public int Mtu = 1280; public int Mtu = 1280;
} }
// profiles.json — локальная копия серверов; в тестовом репозитории не коммитится // Релиз обновляет profiles.base.json. profiles.json хранит только личные
// серверы и скрытые базовые id; local.json независимо хранит ключ и адрес.
public class Profiles public class Profiles
{ {
public WgCfg Wg = new WgCfg(); public WgCfg Wg = new WgCfg();
public List<ServerCfg> Servers = new List<ServerCfg>(); 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) 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(); Profiles p = new Profiles();
if (!File.Exists(path)) return p; if (!File.Exists(path)) return p;
Dictionary<string, object> root = J.Parse(File.ReadAllText(path, Encoding.UTF8)); Dictionary<string, object> root = J.Parse(File.ReadAllText(path, Encoding.UTF8));
if (root == null) return p; 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; object wo;
if (root.TryGetValue("wireguard", out wo)) if (root.TryGetValue("wireguard", out wo))
@@ -252,6 +351,7 @@ namespace SbLauncher
Dictionary<string, object> w = J.Obj(wo); Dictionary<string, object> w = J.Obj(wo);
if (w != null) if (w != null)
{ {
p._wgOverride = true;
p.Wg.Server = J.Str(w, "server", ""); p.Wg.Server = J.Str(w, "server", "");
p.Wg.Port = J.Int(w, "port", 51820); p.Wg.Port = J.Int(w, "port", 51820);
p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", ""); 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.Flow = J.Str(d, "flow", "xtls-rprx-vision");
s.SpiderX = J.Str(d, "spider_x", ""); s.SpiderX = J.Str(d, "spider_x", "");
s.Protocol = J.Str(d, "protocol", "vless"); 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; object outbound;
if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound); if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound);
if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol); if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol);
@@ -284,25 +390,43 @@ namespace SbLauncher
return p; return p;
} }
// Пишется только когда сервер добавили/удалили из GUI. Файл // Пишется при миграции или добавлении/удалении сервера из GUI. Файл
// пересобирается целиком, поэтому свои комментарии ВНУТРИ списка // пересобирается целиком, поэтому свои комментарии ВНУТРИ списка
// серверов при этом теряются — шапка восстанавливается. // серверов при этом теряются — шапка восстанавливается.
public void Save(string path) public void Save(string path)
{ {
if (NeedsMigration && File.Exists(path))
{
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(); StringBuilder sb = new StringBuilder();
sb.AppendLine("// Серверы и общие параметры этого тестового экземпляра."); sb.AppendLine("// Личные серверы и настройки списка. Не включайте этот файл в релиз.");
sb.AppendLine("// Храните данные серверов локально, а ключ WireGuard — в local.json."); sb.AppendLine("// Базовые серверы — profiles.base.json; ключ и адрес — local.json.");
sb.AppendLine("{"); 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(" \"wireguard\": {");
sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\","); sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\",");
sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ","); sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ",");
sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\","); sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\",");
sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture)); sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture));
sb.AppendLine(" },"); sb.AppendLine(" },");
}
sb.AppendLine(" \"servers\": ["); sb.AppendLine(" \"servers\": [");
for (int i = 0; i < Servers.Count; i++) for (int i = 0; i < stored.Count; i++)
{ {
ServerCfg s = Servers[i]; ServerCfg s = stored[i];
sb.AppendLine(" {"); sb.AppendLine(" {");
sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\","); sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\",");
sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\","); sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\",");
@@ -322,11 +446,12 @@ namespace SbLauncher
sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : "")); sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : ""));
if (s.Outbound != null) if (s.Outbound != null)
sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound)); sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound));
sb.AppendLine(i == Servers.Count - 1 ? " }" : " },"); sb.AppendLine(i == stored.Count - 1 ? " }" : " },");
} }
sb.AppendLine(" ]"); sb.AppendLine(" ]");
sb.AppendLine("}"); sb.AppendLine("}");
J.Write(path, sb.ToString()); J.Write(path, sb.ToString());
NeedsMigration = false;
} }
} }
+112 -83
View File
@@ -23,97 +23,97 @@
<ResourceDictionary x:Key="Theme.Dark"> <ResourceDictionary x:Key="Theme.Dark">
<LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1"> <LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#101B36" Offset="0"/> <GradientStop Color="#101113" Offset="0"/>
<GradientStop Color="#1C315C" Offset="0.54"/> <GradientStop Color="#17191D" Offset="0.5"/>
<GradientStop Color="#323B72" Offset="1"/> <GradientStop Color="#1C1F25" Offset="1"/>
</LinearGradientBrush> </LinearGradientBrush>
<SolidColorBrush x:Key="Panel" Color="#B51D2D50"/> <SolidColorBrush x:Key="Panel" Color="#DD1B1E23"/>
<SolidColorBrush x:Key="Card" Color="#8D38527E"/> <SolidColorBrush x:Key="Card" Color="#C0252931"/>
<SolidColorBrush x:Key="CardHover" Color="#B24A6490"/> <SolidColorBrush x:Key="CardHover" Color="#D7333944"/>
<SolidColorBrush x:Key="CardSel" Color="#BE5274AA"/> <SolidColorBrush x:Key="CardSel" Color="#D2354052"/>
<SolidColorBrush x:Key="Stroke" Color="#77C4DAFF"/> <SolidColorBrush x:Key="Stroke" Color="#3AADB7C6"/>
<SolidColorBrush x:Key="GlassEdge" Color="#A6E6F3FF"/> <SolidColorBrush x:Key="GlassEdge" Color="#45C3CFDF"/>
<SolidColorBrush x:Key="Text" Color="#F5F9FF"/> <SolidColorBrush x:Key="Text" Color="#F1F3F6"/>
<SolidColorBrush x:Key="Muted" Color="#B9CBE6"/> <SolidColorBrush x:Key="Muted" Color="#A5AFBD"/>
<SolidColorBrush x:Key="Accent" Color="#6B9FFF"/> <SolidColorBrush x:Key="Accent" Color="#78A9FF"/>
<SolidColorBrush x:Key="AccentHover" Color="#8AB6FF"/> <SolidColorBrush x:Key="AccentHover" Color="#98BEFF"/>
<SolidColorBrush x:Key="AccentDim" Color="#416DC5"/> <SolidColorBrush x:Key="AccentDim" Color="#5385DC"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/> <SolidColorBrush x:Key="OnAccent" Color="#101820"/>
<SolidColorBrush x:Key="SoftBg" Color="#703F5B83"/> <SolidColorBrush x:Key="SoftBg" Color="#AA282D36"/>
<SolidColorBrush x:Key="SoftHover" Color="#A452709B"/> <SolidColorBrush x:Key="SoftHover" Color="#CE353D49"/>
<SolidColorBrush x:Key="SwitchTrack" Color="#66869CC1"/> <SolidColorBrush x:Key="SwitchTrack" Color="#FF363C46"/>
<SolidColorBrush x:Key="SwitchThumb" Color="#F0F7FF"/> <SolidColorBrush x:Key="SwitchThumb" Color="#F4F6FA"/>
<SolidColorBrush x:Key="BadgeBg" Color="#82536F9A"/> <SolidColorBrush x:Key="BadgeBg" Color="#FF303844"/>
<SolidColorBrush x:Key="PingBg" Color="#7B21375E"/> <SolidColorBrush x:Key="PingBg" Color="#A6191D24"/>
<SolidColorBrush x:Key="LogBg" Color="#A0182B4F"/> <SolidColorBrush x:Key="LogBg" Color="#D214171C"/>
<SolidColorBrush x:Key="LogText" Color="#D7E5F8"/> <SolidColorBrush x:Key="LogText" Color="#BECBD9"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#3223375A"/> <SolidColorBrush x:Key="ScrollTrack" Color="#182D3540"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#9DAFC9EA"/> <SolidColorBrush x:Key="ScrollThumb" Color="#706A778B"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#D5DDECFF"/> <SolidColorBrush x:Key="ScrollThumbHover" Color="#ACA7B4C7"/>
<SolidColorBrush x:Key="HeroRing" Color="#B2BBDDFF"/> <SolidColorBrush x:Key="HeroRing" Color="#948BA4C9"/>
<SolidColorBrush x:Key="HeroRingDim" Color="#55A6C8FF"/> <SolidColorBrush x:Key="HeroRingDim" Color="#3D93AACA"/>
<RadialGradientBrush x:Key="GlowA"> <RadialGradientBrush x:Key="GlowA">
<GradientStop Color="#98759CFF" Offset="0"/> <GradientStop Color="#28687890" Offset="0"/>
<GradientStop Color="#00759CFF" Offset="1"/> <GradientStop Color="#00687890" Offset="1"/>
</RadialGradientBrush> </RadialGradientBrush>
<RadialGradientBrush x:Key="GlowB"> <RadialGradientBrush x:Key="GlowB">
<GradientStop Color="#6652C8DC" Offset="0"/> <GradientStop Color="#1C4B6F9C" Offset="0"/>
<GradientStop Color="#0052C8DC" Offset="1"/> <GradientStop Color="#004B6F9C" Offset="1"/>
</RadialGradientBrush> </RadialGradientBrush>
<!-- смысловые цвета: их же читает код для статуса и пинга --> <!-- смысловые цвета: их же читает код для статуса и пинга -->
<SolidColorBrush x:Key="Ok" Color="#34D399"/> <SolidColorBrush x:Key="Ok" Color="#6BDBAE"/>
<SolidColorBrush x:Key="Warn" Color="#FBBF24"/> <SolidColorBrush x:Key="Warn" Color="#E8BF78"/>
<SolidColorBrush x:Key="Bad" Color="#F87171"/> <SolidColorBrush x:Key="Bad" Color="#F28794"/>
<SolidColorBrush x:Key="Idle" Color="#8A94A6"/> <SolidColorBrush x:Key="Idle" Color="#A5AFBD"/>
<SolidColorBrush x:Key="Na" Color="#6B7280"/> <SolidColorBrush x:Key="Na" Color="#85918E"/>
<sys:Double x:Key="ShadowOpacity">0.55</sys:Double> <sys:Double x:Key="ShadowOpacity">0.35</sys:Double>
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Theme.Light"> <ResourceDictionary x:Key="Theme.Light">
<LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1"> <LinearGradientBrush x:Key="Bg" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#DDEEFF" Offset="0"/> <GradientStop Color="#F3F4F6" Offset="0"/>
<GradientStop Color="#BCD6FB" Offset="0.54"/> <GradientStop Color="#E8EBEF" Offset="0.5"/>
<GradientStop Color="#E2E7FA" Offset="1"/> <GradientStop Color="#EFF1F4" Offset="1"/>
</LinearGradientBrush> </LinearGradientBrush>
<SolidColorBrush x:Key="Panel" Color="#B9F8FBFF"/> <SolidColorBrush x:Key="Panel" Color="#EBF8F9FB"/>
<SolidColorBrush x:Key="Card" Color="#9CFFFFFF"/> <SolidColorBrush x:Key="Card" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardHover" Color="#D8FFFFFF"/> <SolidColorBrush x:Key="CardHover" Color="#F7F9FC"/>
<SolidColorBrush x:Key="CardSel" Color="#D7DFEDFF"/> <SolidColorBrush x:Key="CardSel" Color="#E3EAF3"/>
<SolidColorBrush x:Key="Stroke" Color="#A7FFFFFF"/> <SolidColorBrush x:Key="Stroke" Color="#CDD3DC"/>
<SolidColorBrush x:Key="GlassEdge" Color="#EFFFFFFF"/> <SolidColorBrush x:Key="GlassEdge" Color="#D5DAE2"/>
<SolidColorBrush x:Key="Text" Color="#173052"/> <SolidColorBrush x:Key="Text" Color="#242B35"/>
<SolidColorBrush x:Key="Muted" Color="#577190"/> <SolidColorBrush x:Key="Muted" Color="#606C7B"/>
<SolidColorBrush x:Key="Accent" Color="#347CF2"/> <SolidColorBrush x:Key="Accent" Color="#405B80"/>
<SolidColorBrush x:Key="AccentHover" Color="#5291F8"/> <SolidColorBrush x:Key="AccentHover" Color="#4E6D96"/>
<SolidColorBrush x:Key="AccentDim" Color="#2466CB"/> <SolidColorBrush x:Key="AccentDim" Color="#344C6E"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/> <SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
<SolidColorBrush x:Key="SoftBg" Color="#AFFFFFFF"/> <SolidColorBrush x:Key="SoftBg" Color="#E9EDF2"/>
<SolidColorBrush x:Key="SoftHover" Color="#E5FFFFFF"/> <SolidColorBrush x:Key="SoftHover" Color="#F6F8FB"/>
<SolidColorBrush x:Key="SwitchTrack" Color="#8CA7BEDD"/> <SolidColorBrush x:Key="SwitchTrack" Color="#C5CDD7"/>
<SolidColorBrush x:Key="SwitchThumb" Color="#FFFFFF"/> <SolidColorBrush x:Key="SwitchThumb" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BadgeBg" Color="#BDDFEAFA"/> <SolidColorBrush x:Key="BadgeBg" Color="#E0E6EE"/>
<SolidColorBrush x:Key="PingBg" Color="#9AFFFFFF"/> <SolidColorBrush x:Key="PingBg" Color="#EDF1F5"/>
<SolidColorBrush x:Key="LogBg" Color="#B6F5F9FF"/> <SolidColorBrush x:Key="LogBg" Color="#F8F9FB"/>
<SolidColorBrush x:Key="LogText" Color="#365476"/> <SolidColorBrush x:Key="LogText" Color="#49586B"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#34FFFFFF"/> <SolidColorBrush x:Key="ScrollTrack" Color="#18758499"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#8D8FA6C5"/> <SolidColorBrush x:Key="ScrollThumb" Color="#8895A6"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#B37490B5"/> <SolidColorBrush x:Key="ScrollThumbHover" Color="#687A90"/>
<SolidColorBrush x:Key="HeroRing" Color="#C84483EF"/> <SolidColorBrush x:Key="HeroRing" Color="#905F7591"/>
<SolidColorBrush x:Key="HeroRingDim" Color="#665896E7"/> <SolidColorBrush x:Key="HeroRingDim" Color="#407D8FA6"/>
<RadialGradientBrush x:Key="GlowA"> <RadialGradientBrush x:Key="GlowA">
<GradientStop Color="#AA8EC2FF" Offset="0"/> <GradientStop Color="#127689A2" Offset="0"/>
<GradientStop Color="#008EC2FF" Offset="1"/> <GradientStop Color="#007689A2" Offset="1"/>
</RadialGradientBrush> </RadialGradientBrush>
<RadialGradientBrush x:Key="GlowB"> <RadialGradientBrush x:Key="GlowB">
<GradientStop Color="#99B2A5FF" Offset="0"/> <GradientStop Color="#108398B4" Offset="0"/>
<GradientStop Color="#00B2A5FF" Offset="1"/> <GradientStop Color="#008398B4" Offset="1"/>
</RadialGradientBrush> </RadialGradientBrush>
<SolidColorBrush x:Key="Ok" Color="#0E9F6E"/> <SolidColorBrush x:Key="Ok" Color="#207553"/>
<SolidColorBrush x:Key="Warn" Color="#B4790B"/> <SolidColorBrush x:Key="Warn" Color="#95620D"/>
<SolidColorBrush x:Key="Bad" Color="#DC2626"/> <SolidColorBrush x:Key="Bad" Color="#BD3848"/>
<SolidColorBrush x:Key="Idle" Color="#7A8699"/> <SolidColorBrush x:Key="Idle" Color="#606C7B"/>
<SolidColorBrush x:Key="Na" Color="#9AA3B2"/> <SolidColorBrush x:Key="Na" Color="#606C7B"/>
<sys:Double x:Key="ShadowOpacity">0.16</sys:Double> <sys:Double x:Key="ShadowOpacity">0.10</sys:Double>
</ResourceDictionary> </ResourceDictionary>
<!-- Одинаковая прокрутка во всех ScrollViewer/ListBox/TextBox. --> <!-- Одинаковая прокрутка во всех ScrollViewer/ListBox/TextBox. -->
@@ -570,12 +570,14 @@
<Style x:Key="Tab" TargetType="Button"> <Style x:Key="Tab" TargetType="Button">
<Setter Property="FontSize" Value="10.5"/> <Setter Property="FontSize" Value="10.5"/>
<Setter Property="FontWeight" Value="Bold"/> <Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Width" Value="112"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="0,2,20,8"> <Border Background="Transparent" CornerRadius="10">
<ContentPresenter/> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border> </Border>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
@@ -646,7 +648,22 @@
VerticalAlignment="Center" Margin="0,0,12,0"> VerticalAlignment="Center" Margin="0,0,12,0">
<Button x:Name="BtnTheme" Style="{StaticResource Chrome}" <Button x:Name="BtnTheme" Style="{StaticResource Chrome}"
FontFamily="Segoe MDL2 Assets" FontSize="14" 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="&#xE706;" HorizontalAlignment="Center"
VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<TransformGroup><ScaleTransform/><RotateTransform/></TransformGroup>
</TextBlock.RenderTransform>
</TextBlock>
<TextBlock x:Name="ThemeMoon" Text="&#xE708;" 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="&#x2013;"/> <Button x:Name="BtnMin" Style="{StaticResource Chrome}" Content="&#x2013;"/>
<Button x:Name="BtnClose" Style="{StaticResource Chrome}" Content="&#x2715;"/> <Button x:Name="BtnClose" Style="{StaticResource Chrome}" Content="&#x2715;"/>
</StackPanel> </StackPanel>
@@ -884,22 +901,33 @@
</Border> </Border>
<!-- вкладки --> <!-- вкладки -->
<Grid Grid.Row="2" Margin="6,14,4,3"> <Border Grid.Row="2" Margin="0,12,0,10" Padding="3"
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"> 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="ЛОГ" <Button x:Name="BtnTabLog" Style="{StaticResource Tab}" Content="ЛОГ"
Foreground="{Binding TabLogBrush}"/> Foreground="{Binding TabLogBrush}"/>
<Button x:Name="BtnTabSettings" Style="{StaticResource Tab}" Content="НАСТРОЙКИ" <Button x:Name="BtnTabSettings" Style="{StaticResource Tab}" Content="НАСТРОЙКИ"
Foreground="{Binding TabSettingsBrush}"/> Foreground="{Binding TabSettingsBrush}"/>
</StackPanel> </StackPanel>
</Grid> </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}" CornerRadius="21" Background="{DynamicResource LogBg}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="4"> BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="4">
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
<TextBox x:Name="LogBox" Text="{Binding LogText, Mode=OneWay}" <TextBox x:Name="LogBox" Text="{Binding LogText, Mode=OneWay}"
IsReadOnly="True" BorderThickness="0" Background="Transparent" IsReadOnly="True" BorderThickness="0" Background="Transparent"
Foreground="{Binding LogBrush}" SelectionBrush="{DynamicResource Accent}" Foreground="{Binding LogBrush}" SelectionBrush="{DynamicResource Accent}"
@@ -910,9 +938,10 @@
</Border> </Border>
<!-- ── настройки ───────────────────────────────────────── --> <!-- ── настройки ───────────────────────────────────────── -->
<Border Visibility="{Binding SettingsVis}" <Border x:Name="SettingsPane" Visibility="Hidden"
CornerRadius="21" Background="{DynamicResource Panel}" CornerRadius="21" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="22,18"> BorderBrush="{DynamicResource GlassEdge}" BorderThickness="1" Padding="22,18">
<Border.RenderTransform><TranslateTransform/></Border.RenderTransform>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0"> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel> <StackPanel>
+3 -3
View File
@@ -1,6 +1,6 @@
using System.Reflection; using System.Reflection;
// Обновляйте вместе с CHANGELOG.md перед выпуском новой версии. // Обновляйте вместе с CHANGELOG.md перед выпуском новой версии.
[assembly: AssemblyVersion("0.1.1.0")] [assembly: AssemblyVersion("0.1.2.0")]
[assembly: AssemblyFileVersion("0.1.1.0")] [assembly: AssemblyFileVersion("0.1.2.0")]
[assembly: AssemblyInformationalVersion("0.1.1")] [assembly: AssemblyInformationalVersion("0.1.2")]
+18 -28
View File
@@ -1,7 +1,8 @@
// SbLauncher — GUI для sing-box. // SbLauncher — GUI для sing-box.
// //
// ПРИНЦИП (изменён по сравнению с прошлой версией): // ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ, // Источник правды — profiles.base.json (базовые серверы), profiles.json
// (личные серверы) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: // адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся // он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и // в run\config.json. Поэтому папок conf_* больше нет, и править один и
@@ -338,13 +339,12 @@ namespace SbLauncher
get { return _showSettings; } get { return _showSettings; }
set set
{ {
if (_showSettings == value) return;
_showSettings = value; _showSettings = value;
Raise(); Raise("LogVis"); Raise("SettingsVis"); Raise();
Raise("TabLogBrush"); Raise("TabSettingsBrush"); 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 TabLogBrush { get { return Theme.Res(_showSettings ? "Muted" : "Accent"); } }
public Brush TabSettingsBrush { get { return Theme.Res(_showSettings ? "Accent" : "Muted"); } } public Brush TabSettingsBrush { get { return Theme.Res(_showSettings ? "Accent" : "Muted"); } }
@@ -478,15 +478,14 @@ namespace SbLauncher
// ── тема ───────────────────────────────────────────────────────── // ── тема ─────────────────────────────────────────────────────────
public string ThemeName { get { return _theme; } } public string ThemeName { get { return _theme; } }
// показываем действие, а не состояние: в тёмной теме — солнце // Иконки в XAML показывают действие: в тёмной теме — солнце, в светлой — луна.
// (E706 Brightness), в светлой — луна (E708 QuietHours) public string ThemeAction { get { return _theme == Theme.Light ? "Включить тёмную тему" : "Включить светлую тему"; } }
public string ThemeGlyph { get { return _theme == Theme.Light ? "" : ""; } }
public void SetTheme(string t) public void SetTheme(string t)
{ {
_theme = (t == Theme.Light) ? Theme.Light : Theme.Dark; _theme = (t == Theme.Light) ? Theme.Light : Theme.Dark;
if (ThemeApplier != null) ThemeApplier(_theme); if (ThemeApplier != null) ThemeApplier(_theme);
Raise("ThemeName"); Raise("ThemeGlyph"); Raise("ThemeName"); Raise("ThemeAction");
Raise("StatusBrush"); Raise("StatusBrush");
Raise("LogBrush"); Raise("LogBrush");
Raise("TabLogBrush"); Raise("TabSettingsBrush"); Raise("TabLogBrush"); Raise("TabSettingsBrush");
@@ -533,7 +532,8 @@ namespace SbLauncher
Raise(); Raise();
if (_sel != null) 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; Local.Mode = CurrentMode;
SaveLocal(); SaveLocal();
} }
@@ -546,6 +546,7 @@ namespace SbLauncher
{ {
P = paths; P = paths;
Prof = Profiles.Load(P.Profiles); Prof = Profiles.Load(P.Profiles);
if (Prof.NeedsMigration) Prof.Save(P.Profiles);
Local = LocalCfg.Load(P.Local); Local = LocalCfg.Load(P.Local);
_wg = Local.Mode != ConfigBuilder.ModeDirect; _wg = Local.Mode != ConfigBuilder.ModeDirect;
@@ -568,7 +569,7 @@ namespace SbLauncher
RefreshAdapters(); RefreshAdapters();
AppendLog("серверов: " + Prof.Servers.Count + " корень: " + P.Root); 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.Local)) AppendLog("! нет local.json — задайте ключ и адрес во вкладке «настройки»");
if (!File.Exists(P.SbExe)) AppendLog("! нет " + P.SbExe); if (!File.Exists(P.SbExe)) AppendLog("! нет " + P.SbExe);
if (!File.Exists(P.RuleSet)) AppendLog("! нет " + P.RuleSet); if (!File.Exists(P.RuleSet)) AppendLog("! нет " + P.RuleSet);
@@ -578,7 +579,8 @@ namespace SbLauncher
foreach (ServerVm vm in Servers) foreach (ServerVm vm in Servers)
{ {
bool match = Local.LastServer == "*wg" ? vm.IsWgOnly 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 (match) { want = vm; break; }
} }
if (want == null) if (want == null)
@@ -836,10 +838,7 @@ namespace SbLauncher
Profiles CopyProfiles() Profiles CopyProfiles()
{ {
Profiles next = new Profiles(); return Prof.Copy();
next.Wg = Prof.Wg;
next.Servers.AddRange(Prof.Servers);
return next;
} }
sealed class ImportWork sealed class ImportWork
@@ -852,17 +851,7 @@ namespace SbLauncher
static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all) static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all)
{ {
string code = server.Code; Profiles.GiveUniqueCode(server, all);
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;
} }
public async Task<bool> AddFromLinkAsync(string link) public async Task<bool> AddFromLinkAsync(string link)
@@ -954,7 +943,8 @@ namespace SbLauncher
} }
string msg = "Вы действительно хотите удалить сервер \"" + vm.Title + "\" (" + 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, if (MessageBox.Show(msg, "Подтверждение удаления", MessageBoxButton.YesNo,
MessageBoxImage.Question) != MessageBoxResult.Yes) return; MessageBoxImage.Question) != MessageBoxResult.Yes) return;
@@ -962,7 +952,7 @@ namespace SbLauncher
try try
{ {
Profiles next = CopyProfiles(); Profiles next = CopyProfiles();
next.Servers.Remove(vm.Cfg); next.Remove(vm.Cfg);
await Task.Run(delegate { next.Save(P.Profiles); }); await Task.Run(delegate { next.Save(P.Profiles); });
Prof = next; Prof = next;
int index = Servers.IndexOf(vm); int index = Servers.IndexOf(vm);
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <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 требуют администратора. <!-- TUN + auto_route требуют администратора.
Цена: UAC при каждом запуске и автозапуск только через Планировщик Цена: UAC при каждом запуске и автозапуск только через Планировщик
+22 -7
View File
@@ -38,6 +38,8 @@ set "APP_OUT=SbLauncher.exe"
if /i "%~1"=="icon" goto icon if /i "%~1"=="icon" goto icon
if /i "%~1"=="selftest" goto selftest if /i "%~1"=="selftest" goto selftest
if /i "%~1"=="stress" goto stress 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"=="profiletest" goto profiletest
if /i "%~1"=="importtest" goto importtest if /i "%~1"=="importtest" goto importtest
if /i "%~1"=="staged" set "APP_OUT=SbLauncher.updated.exe" if /i "%~1"=="staged" set "APP_OUT=SbLauncher.updated.exe"
@@ -69,15 +71,10 @@ if not exist "%DST%" (
echo [*] Deploying to %DST% echo [*] Deploying to %DST%
copy /y SbLauncher.exe "%DST%\" >nul copy /y SbLauncher.exe "%DST%\" >nul
copy /y Ui.xaml "%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" if not exist "%DST%\templates" mkdir "%DST%\templates"
copy /y templates\*.json "%DST%\templates\" >nul copy /y templates\*.json "%DST%\templates\" >nul
rem local.json is deliberately NOT overwritten: it holds this machine's rem profiles.json and local.json are user data and never deployed.
rem private key and adapter name.
if not exist "%DST%\local.json" (
echo [i] %DST%\local.json missing - seeding it from local.example.json
copy /y local.example.json "%DST%\local.json" >nul
)
echo [+] Deployed. bin\sb.exe and doc\doc.srs must already be there. echo [+] Deployed. bin\sb.exe and doc\doc.srs must already be there.
goto end goto end
@@ -108,6 +105,15 @@ if errorlevel 1 goto failed
if errorlevel 1 goto failed if errorlevel 1 goto failed
goto end 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 :profiletest
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs "%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 if errorlevel 1 goto failed
@@ -117,6 +123,15 @@ if errorlevel 1 goto failed
if errorlevel 1 goto failed if errorlevel 1 goto failed
goto end 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 :importtest
"%CSC%" /nologo /target:exe /platform:x64 /out:SbLauncher.selftest.exe %RES% %REFS% Version.cs SbLauncher.cs ViewModels.cs App.cs Import.cs "%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 if errorlevel 1 goto failed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

+52
View File
@@ -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"
}
]
}
+22 -3
View File
@@ -26,6 +26,14 @@ try {
} }
$tag = 'v' + $Version $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) & git show-ref --verify --quiet ("refs/tags/" + $tag)
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
$tagCommit = (& git rev-list -n 1 $tag).Trim() $tagCommit = (& git rev-list -n 1 $tag).Trim()
@@ -40,9 +48,13 @@ try {
$stressRoot = Join-Path $dist ('stress-' + [guid]::NewGuid().ToString('N')) $stressRoot = Join-Path $dist ('stress-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $stressRoot | Out-Null New-Item -ItemType Directory -Path $stressRoot | Out-Null
$previousStressRoot = $env:SB_STRESS_ROOT $previousStressRoot = $env:SB_STRESS_ROOT
$previousTemp = $env:TEMP
$previousTmp = $env:TMP
$env:SB_STRESS_ROOT = $stressRoot $env:SB_STRESS_ROOT = $stressRoot
$env:TEMP = $stressRoot
$env:TMP = $stressRoot
try { try {
foreach ($mode in @('staged', 'importtest', 'profiletest', 'stress')) { foreach ($mode in @('staged', 'importtest', 'profiletest', 'updatetest', 'stress', 'themetest')) {
Write-Host "[*] build.cmd $mode" Write-Host "[*] build.cmd $mode"
& $env:ComSpec /d /c build.cmd $mode & $env:ComSpec /d /c build.cmd $mode
if ($LASTEXITCODE -ne 0) { throw "Сборка или проверка $mode завершилась с ошибкой." } if ($LASTEXITCODE -ne 0) { throw "Сборка или проверка $mode завершилась с ошибкой." }
@@ -50,6 +62,8 @@ try {
} }
finally { finally {
$env:SB_STRESS_ROOT = $previousStressRoot $env:SB_STRESS_ROOT = $previousStressRoot
$env:TEMP = $previousTemp
$env:TMP = $previousTmp
$stressFull = [IO.Path]::GetFullPath($stressRoot) $stressFull = [IO.Path]::GetFullPath($stressRoot)
$distFull = [IO.Path]::GetFullPath($dist).TrimEnd('\') + '\' $distFull = [IO.Path]::GetFullPath($dist).TrimEnd('\') + '\'
if ($stressFull.StartsWith($distFull, [StringComparison]::OrdinalIgnoreCase)) { if ($stressFull.StartsWith($distFull, [StringComparison]::OrdinalIgnoreCase)) {
@@ -75,10 +89,15 @@ try {
New-Item -ItemType Directory -Path $stage | Out-Null New-Item -ItemType Directory -Path $stage | Out-Null
try { try {
Copy-Item -LiteralPath $exe -Destination (Join-Path $stage 'SbLauncher.exe') Copy-Item -LiteralPath $exe -Destination (Join-Path $stage 'SbLauncher.exe')
foreach ($name in @('Ui.xaml', 'README.md', 'CHANGELOG.md', foreach ($name in @('Ui.xaml', 'README.md', 'CHANGELOG.md', 'HANDOFF.md',
'profiles.example.json', 'local.example.json')) { 'profiles.base.json', 'profiles.example.json', 'local.example.json')) {
Copy-Item -LiteralPath (Join-Path $root $name) -Destination $stage 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' $templates = Join-Path $stage 'templates'
New-Item -ItemType Directory -Path $templates | Out-Null New-Item -ItemType Directory -Path $templates | Out-Null
foreach ($name in @('wg.json', 'direct.json', 'wgonly.json')) { foreach ($name in @('wg.json', 'direct.json', 'wgonly.json')) {
+156
View File
@@ -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);
}
}
+146
View File
@@ -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);
}
}
+4
View File
@@ -1,6 +1,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
@@ -26,6 +27,9 @@ public static class UiStress
vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); }; vm.ThemeApplier = delegate(string theme) { Theme.Apply(window, theme); };
vm.Load(new Paths(dataRoot)); vm.Load(new Paths(dataRoot));
vm.SetTheme(Theme.Dark); vm.SetTheme(Theme.Dark);
// Подключаем переходы вкладок без сетевых действий основного Wire.
typeof(SbLauncher.App).GetMethod("WireTabs", BindingFlags.Static | BindingFlags.NonPublic)
.Invoke(null, new object[] { window, vm });
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
long previous = 0, longest = 0; long previous = 0, longest = 0;
int ticks = 0; int ticks = 0;
+185
View File
@@ -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));
}
}
+13
View File
@@ -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.' }