~ruther/NosTale-PacketLogger

bdbc0d948a7d9e019c03eea91adb7250f4fdc078 — Rutherther 2 years ago c68e782
feat: add injection and named pipes connection support
A src/PacketLogger/Models/PacketComparer.cs => src/PacketLogger/Models/PacketComparer.cs +20 -0
@@ 0,0 1,20 @@
//
//  PacketComparer.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;
using PacketLogger.Models.Packets;

namespace PacketLogger.Models;

/// <inheritdoc />
public class PacketComparer : IComparer<PacketInfo>
{
    /// <inheritdoc />
    public int Compare(PacketInfo x, PacketInfo y)
    {
        return x.PacketIndex.CompareTo(y.PacketIndex);
    }
}
\ No newline at end of file

A src/PacketLogger/Models/Packets/CommsPacketProvider.cs => src/PacketLogger/Models/Packets/CommsPacketProvider.cs +92 -0
@@ 0,0 1,92 @@
//
//  CommsPacketProvider.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.ComponentModel;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using DynamicData;
using NosSmooth.Comms.Local;
using NosSmooth.Core.Packets;
using NosSmooth.PacketSerializer.Abstractions.Attributes;
using Remora.Results;

namespace PacketLogger.Models.Packets;

/// <summary>
/// A packet provider using a connection to a nostale client.
/// </summary>
public class CommsPacketProvider : IPacketSender
{
    private readonly Comms _comms;
    private long _currentIndex;

    /// <summary>
    /// Initializes a new instance of the <see cref="CommsPacketProvider"/> class.
    /// </summary>
    /// <param name="comms">The comms.</param>
    public CommsPacketProvider(Comms comms)
    {
        _comms = comms;
        Packets = new SourceList<PacketInfo>();
    }

    /// <inheritdoc />
    public event PropertyChangedEventHandler? PropertyChanged;

    /// <inheritdoc />
    public bool IsOpen => _comms.Connection.Connection.State == ConnectionState.Open;

    /// <inheritdoc />
    public SourceList<PacketInfo> Packets { get; }

    /// <inheritdoc />
    public bool LogReceived { get; set; } = true;

    /// <inheritdoc />
    public bool LogSent { get; set; } = true;

    /// <inheritdoc />
    public Task<Result> Open()
        => Task.FromResult(Result.FromSuccess());

    /// <inheritdoc />
    public Task<Result> Close()
    {
        _comms.Connection.Connection.Disconnect();
        return Task.FromResult(Result.FromSuccess());
    }

    /// <inheritdoc />
    public void Clear()
    {
        Packets.Clear();
    }

    /// <inheritdoc />
    public Task<Result> SendPacket(string packetString, CancellationToken ct = default)
        => _comms.Client.SendPacketAsync(packetString, ct);

    /// <inheritdoc />
    public Task<Result> ReceivePacket(string packetString, CancellationToken ct = default)
        => _comms.Client.ReceivePacketAsync(packetString, ct);

    /// <summary>
    /// Add the given packets from an event.
    /// </summary>
    /// <param name="packetArgs">The packet event args.</param>
    /// <typeparam name="TPacket">The type of the deserialized packet.</typeparam>
    internal void AddPacket<TPacket>(PacketEventArgs<TPacket> packetArgs)
    {
        var index = Interlocked.Increment(ref _currentIndex);
        if ((packetArgs.Source == PacketSource.Server && LogReceived)
            || (packetArgs.Source == PacketSource.Client && LogSent))
        {
            Packets.Add(new PacketInfo(index, DateTime.Now, packetArgs.Source, packetArgs.PacketString));
        }
    }
}
\ No newline at end of file

M src/PacketLogger/Models/Packets/DummyPacketProvider.cs => src/PacketLogger/Models/Packets/DummyPacketProvider.cs +14 -0
@@ 48,6 48,20 @@ public class DummyPacketProvider : IPacketProvider, IDisposable
    public event PropertyChangedEventHandler? PropertyChanged;

    /// <inheritdoc />
    public bool LogReceived
    {
        get => true;
        set { }
    }

    /// <inheritdoc />
    public bool LogSent
    {
        get => true;
        set { }
    }

    /// <inheritdoc />
    public bool IsOpen => false;

    /// <inheritdoc />

