migration

This commit is contained in:
Kevin Kai Berthold 2025-09-24 22:10:04 +02:00
parent 561881c9aa
commit f133c740e1
55 changed files with 2928 additions and 20 deletions

View file

@ -1,12 +0,0 @@
using System;
namespace dotnetcore
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}

60
discovery.sln Normal file
View file

@ -0,0 +1,60 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36518.9
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discovery.Core", "src\Discovery.Core\Discovery.Core.csproj", "{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discovery.Avalonia", "src\Discovery.Avalonia\Discovery.Avalonia.csproj", "{09326749-BBC2-21DE-BA52-6E3B4BB48BB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discovery.Desktop", "src\Discovery.Desktop\Discovery.Desktop.csproj", "{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{E3E8567F-52C2-4CE2-894D-13FD700D02AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discovery.Loader.Avalonia", "test\Discovery.Loader.Avalonia\Discovery.Loader.Avalonia.csproj", "{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discovery.Loader.Desktop", "test\Discovery.Loader\Discovery.Loader.Desktop.csproj", "{D7CBCD4D-F381-763E-9D57-6200BEC92F8C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61}.Release|Any CPU.Build.0 = Release|Any CPU
{09326749-BBC2-21DE-BA52-6E3B4BB48BB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09326749-BBC2-21DE-BA52-6E3B4BB48BB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09326749-BBC2-21DE-BA52-6E3B4BB48BB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09326749-BBC2-21DE-BA52-6E3B4BB48BB6}.Release|Any CPU.Build.0 = Release|Any CPU
{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B}.Release|Any CPU.Build.0 = Release|Any CPU
{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22}.Release|Any CPU.Build.0 = Release|Any CPU
{D7CBCD4D-F381-763E-9D57-6200BEC92F8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7CBCD4D-F381-763E-9D57-6200BEC92F8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7CBCD4D-F381-763E-9D57-6200BEC92F8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7CBCD4D-F381-763E-9D57-6200BEC92F8C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{69E5A763-1B0B-A8C2-76B9-B5AB1024AA61} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{09326749-BBC2-21DE-BA52-6E3B4BB48BB6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{E1BC3B5E-9798-0849-B8D1-EEDB326CDA8B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{C2CE7D56-2AEB-33AB-B0EA-6854925C8A22} = {E3E8567F-52C2-4CE2-894D-13FD700D02AA}
{D7CBCD4D-F381-763E-9D57-6200BEC92F8C} = {E3E8567F-52C2-4CE2-894D-13FD700D02AA}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C88FC77E-459A-4DFD-9202-FB1070834AAB}
EndGlobalSection
EndGlobal

View file

@ -1,8 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,16 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Discovery.Avalonia.App"
RequestedThemeVariant="Light">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" Accent="#2f0079d9" AltHigh="White" AltLow="White" AltMedium="White" AltMediumHigh="White" AltMediumLow="White" BaseHigh="Black" BaseLow="#ffcccccc" BaseMedium="#ff898989" BaseMediumHigh="#ff5d5d5d" BaseMediumLow="#ff737373" ChromeAltLow="#ff5d5d5d" ChromeBlackHigh="Black" ChromeBlackLow="#ffcccccc" ChromeBlackMedium="#ff5d5d5d" ChromeBlackMediumLow="#ff898989" ChromeDisabledHigh="#ffcccccc" ChromeDisabledLow="#ff898989" ChromeGray="#ff737373" ChromeHigh="#ffcccccc" ChromeLow="#ffececec" ChromeMedium="#ffe6e6e6" ChromeMediumLow="#ffececec" ChromeWhite="White" ListLow="#ffe6e6e6" ListMedium="#ffcccccc" RegionColor="White" />
<ColorPaletteResources x:Key="Dark" Accent="#ff0073cf" AltHigh="Black" AltLow="Black" AltMedium="Black" AltMediumHigh="Black" AltMediumLow="Black" BaseHigh="White" BaseLow="#ff333333" BaseMedium="#ff9a9a9a" BaseMediumHigh="#ffb4b4b4" BaseMediumLow="#ff676767" ChromeAltLow="#ffb4b4b4" ChromeBlackHigh="Black" ChromeBlackLow="#ffb4b4b4" ChromeBlackMedium="Black" ChromeBlackMediumLow="Black" ChromeDisabledHigh="#ff333333" ChromeDisabledLow="#ff9a9a9a" ChromeGray="Gray" ChromeHigh="Gray" ChromeLow="#ff151515" ChromeMedium="#ff1d1d1d" ChromeMediumLow="#ff2c2c2c" ChromeWhite="White" ListLow="#ff1d1d1d" ListMedium="#ff333333" RegionColor="Black" />
</FluentTheme.Palettes>
</FluentTheme>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>

View file

@ -0,0 +1,30 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Discovery.Avalonia.ViewModels;
using Discovery.Avalonia.Windows;
namespace Discovery.Avalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
//BindingPlugins.DataValidators.RemoveAt(0);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainViewModel()
};
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,47 @@
using Avalonia.Data.Converters;
using Discovery.Avalonia.ViewModels;
using System.Globalization;
namespace Discovery.Avalonia.Converters;
public class CanMoveUpConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return true;
if (parameter is IEnumerable<Nameserver> items && value is object item)
{
var list = items.Cast<object>().ToList();
var idx = list.IndexOf(item);
return idx > 0;
}
return false;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class CanMoveDownConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return true;
if (parameter is IEnumerable<Nameserver> items && value is object item)
{
var list = items.Cast<object>().ToList();
var idx = list.IndexOf(item);
return idx >= 0 && idx < list.Count - 1;
}
return false;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Discovery.Avalonia</RootNamespace>
<Product>Discovery</Product>
<Authors>Kevin Kai Berthold</Authors>
<Copyright>Webmatic GmbH</Copyright>
<AssemblyVersion>2025.3.14.0</AssemblyVersion>
<PackageVersion>2025.3.14.0</PackageVersion>
<FileVersion>2025.3.14.0</FileVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
<!--Trimmer Requirenments-->
<ItemGroup>
<TrimmerRootAssembly Include="Avalonia.Controls.DataGrid" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Assets\icon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.6" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.6" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.6" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Discovery.Core\Discovery.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Views\MainView.axaml.cs">
<DependentUpon>MainView.axaml</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View file

@ -0,0 +1,42 @@
using Discovery.Avalonia.Windows;
using Discovery.Models;
using System.Diagnostics;
using System.Net;
namespace Discovery.Avalonia.Models;
public class TargetViewModel(int index, IPAddress ipAddress) : Target(index, ipAddress)
{
public void CopyAddress() => SetClipboard(Address);
public void CopyHostname() => SetClipboard(Hostname);
public void CopyMac() => SetClipboard(Mac);
public void CopyVendor() => SetClipboard(Vendor);
public void HttpAddress() => OpenHref(Address, false);
public void HttpsAddress() => OpenHref(Address, true);
public void HttpHostname() => OpenHref(Hostname, false);
public void HttpsHostname() => OpenHref(Hostname, true);
private static void OpenHref(string? uri, bool secure)
{
if (uri == null) return;
uri = secure ? "https://" + uri : "http://" + uri;
try
{
Process.Start(new ProcessStartInfo { FileName = uri, UseShellExecute = true });
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
public static void SetClipboard(string? text)
{
if (text == null) return;
if (MainWindow.Instance?.Clipboard == null) return;
_ = MainWindow.Instance.Clipboard.SetTextAsync(text);
}
}

View file

@ -0,0 +1,354 @@
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Discovery.Avalonia.Models;
using Discovery.Avalonia.Windows;
using Discovery.Helpers;
using Discovery.Models;
using Discovery.Services;
using System.Net;
namespace Discovery.Avalonia.ViewModels;
public partial class MainViewModel : ViewModelBase
{
public string? HostInput
{
get
{
return _hostInput;
}
set
{
if (value != _hostInput)
{
_hostInput = value;
OnPropertyChanged(nameof(HostInput));
}
}
}
public int OnlineFilter
{
get { return _onlineInput; }
set
{
if (value != _onlineInput)
{
_onlineInput = value;
OnFilterChanged();
OnPropertyChanged(nameof(OnlineFilter));
}
}
}
public string? RegexFilter
{
get
{
return _regexInput;
}
set
{
if (value != _regexInput)
{
_regexInput = value;
OnFilterChanged();
OnPropertyChanged(nameof(RegexFilter));
}
}
}
public string? ScanText
{
get
{
return _scanText;
}
set
{
if (value != _scanText)
{
_scanText = value;
OnPropertyChanged(nameof(ScanText));
}
}
}
public string? StatusMessage
{
get
{
return _status;
}
set
{
if (value != _status)
{
_status = value;
OnPropertyChanged(nameof(StatusMessage));
}
}
}
public string? StatusMessage2
{
get
{
return _status2;
}
set
{
if (value != _status2)
{
_status2 = value;
OnPropertyChanged(nameof(StatusMessage2));
}
}
}
public double Progress
{
get
{
return _progress;
}
set
{
if (value != _progress)
{
_progress = value;
OnPropertyChanged(nameof(Progress));
}
}
}
public string? Version
{
get
{
return _version;
}
set
{
if (value != _version)
{
_version = value;
OnPropertyChanged(nameof(Version));
}
}
}
public ScannerOptions Options { get; set; } = new();
public SynchronizedCollection<TargetViewModel> Targets { get; }
public RelayCommand ScanCommand { get; }
public RelayCommand ExitCommand { get; }
public RelayCommand ExportCsvCommand { get; }
public RelayCommand ExportHtmlCommand { get; }
public RelayCommand TestCommand { get; }
private string? _version = "2025.8.19.0";
private string? _hostInput;
private string? _regexInput;
private int _onlineInput = 1;
private string? _scanText = "Scan";
private string? _status;
private string? _status2;
private double _progress;
private readonly SynchronizationContext _uiContext;
private readonly NetworkInfo? _networkInfo;
private readonly Scanner<TargetViewModel> _scanner = new();
public MainViewModel()
{
_uiContext = SynchronizationContext.Current ?? throw new Exception("SynchronizationContext");
_networkInfo = _scanner.GetPrimaryNetwork();
if (_networkInfo != null)
{
HostInput = $"{_networkInfo.Network}/{_networkInfo.Cidr}";
Options.Nameservers = _networkInfo.Nameservers;
}
Targets = new(p => p.Index, _uiContext);
ScanCommand = new(async () => { await OnScanAsync(); });
ExitCommand = new(Shutdown);
ExportCsvCommand = new(async () => { await ExportCsvAsync(); });
ExportHtmlCommand = new(async () => { await ExportHtmlAsync(); });
TestCommand = new(async () => { await OpenOptionDialog(); });
// init filter, needed
OnFilterChanged();
}
public async Task OnScanAsync()
{
if (_scanner.State.Operation == ScanOperation.Scanning)
{
await _scanner.CancelAsync();
return;
}
if (string.IsNullOrWhiteSpace(_hostInput) ||
!IpHelper.TryParseHostNotation(_hostInput, out var address, out var cidr))
{
StatusMessage = "Invalid Address";
return;
}
if (IPNetwork2.TryParse($"{address}/{cidr}", out var network) is false)
{
StatusMessage = "Invalid Network";
return;
}
var addresses = network.ListIPAddress(filter: Filter.Usable);
Targets.Clear();
for (int i = 0; i < addresses.Count; i++)
{
Targets.Add(new TargetViewModel(i, addresses[i]));
}
var events = new ScannerEvents()
{
OnStateUpdate = (state) => Task.Run(() =>
{
Dispatcher.UIThread.Invoke(() =>
{
Progress = state.Progress;
}, DispatcherPriority.Default, default);
}, default)
};
var scanTask = _scanner.ScanAsync(Targets, Options, events, default).ConfigureAwait(false);
ScanText = "Abort";
StatusMessage = $"Scanning...";
StatusMessage2 = "";
var result = await scanTask;
if (result.Operation == ScanOperation.Idle)
{
ScanText = "Scan";
StatusMessage = $"Scanned {addresses.Count} Hosts in: {result.Elapsed}";
StatusMessage2 = $"{Targets.Where(p => p.Online).Count()} Online";
}
else
{
ScanText = "Scan";
StatusMessage = "Scan aborted!";
StatusMessage2 = $"{Targets.Where(p => p.Online).Count()} Online";
}
}
public static void Shutdown()
{
Dispatcher.UIThread.InvokeShutdown();
}
private void OnFilterChanged()
{
bool online(Target p) => _onlineInput switch
{
0 => true,
1 => p.Online == true,
2 => p.Online == false,
_ => true,
};
bool regex(Target p)
{
if (string.IsNullOrWhiteSpace(_regexInput)) return true;
if (p.Address != null && p.Address.Contains(_regexInput, StringComparison.InvariantCultureIgnoreCase)) return true;
if (p.Hostname != null && p.Hostname.ToString().Contains(_regexInput, StringComparison.InvariantCultureIgnoreCase)) return true;
if (p.Mac != null && p.Mac.ToString().Contains(_regexInput, StringComparison.InvariantCultureIgnoreCase)) return true;
if (p.Vendor != null && p.Vendor.ToString().Contains(_regexInput, StringComparison.InvariantCultureIgnoreCase)) return true;
return false;
}
Targets.Filter = (x) => online(x) && regex(x);
// force re-render (avalonia render bug)
if (MainWindow.Instance != null)
{
Dispatcher.UIThread.Invoke(async () =>
{
await Task.Delay(50);
MainWindow.Instance.Width = MainWindow.Instance.Width + 2;
}, DispatcherPriority.Send);
Dispatcher.UIThread.Invoke(async () =>
{
await Task.Delay(50);
MainWindow.Instance.Width = MainWindow.Instance.Width - 2;
}, DispatcherPriority.Send);
}
}
private async Task ExportCsvAsync()
{
if (Targets is null) return;
var pre = $"discovery_{DateTime.Now:MM-dd-yyyy_hh-mm-ss}";
var topLevel = TopLevel.GetTopLevel(MainWindow.Instance);
if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
SuggestedFileName = pre,
DefaultExtension = "csv",
ShowOverwritePrompt = true,
Title = "Save as CSV"
});
if (file is null || file.Path is null)
return;
await Exporter.ExportAsCsvAsync(Targets, file.Path.LocalPath);
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = $"Exported as CSV to: {file.Path.LocalPath}";
}, DispatcherPriority.Default, default);
}
private async Task ExportHtmlAsync()
{
if (Targets is null) return;
var pre = $"discovery_{DateTime.Now:MM-dd-yyyy_hh-mm-ss}";
var topLevel = TopLevel.GetTopLevel(MainWindow.Instance);
if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
SuggestedFileName = pre,
DefaultExtension = "html",
ShowOverwritePrompt = true,
Title = "Save as HTML"
});
if (file is null || file.Path is null)
return;
await Exporter.ExportAsHtmlAsync(Targets, file.Path.LocalPath);
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = $"Exported as HTML to: {file.Path.LocalPath}";
}, DispatcherPriority.Default, default);
}
private async Task OpenOptionDialog()
{
var option = await WeakReferenceMessenger.Default.Send(new OpenOptionsMessage());
}
}

