From b96b226165133a9737a6ecf55849b9337a18c8fa Mon Sep 17 00:00:00 2001 From: iterma <110034155+iterma@users.noreply.github.com> Date: Wed, 27 Jul 2022 02:56:07 +0200 Subject: [PATCH] Nullable enabled. --- WSLDiskShrinker/Common/ProcessExtension.cs | 37 +- WSLDiskShrinker/MainWindow.xaml.cs | 523 +++++++++++---------- WSLDiskShrinker/Scanner.cs | 103 ++-- WSLDiskShrinker/WSLDiskShrinker.csproj | 1 + WSLDiskShrinker/WSLDistro.cs | 72 +-- 5 files changed, 368 insertions(+), 368 deletions(-) diff --git a/WSLDiskShrinker/Common/ProcessExtension.cs b/WSLDiskShrinker/Common/ProcessExtension.cs index bf6adc7..93f18d8 100644 --- a/WSLDiskShrinker/Common/ProcessExtension.cs +++ b/WSLDiskShrinker/Common/ProcessExtension.cs @@ -5,25 +5,24 @@ namespace WSLDiskShrinker.Common; // https://stackoverflow.com/questions/470256/process-waitforexit-asynchronously static class ProcessExtension { - /// - /// Waits asynchronously for the process to exit. - /// - /// The process to wait for cancellation. - /// A cancellation token. If invoked, the task will return - /// immediately as canceled. - /// A Task representing waiting for the process to end. - public static Task CustomWaitForExitAsync(this Process process, - CancellationToken cancellationToken = default) - { - if (process.HasExited) return Task.CompletedTask; + /// + /// Waits asynchronously for the process to exit. + /// + /// The process to wait for cancellation. + /// A cancellation token. If invoked, the task will return + /// immediately as canceled. + /// A Task representing waiting for the process to end. + public static Task CustomWaitForExitAsync(this Process process, + CancellationToken cancellationToken = default) + { + if (process.HasExited) return Task.CompletedTask; - var tcs = new TaskCompletionSource(); - process.EnableRaisingEvents = true; - process.Exited += (sender, args) => tcs.TrySetResult(null); - if (cancellationToken != default) - cancellationToken.Register(() => tcs.SetCanceled()); + var tcs = new TaskCompletionSource(); + process.EnableRaisingEvents = true; + process.Exited += (sender, args) => tcs.TrySetResult(process.ExitCode); + if (cancellationToken != default) + cancellationToken.Register(() => tcs.SetCanceled()); - return process.HasExited ? Task.CompletedTask : tcs.Task; - } + return process.HasExited ? Task.CompletedTask : tcs.Task; + } } - diff --git a/WSLDiskShrinker/MainWindow.xaml.cs b/WSLDiskShrinker/MainWindow.xaml.cs index 656d610..29fc983 100644 --- a/WSLDiskShrinker/MainWindow.xaml.cs +++ b/WSLDiskShrinker/MainWindow.xaml.cs @@ -12,289 +12,290 @@ namespace WSLDiskShrinker; /// public partial class MainWindow : Window, INotifyPropertyChanged { - private bool _isScanning = false; - private bool _isProcessing = false; - private WSLDistro _currentShrinkingDistro = null; - private int _currentShrinkingIndex = 1; - private string _status = "Idle"; - public bool IsProcessing - { - get => _isProcessing; - private set - { - if (value == _isProcessing) return; - _isProcessing = value; - OnPropertyChanged(); - } - } - public bool IsScanning - { - get => _isScanning; - private set - { - if (value == _isScanning) return; - _isScanning = value; - OnPropertyChanged(); - } - } + private bool _isScanning = false; + private bool _isProcessing = false; + private WSLDistro? _currentShrinkingDistro; + private int _currentShrinkingIndex = 1; + private string _status = "Idle"; + public bool IsProcessing + { + get => _isProcessing; + private set + { + if (value == _isProcessing) return; + _isProcessing = value; + OnPropertyChanged(); + } + } - public int CurrentShrinkingIndex - { - get => _currentShrinkingIndex; - private set - { - if (value == _currentShrinkingIndex) return; - _currentShrinkingIndex = value; - OnPropertyChanged(); - } - } + public bool IsScanning + { + get => _isScanning; + private set + { + if (value == _isScanning) return; + _isScanning = value; + OnPropertyChanged(); + } + } - public string Status - { - get => _status; - private set - { - if (value == _status) return; - _status = value; - OnPropertyChanged(); - } - } - public WSLDistro CurrentShrinkingDistro - { - get => _currentShrinkingDistro; - private set - { - if (value == _currentShrinkingDistro) return; - _currentShrinkingDistro = value; - OnPropertyChanged(); - } - } - public ObservableCollection Distros { get; private set; } = new(); + public int CurrentShrinkingIndex + { + get => _currentShrinkingIndex; + private set + { + if (value == _currentShrinkingIndex) return; + _currentShrinkingIndex = value; + OnPropertyChanged(); + } + } - public ObservableCollection DistrosToShrink { get; private set; } = new(); - public MainWindow() - { - InitializeComponent(); - this.Loaded += RefreshButton_OnClick; - } + public string Status + { + get => _status; + private set + { + if (value == _status) return; + _status = value; + OnPropertyChanged(); + } + } - private void LoadMockObjects() - { - Distros.Add(new() - { - Name = "Ubuntu", - Path = "???????", - Size = 2321412343413, - Icon = PackIconKind.Ubuntu - }); - Distros.Add(new() - { - Name = "Linux", - Path = "???????", - Size = 2321213, - Icon = PackIconKind.Linux - }); - Distros.Add(new() - { - Name = "ArchLinux", - Path = "???????", - Size = 232122313, - Icon = PackIconKind.Arch - }); - DistrosToShrink.Add(Distros[0]); - } + public WSLDistro? CurrentShrinkingDistro + { + get => _currentShrinkingDistro; + private set + { + if (value == _currentShrinkingDistro) return; + _currentShrinkingDistro = value; + OnPropertyChanged(); + } + } - private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) - { - Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); - e.Handled = true; - } + public ObservableCollection Distros { get; private set; } = new(); - private async void RefreshButton_OnClick(object sender, RoutedEventArgs e) - { - var btn = (sender as Button); - DisableButton(btn); - IsScanning = true; - Distros.Clear(); - await Task.Delay(200); - var distros = Scanner.Scan(); - foreach (var distro in distros) Distros.Add(distro); - IsScanning = false; - EnableButton(btn); - } + public ObservableCollection DistrosToShrink { get; private set; } = new(); + public MainWindow() + { + InitializeComponent(); + this.Loaded += RefreshButton_OnClick; + } - public event PropertyChangedEventHandler? PropertyChanged; + private void LoadMockObjects() + { + Distros.Add(new() + { + Name = "Ubuntu", + Path = "???????", + Size = 2321412343413, + Icon = PackIconKind.Ubuntu + }); + Distros.Add(new() + { + Name = "Linux", + Path = "???????", + Size = 2321213, + Icon = PackIconKind.Linux + }); + Distros.Add(new() + { + Name = "ArchLinux", + Path = "???????", + Size = 232122313, + Icon = PackIconKind.Arch + }); + DistrosToShrink.Add(Distros[0]); + } - [NotifyPropertyChangedInvocator] - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + e.Handled = true; + } - private async void ShrinkAllButton_OnClick(object sender, RoutedEventArgs e) - { - var btn = (sender as Button); - DisableButton(btn); - foreach (var distro in Distros) DistrosToShrink.Add(distro); - await dialogHost.ShowDialog(dialogHost.DialogContent); - EnableButton(btn); - } + private async void RefreshButton_OnClick(object sender, RoutedEventArgs e) /* Called during initialization. */ + { + var btn = sender as Button; + DisableButton(btn); + IsScanning = true; + Distros.Clear(); + await Task.Delay(200); + var distros = Scanner.Scan(); + foreach (var distro in distros) Distros.Add(distro); + IsScanning = false; + EnableButton(btn); + } - private void OpenFolderButton_OnClick(object sender, RoutedEventArgs e) - { - try - { - Process.Start(new ProcessStartInfo - { - FileName = System.IO.Path.GetDirectoryName( - ((WSLDistro)((Button)sender).DataContext).Path - ), - UseShellExecute = true - }); - } - catch - { - MessageBox.Show("Error occurred while opening the containing directory.", "Oops!", MessageBoxButton.OK, - MessageBoxImage.Error); - } + public event PropertyChangedEventHandler? PropertyChanged; - } + [NotifyPropertyChangedInvocator] + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } - private static void EnableButton(Button btn) - { - if (btn != null) btn.IsEnabled = true; - } + private async void ShrinkAllButton_OnClick(object sender, RoutedEventArgs e) + { + var btn = sender as Button; + DisableButton(btn); + foreach (var distro in Distros) DistrosToShrink.Add(distro); + await dialogHost.ShowDialog(dialogHost.DialogContent ?? "n/a"); // Keep compiler happy... + EnableButton(btn); + } - private static void DisableButton(Button btn) - { - if (btn != null) btn.IsEnabled = false; - } - private async void ShrinkButton_OnClick(object sender, RoutedEventArgs e) - { - var btn = (sender as Button); - DisableButton(btn); - DistrosToShrink.Add((WSLDistro)(((Button)sender).DataContext)); - await dialogHost.ShowDialog(dialogHost.DialogContent); - EnableButton(btn); - } + private void OpenFolderButton_OnClick(object sender, RoutedEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = Path.GetDirectoryName(((WSLDistro)((Button)sender).DataContext).Path) ?? "n/a", // Keep compiler happy... + UseShellExecute = true + }); + } + catch + { + MessageBox.Show("Error occurred while opening the containing directory.", "Oops!", MessageBoxButton.OK, + MessageBoxImage.Error); + } + } - private async void ProceedButton_OnClick(object sender, RoutedEventArgs e) - { - var btn = (sender as Button); - DisableButton(btn); - IsProcessing = true; - await Shrink(); - IsProcessing = false; - EnableButton(btn); - } + private static void EnableButton(Button? btn) + { + if (btn is not null) btn.IsEnabled = true; + } - private async Task Shrink() - { - CurrentShrinkingIndex = 0; - foreach (var distro in DistrosToShrink) - { - CurrentShrinkingIndex++; - CurrentShrinkingDistro = distro; - if (distro.Name != "Custom VHDX") - { - Status = "Terminating the distro..."; - var terpr = Process.Start("wsl", $"-t {distro.Name}"); + private static void DisableButton(Button? btn) + { + if (btn is not null) btn.IsEnabled = false; + } + + private async void ShrinkButton_OnClick(object sender, RoutedEventArgs e) + { + var btn = sender as Button; + DisableButton(btn); + DistrosToShrink.Add((WSLDistro)((Button)sender).DataContext); + await dialogHost.ShowDialog(dialogHost.DialogContent ?? "n/a"); // Keep compiler happy... + EnableButton(btn); + } + + private async void ProceedButton_OnClick(object sender, RoutedEventArgs e) + { + var btn = sender as Button; + DisableButton(btn); + IsProcessing = true; + await Shrink(); + IsProcessing = false; + EnableButton(btn); + } + + private async Task Shrink() + { + CurrentShrinkingIndex = 0; + foreach (var distro in DistrosToShrink) + { + CurrentShrinkingIndex++; + CurrentShrinkingDistro = distro; + if (distro.Name != "Custom VHDX") + { + Status = "Terminating the distro..."; + var terpr = Process.Start("wsl.exe", $"-t {distro.Name}"); #if NET5_0_OR_GREATER - await terpr.WaitForExitAsync(); + await terpr.WaitForExitAsync(); #else - await terpr.CustomWaitForExitAsync(); + await terpr.CustomWaitForExitAsync(); #endif - if (terpr.ExitCode != 0) - { - Status = "Error while terminating!"; - MessageBox.Show( - $"While shrinking {distro.Name}," + - $"We encountered an error while terminating the WSL distro.\r\n" + - "The virtual disk of this distro is left unshrunk.\r\n" + - $"Technical details:\r\nExit code:{terpr.ExitCode}.", "Error", - MessageBoxButton.OK, MessageBoxImage.Error); - continue; - } - } - Status = "Writing diskpart script..."; - FileInfo script = null; - try - { - script = await Shrinker.GenerateDiskpartScript(distro.Path); - } - catch (Exception e) - { - Status = "Error while writing diskpart script!"; - MessageBox.Show( - $"While shrinking {distro.Name}," + - $"We encountered an error while writing diskpart script.\r\n" + - "The virtual disk of this distro is left unshrunk.\r\n" + - $"Technical details:\r\nStackTrace: {e.StackTrace}", "Error", - MessageBoxButton.OK, MessageBoxImage.Error); - continue; - } + if (terpr.ExitCode != 0) + { + Status = "Error while terminating!"; + MessageBox.Show( + $"While shrinking {distro.Name}," + + $"We encountered an error while terminating the WSL distro.\r\n" + + "The virtual disk of this distro is left unshrunk.\r\n" + + $"Technical details:\r\nExit code:{terpr.ExitCode}.", "Error", + MessageBoxButton.OK, MessageBoxImage.Error); + continue; + } + } - Status = "Shrinking ..."; - try - { - await Shrinker.ShrinkUsingDiskpart(script); - } - catch (CommandFailedException e) - { - Status = "Error while executing diskpart script!"; - MessageBox.Show( - $"While shrinking {distro.Name}," + - $"We encountered an error while executing diskpart script.\r\n" + - "The virtual disk of this distro is in an unknown state (probably unchanged). :(\r\n" + - $"Technical details:\r\n" + - $"Command Stdout: {await e.FailedProcess.StandardOutput.ReadToEndAsync()}\r\n" + - $"Command Stderr: {await e.FailedProcess.StandardError.ReadToEndAsync()}", "Error", - MessageBoxButton.OK, MessageBoxImage.Error); - continue; - } + Status = "Writing diskpart script..."; + FileInfo script; + try + { + script = await Shrinker.GenerateDiskpartScript(distro.Path); + } + catch (Exception e) + { + Status = "Error while writing diskpart script!"; + MessageBox.Show( + $"While shrinking {distro.Name}," + + $"We encountered an error while writing diskpart script.\r\n" + + "The virtual disk of this distro is left unshrunk.\r\n" + + $"Technical details:\r\nStackTrace: {e.StackTrace}", "Error", + MessageBoxButton.OK, MessageBoxImage.Error); + continue; + } - var newSize = new FileInfo(distro.Path).Length; - Status = $"Success! Saved {FileSizeConverter.Convert(distro.Size - newSize)}."; - distro.Size = newSize; - await Task.Delay(1500); - } + Status = "Shrinking ..."; + try + { + await Shrinker.ShrinkUsingDiskpart(script); + } + catch (CommandFailedException e) + { + Status = "Error while executing diskpart script!"; + MessageBox.Show( + $"While shrinking {distro.Name}," + + $"We encountered an error while executing diskpart script.\r\n" + + "The virtual disk of this distro is in an unknown state (probably unchanged). :(\r\n" + + $"Technical details:\r\n" + + $"Command Stdout: {await e.FailedProcess.StandardOutput.ReadToEndAsync()}\r\n" + + $"Command Stderr: {await e.FailedProcess.StandardError.ReadToEndAsync()}", "Error", + MessageBoxButton.OK, MessageBoxImage.Error); + continue; + } - dialogHost.IsOpen = false; - await Task.Delay(500); - DistrosToShrink.Clear(); - } + var newSize = new FileInfo(distro.Path).Length; + Status = $"Success! Saved {FileSizeConverter.Convert(distro.Size - newSize)}."; + distro.Size = newSize; + await Task.Delay(1500); + } - private async void ShrinkCustomButton_OnClick(object sender, RoutedEventArgs e) - { - var btn = (sender as Button); - DisableButton(btn); - OpenFileDialog dlg = new() - { - CheckFileExists = true, - CheckPathExists = true, - Filter = "Virtual Disk Files (*.vhdx)|*.vhdx" - }; - if (dlg.ShowDialog().Value) - { - DistrosToShrink.Add(new WSLDistro - { - Name = "Custom VHDX", - Icon = PackIconKind.File, - Path = dlg.FileName, - Size = new FileInfo(dlg.FileName).Length - }); - await dialogHost.ShowDialog(dialogHost.DialogContent); - } - EnableButton(btn); - } + dialogHost.IsOpen = false; + await Task.Delay(500); + DistrosToShrink.Clear(); + } - private void DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventargs) - { - if ((string)eventargs.Parameter == "cancelled") - { - DistrosToShrink.Clear(); - } - } -} + private async void ShrinkCustomButton_OnClick(object sender, RoutedEventArgs e) + { + var btn = sender as Button; + DisableButton(btn); + OpenFileDialog dlg = new() + { + CheckFileExists = true, + CheckPathExists = true, + Filter = "Virtual Disk Files (*.vhdx)|*.vhdx" + }; + if (dlg.ShowDialog() is true) + { + DistrosToShrink.Add(new WSLDistro + { + Name = "Custom VHDX", + Icon = PackIconKind.File, + Path = dlg.FileName, + Size = new FileInfo(dlg.FileName).Length + }); + await dialogHost.ShowDialog(dialogHost.DialogContent ?? "n/a"); // Keep compiler happy... + } + EnableButton(btn); + } + private void DialogHost_OnDialogClosing(object sender, DialogClosingEventArgs eventargs) + { + if (eventargs.Parameter as string == "cancelled") + { + DistrosToShrink.Clear(); + } + } +} diff --git a/WSLDiskShrinker/Scanner.cs b/WSLDiskShrinker/Scanner.cs index 6d2aa18..d83e2a6 100644 --- a/WSLDiskShrinker/Scanner.cs +++ b/WSLDiskShrinker/Scanner.cs @@ -5,58 +5,57 @@ namespace WSLDiskShrinker; static class Scanner { - private record ExactDistro(string Path, string Name); + private record ExactDistro(string Path, string Name); - private static IEnumerable GetDistrosByRegistry() - { - var lxss = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Lxss"); - if (lxss == null) - { - MessageBox.Show("Can't open WSL's registry key! Did you enabled WSL on your computer?", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - Environment.Exit(1); - } - var distroIDs = lxss.GetSubKeyNames(); - var distroKeys = from distroID in distroIDs - select lxss.OpenSubKey(distroID); - return from distroKey in distroKeys - let basePath = distroKey.GetValue("BasePath") as string - where !string.IsNullOrWhiteSpace(basePath) - let path = Path.Combine(basePath, "ext4.vhdx") - where File.Exists(path) - select new ExactDistro( - path, - distroKey.GetValue("DistributionName") as string ?? "" - ); - } + private static IEnumerable GetDistrosByRegistry() + { + var lxss = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Lxss"); + if (lxss is null) + { + MessageBox.Show("Can't open WSL's registry key! Did you enabled WSL on your computer?", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + Environment.Exit(1); + } + var distroIDs = lxss!.GetSubKeyNames(); // '!' to silence warning about possible null reference... + var distroKeys = from distroID in distroIDs + select lxss.OpenSubKey(distroID); + return from distroKey in distroKeys + let basePath = distroKey.GetValue("BasePath") as string + where !string.IsNullOrWhiteSpace(basePath) + let path = Path.Combine(basePath, "ext4.vhdx") + where File.Exists(path) + select new ExactDistro( + path, + distroKey.GetValue("DistributionName") as string ?? "" + ); + } - public static IEnumerable Scan() - { - try - { - var distros = GetDistrosByRegistry(); - return from distro in distros - select new WSLDistro - { - Path = distro.Path, - Name = distro.Name, - Icon = WSLDistro.GetIconByName(distro.Name), - Size = new FileInfo(distro.Path).Length - }; - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or PathTooLongException) - { - MessageBox.Show("Error occurred while getting the size of the vhdx file.", "Error!", - MessageBoxButton.OK, MessageBoxImage.Error); - } - catch (Exception e) - { - MessageBox.Show($"Unknown error occurred! \r\nIf you think it's necessary, Please submit an issue in the github repo.\r\n{e.Message}\r\n{e.StackTrace}", "Fatal Error!", - MessageBoxButton.OK, MessageBoxImage.Error); - Environment.FailFast("Fatal Error", e); - } - return new List(); - } + public static IEnumerable Scan() + { + try + { + var distros = GetDistrosByRegistry(); + return from distro in distros + select new WSLDistro + { + Path = distro.Path, + Name = distro.Name, + Icon = WSLDistro.GetIconByName(distro.Name), + Size = new FileInfo(distro.Path).Length + }; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or PathTooLongException) + { + MessageBox.Show("Error occurred while getting the size of the vhdx file.", "Error!", + MessageBoxButton.OK, MessageBoxImage.Error); + } + catch (Exception e) + { + MessageBox.Show($"Unknown error occurred! \r\nIf you think it's necessary, Please submit an issue in the github repo.\r\n{e.Message}\r\n{e.StackTrace}", "Fatal Error!", + MessageBoxButton.OK, MessageBoxImage.Error); + Environment.FailFast("Fatal Error", e); + } + return new List(); + } } - diff --git a/WSLDiskShrinker/WSLDiskShrinker.csproj b/WSLDiskShrinker/WSLDiskShrinker.csproj index 25a4434..c625c06 100644 --- a/WSLDiskShrinker/WSLDiskShrinker.csproj +++ b/WSLDiskShrinker/WSLDiskShrinker.csproj @@ -2,6 +2,7 @@ preview + enable WinExe net6.0-windows;net472 true diff --git a/WSLDiskShrinker/WSLDistro.cs b/WSLDiskShrinker/WSLDistro.cs index b56ee2c..d8d257f 100644 --- a/WSLDiskShrinker/WSLDistro.cs +++ b/WSLDiskShrinker/WSLDistro.cs @@ -1,44 +1,44 @@ -using MaterialDesignThemes.Wpf; -using System.ComponentModel; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; +using MaterialDesignThemes.Wpf; namespace WSLDiskShrinker; public class WSLDistro : INotifyPropertyChanged { - private long _size; - public PackIconKind Icon { get; set; } - public string Name { get; set; } - public string Path { get; set; } + private long _size; + public PackIconKind Icon { get; set; } + public string Name { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; - public long Size - { - get => _size; - set - { - if (value == _size) return; - _size = value; - OnPropertyChanged(); - } - } - public static PackIconKind GetIconByName(string name) - { - var lname = name.ToLowerInvariant(); - return lname switch - { - var x when x.Contains("ubuntu") => PackIconKind.Ubuntu, - var x when x.Contains("mint") => PackIconKind.LinuxMint, - var x when x.Contains("debian") => PackIconKind.Debian, - var x when x.Contains("docker") => PackIconKind.Docker, - _ => PackIconKind.Linux - }; - } - public event PropertyChangedEventHandler? PropertyChanged; + public long Size + { + get => _size; + set + { + if (value == _size) return; + _size = value; + OnPropertyChanged(); + } + } - [NotifyPropertyChangedInvocator] - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} + public static PackIconKind GetIconByName(string name) + { + var lname = name.ToLowerInvariant(); + return lname switch + { + var x when x.Contains("ubuntu") => PackIconKind.Ubuntu, + var x when x.Contains("mint") => PackIconKind.LinuxMint, + var x when x.Contains("debian") => PackIconKind.Debian, + var x when x.Contains("docker") => PackIconKind.Docker, + _ => PackIconKind.Linux + }; + } + + public event PropertyChangedEventHandler? PropertyChanged; + [NotifyPropertyChangedInvocator] + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +}