diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
new file mode 100644
index 0000000..716dfd8
--- /dev/null
+++ b/.github/workflows/windows-build.yml
@@ -0,0 +1,35 @@
+name: Windows desktop build
+
+on:
+ workflow_dispatch:
+ push:
+ branches: [main]
+ paths:
+ - 'Windows/**'
+ - '.github/workflows/windows-build.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: windows-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v4
+
+ - name: Install .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Publish Windows app
+ shell: pwsh
+ run: ./Windows/Scripts/build.ps1
+
+ - name: Upload Windows package
+ uses: actions/upload-artifact@v4
+ with:
+ name: SHD-Windows-x64
+ path: Windows/dist/SHD-Windows-x64.zip
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index df10ab6..30cc244 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ Windows/bin/
Windows/obj/
Windows/.vs/
Windows/publish/
+Windows/dist/
diff --git a/README.md b/README.md
index dc70208..41f3ed0 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
## Папки
- `Apple/` — приложение macOS на Swift и WKWebView.
-- `Windows/` — место для версии Windows.
+- `Windows/` — приложение Windows на WPF и WebView2, установочные сценарии и инструкция.
## Вход в macOS-приложении
@@ -18,6 +18,10 @@
Код должен быть одноразовым, связанным с PKCE и действовать не более 60 секунд. Callback URI фиксирован в приложении. Маршрут завершения должен принимать только такой код и verifier; исключение CSRF-защиты допустимо только для этого маршрута при обязательной проверке кода и PKCE.
+## Windows-приложение
+
+Windows-клиент находится в `Windows/`. Он открывает сайт в WebView2 и запускает passkey-вход по той же кнопке на странице, что и macOS-версия. Сборка создаётся GitHub Actions workflow **Windows desktop build**; инструкция по установке и получению архива находится в [`Windows/README.md`](Windows/README.md).
+
## Сборка macOS-приложения
Из каталога `Apple/` запустить `./Scripts/build-app.sh`. Сборка создаёт проверенный архив `Apple/dist/SHD-macOS-arm64.zip` и не устанавливает приложение в `/Applications`.
diff --git a/Windows/README.md b/Windows/README.md
index 5d6c741..176d24b 100644
--- a/Windows/README.md
+++ b/Windows/README.md
@@ -1,3 +1,29 @@
# SHD для Windows
-Здесь будет исходный код Windows-версии SHD. Планируемая оболочка — отдельное Windows-приложение с WebView2 и переключением DEV/PROD.
+Windows-клиент — отдельное WPF-приложение на .NET 10 с Microsoft Edge WebView2. Оно хранит профиль сайта локально, поддерживает DEV и PROD, вкладки и окна, загрузки, печать, масштаб, системные диалоги сайта, доступ к камере и микрофону для доменов SHD и инструменты разработчика.
+
+## Установка
+
+1. Скачайте `SHD-Windows-x64.zip` из артефактов последней сборки **Windows desktop build** в GitHub Actions и распакуйте архив.
+2. Откройте PowerShell в распакованной папке и выполните `powershell -ExecutionPolicy Bypass -File .\install.ps1`. Скрипт установит приложение в `%LOCALAPPDATA%\Programs\SHD`, добавит ярлык в меню «Пуск» и зарегистрирует адрес возврата `shd-auth://callback`.
+3. Запустите SHD из меню «Пуск».
+
+Нужен Microsoft Edge WebView2 Runtime. Обычно он уже установлен вместе с Microsoft Edge; если его нет, приложение откроет страницу установки Runtime.
+
+Для удаления запустите `uninstall.ps1` из распакованной папки или папки установки. Данные сайта и профиль WebView2 остаются в `%LOCALAPPDATA%\SHDDesktop`.
+
+## Вход по passkey
+
+Вход начинается нажатием штатной кнопки passkey на странице SHD. Приложение перехватывает только `#btnPasskeyLogin` и `#twoFactorPasskey` на страницах входа, открывает авторизацию в системном браузере и принимает ответ через `shd-auth://callback`. Одноразовый код и PKCE verifier отправляются POST-запросом в WebView2, чтобы сессионная cookie осталась в профиле приложения.
+
+Этот серверный поток сейчас подключён только на DEV. На PROD приложение сообщает об этом и не начинает отдельную авторизацию.
+
+## Сборка
+
+На Windows с установленным .NET 10 SDK запустите из PowerShell:
+
+```powershell
+./Windows/Scripts/build.ps1
+```
+
+Скрипт публикует автономное приложение для `win-x64` и создаёт `Windows/dist/SHD-Windows-x64.zip`. .NET не требуется устанавливать на компьютере пользователя; WebView2 Runtime нужен отдельно.
diff --git a/Windows/SHD.Windows.sln b/Windows/SHD.Windows.sln
new file mode 100644
index 0000000..c1f2acd
--- /dev/null
+++ b/Windows/SHD.Windows.sln
@@ -0,0 +1,18 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHD.Windows", "SHD.Windows\SHD.Windows.csproj", "{C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/Windows/SHD.Windows/App.xaml b/Windows/SHD.Windows/App.xaml
new file mode 100644
index 0000000..fcd5e2d
--- /dev/null
+++ b/Windows/SHD.Windows/App.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/SHD.Windows/App.xaml.cs b/Windows/SHD.Windows/App.xaml.cs
new file mode 100644
index 0000000..426a55a
--- /dev/null
+++ b/Windows/SHD.Windows/App.xaml.cs
@@ -0,0 +1,137 @@
+using System.IO.Pipes;
+using System.Text;
+using System.Windows;
+using System.Windows.Threading;
+
+namespace SHD.Windows;
+
+public partial class App : Application
+{
+ private const string MutexName = @"Local\SHD.Desktop.Windows";
+ private const string PipeName = "SHD.Desktop.Windows.Auth";
+ private static readonly UTF8Encoding Utf8WithoutBom = new(encoderShouldEmitUTF8Identifier: false);
+ private Mutex? _instanceMutex;
+ private bool _ownsMutex;
+ private MainWindow? _mainWindow;
+ private readonly CancellationTokenSource _pipeCancellation = new();
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ _instanceMutex = new Mutex(initiallyOwned: true, MutexName, out _ownsMutex);
+ if (!_ownsMutex)
+ {
+ var command = e.Args.FirstOrDefault(IsDesktopAuthCallback) ?? "activate";
+ await ForwardToPrimaryInstanceAsync(command);
+ Shutdown();
+ return;
+ }
+
+ _mainWindow = new MainWindow();
+ MainWindow = _mainWindow;
+ _mainWindow.Show();
+ _ = ListenForSecondInstanceAsync(_pipeCancellation.Token);
+
+ var callback = e.Args.FirstOrDefault(IsDesktopAuthCallback);
+ if (callback is not null)
+ {
+ await _mainWindow.Dispatcher.InvokeAsync(() => _mainWindow.HandleAuthCallback(callback), DispatcherPriority.Loaded);
+ }
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _pipeCancellation.Cancel();
+ if (_ownsMutex)
+ {
+ try { _instanceMutex?.ReleaseMutex(); }
+ catch (ApplicationException) { }
+ }
+ _instanceMutex?.Dispose();
+ _pipeCancellation.Dispose();
+ base.OnExit(e);
+ }
+
+ private async Task ListenForSecondInstanceAsync(CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await using var server = new NamedPipeServerStream(
+ PipeName,
+ PipeDirection.In,
+ 1,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ await server.WaitForConnectionAsync(cancellationToken);
+ using var reader = new StreamReader(server, Utf8WithoutBom, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
+ var message = await reader.ReadLineAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(message)) continue;
+
+ await Dispatcher.InvokeAsync(() =>
+ {
+ var windows = Application.Current.Windows.OfType().ToArray();
+ if (IsDesktopAuthCallback(message))
+ {
+ var authWindow = windows.FirstOrDefault(window => window.CanHandleAuthCallback(message));
+ if (authWindow is not null)
+ {
+ if (authWindow.WindowState == WindowState.Minimized) authWindow.WindowState = WindowState.Normal;
+ authWindow.Activate();
+ authWindow.HandleAuthCallback(message);
+ return;
+ }
+ }
+
+ var fallbackWindow = windows.FirstOrDefault() ?? _mainWindow;
+ if (fallbackWindow is null) return;
+ if (fallbackWindow.WindowState == WindowState.Minimized) fallbackWindow.WindowState = WindowState.Normal;
+ fallbackWindow.Activate();
+ if (IsDesktopAuthCallback(message)) fallbackWindow.HandleAuthCallback(message);
+ });
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (IOException)
+ {
+ if (!cancellationToken.IsCancellationRequested) await Task.Delay(150, cancellationToken);
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ }
+ }
+
+ private static async Task ForwardToPrimaryInstanceAsync(string command)
+ {
+ for (var attempt = 0; attempt < 12; attempt++)
+ {
+ try
+ {
+ await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous);
+ await client.ConnectAsync(500);
+ await using var writer = new StreamWriter(client, Utf8WithoutBom, bufferSize: 1024, leaveOpen: true);
+ await writer.WriteLineAsync(command);
+ await writer.FlushAsync();
+ return;
+ }
+ catch (IOException)
+ {
+ await Task.Delay(150);
+ }
+ catch (TimeoutException)
+ {
+ await Task.Delay(150);
+ }
+ }
+ }
+
+ private static bool IsDesktopAuthCallback(string value) =>
+ Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
+ uri.Scheme.Equals("shd-auth", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Windows/SHD.Windows/BrowserTab.cs b/Windows/SHD.Windows/BrowserTab.cs
new file mode 100644
index 0000000..e281779
--- /dev/null
+++ b/Windows/SHD.Windows/BrowserTab.cs
@@ -0,0 +1,13 @@
+using Microsoft.Web.WebView2.Wpf;
+using System.Windows.Controls;
+
+namespace SHD.Windows;
+
+internal sealed class BrowserTab(WebView2 view, TabItem item, ProgressBar progress, TextBlock title, ShdEnvironment environment)
+{
+ public WebView2 View { get; } = view;
+ public TabItem Item { get; } = item;
+ public ProgressBar Progress { get; } = progress;
+ public TextBlock Title { get; } = title;
+ public ShdEnvironment Environment { get; set; } = environment;
+}
diff --git a/Windows/SHD.Windows/DesktopAuthFlow.cs b/Windows/SHD.Windows/DesktopAuthFlow.cs
new file mode 100644
index 0000000..76e5aea
--- /dev/null
+++ b/Windows/SHD.Windows/DesktopAuthFlow.cs
@@ -0,0 +1,81 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace SHD.Windows;
+
+internal sealed record DesktopAuthCallback(string Code, string State);
+
+internal static class DesktopAuthFlow
+{
+ public const string CallbackUri = "shd-auth://callback";
+ private const string ClientId = "shd-desktop";
+
+ public static PendingDesktopAuth Create(ShdEnvironment environment, BrowserTab targetTab)
+ {
+ var verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
+ var state = Base64Url(RandomNumberGenerator.GetBytes(32));
+ var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
+ var url = environment.BaseUrl() + "/desktop-auth/authorize" +
+ "?client_id=" + Uri.EscapeDataString(ClientId) +
+ "&redirect_uri=" + Uri.EscapeDataString(CallbackUri) +
+ "&response_type=code" +
+ "&state=" + Uri.EscapeDataString(state) +
+ "&code_challenge=" + Uri.EscapeDataString(challenge) +
+ "&code_challenge_method=S256";
+
+ return new PendingDesktopAuth(state, verifier, environment, targetTab, new Uri(url));
+ }
+
+ public static Uri CompleteUri(ShdEnvironment environment) =>
+ new(environment.BaseUrl() + "/desktop-auth/complete");
+
+ public static bool TryParseCallback(string value, out DesktopAuthCallback? callback, out bool serverReturnedError)
+ {
+ callback = null;
+ serverReturnedError = false;
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
+ !uri.Scheme.Equals("shd-auth", StringComparison.OrdinalIgnoreCase) ||
+ !uri.Host.Equals("callback", StringComparison.OrdinalIgnoreCase) ||
+ uri.AbsolutePath is not ("" or "/") || uri.UserInfo.Length != 0 || uri.Port != -1 ||
+ uri.Fragment.Length != 0)
+ return false;
+
+ var values = new Dictionary>(StringComparer.Ordinal);
+ foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var separator = pair.IndexOf('=');
+ var name = Decode(separator < 0 ? pair : pair[..separator]);
+ var item = Decode(separator < 0 ? "" : pair[(separator + 1)..]);
+ if (!values.TryGetValue(name, out var list)) values[name] = list = [];
+ list.Add(item);
+ }
+
+ if (values.ContainsKey("error"))
+ {
+ if (values["error"].Count != 1 ||
+ !values.TryGetValue("state", out var errorStates) || errorStates.Count != 1 || string.IsNullOrWhiteSpace(errorStates[0]))
+ return false;
+ serverReturnedError = true;
+ callback = new DesktopAuthCallback("", errorStates[0]);
+ return true;
+ }
+
+ if (!values.TryGetValue("code", out var codes) || codes.Count != 1 || string.IsNullOrWhiteSpace(codes[0]) ||
+ !values.TryGetValue("state", out var states) || states.Count != 1 || string.IsNullOrWhiteSpace(states[0]))
+ return false;
+
+ callback = new DesktopAuthCallback(codes[0], states[0]);
+ return true;
+ }
+
+ public static string FormEncode(string value) => Uri.EscapeDataString(value).Replace("%20", "+", StringComparison.Ordinal);
+
+ private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " ", StringComparison.Ordinal));
+
+ private static string Base64Url(byte[] value) => Convert.ToBase64String(value)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+}
+
+internal sealed record PendingDesktopAuth(string State, string Verifier, ShdEnvironment Environment, BrowserTab TargetTab, Uri AuthorizationUri);
diff --git a/Windows/SHD.Windows/MainWindow.xaml b/Windows/SHD.Windows/MainWindow.xaml
new file mode 100644
index 0000000..81a98fa
--- /dev/null
+++ b/Windows/SHD.Windows/MainWindow.xaml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/SHD.Windows/MainWindow.xaml.cs b/Windows/SHD.Windows/MainWindow.xaml.cs
new file mode 100644
index 0000000..f756ac7
--- /dev/null
+++ b/Windows/SHD.Windows/MainWindow.xaml.cs
@@ -0,0 +1,796 @@
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Web.WebView2.Wpf;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using DrawingColor = System.Drawing.Color;
+
+namespace SHD.Windows;
+
+public partial class MainWindow : Window
+{
+ private const string WebViewProfileFolder = "WebView2";
+ private static readonly Uri WebViewRuntimePage = new("https://developer.microsoft.com/microsoft-edge/webview2/");
+ private static readonly Lazy> SharedWebViewEnvironment = new(CreateWebViewEnvironmentAsync);
+ private readonly List _tabs = [];
+ private readonly ShdEnvironment _initialEnvironment;
+ private CoreWebView2Environment? _webViewEnvironment;
+ private ShdEnvironment _selectedEnvironment;
+ private PendingDesktopAuth? _authAttempt;
+ private MemoryStream? _authPostData;
+ private WebView2? _authPostTarget;
+ private ulong? _authNavigationId;
+ private bool _suppressEnvironmentChange;
+ private bool _initialized;
+ private bool _closing;
+ private bool _isFullScreen;
+ private WindowStyle _previousWindowStyle;
+ private WindowState _previousWindowState;
+ private Rect _previousBounds;
+
+ public MainWindow(ShdEnvironment? initialEnvironment = null)
+ {
+ InitializeComponent();
+ _initialEnvironment = initialEnvironment ?? SettingsStore.LoadEnvironment();
+ _selectedEnvironment = _initialEnvironment;
+ EnvironmentPicker.IsEnabled = false;
+ Tabs.IsEnabled = false;
+ }
+
+ private BrowserTab? ActiveTab => Tabs.SelectedItem is TabItem item
+ ? _tabs.FirstOrDefault(tab => ReferenceEquals(tab.Item, item))
+ : null;
+
+ private async void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (_initialized) return;
+ _initialized = true;
+
+ try
+ {
+ _webViewEnvironment = await SharedWebViewEnvironment.Value;
+ SetEnvironmentPicker(_initialEnvironment);
+ EnvironmentPicker.IsEnabled = true;
+ Tabs.IsEnabled = true;
+ await AddTabAsync(_initialEnvironment);
+ StatusText.Text = "Готово";
+ }
+ catch (Exception)
+ {
+ var result = MessageBox.Show(
+ this,
+ "Не удалось запустить встроенный браузер SHD. Установите Microsoft Edge WebView2 Runtime и запустите приложение ещё раз.\n\nОткрыть страницу загрузки Runtime?",
+ "SHD — встроенный браузер",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+ if (result == MessageBoxResult.Yes) OpenExternalUri(WebViewRuntimePage);
+ Close();
+ }
+ }
+
+ private static async Task CreateWebViewEnvironmentAsync()
+ {
+ var userDataFolder = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "SHDDesktop",
+ WebViewProfileFolder);
+ Directory.CreateDirectory(userDataFolder);
+ return await CoreWebView2Environment.CreateAsync(userDataFolder: userDataFolder);
+ }
+
+ private async Task AddTabAsync(ShdEnvironment environment, Uri? initialUri = null)
+ {
+ if (_webViewEnvironment is null)
+ throw new InvalidOperationException("WebView2 environment is not ready.");
+
+ var view = new WebView2
+ {
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ DefaultBackgroundColor = DrawingColor.FromArgb(18, 18, 18)
+ };
+ var progress = new ProgressBar
+ {
+ Height = 2,
+ IsIndeterminate = true,
+ Visibility = Visibility.Collapsed,
+ VerticalAlignment = VerticalAlignment.Top,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ IsHitTestVisible = false,
+ Foreground = new SolidColorBrush(Color.FromRgb(84, 156, 255)),
+ Background = Brushes.Transparent
+ };
+ var content = new Grid { Background = new SolidColorBrush(Color.FromRgb(18, 18, 18)) };
+ content.Children.Add(view);
+ Panel.SetZIndex(progress, 1);
+ content.Children.Add(progress);
+
+ var item = new TabItem
+ {
+ Background = new SolidColorBrush(Color.FromRgb(34, 34, 34)),
+ Foreground = Brushes.White,
+ Content = content
+ };
+ var headerTitle = new TextBlock
+ {
+ Text = "SHD",
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ MaxWidth = 210,
+ Margin = new Thickness(2, 0, 7, 0)
+ };
+ var closeButton = new Button
+ {
+ Content = "×",
+ Width = 22,
+ Height = 22,
+ Padding = new Thickness(0),
+ Margin = new Thickness(3, 0, 0, 0),
+ FontSize = 15,
+ ToolTip = "Закрыть вкладку",
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var header = new DockPanel { LastChildFill = true, Margin = new Thickness(2, 0, 2, 0) };
+ DockPanel.SetDock(closeButton, Dock.Right);
+ header.Children.Add(closeButton);
+ header.Children.Add(headerTitle);
+ item.Header = header;
+
+ var tab = new BrowserTab(view, item, progress, headerTitle, environment);
+ closeButton.Click += (_, _) => CloseTab(tab);
+ _tabs.Add(tab);
+ Tabs.Items.Add(item);
+ Tabs.SelectedItem = item;
+
+ await view.EnsureCoreWebView2Async(_webViewEnvironment);
+ await ConfigureBrowserAsync(tab);
+ view.CoreWebView2.Navigate((initialUri ?? new Uri(environment.BaseUrl())).AbsoluteUri);
+ UpdateWindowChrome();
+ return tab;
+ }
+
+ private async Task ConfigureBrowserAsync(BrowserTab tab)
+ {
+ var core = tab.View.CoreWebView2;
+ core.DefaultBackgroundColor = DrawingColor.FromArgb(18, 18, 18);
+ core.Settings.IsStatusBarEnabled = false;
+ core.Settings.AreDevToolsEnabled = true;
+ core.Settings.IsZoomControlEnabled = true;
+ core.Settings.IsPasswordAutosaveEnabled = true;
+ core.Settings.IsGeneralAutofillEnabled = true;
+ core.Settings.AreDefaultScriptDialogsEnabled = false;
+
+ core.NavigationStarting += Browser_NavigationStarting;
+ core.NavigationCompleted += Browser_NavigationCompleted;
+ core.DocumentTitleChanged += Browser_DocumentTitleChanged;
+ core.WebMessageReceived += Browser_WebMessageReceived;
+ core.NewWindowRequested += Browser_NewWindowRequested;
+ core.PermissionRequested += Browser_PermissionRequested;
+ core.DownloadStarting += Browser_DownloadStarting;
+ core.ScriptDialogOpening += Browser_ScriptDialogOpening;
+
+ await core.AddScriptToExecuteOnDocumentCreatedAsync("""
+ (() => {
+ const darkStyle = document.createElement('style');
+ darkStyle.id = '__shd_desktop_dark__';
+ darkStyle.textContent = 'html, body { background-color: #121212 !important; color: #e0e0e0 !important; }';
+ const parent = document.head || document.documentElement;
+ if (parent) parent.appendChild(darkStyle);
+ window.print = function () {
+ if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('shd-print');
+ };
+ })();
+ """);
+
+ await core.AddScriptToExecuteOnDocumentCreatedAsync("""
+ (() => {
+ if (window.top !== window) return;
+ document.addEventListener('click', event => {
+ let path = window.location.pathname;
+ while (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
+ if (path !== '/login' && path !== '/login/two-factor') return;
+ const target = event.target instanceof Element
+ ? event.target
+ : (event.target && event.target.parentElement);
+ if (!target || !target.closest('#btnPasskeyLogin, #twoFactorPasskey')) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('shd-passkey');
+ }, true);
+ })();
+ """);
+ }
+
+ private void Browser_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
+ {
+ if (sender is not CoreWebView2 core) return;
+ var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
+ if (tab is not null) tab.Progress.Visibility = Visibility.Visible;
+
+ if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) return;
+ if (tab is not null && ReferenceEquals(tab.View, _authPostTarget) &&
+ uri.AbsolutePath.Equals("/desktop-auth/complete", StringComparison.Ordinal))
+ _authNavigationId = e.NavigationId;
+ if (uri.Scheme is not ("https" or "http"))
+ {
+ e.Cancel = true;
+ if (e.IsUserInitiated) OpenExternalUri(uri);
+ return;
+ }
+
+ if (tab is not null)
+ {
+ if (ShdEnvironmentExtensions.IsDevelopmentHost(uri.Host)) tab.Environment = ShdEnvironment.Dev;
+ else if (ShdEnvironmentExtensions.IsProductionHost(uri.Host)) tab.Environment = ShdEnvironment.Production;
+ if (ReferenceEquals(tab, ActiveTab))
+ {
+ _selectedEnvironment = tab.Environment;
+ SetEnvironmentPicker(tab.Environment);
+ SettingsStore.SaveEnvironment(tab.Environment);
+ StatusEnvironment.Text = tab.Environment.DisplayName();
+ }
+ }
+
+ if (e.IsUserInitiated && !e.IsRedirected && !ShdEnvironmentExtensions.IsInternalHost(uri.Host))
+ {
+ e.Cancel = true;
+ OpenExternalUri(uri);
+ }
+ }
+
+ private void Browser_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
+ {
+ if (sender is not CoreWebView2 core) return;
+ var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
+ if (tab is not null) tab.Progress.Visibility = Visibility.Collapsed;
+ UpdateWindowChrome();
+
+ if (tab is not null && ReferenceEquals(_authPostTarget, tab.View) && _authNavigationId == e.NavigationId)
+ {
+ _authPostData?.Dispose();
+ _authPostData = null;
+ _authPostTarget = null;
+ _authNavigationId = null;
+ StatusText.Text = e.IsSuccess ? "Вход завершён" : "Не удалось завершить вход";
+ }
+ }
+
+ private void Browser_DocumentTitleChanged(object? sender, object e)
+ {
+ if (sender is not CoreWebView2 core) return;
+ var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
+ if (tab is null) return;
+ var title = CleanTitle(core.DocumentTitle);
+ tab.Title.Text = title;
+ if (ReferenceEquals(tab, ActiveTab))
+ {
+ PageTitle.Text = title;
+ Title = $"{title} — SHD";
+ }
+ }
+
+ private async void Browser_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
+ {
+ if (sender is not WebView2 view || !Uri.TryCreate(e.Source, UriKind.Absolute, out var source) ||
+ source.Scheme != Uri.UriSchemeHttps || !ShdEnvironmentExtensions.IsInternalHost(source.Host))
+ return;
+
+ string message;
+ try { message = e.TryGetWebMessageAsString(); }
+ catch (InvalidOperationException) { return; }
+
+ if (message == "shd-print")
+ {
+ view.CoreWebView2.ShowPrintUI(CoreWebView2PrintDialogKind.Browser);
+ return;
+ }
+
+ if (message != "shd-passkey") return;
+ if (!(ShdEnvironmentExtensions.IsDevelopmentHost(source.Host) || ShdEnvironmentExtensions.IsProductionHost(source.Host))) return;
+ var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View, view));
+ if (tab is null) return;
+ var environment = ShdEnvironmentExtensions.IsDevelopmentHost(source.Host)
+ ? ShdEnvironment.Dev
+ : ShdEnvironment.Production;
+ await BeginPasskeyLoginAsync(tab, environment);
+ }
+
+ private async void Browser_NewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs e)
+ {
+ e.Handled = true;
+ if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) return;
+ if (uri.Scheme is "http" or "https" && ShdEnvironmentExtensions.IsInternalHost(uri.Host))
+ {
+ var environment = ShdEnvironmentExtensions.IsDevelopmentHost(uri.Host)
+ ? ShdEnvironment.Dev
+ : ShdEnvironmentExtensions.IsProductionHost(uri.Host)
+ ? ShdEnvironment.Production
+ : _selectedEnvironment;
+ await AddTabAsync(environment, uri);
+ }
+ else
+ {
+ OpenExternalUri(uri);
+ }
+ }
+
+ private void Browser_PermissionRequested(object? sender, CoreWebView2PermissionRequestedEventArgs e)
+ {
+ if (e.PermissionKind is not (CoreWebView2PermissionKind.Camera or CoreWebView2PermissionKind.Microphone)) return;
+ var trusted = Uri.TryCreate(e.Uri, UriKind.Absolute, out var origin) &&
+ origin.Scheme == Uri.UriSchemeHttps && ShdEnvironmentExtensions.IsInternalHost(origin.Host);
+ if (trusted)
+ {
+ e.State = CoreWebView2PermissionState.Allow;
+ }
+ else
+ {
+ var label = e.PermissionKind == CoreWebView2PermissionKind.Camera ? "камеру" : "микрофон";
+ var allow = MessageBox.Show(
+ this,
+ $"Разрешить сайту {e.Uri} доступ к устройству ({label})?",
+ "Разрешение SHD",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question) == MessageBoxResult.Yes;
+ e.State = allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
+ }
+ e.Handled = true;
+ }
+
+ private void Browser_DownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e)
+ {
+ var downloadsFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ downloadsFolder = Path.Combine(downloadsFolder, "Downloads");
+ Directory.CreateDirectory(downloadsFolder);
+ var suggestedName = Path.GetFileName(e.ResultFilePath);
+ if (string.IsNullOrWhiteSpace(suggestedName)) suggestedName = "download";
+ e.ResultFilePath = GetUniquePath(Path.Combine(downloadsFolder, suggestedName));
+ e.Handled = true;
+ StatusText.Text = $"Загрузка: {suggestedName}";
+ e.DownloadOperation.StateChanged += (_, _) =>
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ if (e.DownloadOperation.State == CoreWebView2DownloadState.Completed)
+ StatusText.Text = $"Загружено: {Path.GetFileName(e.ResultFilePath)}";
+ else if (e.DownloadOperation.State == CoreWebView2DownloadState.Interrupted)
+ StatusText.Text = "Загрузка прервана";
+ });
+ };
+ }
+
+ private void Browser_ScriptDialogOpening(object? sender, CoreWebView2ScriptDialogOpeningEventArgs e)
+ {
+ switch (e.Kind)
+ {
+ case CoreWebView2ScriptDialogKind.Alert:
+ MessageBox.Show(this, e.Message, "SHD", MessageBoxButton.OK, MessageBoxImage.Information);
+ e.Accept();
+ break;
+ case CoreWebView2ScriptDialogKind.Confirm:
+ case CoreWebView2ScriptDialogKind.BeforeUnload:
+ if (MessageBox.Show(this, e.Message, "SHD", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
+ e.Accept();
+ break;
+ case CoreWebView2ScriptDialogKind.Prompt:
+ var answer = ShowPrompt(e.Message, e.DefaultText);
+ if (answer is not null)
+ {
+ e.ResultText = answer;
+ e.Accept();
+ }
+ break;
+ }
+ }
+
+ private async Task BeginPasskeyLoginAsync(BrowserTab tab, ShdEnvironment environment)
+ {
+ if (_authAttempt is not null)
+ {
+ MessageBox.Show(this, "Вход уже выполняется. Завершите его в браузере или закройте окно входа.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ if (environment != ShdEnvironment.Dev)
+ {
+ MessageBox.Show(this, "Вход по passkey в приложении пока подключён только для DEV.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ try
+ {
+ var attempt = DesktopAuthFlow.Create(environment, tab);
+ _authAttempt = attempt;
+ Process.Start(new ProcessStartInfo(attempt.AuthorizationUri.AbsoluteUri) { UseShellExecute = true });
+ StatusText.Text = "Продолжите вход в системном браузере";
+ }
+ catch (Exception)
+ {
+ _authAttempt = null;
+ MessageBox.Show(this, "Не удалось открыть системный браузер для входа. Попробуйте ещё раз.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ await Task.CompletedTask;
+ }
+
+ public bool CanHandleAuthCallback(string callbackValue)
+ {
+ if (_authAttempt is null) return false;
+ if (!DesktopAuthFlow.TryParseCallback(callbackValue, out var callback, out _)) return false;
+ return callback is not null && callback.State == _authAttempt.State;
+ }
+
+ public async void HandleAuthCallback(string callbackValue)
+ {
+ if (!DesktopAuthFlow.TryParseCallback(callbackValue, out var callback, out var serverError))
+ {
+ MessageBox.Show(this, "Сервер вернул некорректный ответ входа.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var attempt = _authAttempt;
+ if (attempt is null)
+ {
+ MessageBox.Show(this, "Этот ответ входа уже нельзя использовать. Нажмите кнопку passkey на странице и начните заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ if (callback is null || callback.State != attempt.State)
+ {
+ MessageBox.Show(this, "Не удалось подтвердить ответ сервера. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ if (serverError)
+ {
+ _authAttempt = null;
+ MessageBox.Show(this, "Сервер не завершил вход. Попробуйте ещё раз.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ if (!_tabs.Contains(attempt.TargetTab) || attempt.TargetTab.View.CoreWebView2 is null)
+ {
+ _authAttempt = null;
+ MessageBox.Show(this, "Вкладка, из которой начался вход, уже закрыта. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ try
+ {
+ _authAttempt = null;
+ var formBody = $"code={DesktopAuthFlow.FormEncode(callback.Code)}&code_verifier={DesktopAuthFlow.FormEncode(attempt.Verifier)}";
+ _authPostData?.Dispose();
+ _authPostData = new MemoryStream(Encoding.UTF8.GetBytes(formBody));
+ _authPostTarget = attempt.TargetTab.View;
+ _authNavigationId = null;
+ var request = _webViewEnvironment!.CreateWebResourceRequest(
+ DesktopAuthFlow.CompleteUri(attempt.Environment).AbsoluteUri,
+ "POST",
+ _authPostData,
+ "Content-Type: application/x-www-form-urlencoded; charset=utf-8\r\nCache-Control: no-store\r\n");
+ attempt.TargetTab.Environment = attempt.Environment;
+ Tabs.SelectedItem = attempt.TargetTab.Item;
+ SetEnvironmentPicker(attempt.Environment);
+ SettingsStore.SaveEnvironment(attempt.Environment);
+ attempt.TargetTab.View.CoreWebView2.NavigateWithWebResourceRequest(request);
+ StatusText.Text = "Завершаем вход…";
+ }
+ catch (Exception)
+ {
+ _authPostData?.Dispose();
+ _authPostData = null;
+ _authPostTarget = null;
+ _authNavigationId = null;
+ MessageBox.Show(this, "Не удалось завершить вход в выбранном контуре. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ await Task.CompletedTask;
+ }
+
+ private void SetEnvironment(ShdEnvironment environment)
+ {
+ _selectedEnvironment = environment;
+ SettingsStore.SaveEnvironment(environment);
+ SetEnvironmentPicker(environment);
+ StatusEnvironment.Text = environment.DisplayName();
+ var tab = ActiveTab;
+ if (tab is null || tab.View.CoreWebView2 is null) return;
+ tab.Environment = environment;
+ tab.View.CoreWebView2.Navigate(environment.BaseUrl());
+ }
+
+ private void SetEnvironmentPicker(ShdEnvironment environment)
+ {
+ _suppressEnvironmentChange = true;
+ EnvironmentPicker.SelectedValue = environment.ToString();
+ _suppressEnvironmentChange = false;
+ }
+
+ private async void NewTab_Click(object sender, RoutedEventArgs e)
+ {
+ if (_webViewEnvironment is null) return;
+ await AddTabAsync(_selectedEnvironment);
+ }
+
+ private void NewWindow_Click(object sender, RoutedEventArgs e)
+ {
+ var window = new MainWindow(_selectedEnvironment);
+ window.Show();
+ }
+
+ private void CloseTab_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab is { } tab) CloseTab(tab);
+ }
+
+ private void CloseTab(BrowserTab tab)
+ {
+ if (_authAttempt?.TargetTab == tab) _authAttempt = null;
+ if (ReferenceEquals(_authPostTarget, tab.View))
+ {
+ _authPostData?.Dispose();
+ _authPostData = null;
+ _authPostTarget = null;
+ _authNavigationId = null;
+ }
+ tab.View.Dispose();
+ _tabs.Remove(tab);
+ Tabs.Items.Remove(tab.Item);
+ if (_tabs.Count == 0 && !_closing) _ = AddTabAsync(_selectedEnvironment);
+ UpdateWindowChrome();
+ }
+
+ private void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var tab = ActiveTab;
+ if (tab is null) return;
+ _selectedEnvironment = tab.Environment;
+ SetEnvironmentPicker(tab.Environment);
+ StatusEnvironment.Text = tab.Environment.DisplayName();
+ PageTitle.Text = tab.Title.Text;
+ Title = $"{CleanTitle(tab.View.CoreWebView2?.DocumentTitle ?? "SHD")} — SHD";
+ UpdateNavigationButtons();
+ }
+
+ private void EnvironmentPicker_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressEnvironmentChange || !Enum.TryParse(EnvironmentPicker.SelectedValue as string, out var environment)) return;
+ if (environment == _selectedEnvironment && ActiveTab?.Environment == environment) return;
+ SetEnvironment(environment);
+ }
+
+ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ var modifiers = Keyboard.Modifiers;
+ if (modifiers == ModifierKeys.Control)
+ {
+ switch (e.Key)
+ {
+ case Key.T: NewTab_Click(sender, e); e.Handled = true; break;
+ case Key.N: NewWindow_Click(sender, e); e.Handled = true; break;
+ case Key.W: CloseTab_Click(sender, e); e.Handled = true; break;
+ case Key.R: Reload_Click(sender, e); e.Handled = true; break;
+ case Key.P: Print_Click(sender, e); e.Handled = true; break;
+ case Key.D0: ZoomReset_Click(sender, e); e.Handled = true; break;
+ case Key.Add:
+ case Key.OemPlus: ZoomIn_Click(sender, e); e.Handled = true; break;
+ case Key.Subtract:
+ case Key.OemMinus: ZoomOut_Click(sender, e); e.Handled = true; break;
+ }
+ }
+ else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R)
+ {
+ HardReload_Click(sender, e);
+ e.Handled = true;
+ }
+ else if (e.Key == Key.F12)
+ {
+ DevTools_Click(sender, e);
+ e.Handled = true;
+ }
+ else if (e.Key == Key.F11)
+ {
+ FullScreen_Click(sender, e);
+ e.Handled = true;
+ }
+ else if (modifiers == ModifierKeys.Alt && e.Key == Key.Left)
+ {
+ Back_Click(sender, e);
+ e.Handled = true;
+ }
+ else if (modifiers == ModifierKeys.Alt && e.Key == Key.Right)
+ {
+ Forward_Click(sender, e);
+ e.Handled = true;
+ }
+ }
+
+ private void Back_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab?.View.CoreWebView2?.CanGoBack == true) ActiveTab.View.CoreWebView2.GoBack();
+ }
+
+ private void Forward_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab?.View.CoreWebView2?.CanGoForward == true) ActiveTab.View.CoreWebView2.GoForward();
+ }
+
+ private void Reload_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.Reload();
+
+ private async void HardReload_Click(object sender, RoutedEventArgs e)
+ {
+ var core = ActiveTab?.View.CoreWebView2;
+ if (core is null) return;
+ try { await core.CallDevToolsProtocolMethodAsync("Page.reload", "{\"ignoreCache\":true}"); }
+ catch (InvalidOperationException) { core.Reload(); }
+ }
+
+ private void ZoomIn_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab is { } tab) tab.View.ZoomFactor = Math.Min(tab.View.ZoomFactor + 0.1, 3.0);
+ }
+
+ private void ZoomOut_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab is { } tab) tab.View.ZoomFactor = Math.Max(tab.View.ZoomFactor - 0.1, 0.4);
+ }
+
+ private void ZoomReset_Click(object sender, RoutedEventArgs e)
+ {
+ if (ActiveTab is { } tab) tab.View.ZoomFactor = 1.0;
+ }
+
+ private void Print_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.ShowPrintUI(CoreWebView2PrintDialogKind.Browser);
+
+ private void DevTools_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.OpenDevToolsWindow();
+
+ private void FullScreen_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isFullScreen)
+ {
+ _previousBounds = RestoreBounds;
+ _previousWindowState = WindowState;
+ _previousWindowStyle = WindowStyle;
+ WindowState = WindowState.Normal;
+ WindowStyle = WindowStyle.None;
+ WindowState = WindowState.Maximized;
+ _isFullScreen = true;
+ return;
+ }
+
+ WindowStyle = _previousWindowStyle;
+ WindowState = WindowState.Normal;
+ Left = _previousBounds.Left;
+ Top = _previousBounds.Top;
+ Width = _previousBounds.Width;
+ Height = _previousBounds.Height;
+ WindowState = _previousWindowState;
+ _isFullScreen = false;
+ }
+
+ private void Downloads_Click(object sender, RoutedEventArgs e)
+ {
+ var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
+ Directory.CreateDirectory(path);
+ OpenExternalUri(new Uri(path));
+ }
+
+ private void SelectProduction_Click(object sender, RoutedEventArgs e) => SetEnvironment(ShdEnvironment.Production);
+
+ private void SelectDev_Click(object sender, RoutedEventArgs e) => SetEnvironment(ShdEnvironment.Dev);
+
+ private void Exit_Click(object sender, RoutedEventArgs e) => Application.Current.Shutdown();
+
+ private void Undo_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("undo");
+ private void Redo_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("redo");
+ private void Cut_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("cut");
+ private void Copy_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("copy");
+ private void Paste_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("paste");
+ private void SelectAll_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("selectAll");
+
+ private void ExecuteEditCommand(string command)
+ {
+ var escaped = command.Replace("'", "\\'", StringComparison.Ordinal);
+ _ = ActiveTab?.View.CoreWebView2?.ExecuteScriptAsync($"document.execCommand('{escaped}')");
+ }
+
+ private void UpdateWindowChrome()
+ {
+ if (ActiveTab is { } tab)
+ {
+ PageTitle.Text = tab.Title.Text;
+ StatusEnvironment.Text = tab.Environment.DisplayName();
+ }
+ UpdateNavigationButtons();
+ }
+
+ private void UpdateNavigationButtons()
+ {
+ BackButton.IsEnabled = ActiveTab?.View.CoreWebView2?.CanGoBack == true;
+ ForwardButton.IsEnabled = ActiveTab?.View.CoreWebView2?.CanGoForward == true;
+ }
+
+ private static string CleanTitle(string? raw)
+ {
+ var title = raw ?? string.Empty;
+ foreach (var suffix in new[] { " — SHD Project", " - SHD Project", " | SHD" })
+ {
+ var index = title.IndexOf(suffix, StringComparison.Ordinal);
+ if (index >= 0) title = title[..index];
+ }
+ return string.IsNullOrWhiteSpace(title) ? "SHD" : title.Trim();
+ }
+
+ private static string GetUniquePath(string path)
+ {
+ if (!File.Exists(path)) return path;
+ var directory = Path.GetDirectoryName(path)!;
+ var name = Path.GetFileNameWithoutExtension(path);
+ var extension = Path.GetExtension(path);
+ for (var counter = 1; ; counter++)
+ {
+ var candidate = Path.Combine(directory, $"{name} ({counter}){extension}");
+ if (!File.Exists(candidate)) return candidate;
+ }
+ }
+
+ private string? ShowPrompt(string message, string defaultValue)
+ {
+ var dialog = new Window
+ {
+ Owner = this,
+ Title = "SHD",
+ Width = 430,
+ SizeToContent = SizeToContent.Height,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ResizeMode = ResizeMode.NoResize,
+ Background = new SolidColorBrush(Color.FromRgb(34, 34, 34)),
+ Foreground = Brushes.White,
+ ShowInTaskbar = false
+ };
+ var input = new TextBox { Text = defaultValue, MinWidth = 380, Margin = new Thickness(0, 9, 0, 14), Padding = new Thickness(6) };
+ var accept = new Button { Content = "OK", IsDefault = true, MinWidth = 80 };
+ var cancel = new Button { Content = "Отмена", IsCancel = true, MinWidth = 80 };
+ var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
+ buttons.Children.Add(accept);
+ buttons.Children.Add(cancel);
+ var panel = new StackPanel { Margin = new Thickness(18) };
+ panel.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, MaxWidth = 380 });
+ panel.Children.Add(input);
+ panel.Children.Add(buttons);
+ dialog.Content = panel;
+ string? result = null;
+ accept.Click += (_, _) => { result = input.Text; dialog.DialogResult = true; };
+ dialog.Loaded += (_, _) => { input.Focus(); input.SelectAll(); };
+ dialog.ShowDialog();
+ return result;
+ }
+
+ private void OpenExternalUri(Uri uri)
+ {
+ try
+ {
+ if (uri.IsFile)
+ {
+ Process.Start(new ProcessStartInfo(uri.LocalPath) { UseShellExecute = true });
+ return;
+ }
+ if (uri.Scheme is "http" or "https" or "mailto")
+ Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
+ }
+ catch (Exception)
+ {
+ StatusText.Text = "Не удалось открыть ссылку";
+ }
+ }
+
+ private void Window_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ _closing = true;
+ _authAttempt = null;
+ _authPostData?.Dispose();
+ _authPostData = null;
+ foreach (var tab in _tabs.ToArray()) tab.View.Dispose();
+ _tabs.Clear();
+ }
+}
diff --git a/Windows/SHD.Windows/SHD.Windows.csproj b/Windows/SHD.Windows/SHD.Windows.csproj
new file mode 100644
index 0000000..e2dff66
--- /dev/null
+++ b/Windows/SHD.Windows/SHD.Windows.csproj
@@ -0,0 +1,19 @@
+
+
+ WinExe
+ net10.0-windows
+ true
+ enable
+ enable
+ app.manifest
+ SHD.Windows
+ SHD.Windows
+ 1.0.0
+ SHD
+ SHD desktop app for Windows
+
+
+
+
+
+
diff --git a/Windows/SHD.Windows/SettingsStore.cs b/Windows/SHD.Windows/SettingsStore.cs
new file mode 100644
index 0000000..701f083
--- /dev/null
+++ b/Windows/SHD.Windows/SettingsStore.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+
+namespace SHD.Windows;
+
+internal static class SettingsStore
+{
+ private static readonly string SettingsPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "SHDDesktop",
+ "settings.json");
+
+ public static ShdEnvironment LoadEnvironment()
+ {
+ try
+ {
+ var settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath));
+ if (Enum.TryParse(settings?.Environment, ignoreCase: true, out var environment))
+ return environment;
+ }
+ catch (IOException) { }
+ catch (JsonException) { }
+ catch (UnauthorizedAccessException) { }
+
+ return ShdEnvironment.Production;
+ }
+
+ public static void SaveEnvironment(ShdEnvironment environment)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
+ File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new AppSettings(environment.ToString())));
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+
+ private sealed record AppSettings(string Environment);
+}
diff --git a/Windows/SHD.Windows/ShdEnvironment.cs b/Windows/SHD.Windows/ShdEnvironment.cs
new file mode 100644
index 0000000..d0dbb49
--- /dev/null
+++ b/Windows/SHD.Windows/ShdEnvironment.cs
@@ -0,0 +1,32 @@
+namespace SHD.Windows;
+
+internal enum ShdEnvironment
+{
+ Production,
+ Dev
+}
+
+internal static class ShdEnvironmentExtensions
+{
+ public static string BaseUrl(this ShdEnvironment environment) => environment switch
+ {
+ ShdEnvironment.Dev => "https://dev-shd.xyz.su",
+ _ => "https://shd.xyz.su"
+ };
+
+ public static string DisplayName(this ShdEnvironment environment) => environment switch
+ {
+ ShdEnvironment.Dev => "SHD Dev",
+ _ => "SHD Production"
+ };
+
+ public static bool IsDevelopmentHost(string host) =>
+ host.Equals("dev-shd.xyz.su", StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsProductionHost(string host) =>
+ host.Equals("shd.xyz.su", StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsInternalHost(string host) =>
+ host.Equals("xyz.su", StringComparison.OrdinalIgnoreCase) ||
+ host.EndsWith(".xyz.su", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Windows/SHD.Windows/app.manifest b/Windows/SHD.Windows/app.manifest
new file mode 100644
index 0000000..d6fbd8a
--- /dev/null
+++ b/Windows/SHD.Windows/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/Scripts/build.ps1 b/Windows/Scripts/build.ps1
new file mode 100644
index 0000000..e5f1422
--- /dev/null
+++ b/Windows/Scripts/build.ps1
@@ -0,0 +1,24 @@
+$ErrorActionPreference = 'Stop'
+
+$windowsRoot = Split-Path -Parent $PSScriptRoot
+$project = Join-Path $windowsRoot 'SHD.Windows\SHD.Windows.csproj'
+$publishDir = Join-Path $windowsRoot 'publish'
+$distDir = Join-Path $windowsRoot 'dist'
+$bundleDir = Join-Path $distDir 'SHD-Windows-x64'
+$zipPath = Join-Path $distDir 'SHD-Windows-x64.zip'
+
+if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
+if (Test-Path $bundleDir) { Remove-Item $bundleDir -Recurse -Force }
+New-Item -ItemType Directory -Path $publishDir -Force | Out-Null
+New-Item -ItemType Directory -Path $bundleDir -Force | Out-Null
+
+dotnet publish $project -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false -o $publishDir
+if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
+
+Copy-Item (Join-Path $publishDir '*') $bundleDir -Recurse -Force
+Copy-Item (Join-Path $PSScriptRoot 'install.ps1') $bundleDir -Force
+Copy-Item (Join-Path $PSScriptRoot 'uninstall.ps1') $bundleDir -Force
+Copy-Item (Join-Path $windowsRoot 'README.md') $bundleDir -Force
+if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
+Compress-Archive -Path (Join-Path $bundleDir '*') -DestinationPath $zipPath -CompressionLevel Optimal
+Write-Host "Created $zipPath"
diff --git a/Windows/Scripts/install.ps1 b/Windows/Scripts/install.ps1
new file mode 100644
index 0000000..5e8eb10
--- /dev/null
+++ b/Windows/Scripts/install.ps1
@@ -0,0 +1,43 @@
+param(
+ [switch]$NoLaunch
+)
+
+$ErrorActionPreference = 'Stop'
+$bundleDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sourceExe = Join-Path $bundleDir 'SHD.Windows.exe'
+if (-not (Test-Path $sourceExe)) {
+ throw 'SHD.Windows.exe is missing. Extract the complete SHD-Windows-x64.zip before installing.'
+}
+
+$installDir = Join-Path $env:LOCALAPPDATA 'Programs\SHD'
+New-Item -ItemType Directory -Path $installDir -Force | Out-Null
+$bundleFullPath = [System.IO.Path]::GetFullPath($bundleDir).TrimEnd('\')
+$installFullPath = [System.IO.Path]::GetFullPath($installDir).TrimEnd('\')
+if (-not $bundleFullPath.Equals($installFullPath, [System.StringComparison]::OrdinalIgnoreCase)) {
+ Get-ChildItem -LiteralPath $bundleDir -Force | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $installDir -Recurse -Force
+ }
+}
+
+$installedExe = Join-Path $installDir 'SHD.Windows.exe'
+$protocolKey = 'HKCU:\Software\Classes\shd-auth'
+New-Item -Path $protocolKey -Force | Out-Null
+Set-Item -Path $protocolKey -Value 'URL:SHD Desktop authentication'
+New-ItemProperty -Path $protocolKey -Name 'URL Protocol' -Value '' -PropertyType String -Force | Out-Null
+$commandKey = Join-Path $protocolKey 'shell\open\command'
+New-Item -Path $commandKey -Force | Out-Null
+Set-Item -Path $commandKey -Value ('"{0}" "%1"' -f $installedExe)
+
+$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\SHD'
+New-Item -ItemType Directory -Path $startMenuDir -Force | Out-Null
+$shortcutPath = Join-Path $startMenuDir 'SHD.lnk'
+$shell = New-Object -ComObject WScript.Shell
+$shortcut = $shell.CreateShortcut($shortcutPath)
+$shortcut.TargetPath = $installedExe
+$shortcut.WorkingDirectory = $installDir
+$shortcut.IconLocation = "$installedExe,0"
+$shortcut.Save()
+
+Write-Host "SHD installed to $installDir"
+Write-Host 'The shd-auth:// callback is registered for passkey sign-in.'
+if (-not $NoLaunch) { Start-Process -FilePath $installedExe }
diff --git a/Windows/Scripts/uninstall.ps1 b/Windows/Scripts/uninstall.ps1
new file mode 100644
index 0000000..8f5b337
--- /dev/null
+++ b/Windows/Scripts/uninstall.ps1
@@ -0,0 +1,16 @@
+$ErrorActionPreference = 'Stop'
+$installDir = Join-Path $env:LOCALAPPDATA 'Programs\SHD'
+$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\SHD'
+$protocolKey = 'HKCU:\Software\Classes\shd-auth'
+
+if (Test-Path $protocolKey) {
+ $commandKey = Join-Path $protocolKey 'shell\open\command'
+ $registeredCommand = if (Test-Path $commandKey) { (Get-Item -Path $commandKey).GetValue('') } else { '' }
+ if ($registeredCommand -like '*\Programs\SHD\SHD.Windows.exe*') {
+ Remove-Item -Path $protocolKey -Recurse -Force
+ }
+}
+if (Test-Path $startMenuDir) { Remove-Item -Path $startMenuDir -Recurse -Force }
+if (Test-Path $installDir) { Remove-Item -Path $installDir -Recurse -Force -ErrorAction SilentlyContinue }
+
+Write-Host 'SHD has been uninstalled. Your WebView profile and site data remain under %LOCALAPPDATA%\SHDDesktop.'