View file

@ -0,0 +1,92 @@
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging.Messages;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace Discovery.Avalonia.ViewModels;
public class OpenOptionsMessage : AsyncRequestMessage<OptionViewModel?>;
public class OptionViewModel : ViewModelBase
{
public ObservableCollection<Nameserver> Nameservers { get; } = [];
public int? SelectedNameserver { get; set; }
public ICommand AddCommand { get; }
public ICommand RemoveCommand { get; }
public ICommand MoveUpCommand { get; }
public ICommand MoveDownCommand { get; }
public OptionViewModel()
{
AddCommand = new RelayCommand(Add);
RemoveCommand = new RelayCommand(Remove);
MoveUpCommand = new RelayCommand<Nameserver>(MoveUp);
MoveDownCommand = new RelayCommand<Nameserver>(MoveDown);
//if (Design.IsDesignMode)
//{
// Nameservers.Add(new Nameserver { Host = "1.1.1.1"} );
// Nameservers.Add(new Nameserver { Host = "8.8.8.8" });
//}
}
private void Add()
{
Nameservers.Add(new Nameserver { Host = "127.0.0.1" });
OnPropertyChanged(nameof(Nameservers));
}
private void Remove()
{
if (SelectedNameserver == null)
return;
Nameservers.RemoveAt(SelectedNameserver.Value);
OnPropertyChanged(nameof(Nameservers));
}
private void MoveUp(Nameserver? item)
{
Console.WriteLine("MoveUp");
if (item == null)
{
Console.WriteLine("no item");
return;
}
int index = Nameservers.IndexOf(item);
if (index > 0)
{
Nameservers.RemoveAt(index);
Nameservers.Insert(index - 1, item);
}
OnPropertyChanged(nameof(Nameservers));
}
private void MoveDown(Nameserver? item)
{
Console.WriteLine("MoveDown");
if (item == null)
{
Console.WriteLine("no item");
return;
}
int index = Nameservers.IndexOf(item);
if (index >= 0 && index < Nameservers.Count - 1)
{
Nameservers.RemoveAt(index);
Nameservers.Insert(index + 1, item);
}
OnPropertyChanged(nameof(Nameservers));
}
}
public class Nameserver
{
public string Host { get; set; } = null!;
}

