Initial commit.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -180,15 +180,15 @@ DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
[Pp]ublish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
#*.pubxml
|
||||
#*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
|
||||
45
ApiClient/ApiClient.csproj
Normal file
45
ApiClient/ApiClient.csproj
Normal file
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<RootNamespace>ClientApiPoC.ApiClient</RootNamespace>
|
||||
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
|
||||
<Company>Mike Schumann</Company>
|
||||
<Copyright>Copyright © 2026 $(Company)</Copyright>
|
||||
<AssemblyVersion>0.1.0</AssemblyVersion>
|
||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
||||
<NeutralLanguage>en-US</NeutralLanguage>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared.Wpf\Shared.Wpf.csproj" />
|
||||
<ProjectReference Include="..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
ApiClient/App.xaml
Normal file
8
ApiClient/App.xaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<Application x:Class="ClientApiPoC.ApiClient.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClientApiPoC.ApiClient">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
33
ApiClient/App.xaml.cs
Normal file
33
ApiClient/App.xaml.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Windows;
|
||||
|
||||
namespace ClientApiPoC.ApiClient {
|
||||
public partial class App : Application {
|
||||
private IHost _host;
|
||||
|
||||
public App() {
|
||||
_host = Host.CreateDefaultBuilder().ConfigureServices(services => {
|
||||
// ViewModels:
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
// Views:
|
||||
services.AddSingleton<MainWindow>();
|
||||
}).Build();
|
||||
}
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e) {
|
||||
await _host.StartAsync();
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.Show();
|
||||
|
||||
base.OnStartup(e);
|
||||
}
|
||||
|
||||
protected override async void OnExit(ExitEventArgs e) {
|
||||
await _host.StopAsync();
|
||||
_host.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ApiClient/AssemblyInfo.cs
Normal file
10
ApiClient/AssemblyInfo.cs
Normal file
@@ -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)
|
||||
)]
|
||||
62
ApiClient/MainWindow.xaml
Normal file
62
ApiClient/MainWindow.xaml
Normal file
@@ -0,0 +1,62 @@
|
||||
<wpf:MvvmWindow x:TypeArguments="local:MainWindowViewModel"
|
||||
x:Class="ClientApiPoC.ApiClient.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:local="clr-namespace:ClientApiPoC.ApiClient"
|
||||
xmlns:wpf="clr-namespace:ClientApiPoC.Shared.Wpf;assembly=Shared.Wpf"
|
||||
mc:Ignorable="d"
|
||||
Title="API-Client"
|
||||
Width="512"
|
||||
Height="384"
|
||||
ResizeMode="CanMinimize">
|
||||
<Grid RowDefinitions="AUTO, AUTO, *"
|
||||
ColumnDefinitions="*, AUTO"
|
||||
Margin="12">
|
||||
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="AUTO, AUTO"
|
||||
ColumnDefinitions="*">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="Base-URL:" />
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Text="{Binding ApiServiceUrl}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Button Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Stretch"
|
||||
Padding="5"
|
||||
Margin="5, 0, 0, 0"
|
||||
IsDefault="True"
|
||||
Content="HTTP-GET"
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Command="{Binding HttpGetButtonCommand}" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Margin="0, 5, 0, 0"
|
||||
Text="Result:" />
|
||||
|
||||
<TextBox Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Padding="5"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
Text="{Binding RequestResultDataString, Mode=OneWay}" />
|
||||
|
||||
</Grid>
|
||||
</wpf:MvvmWindow>
|
||||
10
ApiClient/MainWindow.xaml.cs
Normal file
10
ApiClient/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using ClientApiPoC.Shared.Wpf;
|
||||
using System.Windows;
|
||||
|
||||
namespace ClientApiPoC.ApiClient {
|
||||
public partial class MainWindow : MvvmWindow<MainWindowViewModel> {
|
||||
public MainWindow(MainWindowViewModel viewModel) : base(viewModel) {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
92
ApiClient/MainWindowViewModel.cs
Normal file
92
ApiClient/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using ClientApiPoC.Shared;
|
||||
using ClientApiPoC.Shared.Models;
|
||||
using ClientApiPoC.Shared.Wpf;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace ClientApiPoC.ApiClient {
|
||||
public class MainWindowViewModel : BaseViewModel {
|
||||
#region Properties
|
||||
private string _apiServiceUrl = "http://localhost:5228/";
|
||||
public string ApiServiceUrl {
|
||||
get => _apiServiceUrl;
|
||||
set {
|
||||
_apiServiceUrl = value;
|
||||
OnPropertyChanged(nameof(ApiServiceUrl));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isFetchingData = false;
|
||||
public bool IsFetchingData {
|
||||
get => _isFetchingData;
|
||||
set {
|
||||
_isFetchingData = value;
|
||||
OnPropertyChanged(nameof(IsFetchingData));
|
||||
OnPropertyChanged(nameof(ControlsEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
public bool ControlsEnabled => !this.IsFetchingData;
|
||||
|
||||
private ExampleRequestResultModel? _requestResultData = null;
|
||||
public ExampleRequestResultModel? RequestResultData {
|
||||
get => _requestResultData;
|
||||
set {
|
||||
_requestResultData = value;
|
||||
OnPropertyChanged(nameof(RequestResultData));
|
||||
OnPropertyChanged(nameof(RequestResultDataString));
|
||||
}
|
||||
}
|
||||
|
||||
public string RequestResultDataString {
|
||||
get {
|
||||
if (this.RequestResultData == null) return "NULL";
|
||||
var options = new JsonSerializerOptions() {
|
||||
IndentCharacter = ' ',
|
||||
IndentSize = 2,
|
||||
NewLine = "\n",
|
||||
WriteIndented = true
|
||||
};
|
||||
return JsonSerializer.Serialize(this.RequestResultData, options);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Commands
|
||||
public ICommand HttpGetButtonCommand { get; }
|
||||
#endregion
|
||||
|
||||
public MainWindowViewModel() {
|
||||
this.HttpGetButtonCommand = new AsyncRelayCommand(HttpGetButtonActionAsync);
|
||||
}
|
||||
|
||||
private async Task HttpGetButtonActionAsync() {
|
||||
if (this.IsFetchingData) return;
|
||||
try {
|
||||
this.IsFetchingData = true;
|
||||
var url = this.ApiServiceUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) throw new InvalidOperationException("No base url specified.");
|
||||
while (url.EndsWith('/')) url = url[..^1];
|
||||
url += Routes.EXAMPLE_REQUEST;
|
||||
using (var httpClient = Tools.CreateHttpClient()) {
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var responseString = await response.Content.ReadAsStringAsync();
|
||||
var options = new JsonSerializerOptions() {
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
var result = JsonSerializer.Deserialize<ExampleRequestResultModel>(responseString, options);
|
||||
this.RequestResultData = result;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
await HandleErrorAsync(ex);
|
||||
} finally {
|
||||
this.IsFetchingData = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
ApiClient/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
15
ApiClient/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\Publish\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
41
ApiService/ApiService.csproj
Normal file
41
ApiService/ApiService.csproj
Normal file
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<RootNamespace>ClientApiPoC.ApiService</RootNamespace>
|
||||
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
|
||||
<Company>Mike Schumann</Company>
|
||||
<Copyright>Copyright © 2026 $(Company)</Copyright>
|
||||
<AssemblyVersion>0.1.0</AssemblyVersion>
|
||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
||||
<NeutralLanguage>en-US</NeutralLanguage>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
27
ApiService/Controllers/ExampleRequestController.cs
Normal file
27
ApiService/Controllers/ExampleRequestController.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using ClientApiPoC.Shared;
|
||||
using ClientApiPoC.Shared.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ClientApiPoC.ApiService.Controllers {
|
||||
[ApiController]
|
||||
[Route(Routes.EXAMPLE_REQUEST)]
|
||||
public class ExampleRequestController : ControllerBase {
|
||||
private readonly TunnelServer _tunnel;
|
||||
|
||||
public ExampleRequestController(TunnelServer tunnel) {
|
||||
if (tunnel == null) throw new ArgumentNullException(nameof(tunnel));
|
||||
_tunnel = tunnel;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ExampleRequestResultModel> Get() {
|
||||
// Daten der Clients abfragen...
|
||||
var clients = await _tunnel.GetDataFromAllClientsAsync();
|
||||
var result = new ExampleRequestResultModel() {
|
||||
ServerMessage = "Hello, world!",
|
||||
Clients = clients
|
||||
};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
ApiService/Program.cs
Normal file
34
ApiService/Program.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using ClientApiPoC.Shared;
|
||||
using ClientApiPoC.Shared.SignalR;
|
||||
|
||||
namespace ClientApiPoC.ApiService {
|
||||
public class Program {
|
||||
public static void Main(string[] args) {
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddTunnelHub();
|
||||
builder.Services.AddTransient<TunnelServer>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment()) {
|
||||
app.MapOpenApi();
|
||||
app.UseSwaggerUI(options => {
|
||||
options.SwaggerEndpoint("/openapi/v1.json", "ClientApiPoC");
|
||||
});
|
||||
}
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapTunnelHub(Routes.TUNNEL_PATH);
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
20
ApiService/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
20
ApiService/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>..\Publish\ApiService\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>bb6e670a-3d3d-430e-4dd4-25ad2c7d9c26</ProjectGuid>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
14
ApiService/Properties/launchSettings.json
Normal file
14
ApiService/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://*:5228"
|
||||
}
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json"
|
||||
}
|
||||
24
ApiService/TunnelServer.cs
Normal file
24
ApiService/TunnelServer.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ClientApiPoC.Shared.SignalR;
|
||||
using ClientApiPoC.Shared.Models;
|
||||
|
||||
namespace ClientApiPoC.ApiService {
|
||||
public class TunnelServer : TunnelServerBase {
|
||||
public TunnelServer(IHubContext<TunnelHub> hubContext, ClientTracker clientTracker) : base(hubContext, clientTracker) { }
|
||||
|
||||
public async Task<IEnumerable<ClientResultModel>> GetDataFromAllClientsAsync() {
|
||||
var timestampServer = DateTime.UtcNow;
|
||||
var results = new List<ClientResultModel>();
|
||||
var clients = this.GetAllClients();
|
||||
foreach (var client in clients) {
|
||||
var clientData = await client.Value.InvokeAsync<ClientDataModel>("GetClientData", timestampServer, CancellationToken.None);
|
||||
var result = new ClientResultModel() {
|
||||
ClientId = client.Key,
|
||||
ClientData = clientData
|
||||
};
|
||||
results.Add(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
ApiService/appsettings.Development.json
Normal file
8
ApiService/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
ApiService/appsettings.json
Normal file
16
ApiService/appsettings.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://*:5228"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
ApiService/dotnet-tools.json
Normal file
13
ApiService/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.5",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ClientApiPoC.slnx
Normal file
7
ClientApiPoC.slnx
Normal file
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="ApiClient/ApiClient.csproj" />
|
||||
<Project Path="ApiService/ApiService.csproj" />
|
||||
<Project Path="OnPremiseApp/OnPremiseApp.csproj" Id="ddccc69b-ec86-473b-b34f-317e5ff1e4cf" />
|
||||
<Project Path="Shared.Wpf/Shared.Wpf.csproj" />
|
||||
<Project Path="Shared/Shared.csproj" />
|
||||
</Solution>
|
||||
8
OnPremiseApp/App.xaml
Normal file
8
OnPremiseApp/App.xaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<Application x:Class="ClientApiPoC.OnPremiseApp.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClientApiPoC.OnPremiseApp">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
36
OnPremiseApp/App.xaml.cs
Normal file
36
OnPremiseApp/App.xaml.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ClientApiPoC.OnPremiseApp.Services;
|
||||
|
||||
namespace ClientApiPoC.OnPremiseApp {
|
||||
public partial class App : Application {
|
||||
private IHost _host;
|
||||
|
||||
public App() {
|
||||
_host = Host.CreateDefaultBuilder().ConfigureServices(services => {
|
||||
// Services:
|
||||
services.AddSingleton<ClientDataService>();
|
||||
// ViewModels:
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
// Views:
|
||||
services.AddSingleton<MainWindow>();
|
||||
}).Build();
|
||||
}
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e) {
|
||||
await _host.StartAsync();
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.Show();
|
||||
|
||||
base.OnStartup(e);
|
||||
}
|
||||
|
||||
protected override async void OnExit(ExitEventArgs e) {
|
||||
await _host.StopAsync();
|
||||
_host.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
OnPremiseApp/AssemblyInfo.cs
Normal file
10
OnPremiseApp/AssemblyInfo.cs
Normal file
@@ -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)
|
||||
)]
|
||||
76
OnPremiseApp/MainWindow.xaml
Normal file
76
OnPremiseApp/MainWindow.xaml
Normal file
@@ -0,0 +1,76 @@
|
||||
<wpf:MvvmWindow x:TypeArguments="local:MainWindowViewModel"
|
||||
x:Class="ClientApiPoC.OnPremiseApp.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:local="clr-namespace:ClientApiPoC.OnPremiseApp"
|
||||
xmlns:wpf="clr-namespace:ClientApiPoC.Shared.Wpf;assembly=Shared.Wpf"
|
||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||
mc:Ignorable="d"
|
||||
Title="On-Premise Gateway"
|
||||
Width="384"
|
||||
Height="AUTO"
|
||||
MaxHeight="144"
|
||||
ResizeMode="CanMinimize"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid RowDefinitions="AUTO, AUTO"
|
||||
ColumnDefinitions="*, AUTO"
|
||||
Margin="12">
|
||||
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="AUTO, AUTO"
|
||||
ColumnDefinitions="*">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="Base-URL:" />
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
IsEnabled="{Binding UrlTextFieldEnabled}"
|
||||
Text="{Binding ApiServiceUrl}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Button Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Stretch"
|
||||
Padding="5"
|
||||
Margin="5, 0, 0, 0"
|
||||
Content="{Binding ConnectButtonText}"
|
||||
IsEnabled="{Binding ConnectButtonEnabled}"
|
||||
Command="{Binding ToggleConnectionCommand}" />
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="AUTO, AUTO"
|
||||
ColumnDefinitions="*"
|
||||
Margin="0, 12, 0, 0">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="Local client data:" />
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding LocalClientData, UpdateSourceTrigger=PropertyChanged}">
|
||||
</TextBox>
|
||||
|
||||
</Grid>
|
||||
|
||||
<Button Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Stretch"
|
||||
Padding="5"
|
||||
Margin="5, 12, 0, 0"
|
||||
IsDefault="True"
|
||||
Content="Set data"
|
||||
IsEnabled="{Binding SetLocalClientDataButtonEnabled}"
|
||||
Command="{Binding SetLocalClientDataCommand}" />
|
||||
|
||||
</Grid>
|
||||
</wpf:MvvmWindow>
|
||||
9
OnPremiseApp/MainWindow.xaml.cs
Normal file
9
OnPremiseApp/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using ClientApiPoC.Shared.Wpf;
|
||||
|
||||
namespace ClientApiPoC.OnPremiseApp {
|
||||
public partial class MainWindow : MvvmWindow<MainWindowViewModel> {
|
||||
public MainWindow(MainWindowViewModel viewModel) : base(viewModel) {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
114
OnPremiseApp/MainWindowViewModel.cs
Normal file
114
OnPremiseApp/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows.Input;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClientApiPoC.OnPremiseApp.Services;
|
||||
using ClientApiPoC.Shared;
|
||||
using ClientApiPoC.Shared.Wpf;
|
||||
using ClientApiPoC.Shared.SignalR;
|
||||
|
||||
namespace ClientApiPoC.OnPremiseApp {
|
||||
public class MainWindowViewModel : BaseViewModel {
|
||||
private ClientDataService _clientDataService;
|
||||
private TunnelClient _tunnelClient;
|
||||
|
||||
#region Properties
|
||||
private string _apiServiceUrl = "http://localhost:5228/";
|
||||
public string ApiServiceUrl {
|
||||
get => _apiServiceUrl;
|
||||
set {
|
||||
_apiServiceUrl = value;
|
||||
OnPropertyChanged(nameof(ApiServiceUrl));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _tunnelClientIsConnecting = false;
|
||||
public bool TunnelClientIsConnecting {
|
||||
get => _tunnelClientIsConnecting;
|
||||
set {
|
||||
_tunnelClientIsConnecting = value;
|
||||
OnPropertyChanged(nameof(TunnelClientIsConnecting));
|
||||
OnPropertyChanged(nameof(ConnectButtonEnabled));
|
||||
OnPropertyChanged(nameof(UrlTextFieldEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
public bool TunnelClientConnected => (_tunnelClient.IsConnected || _tunnelClient.IsConnecting);
|
||||
|
||||
public string ConnectButtonText => (this.TunnelClientConnected ? "Disconnect" : "Connect");
|
||||
|
||||
public bool ConnectButtonEnabled => !this.TunnelClientIsConnecting;
|
||||
|
||||
public bool UrlTextFieldEnabled => !this.TunnelClientIsConnecting && !this.TunnelClientConnected;
|
||||
|
||||
private string _localClientData = "";
|
||||
public string LocalClientData {
|
||||
get => _localClientData;
|
||||
set {
|
||||
_localClientData = value;
|
||||
OnPropertyChanged(nameof(LocalClientData));
|
||||
OnPropertyChanged(nameof(SetLocalClientDataButtonEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetLocalClientDataButtonEnabled {
|
||||
get {
|
||||
if (string.IsNullOrEmpty(this.LocalClientData) && string.IsNullOrEmpty(_clientDataService.ClientData)) return false;
|
||||
return !string.Equals(this.LocalClientData, _clientDataService.ClientData);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Commands
|
||||
public ICommand ToggleConnectionCommand { get; }
|
||||
|
||||
public ICommand SetLocalClientDataCommand { get; }
|
||||
#endregion
|
||||
|
||||
public MainWindowViewModel(ClientDataService clientDataService) {
|
||||
if (clientDataService == null) throw new ArgumentNullException(nameof(clientDataService));
|
||||
_clientDataService = clientDataService;
|
||||
_tunnelClient = new TunnelClient();
|
||||
_tunnelClient.PropertyChanged += TunnelClient_PropertyChanged;
|
||||
this.ToggleConnectionCommand = new AsyncRelayCommand(ToggleConnectionAsync);
|
||||
this.SetLocalClientDataCommand = new AsyncRelayCommand(SetLocalClientDataAsync);
|
||||
}
|
||||
|
||||
private void TunnelClient_PropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
OnPropertyChanged(nameof(TunnelClientConnected));
|
||||
OnPropertyChanged(nameof(ConnectButtonText));
|
||||
OnPropertyChanged(nameof(UrlTextFieldEnabled));
|
||||
}
|
||||
|
||||
private async Task ToggleConnectionAsync() {
|
||||
if (this.TunnelClientIsConnecting) return;
|
||||
try {
|
||||
this.TunnelClientIsConnecting = true;
|
||||
if (this.TunnelClientConnected) {
|
||||
await _tunnelClient.DisconnectAsync();
|
||||
} else {
|
||||
var url = this.ApiServiceUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) throw new Exception("Please provide a base url.");
|
||||
while (url.EndsWith('/')) url = url[..^1];
|
||||
url += Routes.TUNNEL_PATH;
|
||||
await _tunnelClient.ConnectAsync(url, _clientDataService.ConfigureTunnelActions);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
await HandleErrorAsync(ex);
|
||||
} finally {
|
||||
this.TunnelClientIsConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetLocalClientDataAsync() {
|
||||
try {
|
||||
string? newClientData = this.LocalClientData;
|
||||
if (string.IsNullOrEmpty(newClientData)) newClientData = null;
|
||||
_clientDataService.ClientData = newClientData;
|
||||
} catch (Exception ex) {
|
||||
await HandleErrorAsync(ex);
|
||||
} finally {
|
||||
OnPropertyChanged(nameof(SetLocalClientDataButtonEnabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
OnPremiseApp/OnPremiseApp.csproj
Normal file
45
OnPremiseApp/OnPremiseApp.csproj
Normal file
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<RootNamespace>ClientApiPoC.OnPremiseApp</RootNamespace>
|
||||
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
|
||||
<Company>Mike Schumann</Company>
|
||||
<Copyright>Copyright © 2026 $(Company)</Copyright>
|
||||
<AssemblyVersion>0.1.0</AssemblyVersion>
|
||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
||||
<NeutralLanguage>en-US</NeutralLanguage>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared.Wpf\Shared.Wpf.csproj" />
|
||||
<ProjectReference Include="..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
15
OnPremiseApp/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
15
OnPremiseApp/Properties/PublishProfiles/FolderProfile.pubxml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\Publish\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
21
OnPremiseApp/Services/ClientDataService.cs
Normal file
21
OnPremiseApp/Services/ClientDataService.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ClientApiPoC.Shared.Models;
|
||||
|
||||
namespace ClientApiPoC.OnPremiseApp.Services {
|
||||
public class ClientDataService {
|
||||
public string? ClientData { get; set; } = null;
|
||||
|
||||
public async Task<ClientDataModel> GetClientDataAsync(DateTime timestampServer) {
|
||||
return new ClientDataModel() {
|
||||
Data = this.ClientData,
|
||||
TimestampServerUtc = timestampServer,
|
||||
TimestampClientUtc = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public void ConfigureTunnelActions(HubConnection tunnelConnection) {
|
||||
if (tunnelConnection == null) throw new ArgumentNullException(nameof(tunnelConnection));
|
||||
tunnelConnection.On<DateTime, ClientDataModel>("GetClientData", GetClientDataAsync);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
README.md
21
README.md
@@ -1,2 +1,21 @@
|
||||
# ClientApiPoC
|
||||
# ClientApiPoC
|
||||
|
||||
Proof-of-Concept for transferring local data from a client to a public web service without port-forwarding, using SignalR.
|
||||
|
||||
## ApiService
|
||||
|
||||
Web service, serving HTTP-API for Clients (*ApiClient*). It also serves a SignalR server that local clients (*OnPremiseApp*) can connect to.
|
||||
|
||||
This service can call a function on all connected local clients (*OnPremiseApp*) to receive local data. The local client doesn't have any HTTP-API or what so ever, nor is port-forwarding needed.
|
||||
|
||||
API clients (*ApiClient*) can receive data from all connected local clients (*OnPremiseApp*) using the standard HTTP-API.
|
||||
|
||||
## ApiClient
|
||||
|
||||
This application only connects to the public HTTP-API (*ApiService*) to receive data from local clients (*OnPremiseApp*) and shows the request result.
|
||||
|
||||
## OnPremiseApp
|
||||
|
||||
This application connects to the *ApiService* via SignalR and transfers local data via the SignalR tunnel when the API service requests this.
|
||||
|
||||
No Data ist actively sent to the service; the service requests data if needed.
|
||||
|
||||
11
Shared.Wpf/BaseViewModel.cs
Normal file
11
Shared.Wpf/BaseViewModel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ClientApiPoC.Shared.Wpf {
|
||||
public abstract class BaseViewModel : NotifyPropertyChangedBase {
|
||||
protected async Task HandleErrorAsync(Exception ex) {
|
||||
if (ex == null) return;
|
||||
MessageBox.Show(ex.Message, "Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Shared.Wpf/MvvmWindow.cs
Normal file
13
Shared.Wpf/MvvmWindow.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ClientApiPoC.Shared.Wpf {
|
||||
public abstract class MvvmWindow<TViewModel> : Window where TViewModel : BaseViewModel {
|
||||
protected TViewModel ViewModel { get; }
|
||||
|
||||
public MvvmWindow(TViewModel viewModel) : base() {
|
||||
if (viewModel == null) throw new ArgumentNullException(nameof(viewModel));
|
||||
this.ViewModel = viewModel;
|
||||
this.DataContext = this.ViewModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Shared.Wpf/Shared.Wpf.csproj
Normal file
23
Shared.Wpf/Shared.Wpf.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClientApiPoC.Shared.Wpf</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
Shared/Models/ClientDataModel.cs
Normal file
9
Shared/Models/ClientDataModel.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ClientApiPoC.Shared.Models {
|
||||
public class ClientDataModel {
|
||||
public string? Data { get; set; } = null;
|
||||
|
||||
public DateTime TimestampClientUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime TimestampServerUtc { get; set; } = DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
7
Shared/Models/ClientResultModel.cs
Normal file
7
Shared/Models/ClientResultModel.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ClientApiPoC.Shared.Models {
|
||||
public class ClientResultModel {
|
||||
public string ClientId { get; set; } = "";
|
||||
|
||||
public ClientDataModel? ClientData { get; set; } = null;
|
||||
}
|
||||
}
|
||||
7
Shared/Models/ExampleRequestResultModel.cs
Normal file
7
Shared/Models/ExampleRequestResultModel.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ClientApiPoC.Shared.Models {
|
||||
public class ExampleRequestResultModel {
|
||||
public string? ServerMessage { get; set; }
|
||||
|
||||
public IEnumerable<ClientResultModel> Clients { get; set; } = [];
|
||||
}
|
||||
}
|
||||
12
Shared/NotifyPropertyChangedBase.cs
Normal file
12
Shared/NotifyPropertyChangedBase.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ClientApiPoC.Shared {
|
||||
public abstract class NotifyPropertyChangedBase : INotifyPropertyChanged {
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Shared/Routes.cs
Normal file
9
Shared/Routes.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ClientApiPoC.Shared {
|
||||
public static class Routes {
|
||||
private const string BASE_PATH = "/api/";
|
||||
|
||||
public const string TUNNEL_PATH = "/tunnel";
|
||||
|
||||
public const string EXAMPLE_REQUEST = $"{BASE_PATH}ExampleRequest";
|
||||
}
|
||||
}
|
||||
23
Shared/Shared.csproj
Normal file
23
Shared/Shared.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ClientApiPoC.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
18
Shared/SignalR/ClientTracker.cs
Normal file
18
Shared/SignalR/ClientTracker.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ClientApiPoC.Shared.SignalR {
|
||||
public class ClientTracker {
|
||||
private ConcurrentDictionary<string, string> _knownClientIds = new();
|
||||
|
||||
public ICollection<string> CurrentClientIds => _knownClientIds.Values;
|
||||
|
||||
public bool TryAddClientId(string clientId) {
|
||||
if (string.IsNullOrWhiteSpace(clientId)) throw new ArgumentNullException(nameof(clientId));
|
||||
return _knownClientIds.TryAdd(clientId, clientId);
|
||||
}
|
||||
|
||||
public bool TryRemoveClientId(string clientId) {
|
||||
return _knownClientIds.TryRemove(clientId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Shared/SignalR/TunnelClient.cs
Normal file
84
Shared/SignalR/TunnelClient.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace ClientApiPoC.Shared.SignalR {
|
||||
public class TunnelClient : NotifyPropertyChangedBase, IDisposable, IAsyncDisposable {
|
||||
#region IDisposable-Implementierung
|
||||
private bool _disposed;
|
||||
|
||||
protected virtual void Dispose(bool disposing) {
|
||||
if (!_disposed) {
|
||||
if (disposing) {
|
||||
_ = this.DisposeAsync();
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
await DisconnectAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
private HubConnection? _connection = null;
|
||||
|
||||
public bool IsConnected => (this.ConnectionState == HubConnectionState.Connected);
|
||||
|
||||
public bool IsConnecting => (this.ConnectionState == HubConnectionState.Connecting || this.ConnectionState == HubConnectionState.Reconnecting);
|
||||
|
||||
protected HubConnectionState ConnectionState => (_connection?.State ?? HubConnectionState.Disconnected);
|
||||
|
||||
public async Task ConnectAsync(string url, Action<HubConnection> configureConnection) {
|
||||
try {
|
||||
if (string.IsNullOrWhiteSpace(url)) throw new ArgumentNullException(nameof(url));
|
||||
if (this.IsConnected) throw new InvalidOperationException("SignalR connection is already established.");
|
||||
_connection = new HubConnectionBuilder().WithUrl(url).WithAutomaticReconnect().Build();
|
||||
_connection.Reconnecting += Connection_Reconnecting;
|
||||
_connection.Reconnected += Connection_Reconnected;
|
||||
_connection.Closed += Connection_Closed;
|
||||
configureConnection(_connection);
|
||||
await _connection.StartAsync();
|
||||
} finally {
|
||||
await UpdateStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync() {
|
||||
try {
|
||||
if (_connection == null) return;
|
||||
try {
|
||||
if (this.IsConnected) await _connection.StopAsync();
|
||||
} catch { }
|
||||
_connection.Reconnecting -= Connection_Reconnecting;
|
||||
_connection.Reconnected -= Connection_Reconnected;
|
||||
_connection.Closed -= Connection_Closed;
|
||||
await _connection.DisposeAsync();
|
||||
_connection = null;
|
||||
} finally {
|
||||
await UpdateStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Connection_Reconnecting(Exception? arg) {
|
||||
await UpdateStateAsync();
|
||||
}
|
||||
|
||||
private async Task Connection_Reconnected(string? arg) {
|
||||
await UpdateStateAsync();
|
||||
}
|
||||
|
||||
private async Task Connection_Closed(Exception? arg) {
|
||||
await UpdateStateAsync();
|
||||
}
|
||||
|
||||
private async Task UpdateStateAsync() {
|
||||
OnPropertyChanged(nameof(IsConnected));
|
||||
OnPropertyChanged(nameof(IsConnecting));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Shared/SignalR/TunnelHub.cs
Normal file
20
Shared/SignalR/TunnelHub.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClientApiPoC.Shared.SignalR {
|
||||
public sealed class TunnelHub : Hub {
|
||||
private ClientTracker _clientTracker;
|
||||
|
||||
public TunnelHub(ClientTracker clientTracker) : base() {
|
||||
if (clientTracker == null) throw new ArgumentNullException(nameof(clientTracker));
|
||||
_clientTracker = clientTracker;
|
||||
}
|
||||
|
||||
public override sealed async Task OnConnectedAsync() {
|
||||
_ = _clientTracker.TryAddClientId(this.Context.ConnectionId);
|
||||
}
|
||||
|
||||
public override sealed async Task OnDisconnectedAsync(Exception exception) {
|
||||
_ = _clientTracker.TryRemoveClientId(this.Context.ConnectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Shared/SignalR/TunnelHubExtensions.cs
Normal file
21
Shared/SignalR/TunnelHubExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClientApiPoC.Shared.SignalR {
|
||||
public static class TunnelHubExtensions {
|
||||
public static IServiceCollection AddTunnelHub(this IServiceCollection services) {
|
||||
services.AddSignalR(options => {
|
||||
// Nachrichtengröße auf max. 16MB bringen (Standard = 32KB).
|
||||
options.MaximumReceiveMessageSize = (16 * 1024 * 1024);
|
||||
});
|
||||
services.AddSingleton<ClientTracker>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static HubEndpointConventionBuilder MapTunnelHub(this IEndpointRouteBuilder app, [StringSyntax("Route")] string pattern) {
|
||||
return app.MapHub<TunnelHub>(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Shared/SignalR/TunnelServerBase.cs
Normal file
36
Shared/SignalR/TunnelServerBase.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClientApiPoC.Shared.SignalR {
|
||||
public abstract class TunnelServerBase {
|
||||
protected IHubContext<TunnelHub> HubContext { get; private set; }
|
||||
|
||||
protected ClientTracker Clients { get; private set; }
|
||||
|
||||
public TunnelServerBase(IHubContext<TunnelHub> hubContext, ClientTracker clientTracker) {
|
||||
if (hubContext == null) throw new ArgumentNullException(nameof(hubContext));
|
||||
if (clientTracker == null) throw new ArgumentNullException(nameof(clientTracker));
|
||||
this.HubContext = hubContext;
|
||||
this.Clients = clientTracker;
|
||||
}
|
||||
|
||||
protected ISingleClientProxy? TryGetClient(string clientId) {
|
||||
ISingleClientProxy? client;
|
||||
try {
|
||||
client = this.HubContext.Clients.Client(clientId);
|
||||
} catch {
|
||||
client = null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
protected IDictionary<string, ISingleClientProxy> GetAllClients() {
|
||||
var results = new Dictionary<string, ISingleClientProxy>();
|
||||
var allClientIds = this.Clients.CurrentClientIds;
|
||||
foreach (var clientId in allClientIds) {
|
||||
var client = TryGetClient(clientId);
|
||||
if (client != null) results.Add(clientId, client);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Shared/Tools.cs
Normal file
15
Shared/Tools.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ClientApiPoC.Shared {
|
||||
public static class Tools {
|
||||
public static HttpClient CreateHttpClient() {
|
||||
var httpClientHandler = new HttpClientHandler() {
|
||||
ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => {
|
||||
// HACK: Allen SSL-Zertifikaten vertrauen!
|
||||
return true;
|
||||
}
|
||||
};
|
||||
return new HttpClient(httpClientHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user