M src/PacketLogger/Models/Packets/FilePacketProvider.cs => src/PacketLogger/Models/Packets/FilePacketProvider.cs +14 -0
@@ 40,6 40,20 @@ public class FilePacketProvider : IPacketProvider
    public bool IsOpen => false;

    /// <inheritdoc />
    public bool LogReceived
    {
        get => true;
        set { }
    }

    /// <inheritdoc />
    public bool LogSent
    {
        get => true;
        set { }
    }

    /// <inheritdoc />
    public SourceList<PacketInfo> Packets
        => _packets ?? throw new InvalidOperationException("File client not initialized yet.");


M src/PacketLogger/Models/Packets/IPacketProvider.cs => src/PacketLogger/Models/Packets/IPacketProvider.cs +10 -0
@@ 25,6 25,16 @@ public interface IPacketProvider : INotifyPropertyChanged
    public bool IsOpen { get; }

    /// <summary>
    /// Gets or sets whether to log received packets.
    /// </summary>
    public bool LogReceived { get; set; }

    /// <summary>
    /// Gets or sets whether to log sent pckets.
    /// </summary>
    public bool LogSent { get; set; }

    /// <summary>
    /// Gets the filtered packets from this provider.
    /// </summary>
    public SourceList<PacketInfo> Packets { get; }

M src/PacketLogger/Models/Packets/PacketInfo.cs => src/PacketLogger/Models/Packets/PacketInfo.cs +3 -1
@@ 5,8 5,10 @@
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Diagnostics.CodeAnalysis;
using NosSmooth.PacketSerializer.Abstractions.Attributes;

namespace PacketLogger.Models.Packets;

public record PacketInfo(long PacketIndex, DateTime Date, PacketSource Source, string PacketString);
\ No newline at end of file
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "Fix this.")]
public record struct PacketInfo(long PacketIndex, DateTime Date, PacketSource Source, string PacketString);
\ No newline at end of file

A src/PacketLogger/Models/Packets/PacketResponder.cs => src/PacketLogger/Models/Packets/PacketResponder.cs +40 -0
@@ 0,0 1,40 @@
//
//  PacketResponder.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Threading;
using System.Threading.Tasks;
using DynamicData;
using NosSmooth.Comms.Core;
using NosSmooth.Comms.Inject;
using NosSmooth.Comms.Inject.PacketResponders;
using NosSmooth.Core.Packets;
using NosSmooth.Packets;
using Remora.Results;

namespace PacketLogger.Models.Packets;

/// <inheritdoc />
public class PacketResponder : IEveryPacketResponder
{
    private readonly CommsPacketProvider _provider;

    /// <summary>
    /// Initializes a new instance of the <see cref="PacketResponder"/> class.
    /// </summary>
    /// <param name="provider">The provider.</param>
    public PacketResponder(CommsPacketProvider provider)
    {
        _provider = provider;
    }

    /// <inheritdoc />
    public Task<Result> Respond<TPacket>(PacketEventArgs<TPacket> packetArgs, CancellationToken ct = default)
        where TPacket : IPacket
    {
        _provider.AddPacket(packetArgs);
        return Task.FromResult(Result.FromSuccess());
    }
}
\ No newline at end of file

M src/PacketLogger/PacketLogger.csproj => src/PacketLogger/PacketLogger.csproj +9 -3
@@ 1,6 1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <BuiltInComInteropSupport>true</BuiltInComInteropSupport>


