Switched to Avalonia

This commit is contained in:
Holger Börchers
2026-01-30 15:55:58 +01:00
parent 894fbbfa5a
commit 7182061a5f
23 changed files with 1291 additions and 224 deletions

10
ParentAvalonia/App.axaml Normal file
View File

@@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ParentAvalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace ParentAvalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,128 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO.Pipes;
using CommIpc;
namespace ParentAvalonia;
public sealed class ChildSession : NotifyBase, IAsyncDisposable
{
private readonly SemaphoreSlim _writeLock = new(1, 1);
private string _status = "Starting";
private double _progressPercent;
private string? _currentWorkId;
private bool _isConnected;
public int Id { get; }
public string PipeName { get; }
public string PipePath => CommIpc.PipeName.Describe(PipeName);
public NamedPipeServerStream Pipe { get; }
public Process Process { get; }
public CancellationTokenSource LifetimeCts { get; } = new();
public ObservableCollection<string> Logs { get; } = new();
public bool IsConnected
{
get => _isConnected;
set
{
if (SetField(ref _isConnected, value))
{
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(StatusLine));
}
}
}
public string Status
{
get => _status;
set
{
if (SetField(ref _status, value))
{
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(StatusLine));
}
}
}
public string DisplayName => $"Child {Id} ({(IsConnected ? "Connected" : "Disconnected")})";
public string StatusLine => $"{Status} | Work: {(_currentWorkId ?? "-")}";
public double ProgressPercent
{
get => _progressPercent;
set => SetField(ref _progressPercent, value);
}
public string? CurrentWorkId
{
get => _currentWorkId;
set
{
if (SetField(ref _currentWorkId, value))
{
OnPropertyChanged(nameof(StatusLine));
}
}
}
public ChildSession(int id, string pipeName, NamedPipeServerStream pipe, Process process)
{
Id = id;
PipeName = pipeName;
Pipe = pipe;
Process = process;
}
public async Task SendAsync(IpcFrame frame, CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
await IpcProtocol.WriteFrameAsync(Pipe, frame, cancellationToken);
}
finally
{
_writeLock.Release();
}
}
public void AddLog(string line)
{
// keep it from growing without bounds during prototyping
if (Logs.Count > 2000)
{
Logs.RemoveAt(0);
}
Logs.Add(line);
}
public async ValueTask DisposeAsync()
{
try
{
LifetimeCts.Cancel();
}
catch { }
try
{
if (!Process.HasExited)
{
Process.Kill(entireProcessTree: true);
}
}
catch { }
try { Pipe.Dispose(); } catch { }
try { Process.Dispose(); } catch { }
try { LifetimeCts.Dispose(); } catch { }
try { _writeLock.Dispose(); } catch { }
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,282 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using Avalonia.Threading;
using CommIpc;
namespace ParentAvalonia;
public sealed class MainViewModel : NotifyBase, IAsyncDisposable
{
private ChildSession? _selectedChild;
private bool _childrenStarted;
private readonly CancellationTokenSource _shutdownCts = new();
public ObservableCollection<ChildSession> Children { get; } = new();
public ChildSession? SelectedChild
{
get => _selectedChild;
set => SetField(ref _selectedChild, value);
}
public async Task StartChildrenAsync(int count)
{
if (_childrenStarted)
{
return;
}
_childrenStarted = true;
for (int i = 1; i <= count; i++)
{
await StartChildAsync(i, _shutdownCts.Token);
}
}
public async Task PingSelectedAsync()
{
if (SelectedChild is null)
{
return;
}
string corr = Guid.NewGuid().ToString("N");
await SelectedChild.SendAsync(
new IpcFrame(IpcKinds.Ping, CorrelationId: corr, Payload: IpcProtocol.ToJsonElement(new { from = "parent" })),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> ping ({corr})");
}
public async Task StartWorkSelectedAsync(int steps = 30, int delayMs = 120)
{
if (SelectedChild is null)
{
return;
}
string corr = Guid.NewGuid().ToString("N");
SelectedChild.CurrentWorkId = corr;
SelectedChild.ProgressPercent = 0;
await SelectedChild.SendAsync(
new IpcFrame(
IpcKinds.StartWork,
CorrelationId: corr,
Payload: IpcProtocol.ToJsonElement(new { steps, delayMs })),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> startWork (corr={corr}, steps={steps}, delayMs={delayMs})");
}
public async Task CancelWorkSelectedAsync()
{
if (SelectedChild is null)
{
return;
}
string corr = SelectedChild.CurrentWorkId ?? Guid.NewGuid().ToString("N");
await SelectedChild.SendAsync(
new IpcFrame(IpcKinds.CancelWork, CorrelationId: corr),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> cancelWork (corr={corr})");
}
private async Task StartChildAsync(int childId, CancellationToken cancellationToken)
{
string pipeName = PipeName.ForChild(childId, Environment.ProcessId);
var server = new NamedPipeServerStream(
pipeName: pipeName,
direction: PipeDirection.InOut,
maxNumberOfServerInstances: 1,
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous);
Task waitForConnection = server.WaitForConnectionAsync(cancellationToken);
Process process = StartChildProcess(childId, pipeName);
var session = new ChildSession(childId, pipeName, server, process);
Children.Add(session);
SelectedChild ??= session;
session.AddLog($"[parent] Starting {process.StartInfo.FileName} {process.StartInfo.Arguments}");
await waitForConnection;
session.IsConnected = true;
session.Status = "Connected";
session.AddLog($"[parent] Connected to {PipeName.Describe(pipeName)}");
_ = Task.Run(() => ReceiveLoopAsync(session), _shutdownCts.Token);
}
private static Process StartChildProcess(int childId, string pipeName)
{
// Cross-platform friendly: prefer running the built dll via 'dotnet'.
string? solutionRoot = TryFindSolutionRoot(AppContext.BaseDirectory);
if (solutionRoot is null)
{
throw new InvalidOperationException("Could not find solution root (CommTester.slnx).");
}
string debugDll = Path.Combine(solutionRoot, "ChildWorker", "bin", "Debug", "net10.0", "ChildWorker.dll");
string releaseDll = Path.Combine(solutionRoot, "ChildWorker", "bin", "Release", "net10.0", "ChildWorker.dll");
string childDll = File.Exists(debugDll)
? debugDll
: File.Exists(releaseDll)
? releaseDll
: string.Empty;
string fileName;
string arguments;
if (!string.IsNullOrWhiteSpace(childDll))
{
fileName = "dotnet";
arguments = $"\"{childDll}\" --pipe \"{pipeName}\" --id {childId}";
}
else
{
// Last-resort: run via project (slower but robust in fresh checkouts).
string csproj = Path.Combine(solutionRoot, "ChildWorker", "ChildWorker.csproj");
fileName = "dotnet";
arguments = $"run --project \"{csproj}\" -- --pipe \"{pipeName}\" --id {childId}";
}
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true
};
var p = new Process { StartInfo = psi, EnableRaisingEvents = true };
p.Start();
return p;
}
private static string? TryFindSolutionRoot(string baseDirectory)
{
var dir = new DirectoryInfo(baseDirectory);
for (int i = 0; i < 10 && dir is not null; i++)
{
string slnx = Path.Combine(dir.FullName, "CommTester.slnx");
if (File.Exists(slnx))
{
return dir.FullName;
}
dir = dir.Parent;
}
return null;
}
private async Task ReceiveLoopAsync(ChildSession session)
{
try
{
while (!session.LifetimeCts.IsCancellationRequested)
{
IpcFrame? frame = await IpcProtocol.ReadFrameAsync(session.Pipe, cancellationToken: session.LifetimeCts.Token);
if (frame is null)
{
break;
}
await Dispatcher.UIThread.InvokeAsync(() => HandleIncomingFrame(session, frame));
}
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
session.Status = "Error";
session.AddLog($"[parent] Receive loop error: {ex.Message}");
});
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
session.IsConnected = false;
session.Status = "Disconnected";
session.AddLog("[parent] Disconnected.");
});
}
}
private static void HandleIncomingFrame(ChildSession session, IpcFrame frame)
{
switch (frame.Kind)
{
case IpcKinds.Hello:
{
var hello = IpcProtocol.FromJsonElement<HelloPayload>(frame.Payload);
session.AddLog($"[child] hello: id={hello?.ChildId}, pid={hello?.Pid}");
break;
}
case IpcKinds.Pong:
session.AddLog($"[child] pong ({frame.CorrelationId})");
break;
case IpcKinds.Log:
{
var log = IpcProtocol.FromJsonElement<LogPayload>(frame.Payload);
session.AddLog($"[child:{log?.Level ?? "info"}] {log?.Message}");
break;
}
case IpcKinds.Progress:
{
var prog = IpcProtocol.FromJsonElement<ProgressPayload>(frame.Payload);
if (prog is not null)
{
session.ProgressPercent = prog.Percent;
session.Status = $"Working ({prog.Step}/{prog.Total})";
}
break;
}
case IpcKinds.Result:
{
var res = IpcProtocol.FromJsonElement<ResultPayload>(frame.Payload);
session.ProgressPercent = 100;
session.Status = "Idle";
session.AddLog($"[child] result: {res?.Message}");
break;
}
case IpcKinds.Error:
{
var err = IpcProtocol.FromJsonElement<ErrorPayload>(frame.Payload);
session.Status = "Error";
session.AddLog($"[child] error: {err?.Message}");
break;
}
default:
session.AddLog($"[child] {frame.Kind} ({frame.CorrelationId})");
break;
}
}
public async ValueTask DisposeAsync()
{
_shutdownCts.Cancel();
foreach (var child in Children.ToArray())
{
try { await child.DisposeAsync(); } catch { }
}
_shutdownCts.Dispose();
}
private sealed record HelloPayload(int ChildId, int Pid);
private sealed record LogPayload(string Level, string Message);
private sealed record ProgressPayload(int Step, int Total, double Percent);
private sealed record ResultPayload(string Message);
private sealed record ErrorPayload(string Message);
}

