Initial WebhookServer implementation
Add the .NET 8 solution scaffolded against PLAN.md. Three projects share WebhookServer.Core (models, auth, execution, storage, IPC, callbacks) and WebhookServer.Service hosts an embedded Kestrel listener plus the named-pipe admin server. WebhookServer.Gui is a thin MVVM client over the pipe. Includes 25 unit tests covering HMAC verification, bearer auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip, and the encrypt-on-save config store. Install/uninstall PowerShell scripts default to LocalSystem and accept a domain user or gMSA via -ServiceAccount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<Application x:Class="WebhookServer.Gui.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="clr-namespace:WebhookServer.Gui.Converters"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<conv:NullToBoolConverter x:Key="NotNull"/>
|
||||
<conv:BoolToBrushConverter x:Key="ConnFill"/>
|
||||
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
|
||||
namespace WebhookServer.Gui;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace WebhookServer.Gui.Converters;
|
||||
|
||||
public sealed class NullToBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not null;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class StringEqualsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> string.Equals(value as string, parameter as string, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> (value is bool b && b) ? parameter : Binding.DoNothing;
|
||||
}
|
||||
|
||||
public sealed class BoolToBrushConverter : IValueConverter
|
||||
{
|
||||
public Brush TrueBrush { get; set; } = Brushes.SeaGreen;
|
||||
public Brush FalseBrush { get; set; } = Brushes.IndianRed;
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> (value is bool b && b) ? TrueBrush : FalseBrush;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<Window x:Class="WebhookServer.Gui.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
|
||||
mc:Ignorable="d"
|
||||
Title="Webhook Server" Height="600" Width="1000"
|
||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<StatusBar DockPanel.Dock="Bottom">
|
||||
<StatusBarItem>
|
||||
<Ellipse Width="10" Height="10"
|
||||
Fill="{Binding IsConnected, Converter={StaticResource ConnFill}}"/>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem>
|
||||
<TextBlock Text="{Binding ConnectionStatus}"/>
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
|
||||
<ToolBar DockPanel.Dock="Top">
|
||||
<Button Content="Refresh" Command="{Binding RefreshCommand}"/>
|
||||
<Separator/>
|
||||
<Button Content="Add" Command="{Binding AddEndpointCommand}"/>
|
||||
<Button Content="Edit" Command="{Binding EditEndpointCommand}"
|
||||
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
|
||||
<Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
|
||||
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
|
||||
<Separator/>
|
||||
<Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
|
||||
</ToolBar>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="5"/>
|
||||
<RowDefinition Height="200"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DataGrid Grid.Row="0"
|
||||
ItemsSource="{Binding Endpoints}"
|
||||
SelectedItem="{Binding SelectedEndpoint, Mode=TwoWay}"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
IsReadOnly="True"
|
||||
HeadersVisibility="Column">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Header="Enabled" Width="80">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate DataType="{x:Type models:EndpointConfig}">
|
||||
<CheckBox IsChecked="{Binding Enabled, Mode=OneWay}"
|
||||
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="*"/>
|
||||
<DataGridTextColumn Header="Auth" Binding="{Binding AuthMode}" Width="80"/>
|
||||
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
|
||||
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
|
||||
<DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<GridSplitter Grid.Row="1" HorizontalAlignment="Stretch" Background="#DDD"/>
|
||||
|
||||
<DockPanel Grid.Row="2">
|
||||
<Grid DockPanel.Dock="Top">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
|
||||
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
|
||||
</Grid>
|
||||
<TextBox Text="{Binding LogTail, Mode=OneWay}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
TextWrapping="NoWrap"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Windows;
|
||||
using WebhookServer.Gui.Services;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var vm = new MainViewModel(new AdminPipeClient());
|
||||
DataContext = vm;
|
||||
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Gui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin client around the admin named pipe. Each call connects, sends one request,
|
||||
/// reads one response, and disconnects — keeps lifecycle simple at the cost of
|
||||
/// connect-per-call overhead. The service single-instance pipe queues requests so
|
||||
/// concurrent calls from the GUI serialize automatically.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class AdminPipeClient
|
||||
{
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task<AdminResponse> InvokeAsync(string op, object? data = null, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AdminRequest
|
||||
{
|
||||
Op = op,
|
||||
Data = data is null
|
||||
? null
|
||||
: JsonSerializer.SerializeToDocument(data, AdminProtocol.JsonOptions).RootElement.Clone(),
|
||||
};
|
||||
|
||||
await using var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
PipeSecurityFactory.PipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await pipe.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
|
||||
|
||||
await PipeFraming.WriteAsync(pipe, request, ct).ConfigureAwait(false);
|
||||
|
||||
using var reader = PipeFraming.CreateReader(pipe);
|
||||
var response = await PipeFraming.ReadAsync<AdminResponse>(reader, ct).ConfigureAwait(false);
|
||||
return response ?? AdminResponse.Failure("empty response from service");
|
||||
}
|
||||
|
||||
public async Task<T?> InvokeAsync<T>(string op, object? data = null, CancellationToken ct = default) where T : class
|
||||
{
|
||||
var resp = await InvokeAsync(op, data, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return null;
|
||||
return resp.Data.Value.Deserialize<T>(AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
public Task<AdminResponse> PingAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.Ping, null, ct);
|
||||
|
||||
public Task<StatusInfo?> GetStatusAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<StatusInfo>(AdminOps.GetStatus, null, ct);
|
||||
|
||||
public Task<ServerConfig?> GetConfigAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<ServerConfig>(AdminOps.GetConfig, null, ct);
|
||||
|
||||
public Task<EndpointConfig?> CreateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.CreateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<EndpointConfig?> UpdateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.UpdateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<AdminResponse> DeleteEndpointAsync(Guid id, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.DeleteEndpoint, new DeleteEndpointArgs { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> SetEndpointEnabledAsync(Guid id, bool enabled, CancellationToken ct = default) =>
|
||||
InvokeAsync(enabled ? AdminOps.EnableEndpoint : AdminOps.DisableEndpoint, new EndpointToggle { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> BindHttpsAsync(HttpsBinding? binding, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.BindHttps, binding, ct);
|
||||
|
||||
public Task<AdminResponse> RestartListenerAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.RestartListener, null, ct);
|
||||
|
||||
public async Task<List<LogLine>> TailLogsAsync(int lines, CancellationToken ct = default)
|
||||
{
|
||||
var resp = await InvokeAsync(AdminOps.TailLogs, new TailLogsArgs { LinesToBacklog = lines, Follow = false }, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return new List<LogLine>();
|
||||
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
||||
return lst ?? new List<LogLine>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class EndpointEditorViewModel : ObservableObject
|
||||
{
|
||||
public EndpointConfig Endpoint { get; }
|
||||
public bool IsNew { get; }
|
||||
|
||||
[ObservableProperty] private bool _accepted;
|
||||
|
||||
public EndpointEditorViewModel(EndpointConfig template, bool isNew)
|
||||
{
|
||||
// Deep clone via JSON so cancel-on-close cleanly drops edits.
|
||||
var json = JsonSerializer.Serialize(template, ConfigJson.Compact);
|
||||
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
|
||||
Endpoint.Bearer ??= new BearerOptions();
|
||||
Endpoint.Hmac ??= new HmacOptions();
|
||||
IsNew = isNew;
|
||||
}
|
||||
|
||||
public Array AuthModes { get; } = Enum.GetValues(typeof(AuthMode));
|
||||
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
|
||||
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
|
||||
|
||||
public string AllowedClientsText
|
||||
{
|
||||
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
|
||||
set
|
||||
{
|
||||
Endpoint.AllowedClients = (value ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string ExecutableArgsText
|
||||
{
|
||||
get => string.Join(" ", Endpoint.ExecutableArgs);
|
||||
set
|
||||
{
|
||||
Endpoint.ExecutableArgs = (value ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string BearerSecretInput
|
||||
{
|
||||
get => "";
|
||||
set
|
||||
{
|
||||
Endpoint.Bearer ??= new BearerOptions();
|
||||
Endpoint.Bearer.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string HmacSecretInput
|
||||
{
|
||||
get => "";
|
||||
set
|
||||
{
|
||||
Endpoint.Hmac ??= new HmacOptions();
|
||||
Endpoint.Hmac.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => Accepted = true;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Gui.Services;
|
||||
using WebhookServer.Gui.Views;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminPipeClient _client;
|
||||
|
||||
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
|
||||
|
||||
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
|
||||
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
||||
[ObservableProperty] private bool _isConnected;
|
||||
[ObservableProperty] private string _logTail = "";
|
||||
[ObservableProperty] private ServerConfig _serverConfig = new();
|
||||
|
||||
public MainViewModel(AdminPipeClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _client.GetStatusAsync().ConfigureAwait(false);
|
||||
var config = await _client.GetConfigAsync().ConfigureAwait(false);
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
IsConnected = status?.Running == true;
|
||||
ConnectionStatus = IsConnected
|
||||
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
||||
: "Disconnected";
|
||||
|
||||
Endpoints.Clear();
|
||||
if (config is not null)
|
||||
{
|
||||
ServerConfig = config;
|
||||
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = $"Disconnected: {ex.Message}";
|
||||
});
|
||||
}
|
||||
|
||||
await RefreshLogTailAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshLogTailAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
|
||||
var text = new StringBuilder();
|
||||
foreach (var line in lines) text.AppendLine(line.Message);
|
||||
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore — main connection state already reflects pipe failure
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddEndpointAsync()
|
||||
{
|
||||
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
|
||||
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
||||
var vm = new EndpointEditorViewModel(draft, isNew: true);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Create failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditEndpointAsync()
|
||||
{
|
||||
if (SelectedEndpoint is null) return;
|
||||
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
||||
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Update failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteEndpointAsync()
|
||||
{
|
||||
if (SelectedEndpoint is null) return;
|
||||
var ok = MessageBox.Show(
|
||||
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
|
||||
"Confirm",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Delete failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleEnabledAsync(EndpointConfig? ep)
|
||||
{
|
||||
if (ep is null) return;
|
||||
try
|
||||
{
|
||||
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Toggle failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditServerSettingsAsync()
|
||||
{
|
||||
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
|
||||
var vm = new ServerSettingsViewModel(ServerConfig);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
ServerConfig.HttpPort = vm.HttpPort;
|
||||
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
||||
ServerConfig.HttpsBinding = vm.BuildBinding();
|
||||
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Save failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowError(string title, Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Runtime.Versioning;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class ServerSettingsViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private int _httpPort;
|
||||
[ObservableProperty] private int _httpsPort;
|
||||
[ObservableProperty] private bool _httpsEnabled;
|
||||
[ObservableProperty] private string _httpsMode = "PfxFile";
|
||||
[ObservableProperty] private string _pfxPath = "";
|
||||
[ObservableProperty] private string _pfxPasswordInput = "";
|
||||
[ObservableProperty] private string _thumbprint = "";
|
||||
[ObservableProperty] private string _trustedProxiesText = "";
|
||||
|
||||
public bool Accepted { get; private set; }
|
||||
|
||||
public ServerSettingsViewModel(ServerConfig config)
|
||||
{
|
||||
HttpPort = config.HttpPort;
|
||||
TrustedProxiesText = string.Join(Environment.NewLine, config.TrustedProxies);
|
||||
|
||||
var b = config.HttpsBinding;
|
||||
HttpsEnabled = b is not null && b.Kind != HttpsBindingKind.None;
|
||||
HttpsPort = b?.Port ?? 8443;
|
||||
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
|
||||
PfxPath = b?.PfxPath ?? "";
|
||||
Thumbprint = b?.Thumbprint ?? "";
|
||||
}
|
||||
|
||||
public List<string> TrustedProxiesList =>
|
||||
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
|
||||
public HttpsBinding? BuildBinding()
|
||||
{
|
||||
if (!HttpsEnabled) return null;
|
||||
|
||||
var binding = new HttpsBinding { Port = HttpsPort };
|
||||
if (string.Equals(HttpsMode, "Thumbprint", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
binding.Kind = HttpsBindingKind.CertStoreThumbprint;
|
||||
binding.Thumbprint = Thumbprint?.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
binding.Kind = HttpsBindingKind.PfxFile;
|
||||
binding.PfxPath = PfxPath;
|
||||
if (!string.IsNullOrEmpty(PfxPasswordInput))
|
||||
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => Accepted = true;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.EndpointEditor"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
Title="Endpoint" Height="700" Width="640"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:EndpointEditorViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave" />
|
||||
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<GroupBox Header="Identity" Padding="6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Slug" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Endpoint.Slug, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Description" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.Description, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Enabled" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.Enabled}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Auth" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
|
||||
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
|
||||
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
|
||||
<PasswordBox Grid.Column="1" PasswordChanged="OnHmacPasswordChanged"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="IP allowlist (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding AllowedClientsText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="60" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Executor" Padding="6" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Type" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding ExecutorTypes}" SelectedItem="{Binding Endpoint.ExecutorType, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Script path" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.ScriptPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Inline command" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Endpoint.InlineCommand, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" AcceptsReturn="True" MinHeight="40"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Executable" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Endpoint.ExecutablePath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Static args" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding ExecutableArgsText, UpdateSourceTrigger=LostFocus}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="5" Text="Working dir" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Endpoint.WorkingDirectory, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Data passing" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<CheckBox Content="JSON body to stdin" IsChecked="{Binding Endpoint.DataPassing.StdinJson}"/>
|
||||
<CheckBox Content="Headers/query as env vars (WEBHOOK_HEADER_*, WEBHOOK_QUERY_*)" IsChecked="{Binding Endpoint.DataPassing.EnvVars}" Margin="0,4,0,0"/>
|
||||
<CheckBox Content="Argument template" IsChecked="{Binding Endpoint.DataPassing.ArgTemplate}" Margin="0,4,0,0"/>
|
||||
<TextBox Text="{Binding Endpoint.DataPassing.ArgTemplateString, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" />
|
||||
<TextBlock Text="Tokens: {{body.foo}} {{header.X-Foo}} {{query.bar}} {{route.slug}}" Foreground="Gray" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Response" Padding="6" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding ResponseModes}" SelectedItem="{Binding Endpoint.ResponseMode, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Timeout (sec)" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.TimeoutSeconds, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Fail on non-zero exit" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.FailOnNonZeroExit}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Serialize runs" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" IsChecked="{Binding Endpoint.Serialize}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class EndpointEditor : Window
|
||||
{
|
||||
public EndpointEditor()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm)
|
||||
vm.SaveCommand.Execute(null);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
|
||||
vm.BearerSecretInput = box.Password;
|
||||
}
|
||||
|
||||
private void OnHmacPasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
|
||||
vm.HmacSecretInput = box.Password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.ServerSettings"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
Title="Server Settings" Height="500" Width="540"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave"/>
|
||||
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<GroupBox Header="HTTP" Padding="6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HTTP port" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding HttpPort, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="HTTPS port" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding HttpsPort, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Mode" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<RadioButton GroupName="HttpsMode" Content="PFX file"
|
||||
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=PfxFile}"
|
||||
Tag="PfxFile" Checked="OnModeChecked"/>
|
||||
<RadioButton GroupName="HttpsMode" Content="Cert store thumbprint" Margin="12,0,0,0"
|
||||
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=Thumbprint}"
|
||||
Tag="Thumbprint" Checked="OnModeChecked"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="PFX path" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PfxPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="PFX password" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<PasswordBox Grid.Row="3" Grid.Column="1" PasswordChanged="OnPfxPasswordChanged" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Thumbprint" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Thumbprint, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Trusted proxies (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class ServerSettings : Window
|
||||
{
|
||||
public ServerSettings()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ServerSettingsViewModel vm)
|
||||
vm.SaveCommand.Execute(null);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnPfxPasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ServerSettingsViewModel vm && sender is PasswordBox box)
|
||||
vm.PfxPasswordInput = box.Password;
|
||||
}
|
||||
|
||||
private void OnModeChecked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
|
||||
vm.HttpsMode = tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user