@@ 27,12 27,18 @@
    <PackageReference Include="DataBox" Version="0.10.13" />
    <PackageReference Include="Dock.Avalonia" Version="0.10.18" />
    <PackageReference Include="Dock.Model.Mvvm" Version="0.10.18" />
    <PackageReference Include="NosSmooth.Comms.Local" Version="1.0.2" />
    <PackageReference Include="NosSmooth.Comms.Local" Version="1.0.3">
      <IncludeAssets>All</IncludeAssets>
      <PrivateAssets>None</PrivateAssets>
    </PackageReference>
    <PackageReference Include="NosSmooth.Core" Version="3.4.2-main4077986910" />
    <PackageReference Include="NosSmooth.LocalBinding" Version="1.0.0" />
    <PackageReference Include="NosSmooth.PacketSerializer.Abstractions" Version="1.3.1" />
    <PackageReference Include="Projektanker.Icons.Avalonia" Version="5.8.0" />
    <PackageReference Include="Projektanker.Icons.Avalonia.MaterialDesign" Version="5.8.0" />
    <PackageReference Include="PropertyChanged.Fody" Version="4.1.0" />
    <PackageReference Include="PropertyChanged.Fody" Version="4.1.0">
      <PrivateAssets>All</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Remora.Results" Version="7.2.3" />
    <PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
  </ItemGroup>

M src/PacketLogger/ViewModels/DockFactory.cs => src/PacketLogger/ViewModels/DockFactory.cs +12 -7
@@ 11,6 11,8 @@ using Dock.Model.Controls;
using Dock.Model.Core;
using Dock.Model.Mvvm;
using Dock.Model.Mvvm.Controls;
using NosSmooth.Comms.Local;
using NosSmooth.Core.Stateful;
using PacketLogger.Models;
using ReactiveUI;



@@ 21,7 23,9 @@ namespace PacketLogger.ViewModels;
/// </summary>
public class DockFactory : Factory, IDisposable
{
    private NostaleProcesses _processes = new();
    private readonly StatefulRepository _repository;
    private readonly NostaleProcesses _processes = new();
    private readonly CommsInjector _injector;

    /// <inheritdoc />
    public override IDocumentDock CreateDocumentDock()


@@ 37,7 41,7 @@ public class DockFactory : Factory, IDisposable
                }

                var index = documentDock.VisibleDockables?.Count + 1;
                var document = new PacketLogDocumentViewModel(_processes)
                var document = new PacketLogDocumentViewModel(_injector, _repository, _processes)
                    { Id = $"New tab {index}", Title = $"New tab {index}" };

                AddDockable(documentDock, document);