View File

@@ -0,0 +1,52 @@
<Window xmlns="https://github.com/avaloniaui"
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"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="ParentAvalonia.MainWindow"
Title="CommTester (Parent - Avalonia)" Width="1000" Height="650">
<Grid Margin="12" RowDefinitions="Auto,12,*" ColumnDefinitions="260,12,*">
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Spacing="8">
<Button Content="Start 3 Children" Padding="12,6" Click="StartChildren_Click" />
<Button Content="Ping Selected" Padding="12,6" Click="PingSelected_Click" />
<Button Content="Start Work" Padding="12,6" Click="StartWork_Click" />
<Button Content="Cancel Work" Padding="12,6" Click="CancelWork_Click" />
</StackPanel>
<Border Grid.Row="2" Grid.Column="0" BorderBrush="#444" BorderThickness="1" CornerRadius="6">
<DockPanel Margin="10">
<TextBlock DockPanel.Dock="Top" FontSize="14" FontWeight="SemiBold" Text="Children" />
<TextBlock DockPanel.Dock="Top" Margin="0,6,0,0" Foreground="Gray" FontSize="11"
Text="Select a child to inspect its stream." />
<ListBox Margin="0,8,0,0"
ItemsSource="{Binding Children}"
SelectedItem="{Binding SelectedChild}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<Grid Grid.Row="2" Grid.Column="2" RowDefinitions="Auto,Auto,12,*,Auto">
<TextBlock Grid.Row="0" FontSize="14" FontWeight="SemiBold"
Text="{Binding SelectedChild.StatusLine, FallbackValue=No child selected}" />
<ProgressBar Grid.Row="1" Height="18" Minimum="0" Maximum="100"
Value="{Binding SelectedChild.ProgressPercent, FallbackValue=0}" />
<Border Grid.Row="3" BorderBrush="#444" BorderThickness="1" CornerRadius="6">
<DockPanel Margin="10">
<TextBlock DockPanel.Dock="Top" FontSize="14" FontWeight="SemiBold" Text="Stream (Log/Progress/Result)" />
<ListBox Margin="0,8,0,0" ItemsSource="{Binding SelectedChild.Logs}" />
</DockPanel>
</Border>
<TextBlock Grid.Row="4" Foreground="Gray" FontSize="11"
Text="{Binding SelectedChild.PipePath, FallbackValue=-}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace ParentAvalonia;
public partial class MainWindow : Window
{
private readonly MainViewModel _vm;
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel();
DataContext = _vm;
Closed += async (_, __) =>
{
try { await _vm.DisposeAsync(); } catch { }
};
}
private async void StartChildren_Click(object? sender, RoutedEventArgs e)
{
try { await _vm.StartChildrenAsync(count: 3); }
catch (Exception ex) { await MessageBoxAsync(ex.Message); }
}
private async void PingSelected_Click(object? sender, RoutedEventArgs e)
{
try { await _vm.PingSelectedAsync(); }
catch (Exception ex) { await MessageBoxAsync(ex.Message); }
}
private async void StartWork_Click(object? sender, RoutedEventArgs e)
{
try { await _vm.StartWorkSelectedAsync(); }
catch (Exception ex) { await MessageBoxAsync(ex.Message); }
}
private async void CancelWork_Click(object? sender, RoutedEventArgs e)
{
try { await _vm.CancelWorkSelectedAsync(); }
catch (Exception ex) { await MessageBoxAsync(ex.Message); }
}
private async Task MessageBoxAsync(string message)
{
var dlg = new Window
{
Title = "Error",
Width = 500,
Height = 160,
Content = new StackPanel
{
Margin = new Avalonia.Thickness(12),
Spacing = 12,
Children =
{
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
new Button
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Content = "OK",
IsDefault = true
}
}
}
};
if (dlg.Content is StackPanel sp && sp.Children.LastOrDefault() is Button ok)
{
ok.Click += (_, __) => dlg.Close();
}
await dlg.ShowDialog(this);
}
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ParentAvalonia;
public abstract class NotifyBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.11" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.11" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.11" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.11">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CommIpc\CommIpc.csproj" />
</ItemGroup>
</Project>

21
ParentAvalonia/Program.cs Normal file
View File

@@ -0,0 +1,21 @@
using Avalonia;
using System;
namespace ParentAvalonia;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="ParentAvalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>