View file

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Discovery.Avalonia.ViewModels;
public class ViewModelBase : ObservableObject
{
}

View file

@ -0,0 +1,280 @@
<UserControl 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="Discovery.Avalonia.Views.MainView"
xmlns:vm="clr-namespace:Discovery.Avalonia.ViewModels"
xmlns:models="using:Discovery.Avalonia.Models"
x:DataType="vm:MainViewModel">
<Design.DataContext>
<vm:MainViewModel />
</Design.DataContext>
<Grid>
<Grid.Resources>
<SolidColorBrush x:Key="GrayBackground" Color="White" Opacity=".8" />
<SolidColorBrush x:Key="GrayBackground2" Color="WhiteSmoke" Opacity=".3" />
<SolidColorBrush x:Key="GrayBackground3" Color="WhiteSmoke" Opacity="1" />
<SolidColorBrush x:Key="GrayBorder" Color="LightGray" Opacity="1" />
<SolidColorBrush x:Key="BlackBorder" Color="Black" Opacity=".3" />
<SolidColorBrush x:Key="PrimarySelector" Color="Red" Opacity=".3" />
<SolidColorBrush x:Key="SecondarySelector" Color="Orange" Opacity=".3" />
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0">
<Menu DockPanel.Dock="Top">
<MenuItem Header="_Datei">
<MenuItem Header="_Export">
<MenuItem Header="_CSV"
Command="{Binding ExportCsvCommand}" />
<MenuItem Header="_HTML"
Command="{Binding ExportHtmlCommand}"/>
</MenuItem>
<MenuItem Header="_Beenden"
Command="{Binding ExitCommand}" />
</MenuItem>
<MenuItem Header="_Options" Command="{Binding TestCommand}">
</MenuItem>
</Menu>
</DockPanel>
<TextBlock Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,-10,3,0"
Opacity="0.3"
FontSize="12"
Text="{Binding Version, Mode=OneWay}" />
</Grid>
<Grid Grid.Row="1"
Height="40">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
MinWidth="200"
Margin="5,5,15,5"
Background="{StaticResource GrayBackground}"
HorizontalAlignment="Stretch"
Text="{Binding HostInput}"
Watermark="IP/CIDR">
</TextBox>
<TextBox Grid.Column="1"
MinWidth="200"
Margin="5,5,15,5"
Background="{StaticResource GrayBackground}"
HorizontalAlignment="Stretch"
Text="{Binding RegexFilter}"
Watermark="Filter">
</TextBox>
<Grid Grid.Column="3"
Margin="0,0,5,0"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<ComboBox Margin="0,0,0,0"
BorderThickness="0"
SelectedIndex="{Binding OnlineFilter}">
<ComboBoxItem>Alle</ComboBoxItem>
<ComboBoxItem>Online</ComboBoxItem>
<ComboBoxItem>Offline</ComboBoxItem>
</ComboBox>
</Grid>
<Button Grid.Column="4"
Width="78"
Margin="0,5,5,5"
Background="{StaticResource GrayBackground3}"
HorizontalAlignment="Right"
Command="{Binding OnScanAsync}">
<TextBlock HorizontalAlignment="Center"
Text="{Binding ScanText}"/>
</Button>
</Grid>
<DataGrid Grid.Row="2"
Background="{StaticResource GrayBackground2}"
BorderBrush="{StaticResource BlackBorder}"
BorderThickness="0,0,0,0"
GridLinesVisibility="All"
ItemsSource="{Binding Targets, Mode=TwoWay}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserResizeColumns="True"
CanUserSortColumns="False"
CanUserReorderColumns="False">
<DataGrid.Styles>
<Style Selector="DataGridColumnHeader:nth-child(1),
DataGridColumnHeader:nth-child(2),
DataGridColumnHeader:nth-child(3),
DataGridColumnHeader:nth-child(4),
DataGridColumnHeader:nth-child(5)">
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,0,0" />
<Setter Property="Padding" Value="8,0,0,0" />
<Setter Property="Height" Value="20" />
<Setter Property="TextBlock.FontSize" Value="13" />
<Setter Property="TextBlock.FontWeight" Value="DemiBold" />
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value=".4,.4,0,.4" />
<!--<Setter Property="Foreground" Value="Red" />-->
</Style>
<Style Selector="DataGridCell:current /template/ Grid#FocusVisual">
<Setter Property="IsVisible"
Value="false" />
</Style>
<!--<Style Selector="DataGridRow:pointerover /template/ Rectangle">
<Setter Property="Fill" Value="Transparent"/>
</Style>-->
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTemplateColumn MinWidth="150"
Width="Auto"
Header="Address" >
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Background="Transparent">
<TextBlock FontWeight="SemiLight"
FontSize="14"
Margin="7,7,7,7"
Text="{Binding Address}" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy"
Command="{Binding CopyAddress}" />
<MenuItem Header="Open (Http)"
Command="{Binding HttpAddress}" />
<MenuItem Header="Open (Https)"
Command="{Binding HttpsAddress}" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn MinWidth="180"
Width="Auto"
Header="Hostname" >
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Background="Transparent">
<TextBlock Margin="7,7,7,7"
FontWeight="SemiLight"
FontSize="14"
Text="{Binding Hostname}" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy"
Command="{Binding CopyHostname}" />
<MenuItem Header="Open (Http)"
Command="{Binding HttpHostname}" />
<MenuItem Header="Open (Https)"
Command="{Binding HttpsHostname}" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn MinWidth="150"
Width="Auto"
Header="MAC" >
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Background="Transparent">
<TextBlock Margin="7,7,7,7"
FontWeight="SemiLight"
FontSize="14"
Text="{Binding Mac}" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy"
Command="{Binding CopyMac}" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn MinWidth="200"
Width="*"
Header="Hersteller">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Background="Transparent">
<TextBlock Margin="7,7,7,7"
FontWeight="SemiLight"
FontSize="14"
Text="{Binding Vendor}" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy"
Command="{Binding CopyVendor}" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn MinWidth="50"
Width="Auto"
Header="Online">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Background="Transparent"
BorderThickness="0,0,0,0">
<Viewbox Height="28"
HorizontalAlignment="Center">
<CheckBox IsChecked="{Binding Online}"
IsEnabled="False" />
</Viewbox>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
<!--<DataGrid.RowDetailsTemplate>
<DataTemplate>
</DataTemplate>
</DataGrid.RowDetailsTemplate>-->
</DataGrid>
<Border Grid.Column="3"
BorderBrush="{StaticResource GrayBorder}"
BorderThickness="0,1,0,0" />
<Grid Grid.Row="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Margin="5,5,0,5"
HorizontalAlignment="Left"
Text="{Binding StatusMessage}" />
<TextBlock Grid.Column="2"
Margin="0,5,5,5"
HorizontalAlignment="Right"
Text="{Binding StatusMessage2}" />
</Grid>
<ProgressBar Grid.Row="5"
Height="5"
Background="WhiteSmoke"
Foreground="#64B5F6"
Minimum="0" Maximum="100"
Value="{Binding Progress}" />
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Discovery.Avalonia.Views;
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,15 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Discovery.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:Discovery.Avalonia.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Discovery.Avalonia.Windows.MainWindow"
Icon="/Assets/icon.ico"
Title="Webmatic Discovery"
Width="900"
Height="550"
RenderOptions.BitmapInterpolationMode="HighQuality">
<views:MainView />
</Window>

View file

@ -0,0 +1,32 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.Messaging;
using Discovery.Avalonia.ViewModels;
namespace Discovery.Avalonia.Windows;
public partial class MainWindow : Window
{
public static MainWindow? Instance { get; set; }
public MainWindow()
{
Instance = this;
InitializeComponent();
if (Design.IsDesignMode)
return;
// Whenever 'Send(new PurchaseAlbumMessage())' is called, invoke this callback on the MainWindow instance:
WeakReferenceMessenger.Default.Register<MainWindow, OpenOptionsMessage>(this, static (w, m) =>
{
// Create an instance of MusicStoreWindow and set MusicStoreViewModel as its DataContext.
var dialog = new OptionWindow
{
DataContext = new OptionViewModel()
};
// Show dialog window and reply with returned AlbumViewModel or null when the dialog is closed.
m.Reply(dialog.ShowDialog<OptionViewModel?>(w));
});
}
}

View file

@ -0,0 +1,177 @@
<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="500" d:DesignHeight="400"
x:Class="Discovery.Avalonia.Windows.OptionWindow"
xmlns:vm="clr-namespace:Discovery.Avalonia.ViewModels"
xmlns:models="using:Discovery.Avalonia.Models"
xmlns:conv="using:Discovery.Avalonia.Converters"
x:DataType="vm:OptionViewModel"
Title="OptionWindow"
Width="500" Height="400"
WindowStartupLocation="CenterOwner">
<Design.DataContext>
<vm:OptionViewModel />
</Design.DataContext>
<Window.Resources>
<conv:CanMoveUpConverter x:Key="CanMoveUpConverter" />
<conv:CanMoveDownConverter x:Key="CanMoveDownConverter" />
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<TabControl Margin="10">
<TabItem Header="General">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="20" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
Concurrency
</TextBlock>
<TextBox Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Stretch"
Text=""
Watermark="128" />
</Grid>
</TabItem>
<TabItem Header="ARP">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
Timeout (ms)
</TextBlock>
<TextBox Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Stretch"
Text=""
Watermark="100">
</TextBox>
</Grid>
</TabItem>
<TabItem Header="ICMP">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="10" />
<RowDefinition Height="*" />
<RowDefinition Height="10" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
Timeout (ms)
</TextBlock>
<TextBox Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Stretch"
Text=""
Watermark="100" />
<TextBlock Grid.Row="2"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
TTL
</TextBlock>
<TextBox Grid.Row="2"
Grid.Column="1"
HorizontalAlignment="Stretch"
Text=""
Watermark="128" />
<TextBlock Grid.Row="4"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
Dont Fragment
</TextBlock>
<ToggleSwitch Grid.Row="4"
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
</TabItem>
<TabItem Header="DNS">
<StackPanel Margin="0,10,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button Grid.Column="0" Margin="0,0,0,10" Content="Add" Command="{Binding AddCommand}"/>
<Button Grid.Column="2" Margin="0,0,0,10" Content="Remove" Command="{Binding RemoveCommand}" />
</Grid>
<DataGrid ItemsSource="{Binding Nameservers, Mode=TwoWay}"
AutoGenerateColumns="False"
HeadersVisibility="None"
SelectedIndex="{Binding SelectedNameserver}"
SelectionMode="Single">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Host}" Width="*" />
<DataGridTemplateColumn Header="Move">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Content="▲"
Background="Transparent"
Command="{Binding $parent[DataGrid].DataContext.MoveUpCommand}"
CommandParameter="{Binding .}"
IsEnabled="{Binding $parent[DataGridRow].DataContext,
Converter={StaticResource CanMoveUpConverter},
ConverterParameter={Binding $parent[DataGrid].ItemsSource}}"/>
<Button Content="▼"
Background="Transparent"
Command="{Binding $parent[DataGrid].DataContext.MoveDownCommand}"
CommandParameter="{Binding .}"
IsEnabled="{Binding $parent[DataGridRow].DataContext,
Converter={StaticResource CanMoveDownConverter},
ConverterParameter={Binding $parent[DataGrid].ItemsSource}}"
/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</TabItem>
</TabControl>
</Grid>
</Window>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Discovery.Avalonia.Windows;
public partial class OptionWindow : Window
{
public OptionWindow()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Discovery.Core</AssemblyName>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Discovery</RootNamespace>
<Product>Discovery</Product>
<Authors>Kevin Kai Berthold</Authors>
<Copyright>Webmatic GmbH</Copyright>
<AssemblyVersion>2025.3.14.0</AssemblyVersion>
<PackageVersion>2025.3.14.0</PackageVersion>
<FileVersion>2025.3.14.0</FileVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="IPNetwork2" Version="3.4.832" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<None Update="vendors.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,24 @@
using System.Net;
namespace Discovery.Helpers;
public static class IpHelper
{
public static bool TryParseHostNotation(string input, out IPAddress? host, out int cidr)
{
cidr = 32;
var cidrNotation = input.Contains('/');
var hostRaw = cidrNotation ? input.Split("/")[0] : input;
var cidrRaw = cidrNotation ? input.Split("/")[1] : "32";
if (IPAddress.TryParse(hostRaw, out host) is false)
return false;
if (int.TryParse(cidrRaw, out cidr) is false)
return false;
return true;
}
}

View file

@ -0,0 +1,5 @@
using System.Net;
namespace Discovery.Models;
public record NetworkInfo(IPAddress Client, IPAddress Network, int Cidr, List<IPAddress>? Nameservers);

View file

@ -0,0 +1,6 @@
namespace Discovery.Models;
public class ScannerEvents
{
public Func<ScannerState, Task>? OnStateUpdate { get; set; }
}

View file

@ -0,0 +1,14 @@
using System.Net;
namespace Discovery.Models;
public class ScannerOptions
{
public int Concurrency { get; set; } = 128;
public int Ttl { get; set; } = 128;
public bool DontFragment { get; set; } = true;
public bool ResolveAll { get; set; } = true;
public TimeSpan ArpTimeout { get; set; } = TimeSpan.FromMilliseconds(100);
public TimeSpan PingTimeout { get; set; } = TimeSpan.FromMilliseconds(100);
public List<IPAddress>? Nameservers { get; set; }
}

View file

@ -0,0 +1,15 @@
namespace Discovery.Models;
public class ScannerState
{
public ScanOperation Operation { get; set; } = ScanOperation.Idle;
public TimeSpan Elapsed { get; set; } = TimeSpan.Zero;
public double Progress { get; set; } = 0;
}
public enum ScanOperation
{
Idle,
Scanning,
Aborted
}

View file

@ -0,0 +1,190 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Discovery.Models;
public class SynchronizedCollection<T> : SortableObservableCollection<T>
{
private readonly SynchronizationContext _context;
public SynchronizedCollection(Func<T, int> indexSelector, SynchronizationContext? context) : base(indexSelector)
{
_context = context ?? new SynchronizationContext();
}
public SynchronizedCollection(IEnumerable<T> collection, Func<T, int> indexSelector, SynchronizationContext? context) : base(collection, indexSelector)
{
_context = context ?? new SynchronizationContext();
}
public override void Sort()
{
base.Sort();
if (SynchronizationContext.Current == _context)
{
base.Sort();
}
else
{
_context.Send(_ => base.Sort(), null);
}
}
protected override void InsertItem(int index, T item)
{
if (SynchronizationContext.Current == _context)
{
base.InsertItem(index, item);
}
else
{
_context.Send(_ => base.InsertItem(index, item), null);
}
}
protected override void RemoveItem(int index)
{
if (SynchronizationContext.Current == _context)
{
base.RemoveItem(index);
}
else
{
_context.Send(_ => base.RemoveItem(index), null);
}
}
protected override void ClearItems()
{
if (SynchronizationContext.Current == _context)
{
base.ClearItems();
}
else
{
_context.Send(_ => base.ClearItems(), null);
}
}
protected override void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (SynchronizationContext.Current == _context)
{
base.OnItemPropertyChanged(sender, e);
}
else
{
_context.Send(_ => base.OnItemPropertyChanged(sender, e), null);
}
}
}
public class SortableObservableCollection<T> : ObservableCollection<T>
{
public List<T> InternalList => _items;
public Func<T, bool>? Filter
{
get => _filter;
set
{
_filter = value;
Update();
}
}
private Func<T, bool>? _filter;
private readonly Func<T, int> _indexSelector;
private readonly List<T> _items = [];
public SortableObservableCollection(Func<T, int> indexSelector)
{
_indexSelector = indexSelector;
}
public SortableObservableCollection(IEnumerable<T> collection, Func<T, int> indexSelector) : base(collection)
{
_indexSelector = indexSelector;
Sort();
}
public virtual void Sort()
{
var sortedList = this.ToList();
var ordered = sortedList.OrderBy(_indexSelector).ToList();
// Rearrange the collection to reflect the sorted order
for (int i = 0; i < ordered.Count; i++)
{
Move(IndexOf(ordered[i]), i);
}
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
protected override void InsertItem(int index, T item)
{
_items.Insert(index, item);
base.InsertItem(index, item);
if (item is INotifyPropertyChanged npc)
{
npc.PropertyChanged += Item_PropertyChanged;
}
Update();
}
protected override void RemoveItem(int index)
{
_items.RemoveAt(index);
if (_items[index] is INotifyPropertyChanged npc)
{
npc.PropertyChanged -= Item_PropertyChanged;
}
//base.RemoveItem(index);
Update();
}
protected override void ClearItems()
{
_items.Clear();
Update();
return;
}
protected virtual void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
//Console.WriteLine($"Item property changed: {e.PropertyName}");
Update();
}
private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
OnItemPropertyChanged(sender, e);
}
private void Update()
{
var sorted = _items
.Where(p => _filter == null || _filter(p))
.OrderBy(_indexSelector)
.ToList();
base.Items.Clear();
for (int i = 0; i < sorted.Count; i++)
{
base.Items.Insert(i, sorted[i]);
}
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}

View file

@ -0,0 +1,102 @@
using System.ComponentModel;
using System.Net;
using System.Net.NetworkInformation;
namespace Discovery.Models;
public partial class Target(int index, IPAddress ipAddress) : INotifyPropertyChanged
{
public int Index { get; } = index;
public IPAddress IPAddress => _ipAddress;
public string Address => _ipAddress.ToString();
public bool Online => IsOnline();
public string? Hostname
{
get => _hostname;
set
{
_hostname = value;
OnPropertyChanged(nameof(Hostname));
}
}
public string? Mac
{
get => _mac;
set
{
_mac = value;
OnPropertyChanged(nameof(Mac));
}
}
public string? Vendor
{
get => _vendor;
set
{
_vendor = value;
OnPropertyChanged(nameof(Vendor));
}
}
public string? Ports
{
get => _ports;
set
{
_ports = value;
OnPropertyChanged(nameof(Ports));
OnPropertyChanged(nameof(Online));
}
}
public PingReply? Ping
{
get => _ping;
set
{
_ping = value;
OnPropertyChanged(nameof(Ping));
OnPropertyChanged(nameof(Online));
}
}
public bool Arp
{
get => _arp;
set
{
_arp = value;
OnPropertyChanged(nameof(Arp));
OnPropertyChanged(nameof(Online));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private readonly IPAddress _ipAddress = ipAddress;
private bool _arp;
private PingReply? _ping;
private string? _hostname;
private string? _mac;
private string? _vendor;
private string? _ports;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private bool IsOnline()
{
if (Ping?.Status == IPStatus.Success)
return true;
if (Arp == true)
return true;
if (!string.IsNullOrWhiteSpace(Ports))
return true;
return false;
}
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Discovery.Models;
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(Vendor[]))]
public partial class VendorsJsonContext : JsonSerializerContext
{
}
public class Vendor
{
public string? MacPrefix { get; set; }
public string? VendorName { get; set; }
}

View file

@ -0,0 +1,113 @@
using Discovery.Models;
using System.Text;
namespace Discovery.Services;
public static class Exporter
{
public static async Task ExportAsCsvAsync(IEnumerable<Target> targets, string file, string delimiter = ";")
{
using var writer = new StreamWriter(file);
var sb = new StringBuilder();
sb.AppendLine(string.Join(delimiter,
nameof(Target.Address),
nameof(Target.Hostname),
nameof(Target.Mac),
nameof(Target.Vendor),
nameof(Target.Online)));
foreach (var item in targets)
{
if (item == null || item.Address == null)
continue;
sb.AppendLine(string.Join(delimiter,
item.Address,
item.Hostname,
item.Mac,
item.Vendor,
item.Online));
}
await writer.WriteAsync(sb);
await writer.FlushAsync();
}
public static async Task ExportAsHtmlAsync(IEnumerable<Target> targets, string file, string delimiter = ";")
{
using var writer = new StreamWriter(file);
var sb = new StringBuilder();
var csvWriter = new StringWriter();
await csvWriter.WriteLineAsync(string.Join(delimiter,
nameof(Target.Address),
nameof(Target.Hostname),
nameof(Target.Mac),
nameof(Target.Vendor),
nameof(Target.Online)));
foreach (var item in targets)
{
if (item == null || item.Address == null)
continue;
await csvWriter.WriteLineAsync(string.Join(delimiter,
item.Address,
item.Hostname,
item.Mac,
item.Vendor,
item.Online));
}
await writer.WriteAsync(HmtlTableHelper(csvWriter.ToString(), delimiter));
await writer.FlushAsync();
}
private static string HmtlTableHelper(string csv, string separator)
{
var html = string.Empty;
html += "<!DOCTYPE html>";
html += "<html>";
html += "<head>";
html += "<style>";
html += "#style#";
html += "</style>";
html += "</head>";
html += "<body>";
html += "<table>";
html += "#table#";
html += "</table>";
html += "</body>";
html += "</html>";
var style = string.Empty;
style += string.Empty;
style += "body { font-family: \"Open Sans\", sans-serif; line-height: 1.25; }";
style += "table { border: 1px solid #ccc; border-collapse: collapse; margin: 0; padding: 0; width: 100%; table-layout: fixed; } table caption { font-size: 1.5em; margin: .5em 0 .75em; } table tr { background-color: #f8f8f8; border: 1px solid #ddd; padding: .35em; } table th, table td { padding: .625em; text-align: center; } table th { font-size: .85em; letter-spacing: .1em; text-transform: uppercase; }";
html = html.Replace("#style#", style);
var table = string.Empty;
foreach (var line in csv.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line))
continue;
table += "<tr>";
foreach (var prop in line.Split(separator))
{
table += "<td>";
table += prop;
table += "</td>";
}
table += "</tr>";
}
return html.Replace("#table#", table);
}
}

