Initial commit.

This commit is contained in:
2026-03-13 10:23:47 +01:00
parent fd81c63020
commit 39c8b861c0
44 changed files with 1144 additions and 4 deletions

6
.gitignore vendored
View File

@@ -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

View 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
View 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
View 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
View 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
View 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>

View 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();
}
}
}

View 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;
}
}
}
}

View 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>

View 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>

View 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
View 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();
}
}
}

View 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>

View 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"
}

View 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;
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://*:5228"
}
}
}
}

View 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
View 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
View 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
View 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);
}
}
}

View 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)
)]

View 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>

View 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();
}
}
}

View 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));
}
}
}
}

View 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>

View 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>

View 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);
}
}
}

View File

@@ -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.

View 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
View 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;
}
}
}

View 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>

View 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;
}
}

View File

@@ -0,0 +1,7 @@
namespace ClientApiPoC.Shared.Models {
public class ClientResultModel {
public string ClientId { get; set; } = "";
public ClientDataModel? ClientData { get; set; } = null;
}
}

View File

@@ -0,0 +1,7 @@
namespace ClientApiPoC.Shared.Models {
public class ExampleRequestResultModel {
public string? ServerMessage { get; set; }
public IEnumerable<ClientResultModel> Clients { get; set; } = [];
}
}

View 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
View 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
View 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>

View 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 _);
}
}
}

View 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;
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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
View 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);
}
}
}