@@ 48,23 52,24 @@ public class DockFactory : Factory, IDisposable
        return documentDock;
    }

    private readonly object _context;
    private IRootDock? _rootDock;
    private IDocumentDock? _documentDock;

    /// <summary>
    /// Initializes a new instance of the <see cref="DockFactory"/> class.
    /// </summary>
    /// <param name="context">The context.</param>
    public DockFactory(object context)
    /// <param name="injector">The communications injector.</param>
    /// <param name="repository">The repository.</param>
    public DockFactory(CommsInjector injector, StatefulRepository repository)
    {
        _context = context;
        _repository = repository;
        _injector = injector;
    }

    /// <inheritdoc />
    public override IRootDock CreateLayout()
    {
        var initialTab = new PacketLogDocumentViewModel(_processes)
        var initialTab = new PacketLogDocumentViewModel(_injector, _repository, _processes)
            { Id = $"New tab", Title = $"New tab" };
        var documentDock = CreateDocumentDock();
        documentDock.IsCollapsable = false;

M src/PacketLogger/ViewModels/LogTabViewModel.cs => src/PacketLogger/ViewModels/LogTabViewModel.cs +50 -6
@@ 10,13 10,18 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Avalonia;
using Avalonia.Collections;
using DynamicData;
using DynamicData.Binding;
using PacketLogger.Models;
using PacketLogger.Models.Filters;
using PacketLogger.Models.Packets;
using ReactiveUI;
using Reloaded.Memory.Kernel32;

namespace PacketLogger.ViewModels;



@@ 24,7 29,9 @@ namespace PacketLogger.ViewModels;
public class LogTabViewModel : ViewModelBase, IDisposable
{
    private readonly ReadOnlyObservableCollection<PacketInfo> _packets;
    private IDisposable _packetsSubscription;
    private readonly IDisposable _cleanUp;
    private bool _logReceived = true;
    private bool _logSent = true;

    /// <summary>
    /// Initializes a new instance of the <see cref="LogTabViewModel"/> class.


@@ 51,14 58,30 @@ public class LogTabViewModel : ViewModelBase, IDisposable
                }
            );

        _packetsSubscription = Provider.Packets.Connect()
        var packetsSubscription = Provider.Packets.Connect()
            .Filter(dynamicFilter)
            .Sort(SortExpressionComparer<PacketInfo>.Ascending(x => x.PacketIndex))
            .Sort(new PacketComparer())
            .Bind(out _packets)
            .ObserveOn(RxApp.MainThreadScheduler)
            .DisposeMany()
            .Subscribe();

        var scrollSubscription = FilteredPackets.ObserveCollectionChanges()
            .ObserveOn(RxApp.MainThreadScheduler)
            .Do
            (
                change =>
                {
                    if (Scroll && change.EventArgs.NewItems is not null)
                    {
                        var last = FilteredPackets[^1];
                        RxApp.MainThreadScheduler.Schedule(DateTimeOffset.Now.AddMilliseconds(1), () => SelectedPacket = last);
                    }
                }
            )
            .Subscribe();

        _cleanUp = new CompositeDisposable(scrollSubscription, packetsSubscription);
        CopyPackets = ReactiveCommand.CreateFromObservable<IList, Unit>
        (
            list => Observable.StartAsync


@@ 151,12 174,28 @@ public class LogTabViewModel : ViewModelBase, IDisposable
    /// <summary>
    /// Gets or sets whether to log received packets.
    /// </summary>
    public bool LogReceived { get; set; } = true;
    public bool LogReceived
    {
        get => _logReceived;
        set
        {
            Provider.LogReceived = value;
            _logReceived = value;
        }
    }

    /// <summary>
    /// Gets or sets whether to log sent packets.
    /// </summary>
    public bool LogSent { get; set; } = true;
    public bool LogSent
    {
        get => _logSent;
        set
        {
            Provider.LogSent = value;
            _logSent = value;
        }
    }

    /// <summary>
    /// Gets or sets whether to scroll to teh bottom of the grid.


@@ 164,6 203,11 @@ public class LogTabViewModel : ViewModelBase, IDisposable
    public bool Scroll { get; set; } = true;

    /// <summary>
    /// Gets or sets the currently selected packet.
    /// </summary>
    public object? SelectedPacket { get; set; }

    /// <summary>
    /// Gets empty string.
    /// </summary>
    public string Empty { get; } = string.Empty;


@@ 192,6 236,6 @@ public class LogTabViewModel : ViewModelBase, IDisposable
    public void Dispose()
    {
        TogglePane.Dispose();
        _packetsSubscription.Dispose();
        _cleanUp.Dispose();
    }
}
\ No newline at end of file

M src/PacketLogger/ViewModels/MainWindowViewModel.cs => src/PacketLogger/ViewModels/MainWindowViewModel.cs +25 -1
@@ 4,10 4,17 @@
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using Dock.Model.Controls;
using Dock.Model.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NosSmooth.Comms.Local.Extensions;
using NosSmooth.Core.Extensions;
using NosSmooth.PacketSerializer.Extensions;
using NosSmooth.PacketSerializer.Packets;
using PacketLogger.Models;
using PacketLogger.Models.Packets;



@@ 23,7 30,24 @@ public class MainWindowViewModel : ViewModelBase, INotifyPropertyChanged
    /// </summary>
    public MainWindowViewModel()
    {
        _factory = new DockFactory(this);
        var services = new ServiceCollection()
            .AddLogging(b => b.ClearProviders().AddConsole())
            .AddSingleton<DockFactory>()
            .AddNostaleCore()
            .AddStatefulInjector()
            .AddStatefulEntity<CommsPacketProvider>()
            .AddLocalComms()
            .AddPacketResponder<PacketResponder>()
            .BuildServiceProvider();

        var packetTypes = services.GetRequiredService<IPacketTypesRepository>();
        var result = packetTypes.AddDefaultPackets();
        if (!result.IsSuccess)
        {
            Console.WriteLine(result.ToFullString());
        }

        _factory = services.GetRequiredService<DockFactory>();

        Layout = _factory?.CreateLayout();
        if (Layout is { })

M src/PacketLogger/ViewModels/PacketLogDocumentViewModel.cs => src/PacketLogger/ViewModels/PacketLogDocumentViewModel.cs +78 -9
@@ 13,10 13,17 @@ using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Dock.Model.Mvvm.Controls;
using DynamicData.Binding;
using NosSmooth.Comms.Data.Messages;
using NosSmooth.Comms.Local;
using NosSmooth.Core.Contracts;
using NosSmooth.Core.Extensions;
using NosSmooth.Core.Stateful;
using PacketLogger.Models;
using PacketLogger.Models.Packets;
using ReactiveUI;


@@ 26,25 33,35 @@ namespace PacketLogger.ViewModels;
/// <inheritdoc />
public class PacketLogDocumentViewModel : Document, INotifyPropertyChanged
{
    private readonly CommsInjector _injector;
    private readonly NostaleProcesses _processes;
    private CancellationTokenSource _ctSource;

    /// <summary>
    /// Initializes a new instance of the <see cref="PacketLogDocumentViewModel"/> class.
    /// </summary>
    /// <param name="injector">The injector.</param>
    /// <param name="repository">The repository.</param>
    /// <param name="processes">The NosTale processes collection.</param>
    public PacketLogDocumentViewModel(NostaleProcesses processes)
    public PacketLogDocumentViewModel(CommsInjector injector, StatefulRepository repository, NostaleProcesses processes)
    {
        _ctSource = new CancellationTokenSource();
        _injector = injector;
        _processes = processes;
        OpenDummy = ReactiveCommand.CreateFromTask
        (
            () => Task.Run(() =>
            {
                Loading = true;
                Name = "Dummy";
                LogViewModel = new LogTabViewModel(new DummyPacketProvider());
                Loaded = true;
            })
            () => Task.Run
            (
                () =>
                {
                    Loading = true;
                    Name = "Dummy";
                    LogViewModel = new LogTabViewModel(new DummyPacketProvider());
                    Loaded = true;
                }
            )
        );

        OpenFile = ReactiveCommand.CreateFromTask
        (
            async () =>


@@ 79,6 96,53 @@ public class PacketLogDocumentViewModel : Document, INotifyPropertyChanged
                Loading = false;
            }
        );

        OpenProcess = ReactiveCommand.CreateFromTask<NostaleProcess>
        (
            async (process, ct) =>
            {
                Loading = true;
                var connectionResult = await injector.EstablishNamedPipesConnectionAsync
                    (process.Process, _ctSource.Token, ct);
                if (!connectionResult.IsDefined(out var connection))
                {
                    Console.WriteLine(connectionResult.ToFullString());
                    return;
                }

                var provider = new CommsPacketProvider(connection);
                repository.SetEntity<CommsPacketProvider>(connection.Client, provider);

                var contractResult = await connection.Connection.ContractHanshake
                        (new HandshakeRequest("PacketLogger", true, false))
                    .WaitForAsync(DefaultStates.ResponseObtained, ct: ct);

                if (!contractResult.IsDefined(out var handshakeResponse))
                {
                    repository.Remove(connection.Client);
                    Console.WriteLine(contractResult.ToFullString());
                    return;
                }

                process.WhenPropertyChanged(x => x.CharacterString)
                    .ObserveOn(RxApp.MainThreadScheduler)
                    .Do
                    (
                        _ =>
                        {
                            Title = (process.BrowserManager.IsInGame
                                ? process.BrowserManager.PlayerManager.Player.Name
                                : null) ?? $"Not in game ({process.Process.Id})";
                        }
                    )
                    .Subscribe();

                LogViewModel = new LogTabViewModel(provider);
                Title = handshakeResponse.CharacterName ?? $"Not in game ({process.Process.Id})";
                Loading = false;
                Loaded = true;
            }
        );
    }

    /// <summary>


@@ 94,7 158,7 @@ public class PacketLogDocumentViewModel : Document, INotifyPropertyChanged
    /// <summary>
    /// Gets whether the document is currently being loaded.
    /// </summary>
    public bool Loading { get; private set; } = false;
    public bool Loading { get; private set; }

    /// <summary>
    /// Gets whether a document has been loaded.


@@ 115,4 179,9 @@ public class PacketLogDocumentViewModel : Document, INotifyPropertyChanged
    /// Gets command for opening a file.
    /// </summary>
    public ReactiveCommand<Unit, Unit> OpenFile { get; }

    /// <summary>
    /// Gets the command for opening a process / connecting to a process.
    /// </summary>
    public ReactiveCommand<NostaleProcess, Unit> OpenProcess { get; }
}
\ No newline at end of file