View file

@ -0,0 +1,295 @@
using Discovery.Models;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Discovery.Services;
public partial class Scanner<T> where T : Target
{
public ScannerState State => _state;
private CancellationTokenSource? _cts;
private readonly ScannerState _state = new();
private readonly static Dictionary<string, string>? _vendors = BuildVendorDictionary();
public async Task<ScannerState> ScanAsync(
SynchronizedCollection<T> targets,
ScannerOptions options,
ScannerEvents events,
CancellationToken cancellationToken = default)
{
// cancel if already running
if (_state.Operation == ScanOperation.Scanning && _cts != null)
{
await _cts.CancelAsync();
return _state;
}
// renew state
_state.Operation = ScanOperation.Scanning;
_state.Progress = 0;
// set cts
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var orderedTargets = targets.InternalList.OrderBy(p => p.Index);
var start = Stopwatch.GetTimestamp();
try
{
var tasks = new List<Task>(targets.InternalList.Count);
var con = new SemaphoreSlim(options.Concurrency);
var pingOptions = new PingOptions
{
DontFragment = options.DontFragment,
Ttl = options.Ttl
};
foreach (var target in targets.InternalList)
{
tasks.Add(Task.Run(async () =>
{
try
{
await con.WaitAsync(cancellationToken).ConfigureAwait(false);
target.Ping = await PingAsync(target.IPAddress, options.PingTimeout, null, pingOptions, cancellationToken).ConfigureAwait(false);
target.Mac = await ArpAsync(target.IPAddress, options.ArpTimeout, cancellationToken).ConfigureAwait(false);
if (target.Mac != null)
{
target.Arp = true;
target.Vendor = ResolveVendor(target.Mac!);
}
// local resolve fix
string? sAddress = null;
if (TryGetPrimaryInterface(out var pInterface, out var pAddress))
sAddress = pAddress?.ToString();
if (target.Address == sAddress)
{
target.Hostname = Dns.GetHostName();
}
else if (options.Nameservers == null)
{
target.Hostname = "N/A";
}
else
{
if (target.Online || (!target.Online && options.ResolveAll))
{
var result = await ResolveHostnameAsync(target.IPAddress, options.Nameservers, cancellationToken).ConfigureAwait(false);
if (result != null)
target.Hostname = result;
}
}
}
catch (OperationCanceledException) { }
finally
{
_state.Progress += (100 / (double)targets.InternalList.Count);
if (events.OnStateUpdate != null)
await events.OnStateUpdate(_state);
con.Release();
}
}, cancellationToken));
}
await Task.WhenAll(tasks);
_state.Operation = ScanOperation.Idle;
}
catch (OperationCanceledException)
{
_state.Operation = ScanOperation.Aborted;
}
finally
{
_state.Progress = 100;
_state.Elapsed = Stopwatch.GetElapsedTime(start);
_cts = null;
}
return _state;
}
public async Task CancelAsync()
{
if (_cts == null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
_state.Operation = ScanOperation.Aborted;
}
public bool TryGetPrimaryInterface(out NetworkInterface? pInterface, out IPAddress? pAddress)
{
pInterface = null;
pAddress = null;
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect("8.8.8.8", 65530);
var ep = socket.LocalEndPoint as IPEndPoint;
if (ep?.Address == null)
return false;
pAddress = ep.Address;
pInterface = NetworkInterface
.GetAllNetworkInterfaces()
.FirstOrDefault(p => p
.GetIPProperties().UnicastAddresses.Any(x => x.Address.ToString() == ep.Address.ToString()));
if (pInterface == null)
return false;
return true;
}
catch
{
return false;
}
}
public NetworkInfo? GetPrimaryNetwork()
{
if (!TryGetPrimaryInterface(out var pInterface, out var pAddress))
return null;
var pUnicast = pInterface!.GetIPProperties().UnicastAddresses.Single(p => p.Address.ToString() == pAddress!.ToString());
if (!IPNetwork2.TryParse(pAddress!, pUnicast!.IPv4Mask, out var net))
return null;
var nameservers = pInterface!.GetIPProperties().DnsAddresses;
return new NetworkInfo(pAddress!, net.Network, net.Cidr, [.. nameservers]);
}
private static Dictionary<string, string> BuildVendorDictionary()
{
var dictionary = new Dictionary<string, string>();
using var fileStream = new FileStream("vendors.json", FileMode.Open, FileAccess.Read);
var vendors = JsonSerializer.Deserialize(
fileStream,
VendorsJsonContext.Default.VendorArray
) ?? throw new InvalidOperationException("vendors.json");
foreach (var item in vendors)
{
if (item is null)
continue;
if (item.MacPrefix == null || item.VendorName == null)
continue;
dictionary.Add(item.MacPrefix, item.VendorName);
}
return dictionary;
}
private static async Task<PingReply?> PingAsync(IPAddress address, TimeSpan timeout, byte[]? buffer, PingOptions? options, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return null;
using var ping = new Ping();
var reply = await ping.SendPingAsync(address, timeout, buffer, options, cancellationToken: cancellationToken).ConfigureAwait(false);
ping.SendAsyncCancel();
return reply;
}
private static async Task<string?> ArpAsync(IPAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) return null;
if (address.AddressFamily != AddressFamily.InterNetwork) return null; // IPv4 only
// 1) Try ARP cache (GetIpNetTable)
if (WinArpInterop.TryGetFromArpCache(address, out var mac))
return mac;
// 2) Active probe on a dedicated thread; still honor the timeout
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var probeTask = Task.Factory.StartNew(
() => WinArpInterop.ProbeWithSendArp(address),
linked.Token,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
var completed = await Task.WhenAny(probeTask, Task.Delay(timeout, linked.Token)).ConfigureAwait(false);
if (completed != probeTask) return null;
linked.Cancel(); // cancel the delay if still pending
return await probeTask.ConfigureAwait(false);
}
private static async Task<string?> ResolveHostnameAsync(IPAddress address, List<IPAddress> nameservers, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return null;
string? hostname = null;
for (int i = 0; i < nameservers.Count; i++)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "nslookup",
Arguments = $"{address} {nameservers[i]}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var output = await process.StandardOutput
.ReadToEndAsync(cancellationToken)
.ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var match = NameRegex().Match(output);
if (match.Success)
hostname = match.Groups["Name"].Value;
if (hostname != null)
break;
}
return hostname;
}
private static string? ResolveVendor(string mac)
{
if (!_vendors!.TryGetValue(mac[..8].ToUpperInvariant(), out string? vendor))
return null;
return vendor;
}
[GeneratedRegex(@"Name:\s*(?<Name>[^\s]+)")]
private static partial Regex NameRegex();
}

View file

