From 29a8b935fb2a96315d72f4dbb738a1e29ac1d037 Mon Sep 17 00:00:00 2001 From: bulava92 Date: Wed, 28 Oct 2026 16:04:52 +0300 Subject: [PATCH] Add Windows desktop app --- .github/workflows/windows-build.yml | 35 ++ .gitignore | 1 + README.md | 6 +- Windows/README.md | 28 +- Windows/SHD.Windows.sln | 18 + Windows/SHD.Windows/App.xaml | 50 ++ Windows/SHD.Windows/App.xaml.cs | 137 +++++ Windows/SHD.Windows/BrowserTab.cs | 13 + Windows/SHD.Windows/DesktopAuthFlow.cs | 81 +++ Windows/SHD.Windows/MainWindow.xaml | 92 +++ Windows/SHD.Windows/MainWindow.xaml.cs | 796 +++++++++++++++++++++++++ Windows/SHD.Windows/SHD.Windows.csproj | 19 + Windows/SHD.Windows/SettingsStore.cs | 39 ++ Windows/SHD.Windows/ShdEnvironment.cs | 32 + Windows/SHD.Windows/app.manifest | 16 + Windows/Scripts/build.ps1 | 24 + Windows/Scripts/install.ps1 | 43 ++ Windows/Scripts/uninstall.ps1 | 16 + 18 files changed, 1444 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/windows-build.yml create mode 100644 Windows/SHD.Windows.sln create mode 100644 Windows/SHD.Windows/App.xaml create mode 100644 Windows/SHD.Windows/App.xaml.cs create mode 100644 Windows/SHD.Windows/BrowserTab.cs create mode 100644 Windows/SHD.Windows/DesktopAuthFlow.cs create mode 100644 Windows/SHD.Windows/MainWindow.xaml create mode 100644 Windows/SHD.Windows/MainWindow.xaml.cs create mode 100644 Windows/SHD.Windows/SHD.Windows.csproj create mode 100644 Windows/SHD.Windows/SettingsStore.cs create mode 100644 Windows/SHD.Windows/ShdEnvironment.cs create mode 100644 Windows/SHD.Windows/app.manifest create mode 100644 Windows/Scripts/build.ps1 create mode 100644 Windows/Scripts/install.ps1 create mode 100644 Windows/Scripts/uninstall.ps1 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +