diff --git a/.gitignore b/.gitignore index 0aa520f..e288062 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # private_key, local_address, имя сетевого адаптера. Никогда не коммитим. local.json profiles.json +profiles.legacy-backup.json +profiles.json.tmp +local.json.tmp # локальные ярлыки Windows *.lnk diff --git a/App.cs b/App.cs index 1404c3d..b9f68cb 100644 --- a/App.cs +++ b/App.cs @@ -1,7 +1,8 @@ // SbLauncher — GUI для sing-box. // // ПРИНЦИП (изменён по сравнению с прошлой версией): -// Источник правды — profiles.json (серверы, общее) и local.json (ключ, +// Источник правды — profiles.base.json (базовые серверы), profiles.json +// (личные серверы) и local.json (ключ, // адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: // он собирается из шаблона templates\*.json при каждом запуске и кладётся // в run\config.json. Поэтому папок conf_* больше нет, и править один и @@ -71,6 +72,7 @@ namespace SbLauncher { public const string Dark = "dark"; public const string Light = "light"; + static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(460); public static string DetectSystem() { @@ -103,12 +105,92 @@ namespace SbLauncher ResourceDictionary src = w.Resources[key] as ResourceDictionary; if (src == null) return; + // Первый кадр и служебные рендеры сразу получают конечную тему. + bool animate = w.IsLoaded && SystemParameters.ClientAreaAnimation; foreach (object k in src.Keys) { object v = src[k]; + Brush target = v as Brush; + if (target != null) + v = TransitionBrush(w.Resources[k] as Brush, target, animate); w.Resources[k] = v; if (Application.Current != null) Application.Current.Resources[k] = v; } + UpdateIcon(w, "ThemeSun", theme != Light, -100, animate); + UpdateIcon(w, "ThemeMoon", theme == Light, 90, animate); + } + + static Brush TransitionBrush(Brush current, Brush target, bool animate) + { + // Исходные словари остаются неизменными. Новый переход начинается + // с видимого цвета, даже если предыдущий ещё не успел завершиться. + Brush next = target.Clone(); + if (!animate || current == null) return next; + + SolidColorBrush fromSolid = current as SolidColorBrush; + SolidColorBrush toSolid = next as SolidColorBrush; + GradientBrush fromGradient = current as GradientBrush; + GradientBrush toGradient = next as GradientBrush; + if (fromSolid != null && toSolid != null) + AnimateColor(toSolid, SolidColorBrush.ColorProperty, fromSolid.Color, toSolid.Color); + else if (fromGradient != null && toGradient != null && + current.GetType() == target.GetType() && + fromGradient.GradientStops.Count == toGradient.GradientStops.Count) + { + for (int i = 0; i < toGradient.GradientStops.Count; i++) + { + GradientStop from = fromGradient.GradientStops[i]; + GradientStop to = toGradient.GradientStops[i]; + AnimateColor(to, GradientStop.ColorProperty, from.Color, to.Color); + AnimateDouble(to, GradientStop.OffsetProperty, from.Offset, to.Offset, true); + } + } + AnimateDouble(next, Brush.OpacityProperty, current.Opacity, target.Opacity, true); + return next; + } + + static void AnimateColor(Animatable target, DependencyProperty property, Color from, Color to) + { + if (from == to) return; + // WPF активирует clock на следующем кадре: до него тоже должен + // оставаться исходный цвет, иначе при клике видна вспышка цели. + target.SetValue(property, from); + var animation = new ColorAnimation(from, to, TransitionDuration); + animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut }; + animation.FillBehavior = FillBehavior.HoldEnd; + target.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace); + } + + static void AnimateDouble(DependencyObject target, DependencyProperty property, + double from, double to, bool animate) + { + var animated = (IAnimatable)target; + animated.BeginAnimation(property, null); + bool transition = animate && Math.Abs(from - to) >= 0.001; + target.SetValue(property, transition ? from : to); + if (!transition) return; + var animation = new DoubleAnimation(from, to, TransitionDuration); + animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut }; + // SnapshotAndReplace keeps one clock per property. Holding the + // endpoint avoids callbacks from an interrupted transition. + animation.FillBehavior = FillBehavior.HoldEnd; + animated.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace); + } + + static void UpdateIcon(Window window, string name, bool visible, double hiddenAngle, bool animate) + { + FrameworkElement icon = window.FindName(name) as FrameworkElement; + if (icon == null) return; + TransformGroup transforms = icon.RenderTransform as TransformGroup; + if (transforms == null || transforms.Children.Count != 2) return; + if (transforms.IsFrozen) icon.RenderTransform = transforms = transforms.Clone(); + var scale = transforms.Children[0] as ScaleTransform; + var rotation = transforms.Children[1] as RotateTransform; + if (scale == null || rotation == null) return; + AnimateDouble(icon, UIElement.OpacityProperty, icon.Opacity, visible ? 1 : 0, animate); + AnimateDouble(scale, ScaleTransform.ScaleXProperty, scale.ScaleX, visible ? 1 : 0.4, animate); + AnimateDouble(scale, ScaleTransform.ScaleYProperty, scale.ScaleY, visible ? 1 : 0.4, animate); + AnimateDouble(rotation, RotateTransform.AngleProperty, rotation.Angle, visible ? 0 : hiddenAngle, animate); } // Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в @@ -534,6 +616,7 @@ namespace SbLauncher Border selection = w.FindName("ServerSelectionWindow") as Border; if (serverList != null && serverHost != null && selection != null) MoveServerSelection(serverList, serverHost, selection, false); + UpdateTabs(w, vm, false); root2.UpdateLayout(); RenderTargetBitmap rtb = new RenderTargetBitmap( @@ -569,10 +652,12 @@ namespace SbLauncher if (argv[i] == "--root") return argv[i + 1]; string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - if (File.Exists(Path.Combine(exeDir, "profiles.json"))) return exeDir; + if (File.Exists(Path.Combine(exeDir, "profiles.json")) || + File.Exists(Path.Combine(exeDir, "profiles.base.json"))) return exeDir; string parent = Path.GetDirectoryName(exeDir); - if (parent != null && File.Exists(Path.Combine(parent, "profiles.json"))) return parent; + if (parent != null && (File.Exists(Path.Combine(parent, "profiles.json")) || + File.Exists(Path.Combine(parent, "profiles.base.json")))) return parent; return exeDir; } @@ -665,9 +750,83 @@ namespace SbLauncher WireSwitch(w); WireServerSelection(w); + WireTabs(w, vm); w.Loaded += delegate { vm.PingAll(); }; } + static void WireTabs(Window w, MainVm vm) + { + FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement; + if (host == null) return; + + w.Loaded += delegate { UpdateTabs(w, vm, false); }; + host.SizeChanged += delegate { UpdateTabs(w, vm, false); }; + PropertyChangedEventHandler changed = delegate(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == "ShowSettings") UpdateTabs(w, vm, w.IsLoaded); + }; + vm.PropertyChanged += changed; + w.Closed += delegate { vm.PropertyChanged -= changed; }; + } + + static void UpdateTabs(Window w, MainVm vm, bool animate) + { + FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement; + FrameworkElement selector = w.FindName("TabSelectorHost") as FrameworkElement; + Border highlight = w.FindName("TabSelectionWindow") as Border; + Border log = w.FindName("LogPane") as Border; + Border settings = w.FindName("SettingsPane") as Border; + Button selected = w.FindName(vm.ShowSettings ? "BtnTabSettings" : "BtnTabLog") as Button; + if (host == null || selector == null || highlight == null || log == null || + settings == null || selected == null || host.ActualWidth <= 0) return; + + bool showSettings = vm.ShowSettings; + Border inactive = showSettings ? log : settings; + // Уходящая панель остаётся видимой до конца движения, но её поля + // уже не должны получать клики или фокус с клавиатуры. + if (inactive.IsKeyboardFocusWithin) selected.Focus(); + log.IsHitTestVisible = !showSettings; + settings.IsHitTestVisible = showSettings; + KeyboardNavigation.SetTabNavigation(log, showSettings ? KeyboardNavigationMode.None : KeyboardNavigationMode.Continue); + KeyboardNavigation.SetTabNavigation(settings, showSettings ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None); + log.Visibility = settings.Visibility = Visibility.Visible; + + double travel = host.ActualWidth + 16; + SlideTabElement(log, showSettings ? -travel : 0, animate, + delegate { if (vm.ShowSettings) log.Visibility = Visibility.Hidden; }); + SlideTabElement(settings, showSettings ? 0 : travel, animate, + delegate { if (!vm.ShowSettings) settings.Visibility = Visibility.Hidden; }); + double targetX = selected.TransformToAncestor(selector).Transform(new Point()).X; + SlideTabElement(highlight, targetX, animate, null); + } + + static void SlideTabElement(FrameworkElement element, double targetX, bool animate, Action completed) + { + TranslateTransform shift = element.RenderTransform as TranslateTransform; + if (shift == null) element.RenderTransform = shift = new TranslateTransform(); + else if (shift.IsFrozen) element.RenderTransform = shift = shift.Clone(); + + double fromX = shift.X; + shift.BeginAnimation(TranslateTransform.XProperty, null); + shift.X = fromX; + if (!animate || Math.Abs(fromX - targetX) < 0.1) + { + shift.X = targetX; + if (completed != null) completed(); + return; + } + + DoubleAnimation slide = new DoubleAnimation(fromX, targetX, TimeSpan.FromMilliseconds(520)); + slide.EasingFunction = new BackEase { Amplitude = 0.22, EasingMode = EasingMode.EaseOut }; + slide.Completed += delegate + { + shift.X = targetX; + shift.BeginAnimation(TranslateTransform.XProperty, null); + if (completed != null) completed(); + }; + shift.BeginAnimation(TranslateTransform.XProperty, slide); + } + static void WireSwitch(Window w) { ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton; diff --git a/CHANGELOG.md b/CHANGELOG.md index 60875d6..3d1d6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Пока нет записей. +## 0.1.2 — 2026-10-10 + +- Вкладки «Лог» и «Настройки» получили скользящую стеклянную подсветку и плавный переход между панелями с лёгкой пружинной отдачей. +- Тёмная тема заменена выбранной палитрой «Графит»: нейтральные серые поверхности, мягкие границы стекла и синий акцент. +- Светлая тема заменена выбранной палитрой «Серебро»: нейтральные серебристые поверхности, белые карточки и графитовый акцент. +- Переключение темы плавно меняет цвета интерфейса и переводит солнце в луну с вращением, масштабированием и затуханием; повторное нажатие продолжает переход из текущего состояния. +- В релиз включены базовые серверы из текущего рабочего списка, sing-box и файл правил: после распаковки достаточно ввести собственные ключ и IP WireGuard. +- Базовый каталог `profiles.base.json` отделён от личных серверов в `profiles.json` и настроек компьютера в `local.json`. Обновление каталога сохраняет личные серверы, ключ, адрес, адаптер и выбранный сервер. +- Удаление базового сервера сохраняется как персональное скрытие; сервер не возвращается после обновления. Старые списки автоматически мигрируют с резервной копией. +- В процедуру релиза включены проверки обновления профилей и переходов темы; в архив добавлена инструкция по передаче приложения и обновлению без потери настроек. + ## 0.1.1 — 2026-09-27 - Название окна и значка в трее приведено к `SbLauncher`; в шапке показывается версия сборки. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..7b8a9c0 --- /dev/null +++ b/HANDOFF.md @@ -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`. diff --git a/README.md b/README.md index 8e89c17..90b9061 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,106 @@ # SbLauncher -Лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11 x64 с -.NET Framework 4.x. Поддерживает импорт и выбор серверов, три режима -подключения (`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку -sing-box, логи и светлую/тёмную тему. - -Это отдельный прототип интерфейса; рабочая копия `sb_launcher_rev2` не меняется. -Приложение запрашивает права администратора для настройки сетевых маршрутов. +Портативный лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11 +x64 с .NET Framework 4.x. Поддерживает импорт серверов, три режима подключения +(`wg`, `direct`, `wgonly`), проверку конфигурации, запуск и остановку sing-box, +логи и светлую/тёмную тему. Для TUN и сетевых маршрутов запрашивает права +администратора. ![Светлая тема](preview/glass-light.png) [Тёмная тема](preview/glass-dark.png) -## Подготовка +## Установка из релиза -Для сборки нужен `csc.exe` из установленного .NET Framework 4.x. `build.cmd` -использует `%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`; Visual Studio -и .NET SDK не требуются. +Распакуйте `SbLauncher-v0.1.2-win-x64.zip` в постоянную папку и запустите +`SbLauncher.exe`. В архив уже включены sing-box, файл правил и базовые серверы. +На вкладке «Настройки» задайте свой приватный ключ и локальный IP WireGuard; +для режима `direct` выберите свой сетевой адаптер. Программа автоматически +сохранит настройки. -Для подключения дополнительно нужны совместимый исполняемый файл sing-box и -файл правил. Они хранятся только локально и в репозиторий или релизный архив -не входят: +Передача коллеге, проверка контрольной суммы и обновление — в +[HANDOFF.md](HANDOFF.md). -```text -bin/sb.exe -doc/doc.srs -``` +## Базовые серверы и личные данные -Из чистого клона создайте локальные настройки и профили: +- `profiles.base.json` содержит общий каталог серверов и публичные параметры + WireGuard-сервера. Он входит в релиз и обновляется вместе с программой. +- `profiles.json` содержит личные серверы, добавленные пользователем, и список + скрытых базовых серверов. Он исключён из Git и релиза. +- `local.json` хранит приватный ключ, локальный IP WireGuard, сетевой адаптер, + тему и выбор сервера. Он исключён из Git и релиза. + +При обновлении закройте программу через трей и распакуйте ZIP поверх той же +папки. Личные файлы сохранятся, а базовые серверы обновятся. Для перехода с +v0.1.1 старый общий список автоматически преобразуется в личный слой с +резервной копией `profiles.legacy-backup.json`. + +`run/config.json` генерируется при подключении. Постоянные изменения правил +вносите в `templates/*.json`, а базовых серверов — в `profiles.base.json`. +Для новых базовых серверов задавайте уникальный `id`; у существующих его +сохраняйте. Личный `profiles.json` никогда не копируйте в релизный каталог. + +## Сборка из исходников + +Нужен `csc.exe` из установленного .NET Framework 4.x. `build.cmd` использует +`%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`; Visual Studio и +.NET SDK не требуются. + +В чистом клоне для подключения и проверки конфигов дополнительно разместите +совместимый `bin/sb.exe` и файл правил `doc/doc.srs`. Бинарники не хранятся +в Git, но включаются в готовый релизный ZIP. Текущий комплект использует +sing-box `1.12.19`, `windows/amd64`. ```powershell -Copy-Item .\local.example.json .\local.json -Copy-Item .\profiles.example.json .\profiles.json cmd /c build.cmd .\SbLauncher.exe ``` -Демонстрационные адреса из `profiles.example.json` не подключаются. Добавьте -свои серверы через импорт или в `profiles.json`, затем укажите приватный ключ -WireGuard, адрес туннеля и сетевой адаптер на вкладке «Настройки». Секреты в -`local.json`, а также серверы в `profiles.json` исключены из Git. Не добавляйте -их в коммиты и релизные архивы. +Базовые серверы загрузятся из `profiles.base.json`, личные файлы создаются +программой при сохранении настроек или импорте. `profiles.example.json` +содержит демонстрационные адреса для тестирования старого формата и не +используется для первой установки из релиза. -Готовый ZIP из релиза распаковывается аналогично: поместите рядом с лаунчером -`bin/sb.exe` и `doc/doc.srs`, скопируйте два файла `.example.json` без слова -`.example` в имени и заполните свои данные. Сборка из исходников для ZIP не нужна. - -## Сборка и проверки +## Проверки ```powershell -cmd /c build.cmd # SbLauncher.exe -cmd /c build.cmd importtest # импорт ссылок -cmd /c build.cmd profiletest # сохранение профилей +cmd /c build.cmd importtest # форматы импорта и конфиги sing-box +cmd /c build.cmd profiletest # импорт и сохранение профилей +cmd /c build.cmd updatetest # обновление базы и сохранение личных данных cmd /c build.cmd stress # нагрузка интерфейса +cmd /c build.cmd themetest # анимация темы и быстрые переключения ``` -`build.cmd selftest` проверяет локальную конфигурацию и может использовать -текущие профили. `build.cmd deploy "путь"` копирует сборку в явно указанную -тестовую папку; локальный `local.json` в ней не перезаписывается. +`build.cmd selftest` проверяет конфигурацию и может читать текущие профили. +`build.cmd deploy "путь"` копирует программу, базовый каталог и шаблоны в +явно указанную тестовую папку; `local.json` и личный `profiles.json` не заменяет. ## Git и релизы -Основная ветка — `main`. Разработку фиксируйте коммитами в Git. Для очередного -релиза обновите [CHANGELOG.md](CHANGELOG.md), три версии в `Version.cs` и номер -`assemblyIdentity` в `app.manifest`; используйте формат `MAJOR.MINOR.PATCH`. -После этого, например для версии `0.1.1`: +Основная ветка — `main`. Обновите [CHANGELOG.md](CHANGELOG.md), три версии +в `Version.cs` и номер `assemblyIdentity` в `app.manifest`. Перед коммитом +проверьте список файлов: личных настроек, серверов и резервных копий +в нём быть не должно. Для версии `0.1.2`: ```powershell git status git add -A git diff --cached --stat -git commit -m "Release v0.1.1" -.\release.ps1 -Version 0.1.1 -git tag -a v0.1.1 -m "SbLauncher v0.1.1" -``` - -Перед коммитом проверьте список добавленных файлов: в нём должны быть только -изменения для этого релиза. `release.ps1` требует чистую рабочую копию, запускает сборку и три -проверки, сверяет версию EXE и собирает `dist/SbLauncher-vX.Y.Z-win-x64.zip` -с SHA-256 в соседнем `.sha256`. Архив содержит только приложение, шаблоны, -примеры конфигурации и документацию. Сборка идёт через `build.cmd staged`, -поэтому открытый лаунчер не блокирует выпуск. Для пробного запуска на незакоммиченных -изменениях есть `-AllowDirty`; архив получает суффикс `-dev` с хешем коммита. - -При настроенном удалённом репозитории отправьте ветку и тег: - -```powershell +git commit -m "Release v0.1.2" +.\release.ps1 -Version 0.1.2 +git tag -a v0.1.2 -m "SbLauncher v0.1.2" git push origin main -git push origin v0.1.1 +git push origin v0.1.2 ``` -Затем создайте релиз `v0.1.1` в GitHub/Gitea, вставьте заметки из -`CHANGELOG.md` и прикрепите ZIP и `.sha256`. Если `origin` ещё не настроен, -сначала добавьте адрес своего удалённого репозитория. +`release.ps1` требует чистый Git-коммит, собирает приложение и запускает +пять проверок. Сверяет версию EXE, запрещает персональные поля WireGuard в +базовом каталоге и собирает `dist/SbLauncher-vX.Y.Z-win-x64.zip` с SHA-256 +в соседнем `.sha256`. Архив содержит приложение, sing-box, файл правил, +базовые серверы, шаблоны, примеры и документацию. Личные файлы в него не входят. +Сборка идёт через `build.cmd staged`, поэтому открытый лаунчер не блокирует +выпуск. `-AllowDirty` предназначен для пробной сборки: архив получает +суффикс `-dev` и не должен публиковаться как стабильный релиз. + +Создайте релиз `v0.1.2` в GitHub/Gitea, вставьте заметки из `CHANGELOG.md` +и прикрепите ZIP и `.sha256`. diff --git a/SbLauncher.cs b/SbLauncher.cs index b534c15..f567577 100644 --- a/SbLauncher.cs +++ b/SbLauncher.cs @@ -1,7 +1,8 @@ // SbLauncher — GUI для sing-box. // // ПРИНЦИП (изменён по сравнению с прошлой версией): -// Источник правды — profiles.json (серверы, общее) и local.json (ключ, +// Источник правды — profiles.base.json (базовые серверы), profiles.json +// (личные серверы) и local.json (ключ, // адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: // он собирается из шаблона templates\*.json при каждом запуске и кладётся // в run\config.json. Поэтому папок conf_* больше нет, и править один и @@ -187,6 +188,7 @@ namespace SbLauncher public Paths(string root) { Root = root; } public string Profiles { get { return Path.Combine(Root, "profiles.json"); } } + public string BaseProfiles { get { return Path.Combine(Root, "profiles.base.json"); } } public string Local { get { return Path.Combine(Root, "local.json"); } } public string TemplatesDir { get { return Path.Combine(Root, "templates"); } } public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } } @@ -217,6 +219,7 @@ namespace SbLauncher public string SpiderX = ""; // старые профили сохраняют это поле public string Protocol = "vless"; public Dictionary Outbound; // точная конфигурация импортированного ключа + public string BaseId = ""; // стабильный id только у серверов из релиза public string Endpoint { @@ -232,19 +235,115 @@ namespace SbLauncher public int Mtu = 1280; } - // profiles.json — локальная копия серверов; в тестовом репозитории не коммитится + // Релиз обновляет profiles.base.json. profiles.json хранит только личные + // серверы и скрытые базовые id; local.json независимо хранит ключ и адрес. public class Profiles { public WgCfg Wg = new WgCfg(); public List Servers = new List(); + public HashSet HiddenBaseServers = new HashSet(StringComparer.Ordinal); + public bool Layered; + public bool NeedsMigration; + bool _wgOverride; public static Profiles Load(string path) + { + Profiles personal = ReadFile(path, false); + string basePath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.base.json"); + if (!string.Equals(Path.GetFileName(path), "profiles.json", StringComparison.OrdinalIgnoreCase) || + !File.Exists(basePath)) return personal; + + Profiles baseline = ReadFile(basePath, true); + Profiles merged = new Profiles(); + merged.Layered = true; + merged.NeedsMigration = File.Exists(path) && !personal.Layered; + merged.HiddenBaseServers.UnionWith(personal.HiddenBaseServers); + merged._wgOverride = personal._wgOverride && (personal.Layered || !SameWg(personal.Wg, baseline.Wg)); + merged.Wg = merged._wgOverride ? personal.Wg : baseline.Wg; + + // Старый общий список превращается в личный слой без дубликатов. + // Непохожие на базовые записи сохраняем, включая старые настройки. + List own = new List(); + 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 reserved = new List(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 all) + { + string original = server.Code; + int number = 2; + while (all.Exists(delegate(ServerCfg existing) { + return string.Equals(existing.Code, server.Code, StringComparison.OrdinalIgnoreCase); + })) server.Code = original + (number++).ToString(CultureInfo.InvariantCulture); + } + + public Profiles Copy() + { + Profiles next = new Profiles(); + next.Wg = Wg; + next.Servers.AddRange(Servers); + next.HiddenBaseServers.UnionWith(HiddenBaseServers); + next.Layered = Layered; + next.NeedsMigration = NeedsMigration; + next._wgOverride = _wgOverride; + return next; + } + + public void Remove(ServerCfg server) + { + Servers.Remove(server); + if (server.BaseId.Length > 0) HiddenBaseServers.Add(server.BaseId); + } + + static Profiles ReadFile(string path, bool isBase) { Profiles p = new Profiles(); if (!File.Exists(path)) return p; Dictionary root = J.Parse(File.ReadAllText(path, Encoding.UTF8)); if (root == null) return p; + p.Layered = J.Int(root, "schema_version", 0) >= 2; + object hidden; + if (root.TryGetValue("hidden_base_servers", out hidden)) + { + object[] ids = hidden as object[]; + if (ids != null) foreach (object id in ids) + if (id is string) p.HiddenBaseServers.Add((string)id); + } object wo; if (root.TryGetValue("wireguard", out wo)) @@ -252,6 +351,7 @@ namespace SbLauncher Dictionary w = J.Obj(wo); if (w != null) { + p._wgOverride = true; p.Wg.Server = J.Str(w, "server", ""); p.Wg.Port = J.Int(w, "port", 51820); p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", ""); @@ -276,6 +376,12 @@ namespace SbLauncher s.Flow = J.Str(d, "flow", "xtls-rprx-vision"); s.SpiderX = J.Str(d, "spider_x", ""); s.Protocol = J.Str(d, "protocol", "vless"); + if (isBase) + { + s.BaseId = J.Str(d, "id", s.Code); + if (s.BaseId.Length == 0 || p.Servers.Exists(delegate(ServerCfg existing) { return existing.BaseId == s.BaseId; })) + throw new FormatException("У базовых серверов должны быть уникальные непустые id."); + } object outbound; if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound); if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol); @@ -284,25 +390,43 @@ namespace SbLauncher return p; } - // Пишется только когда сервер добавили/удалили из GUI. Файл + // Пишется при миграции или добавлении/удалении сервера из GUI. Файл // пересобирается целиком, поэтому свои комментарии ВНУТРИ списка // серверов при этом теряются — шапка восстанавливается. public void Save(string path) { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("// Серверы и общие параметры этого тестового экземпляра."); - sb.AppendLine("// Храните данные серверов локально, а ключ WireGuard — в local.json."); - sb.AppendLine("{"); - sb.AppendLine(" \"wireguard\": {"); - sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\","); - sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ","); - sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\","); - sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture)); - sb.AppendLine(" },"); - sb.AppendLine(" \"servers\": ["); - for (int i = 0; i < Servers.Count; i++) + if (NeedsMigration && File.Exists(path)) { - ServerCfg s = Servers[i]; + string backup = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.legacy-backup.json"); + if (!File.Exists(backup)) File.Copy(path, backup); + } + List stored = Layered + ? Servers.FindAll(delegate(ServerCfg server) { return server.BaseId.Length == 0; }) + : Servers; + StringBuilder sb = new StringBuilder(); + sb.AppendLine("// Личные серверы и настройки списка. Не включайте этот файл в релиз."); + sb.AppendLine("// Базовые серверы — profiles.base.json; ключ и адрес — local.json."); + sb.AppendLine("{"); + if (Layered) + { + sb.AppendLine(" \"schema_version\": 2,"); + List hidden = new List(HiddenBaseServers); + hidden.Sort(StringComparer.Ordinal); + sb.AppendLine(" \"hidden_base_servers\": " + J.ToJson(hidden.ToArray()) + ","); + } + if (!Layered || _wgOverride) + { + sb.AppendLine(" \"wireguard\": {"); + sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\","); + sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ","); + sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\","); + sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture)); + sb.AppendLine(" },"); + } + sb.AppendLine(" \"servers\": ["); + for (int i = 0; i < stored.Count; i++) + { + ServerCfg s = stored[i]; sb.AppendLine(" {"); sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\","); sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\","); @@ -322,11 +446,12 @@ namespace SbLauncher sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : "")); if (s.Outbound != null) sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound)); - sb.AppendLine(i == Servers.Count - 1 ? " }" : " },"); + sb.AppendLine(i == stored.Count - 1 ? " }" : " },"); } sb.AppendLine(" ]"); sb.AppendLine("}"); J.Write(path, sb.ToString()); + NeedsMigration = false; } } diff --git a/Ui.xaml b/Ui.xaml index 513e5b1..9b5648b 100644 --- a/Ui.xaml +++ b/Ui.xaml @@ -23,97 +23,97 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - - - - - 0.55 + + + + + + 0.35 - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - + + + - - - - - - - - - + + + + + + + + + - - + + - - + + - - - - - - 0.16 + + + + + + 0.10 @@ -570,12 +570,14 @@