@ -0,0 +1,106 @@
using System.Net;
using System.Runtime.InteropServices;
namespace Discovery.Services;
internal static partial class WinArpInterop
{
public static bool TryGetFromArpCache(IPAddress ip, out string? mac)
{
mac = null;
int size = 0;
// First call to get required size
int err = GetIpNetTable(IntPtr.Zero, ref size, bOrder: false);
if (err != ERROR_INSUFFICIENT_BUFFER || size <= 0)
return false;
IntPtr buf = Marshal.AllocHGlobal(size);
try
{
err = GetIpNetTable(buf, ref size, bOrder: false);
if (err != NO_ERROR) return false;
// Layout: DWORD dwNumEntries; then dwNumEntries * MIB_IPNETROW
int num = Marshal.ReadInt32(buf);
IntPtr rowPtr = buf + sizeof(int);
int rowSize = Marshal.SizeOf<MIB_IPNETROW>();
int target = BitConverter.ToInt32(ip.GetAddressBytes(), 0);
for (int i = 0; i < num; i++)
{
var row = Marshal.PtrToStructure<MIB_IPNETROW>(rowPtr);
if (row.dwAddr == target && row.dwPhysAddrLen >= 6)
{
// Accept dynamic/valid types: 3=dynamic, 4=static (2=incomplete)
if (row.dwType == MIB_IPNET_TYPE_DYNAMIC || row.dwType == MIB_IPNET_TYPE_STATIC)
{
mac = FormatMac(row.bPhysAddr.AsSpan(0, 6));
return true;
}
}
rowPtr += rowSize;
}
return false;
}
finally
{
Marshal.FreeHGlobal(buf);
}
}
public static string? ProbeWithSendArp(IPAddress ip)
{
Span<byte> mac = stackalloc byte[6];
uint len = 6;
// SendARP expects dest IPv4 as DWORD (same byte order as BitConverter.ToInt32)
int dest = BitConverter.ToInt32(ip.GetAddressBytes(), 0);
int r = SendARP(dest, 0, ref MemoryMarshal.GetReference(mac), ref len);
if (r != 0 || len < 6) return null;
return FormatMac(mac);
}
private static string FormatMac(ReadOnlySpan<byte> mac)
{
// Fast formatter without intermediate strings
Span<char> chars = stackalloc char[17];
for (int i = 0; i < 6; i++)
{
byte b = mac[i];
chars[i * 3] = GetHexUpper(b >> 4);
chars[i * 3 + 1] = GetHexUpper(b & 0xF);
if (i != 5) chars[i * 3 + 2] = ':';
}
return new string(chars);
}
private static char GetHexUpper(int v) => (char)(v < 10 ? '0' + v : 'A' + (v - 10));
private const int NO_ERROR = 0;
private const int ERROR_INSUFFICIENT_BUFFER = 122;
private const int MIB_IPNET_TYPE_OTHER = 1;
private const int MIB_IPNET_TYPE_INCOMPLETE = 2;
private const int MIB_IPNET_TYPE_DYNAMIC = 3;
private const int MIB_IPNET_TYPE_STATIC = 4;
[LibraryImport("iphlpapi.dll", SetLastError = true)]
private static partial int GetIpNetTable(IntPtr pIpNetTable, ref int pdwSize, [MarshalAs(UnmanagedType.Bool)] bool bOrder);
[LibraryImport("iphlpapi.dll")]
private static partial int SendARP(int DestIP, int SrcIP, ref byte pMacAddr, ref uint PhyAddrLen);
[StructLayout(LayoutKind.Sequential)]
private struct MIB_IPNETROW
{
public int dwIndex;
public int dwPhysAddrLen;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
public byte[] bPhysAddr;
public int dwAddr;
public int dwType;
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Discovery</AssemblyName>
<RootNamespace>Discovery.Desktop</RootNamespace>
<Product>Discovery</Product>
<Authors>Kevin Kai Berthold</Authors>
<Copyright>Webmatic GmbH</Copyright>
<AssemblyVersion>2025.3.14.0</AssemblyVersion>
<PackageVersion>2025.3.14.0</PackageVersion>
<FileVersion>2025.3.14.0</FileVersion>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>icon.ico</ApplicationIcon>
<InvariantGlobalization>true</InvariantGlobalization>
<ProduceReferenceAssembly>False</ProduceReferenceAssembly>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<GenerateAssemblyInfo>True</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Label="Avalonia">
<AvaloniaUseCompiledBindingsByDefault>True</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Discovery.Avalonia\Discovery.Avalonia.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
using Avalonia;
using Discovery.Avalonia;
namespace Discovery.Desktop;
class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net9.0\publish\aot</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net9.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<PublishTrimmed>true</PublishTrimmed>
<PublishAot>true</PublishAot>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net9.0\publish\gc</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net9.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<PublishTrimmed>true</PublishTrimmed>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

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 embeded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="AvaloniaTest.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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

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

View file

@ -0,0 +1,135 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Discovery.Loader.Models;
using Discovery.Loader.Services;
using Discovery.Loader.ViewModels;
using Discovery.Loader.Windows;
namespace Discovery.Loader;
public partial class App : Application
{
private UpdateService _updateService = new();
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override async void OnFrameworkInitializationCompleted()
{
BindingPlugins.DataValidators.RemoveAt(0);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var installed = _updateService.IsInstalled();
// update-check
Update? update = null;
try
{
update = await _updateService.GetUpdateAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
try
{
// if not installed (need update to install)
if (!installed)
{
if (update == null)
{
// set window
var vm = new MainViewModel();
var mw = new MainWindow { DataContext = vm };
desktop.MainWindow = mw;
// display major error
mw.Show();
await vm.ErrorAsync("Install failed (Source unavailable)");
mw.Close();
}
else
{
// set window
var vm = new MainViewModel();
var mw = new MainWindow { DataContext = vm };
desktop.MainWindow = mw;
// display install progress
mw.Show();
await vm.InstallAsync(_updateService, update);
mw.Close();
// start
_updateService.StartExecutable();
}
}
// if installed (should update if needed)
else
{
if (update == null)
{
// set window
var vm = new MainViewModel();
var mw = new MainWindow { DataContext = vm };
desktop.MainWindow = mw;
// display cant fetch updates, start afterwards
mw.Show();
await vm.ErrorAsync("Failed to fetch updates.");
mw.Close();
// start anyway
_updateService.StartExecutable();
}
else
{
// compare versions
var isUpdated = _updateService.CompareVersion(update);
if (!isUpdated)
{
// set window
var vm = new MainViewModel();
var mw = new MainWindow { DataContext = vm };
desktop.MainWindow = mw;
// display update
mw.Show();
await vm.UpdateAsync(_updateService, update);
mw.Close();
}
else
{
// on current-version just start
_updateService.StartExecutable();
}
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
finally
{
//mw.Close();
//if (_updateService.IsInstalled())
// _updateService.StartExecutable();
desktop.Shutdown();
}
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Discovery.Loader</RootNamespace>
<Product>Discovery</Product>
<Authors>Kevin Kai Berthold</Authors>
<Copyright>Webmatic GmbH</Copyright>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.6" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.6" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.6" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Discovery.Loader.Models;
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Update))]
public partial class UpdateContext : JsonSerializerContext
{
}
public class Update
{
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("link")]
public string? Link { get; set; }
}

View file

@ -0,0 +1,167 @@
using Discovery.Loader.Models;
using System;
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Json;
using System.Threading;
namespace Discovery.Loader.Services;
public class UpdateService
{
public DirectoryInfo AppDir { get; private set; }
public UpdateService()
{
var localApps = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
AppDir = new(Path.Combine(localApps.FullName + @"\Discovery"));
}
public bool IsInstalled()
{
if (!AppDir.Exists)
{
return false;
}
if (!AppDir.EnumerateFiles().Where(p => p.Name.Contains("Discovery.exe", StringComparison.InvariantCultureIgnoreCase)).Any())
{
return false;
}
return true;
}
public async Task<Update?> GetUpdateAsync()
{
using var httpClient = new HttpClient();
var update = await httpClient.GetFromJsonAsync(
"https://raw.githubusercontent.com/vaitr/discovery/refs/heads/main/version.json",
UpdateContext.Default.Update,
default);
if (update == null)
{
Console.WriteLine("Source not available");
return null;
}
return update;
}
public bool CompareVersion(Update update)
{
Console.WriteLine(update.Version);
Console.WriteLine(update.Link);
var mainExecutable = new FileInfo(Path.Combine(AppDir.FullName + @"/discovery.exe"));
if (!mainExecutable.Exists)
return false;
var currentVersion = FileVersionInfo.GetVersionInfo(mainExecutable.FullName).FileVersion;
var vCurrent = Version.Parse(currentVersion!);
var vUpdate = Version.Parse(update.Version);
if (vUpdate > vCurrent)
return false;
return true;
}
public async Task<bool> DownloadAsync(Update update, Func<double, Task>? onProgress)
{
try
{
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(3);
using var fileStream = new FileStream(Path.Combine(AppDir.FullName + @"/update.zip"), FileMode.Create);
// Get the http headers first to examine the content length
using var response = await httpClient.GetAsync(update.Link, HttpCompletionOption.ResponseHeadersRead);
var contentLength = response.Content.Headers.ContentLength;
using var download = await response.Content.ReadAsStreamAsync(default);
if (onProgress == null || !contentLength.HasValue)
{
await download.CopyToAsync(fileStream);
return true;
}
// Convert absolute progress (bytes downloaded) into relative progress (0% - 100%)
var relativeProgress = new Progress<long>(async (totalBytes) =>
{
Console.WriteLine(totalBytes + " / " + contentLength.Value + " / " + (float)totalBytes / contentLength.Value);
await onProgress((float)totalBytes / contentLength.Value);
});
// Use extension method to report progress while downloading
await download.CopyToAsync(fileStream, 81920, relativeProgress, default);
return true;
}
catch
{
return false;
}
}
public void Extract()
{
var archivePath = Path.Combine(AppDir.FullName + @"/update.zip");
using (var archive = ZipFile.OpenRead(archivePath))
{
foreach (var entry in archive.Entries)
{
var file = new FileInfo(Path.Combine(AppDir.FullName, entry.Name));
if (file.Exists)
file.Delete();
entry.ExtractToFile(file.FullName);
}
}
File.Delete(archivePath);
}
public void StartExecutable()
{
var exec = new FileInfo(Path.Combine(AppDir.FullName + @"/discovery.exe"));
_ = Process.Start(new ProcessStartInfo
{
UseShellExecute = true,
FileName = exec.FullName
});
}
}
public static class StreamExtensions
{
public static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress<long> progress = null, CancellationToken cancellationToken = default)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (!source.CanRead)
throw new ArgumentException("Has to be readable", nameof(source));
if (destination == null)
throw new ArgumentNullException(nameof(destination));
if (!destination.CanWrite)
throw new ArgumentException("Has to be writable", nameof(destination));
if (bufferSize < 0)
throw new ArgumentOutOfRangeException(nameof(bufferSize));
var buffer = new byte[bufferSize];
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
{
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalBytesRead += bytesRead;
progress?.Report(totalBytesRead);
}
}
}

View file

@ -0,0 +1,105 @@
using Avalonia.Threading;
using Discovery.Loader.Models;
using Discovery.Loader.Services;
using ReactiveUI;
namespace Discovery.Loader.ViewModels;
public class MainViewModel : ViewModelBase
{
public string? Text
{
get => _text;
set => this.RaiseAndSetIfChanged(ref _text, value);
}
private string? _text;
public double Progress
{
get => _progress;
set => this.RaiseAndSetIfChanged(ref _progress, value);
}
private double _progress;
public async Task InstallAsync(UpdateService updater, Update update)
{
if (!updater.AppDir.Exists)
updater.AppDir.Create();
Dispatcher.UIThread.Invoke(() =>
{
Text = "Install";
});
var download = await updater.DownloadAsync(update, (p) => Task.Run(() =>
{
Dispatcher.UIThread.Invoke(() =>
{
Progress = p;
}, DispatcherPriority.Default, default);
}, default));
if (!download)
{
Dispatcher.UIThread.Invoke(() =>
{
Text = "Failed to install";
});
await Task.Delay(3000);
return;
}
updater.Extract();
await Task.Delay(1000);
}
public async Task UpdateAsync(UpdateService updater, Update update)
{
Dispatcher.UIThread.Invoke(() =>
{
Text = "Download Update";
});
var download = await updater.DownloadAsync(update, (p) => Task.Run(() =>
{
Dispatcher.UIThread.Invoke(() =>
{
Progress = p;
}, DispatcherPriority.Default, default);
}, default));
if (!download)
{
Dispatcher.UIThread.Invoke(() =>
{
Text = "Failed to download Update!";
});
await Task.Delay(3000);
return;
}
Dispatcher.UIThread.Invoke(() =>
{
Text = "Extract Update";
});
updater.Extract();
await Task.Delay(1000);
}
public async Task ErrorAsync(string message)
{
Dispatcher.UIThread.Invoke(() =>
{
Text = message;
});
await Task.Delay(3000);
}
}

View file

@ -0,0 +1,7 @@
using ReactiveUI;
namespace Discovery.Loader.ViewModels;
public class ViewModelBase : ReactiveObject
{
}

View file

@ -0,0 +1,33 @@
<UserControl 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"
xmlns:vm="clr-namespace:Discovery.Loader.ViewModels"
mc:Ignorable="d"
x:Class="Discovery.Loader.Views.MainView"
x:DataType="vm:MainViewModel"
Width="300"
Height="100">
<Design.DataContext>
<vm:MainViewModel />
</Design.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,0,0,20"
Text="{Binding Text, Mode=OneWay}"/>
<ProgressBar Grid.Row="2"
Margin="10,0,10,0"
Minimum="0"
Maximum="1"
Value="{Binding Progress, Mode=OneWay}" />
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Discovery.Loader.Views;
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,20 @@
<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"
xmlns:views="clr-namespace:Discovery.Loader.Views"
xmlns:vm="using:Discovery.Loader.ViewModels"
mc:Ignorable="d"
x:Class="Discovery.Loader.Windows.MainWindow"
Icon="/Assets/icon.ico"
Title="Discovery"
Width="300"
Height="100"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1">
<views:MainView />
</Window>

View file

@ -0,0 +1,14 @@
using Avalonia.Controls;
namespace Discovery.Loader.Windows;
public partial class MainWindow : Window
{
public static MainWindow? Instance { get; set; }
public MainWindow()
{
Instance = this;
InitializeComponent();
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Discovery</AssemblyName>
<RootNamespace>Discovery.Loader</RootNamespace>
<Product>Discovery</Product>
<Authors>Kevin Kai Berthold</Authors>
<Copyright>Webmatic GmbH</Copyright>
<AssemblyVersion>2024.1.25.0</AssemblyVersion>
<PackageVersion>2024.1.25.0</PackageVersion>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>icon.ico</ApplicationIcon>
<InvariantGlobalization>true</InvariantGlobalization>
<ProduceReferenceAssembly>False</ProduceReferenceAssembly>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Discovery.Loader.Avalonia\Discovery.Loader.Avalonia.csproj" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Remove="icon.ico" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
using Avalonia;
using Avalonia.ReactiveUI;
namespace Discovery.Loader;
public class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0\publish\aot</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<PublishTrimmed>true</PublishTrimmed>
<PublishAot>true</PublishAot>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0\publish\jit</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<PublishTrimmed>true</PublishTrimmed>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

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 embeded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="AvaloniaTest.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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB