Add Windows desktop app
Windows desktop build / publish (push) Canceled after 0s

This commit is contained in:
bulava92
2026-10-28 16:04:52 +03:00
parent 1556470bcb
commit 29a8b935fb
18 changed files with 1444 additions and 2 deletions
+35
View File
@@ -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
+1
View File
@@ -6,3 +6,4 @@ Windows/bin/
Windows/obj/
Windows/.vs/
Windows/publish/
Windows/dist/
+5 -1
View File
@@ -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`.
+27 -1
View File
@@ -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 нужен отдельно.
+18
View File
@@ -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
+50
View File
@@ -0,0 +1,50 @@
<Application x:Class="SHD.Windows.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnLastWindowClose">
<Application.Resources>
<SolidColorBrush x:Key="ShellBackground" Color="#171717" />
<SolidColorBrush x:Key="ShellSurface" Color="#222222" />
<SolidColorBrush x:Key="ShellForeground" Color="#E8E8E8" />
<SolidColorBrush x:Key="ShellMuted" Color="#A8A8A8" />
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
<Setter Property="Background" Value="{StaticResource ShellSurface}" />
<Setter Property="Padding" Value="10,5" />
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
<Setter Property="Background" Value="#303030" />
<Setter Property="BorderBrush" Value="#454545" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="Margin" Value="3,0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style TargetType="ComboBox">
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
<Setter Property="Background" Value="#292929" />
<Setter Property="BorderBrush" Value="#454545" />
<Setter Property="Padding" Value="8,4" />
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
<Setter Property="Background" Value="#292929" />
<Setter Property="Padding" Value="8,5" />
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="#3A3A3A" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#454545" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TabItem">
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
<Setter Property="Background" Value="#222222" />
<Setter Property="BorderBrush" Value="#303030" />
<Setter Property="Padding" Value="10,6" />
</Style>
</Application.Resources>
</Application>
+137
View File
@@ -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<MainWindow>().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);
}
+13
View File
@@ -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;
}
+81
View File
@@ -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<string, List<string>>(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);
+92
View File
@@ -0,0 +1,92 @@
<Window x:Class="SHD.Windows.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="SHD" Width="1320" Height="860" MinWidth="900" MinHeight="560"
WindowStartupLocation="CenterScreen" Background="#171717" Foreground="#E8E8E8"
FontFamily="Segoe UI" FontSize="13"
Loaded="Window_Loaded" Closing="Window_Closing" PreviewKeyDown="Window_PreviewKeyDown">
<DockPanel LastChildFill="True">
<Menu DockPanel.Dock="Top" Background="#222222" Foreground="#E8E8E8">
<MenuItem Header="_Файл">
<MenuItem Header="Новая вкладка" InputGestureText="Ctrl+T" Click="NewTab_Click" />
<MenuItem Header="Новое окно" InputGestureText="Ctrl+N" Click="NewWindow_Click" />
<Separator />
<MenuItem Header="Печать…" InputGestureText="Ctrl+P" Click="Print_Click" />
<MenuItem Header="Открыть папку загрузок" Click="Downloads_Click" />
<Separator />
<MenuItem Header="Закрыть вкладку" InputGestureText="Ctrl+W" Click="CloseTab_Click" />
<MenuItem Header="Выход" Click="Exit_Click" />
</MenuItem>
<MenuItem Header="_Правка">
<MenuItem Header="Отменить" InputGestureText="Ctrl+Z" Click="Undo_Click" />
<MenuItem Header="Повторить" InputGestureText="Ctrl+Y" Click="Redo_Click" />
<Separator />
<MenuItem Header="Вырезать" InputGestureText="Ctrl+X" Click="Cut_Click" />
<MenuItem Header="Копировать" InputGestureText="Ctrl+C" Click="Copy_Click" />
<MenuItem Header="Вставить" InputGestureText="Ctrl+V" Click="Paste_Click" />
<MenuItem Header="Выбрать всё" InputGestureText="Ctrl+A" Click="SelectAll_Click" />
</MenuItem>
<MenuItem Header="_Вид">
<MenuItem Header="Назад" InputGestureText="Alt+Left" Click="Back_Click" />
<MenuItem Header="Вперёд" InputGestureText="Alt+Right" Click="Forward_Click" />
<Separator />
<MenuItem Header="Обновить" InputGestureText="Ctrl+R" Click="Reload_Click" />
<MenuItem Header="Очистить кэш и обновить" InputGestureText="Ctrl+Shift+R" Click="HardReload_Click" />
<Separator />
<MenuItem Header="Увеличить" InputGestureText="Ctrl++" Click="ZoomIn_Click" />
<MenuItem Header="Уменьшить" InputGestureText="Ctrl+-" Click="ZoomOut_Click" />
<MenuItem Header="Исходный масштаб" InputGestureText="Ctrl+0" Click="ZoomReset_Click" />
<MenuItem Header="Полноэкранный режим" InputGestureText="F11" Click="FullScreen_Click" />
<Separator />
<MenuItem Header="Инструменты разработчика" InputGestureText="F12" Click="DevTools_Click" />
</MenuItem>
<MenuItem Header="_Контур">
<MenuItem Header="SHD Production" Click="SelectProduction_Click" />
<MenuItem Header="SHD Dev" Click="SelectDev_Click" />
</MenuItem>
<MenuItem Header="_История">
<MenuItem Header="Назад" InputGestureText="Alt+Left" Click="Back_Click" />
<MenuItem Header="Вперёд" InputGestureText="Alt+Right" Click="Forward_Click" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Background="#1C1C1C" BorderBrush="#303030" BorderThickness="0,0,0,1" Padding="8,7">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="210" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button x:Name="BackButton" Grid.Column="0" Content="←" Width="38" ToolTip="Назад" Click="Back_Click" />
<Button x:Name="ForwardButton" Grid.Column="1" Content="→" Width="38" ToolTip="Вперёд" Click="Forward_Click" />
<Button Grid.Column="2" Content="⟳" Width="38" ToolTip="Обновить" Click="Reload_Click" />
<ComboBox x:Name="EnvironmentPicker" Grid.Column="4" Height="32" Padding="8,4"
SelectedValuePath="Tag" SelectionChanged="EnvironmentPicker_SelectionChanged"
Background="#292929" Foreground="#E8E8E8" BorderBrush="#454545">
<ComboBoxItem Content="SHD Production" Tag="Production" />
<ComboBoxItem Content="SHD Dev" Tag="Dev" />
</ComboBox>
<TextBlock x:Name="PageTitle" Grid.Column="5" VerticalAlignment="Center" Margin="14,0,8,0"
Foreground="#BDBDBD" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="6" Content="Печать" Click="Print_Click" />
<Button Grid.Column="7" Content="Загрузки" Click="Downloads_Click" />
<Button Grid.Column="8" Content="Инструменты" Click="DevTools_Click" />
</Grid>
</Border>
<StatusBar DockPanel.Dock="Bottom" Background="#202020" Foreground="#BDBDBD" BorderBrush="#303030" BorderThickness="0,1,0,0">
<StatusBarItem><TextBlock x:Name="StatusText" Text="Готово" /></StatusBarItem>
<Separator />
<StatusBarItem><TextBlock x:Name="StatusEnvironment" Text="SHD Production" /></StatusBarItem>
</StatusBar>
<TabControl x:Name="Tabs" Background="#171717" BorderThickness="0"
SelectionChanged="Tabs_SelectionChanged" />
</DockPanel>
</Window>
+796
View File
@@ -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<Task<CoreWebView2Environment>> SharedWebViewEnvironment = new(CreateWebViewEnvironmentAsync);
private readonly List<BrowserTab> _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<CoreWebView2Environment> CreateWebViewEnvironmentAsync()
{
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SHDDesktop",
WebViewProfileFolder);
Directory.CreateDirectory(userDataFolder);
return await CoreWebView2Environment.CreateAsync(userDataFolder: userDataFolder);
}
private async Task<BrowserTab> 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<ShdEnvironment>(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();
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>SHD.Windows</AssemblyName>
<RootNamespace>SHD.Windows</RootNamespace>
<Version>1.0.0</Version>
<Authors>SHD</Authors>
<Description>SHD desktop app for Windows</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
</ItemGroup>
</Project>
+39
View File
@@ -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<AppSettings>(File.ReadAllText(SettingsPath));
if (Enum.TryParse<ShdEnvironment>(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);
}
+32
View File
@@ -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);
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SHD.Windows" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+24
View File
@@ -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"
+43
View File
@@ -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 }
+16
View File
@@ -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.'