M src/PacketLogger/Views/LogTabView.axaml => src/PacketLogger/Views/LogTabView.axaml +2 -0
@@ 65,6 65,8 @@

        <DataGrid Items="{Binding FilteredPackets}" IsReadOnly="True" CanUserSortColumns="False"
                  x:Name="PacketsLog"
                  SelectedItem="{Binding SelectedPacket, Mode=TwoWay}"
                  SelectionChanged="PacketsLog_OnSelectionChanged"
                  CanUserReorderColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Time" Binding="{Binding Date, StringFormat = {}{0:HH:mm:ss}}"

M src/PacketLogger/Views/LogTabView.axaml.cs => src/PacketLogger/Views/LogTabView.axaml.cs +9 -0
@@ 4,6 4,7 @@
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;


@@ 26,4 27,12 @@ public partial class LogTabView : UserControl
    {
        AvaloniaXamlLoader.Load(this);
    }

    private void PacketsLog_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
    {
        if (sender is DataGrid dataGrid && dataGrid.SelectedItem is not null)
        {
            dataGrid.ScrollIntoView(dataGrid.SelectedItem, dataGrid.Columns.First());
        }
    }
}
\ No newline at end of file

M src/PacketLogger/Views/PacketLogDocumentView.axaml => src/PacketLogger/Views/PacketLogDocumentView.axaml +6 -3
@@ 13,7 13,7 @@

    <Grid>
        <Border IsVisible="{Binding !Loaded}"
                MaxWidth="800" MaxHeight="600"
                MaxWidth="1000" MaxHeight="600"
                CornerRadius="25"
                Background="{DynamicResource SystemControlPageBackgroundChromeLowBrush}">
            <Grid Margin="50">


@@ 69,7 69,10 @@
                                <DataGridTemplateColumn Header="Connect">
                                    <DataGridTemplateColumn.CellTemplate>
                                        <DataTemplate>
                                            <Button Content="Connect" />
                                            <Button Content="Connect"
                                                    Command="{Binding $parent[UserControl].DataContext.OpenProcess}"
                                                    IsEnabled="{Binding !$parent[UserControl].DataContext.Loading}"
                                                    CommandParameter="{Binding }" />
                                        </DataTemplate>
                                    </DataGridTemplateColumn.CellTemplate>
                                </DataGridTemplateColumn>


@@ 78,7 81,7 @@
                    </StackPanel>

                    <TextBlock Grid.Row="0" Grid.Column="1" FontSize="34" Margin="-10,0,0,0" Text="Packet Sender" />
                    

                    <TextBlock Grid.Row="1" Grid.Column="1" Text="To be implemented..." />
                </Grid>
            </Grid>

Do not follow this link