[EnvVar][Hosts][RegPrev]Decouple and refactor to make it "packable" as nuget package (#32604)

* WIP Hosts - remove deps

* Add consumer app

* Move App and MainWindow to Consumer app. Make Hosts dll

* Try consume it

* Fix errors

* Make it work with custom build targets

* Dependency injection
Refactor
Explicit page creation
Wire missing dependencies

* Fix installer

* Remove unneeded stuff

* Fix build again

* Extract UI and logic from MainWindow to RegistryPreviewMainPage

* Convert to lib
Change namespace to RegistryPreviewUILib
Remove PT deps

* Add exe app and move App.xaml and MainWindow.xaml

* Consume the lib

* Update Hosts package creation

* Fix RegistryPreview package creation

* Rename RegistryPreviewUI back to RegistryPreview

* Back to consuming lib

* Ship icons and assets in nuget packages

* Rename to EnvironmentVariablesUILib and convert to lib

* Add app and consume

* Telemetry

* GPO

* nuget

* Rename HostsPackageConsumer to Hosts and Hosts lib to HostsUILib

* Assets cleanup

* nuget struct

* v0

* assets

* [Hosts] Re-add AppList to Lib Assets, [RegPrev] Copy lib assets to out dir

* Sign UI dlls

* Revert WinUIEx bump

* Cleanup

* Align deps

* version exception dll

* Fix RegistryPreview crashes

* XAML format

* XAML format 2

* Pack .pri files in lib/ dir

---------

Co-authored-by: Darshak Bhatti <dabhatti@microsoft.com>
This commit is contained in:
Stefan Markovic
2024-04-26 19:41:44 +02:00
committed by GitHub
parent 28ba2bd301
commit 41a0114efe
125 changed files with 2097 additions and 1212 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib
{
public static class Consts
{
/// <summary>
/// Maximum number of hosts detected by the system in a single line
/// </summary>
public const int MaxHostsCount = 9;
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace HostsUILib.Exceptions
{
public class NotRunningElevatedException : Exception
{
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace HostsUILib.Exceptions
{
public class ReadOnlyHostsException : Exception
{
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Security.Principal;
namespace HostsUILib.Helpers
{
public class ElevationHelper : IElevationHelper
{
private readonly bool _isElevated;
public bool IsElevated => _isElevated;
public ElevationHelper()
{
_isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
}
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq.Expressions;
namespace HostsUILib.Helpers
{
// https://stackoverflow.com/a/22569086
public static class ExpressionExtensions
{
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var secondBody = expr2.Body.Replace(expr2.Parameters[0], expr1.Parameters[0]);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(expr1.Body, secondBody), expr1.Parameters);
}
public static Expression Replace(this Expression expression, Expression searchEx, Expression replaceEx)
{
return new ReplaceVisitor(searchEx, replaceEx).Visit(expression);
}
internal sealed class ReplaceVisitor : ExpressionVisitor
{
private readonly Expression _from;
private readonly Expression _to;
public ReplaceVisitor(Expression from, Expression to)
{
_from = from;
_to = to;
}
public override Expression Visit(Expression node)
{
return node == _from ? _to : base.Visit(node);
}
}
}
}

View File

@@ -0,0 +1,329 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Net.NetworkInformation;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using HostsUILib.Exceptions;
using HostsUILib.Models;
using HostsUILib.Settings;
using Microsoft.Win32;
namespace HostsUILib.Helpers
{
public class HostsService : IHostsService, IDisposable
{
private const string _backupSuffix = $"_PowerToysBackup_";
private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1);
private readonly IFileSystem _fileSystem;
private readonly IUserSettings _userSettings;
private readonly IElevationHelper _elevationHelper;
private readonly IFileSystemWatcher _fileSystemWatcher;
private readonly string _hostsFilePath;
private bool _backupDone;
private bool _disposed;
public string HostsFilePath => _hostsFilePath;
public event EventHandler FileChanged;
public Encoding Encoding => _userSettings.Encoding == HostsEncoding.Utf8 ? new UTF8Encoding(false) : new UTF8Encoding(true);
public HostsService(
IFileSystem fileSystem,
IUserSettings userSettings,
IElevationHelper elevationHelper)
{
_fileSystem = fileSystem;
_userSettings = userSettings;
_elevationHelper = elevationHelper;
_hostsFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc\hosts");
_fileSystemWatcher = _fileSystem.FileSystemWatcher.CreateNew();
_fileSystemWatcher.Path = _fileSystem.Path.GetDirectoryName(HostsFilePath);
_fileSystemWatcher.Filter = _fileSystem.Path.GetFileName(HostsFilePath);
_fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite;
_fileSystemWatcher.Changed += FileSystemWatcher_Changed;
_fileSystemWatcher.EnableRaisingEvents = true;
}
public bool Exists()
{
return _fileSystem.File.Exists(HostsFilePath);
}
public async Task<HostsData> ReadAsync()
{
var entries = new List<Entry>();
var unparsedBuilder = new StringBuilder();
var splittedEntries = false;
if (!Exists())
{
return new HostsData(entries, unparsedBuilder.ToString(), false);
}
var lines = await _fileSystem.File.ReadAllLinesAsync(HostsFilePath, Encoding);
var id = 0;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var entry = new Entry(id, line);
if (entry.Valid)
{
entries.Add(entry);
id++;
}
else if (entry.Validate(false))
{
foreach (var hostsChunk in entry.SplittedHosts.Chunk(Consts.MaxHostsCount))
{
var clonedEntry = entry.Clone();
clonedEntry.Id = id;
clonedEntry.Hosts = string.Join(' ', hostsChunk);
entries.Add(clonedEntry);
id++;
}
splittedEntries = true;
}
else
{
if (unparsedBuilder.Length > 0)
{
unparsedBuilder.Append(Environment.NewLine);
}
unparsedBuilder.Append(line);
}
}
return new HostsData(entries, unparsedBuilder.ToString(), splittedEntries);
}
public async Task WriteAsync(string additionalLines, IEnumerable<Entry> entries)
{
if (!_elevationHelper.IsElevated)
{
throw new NotRunningElevatedException();
}
if (_fileSystem.FileInfo.FromFileName(HostsFilePath).IsReadOnly)
{
throw new ReadOnlyHostsException();
}
var lines = new List<string>();
if (entries.Any())
{
var addressPadding = entries.Max(e => e.Address.Length) + 1;
var hostsPadding = entries.Max(e => e.Hosts.Length) + 1;
var anyDisabled = entries.Any(e => !e.Active);
foreach (var e in entries)
{
var lineBuilder = new StringBuilder();
if (!e.Valid)
{
lineBuilder.Append(e.Line);
}
else
{
if (!e.Active)
{
lineBuilder.Append('#').Append(' ');
}
else if (anyDisabled)
{
lineBuilder.Append(' ').Append(' ');
}
lineBuilder.Append(e.Address.PadRight(addressPadding));
lineBuilder.Append(string.Join(' ', e.Hosts).PadRight(hostsPadding));
if (e.Comment != string.Empty)
{
lineBuilder.Append('#').Append(' ');
lineBuilder.Append(e.Comment);
}
lines.Add(lineBuilder.ToString().TrimEnd());
}
}
}
if (!string.IsNullOrWhiteSpace(additionalLines))
{
if (_userSettings.AdditionalLinesPosition == HostsAdditionalLinesPosition.Top)
{
lines.Insert(0, additionalLines);
}
else if (_userSettings.AdditionalLinesPosition == HostsAdditionalLinesPosition.Bottom)
{
lines.Add(additionalLines);
}
}
try
{
await _asyncLock.WaitAsync();
_fileSystemWatcher.EnableRaisingEvents = false;
if (!_backupDone && Exists())
{
_fileSystem.File.Copy(HostsFilePath, HostsFilePath + _backupSuffix + DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture));
_backupDone = true;
}
await _fileSystem.File.WriteAllLinesAsync(HostsFilePath, lines, Encoding);
}
finally
{
_fileSystemWatcher.EnableRaisingEvents = true;
_asyncLock.Release();
}
}
public async Task<bool> PingAsync(string address)
{
try
{
using var ping = new Ping();
var reply = await ping.SendPingAsync(address, 4000); // 4000 is the default ping timeout for ping.exe
return reply.Status == IPStatus.Success;
}
catch
{
return false;
}
}
public void CleanupBackup()
{
Directory.GetFiles(Path.GetDirectoryName(HostsFilePath), $"*{_backupSuffix}*")
.Select(f => new FileInfo(f))
.Where(f => f.CreationTime < DateTime.Now.AddDays(-15))
.ToList()
.ForEach(f => f.Delete());
}
public void OpenHostsFile()
{
var notepadFallback = false;
try
{
// Try to open in default editor
var key = Registry.ClassesRoot.OpenSubKey("SystemFileAssociations\\text\\shell\\edit\\command");
if (key != null)
{
var commandPattern = key.GetValue(string.Empty).ToString(); // Default value
var file = null as string;
var args = null as string;
if (commandPattern.StartsWith('\"'))
{
var endQuoteIndex = commandPattern.IndexOf('\"', 1);
if (endQuoteIndex != -1)
{
file = commandPattern[1..endQuoteIndex];
args = commandPattern[(endQuoteIndex + 1)..].Trim();
}
}
else
{
var spaceIndex = commandPattern.IndexOf(' ');
if (spaceIndex != -1)
{
file = commandPattern[..spaceIndex];
args = commandPattern[(spaceIndex + 1)..].Trim();
}
}
if (file != null && args != null)
{
args = args.Replace("%1", HostsFilePath);
Process.Start(new ProcessStartInfo(file, args));
}
else
{
notepadFallback = true;
}
}
}
catch (Exception ex)
{
LoggerInstance.Logger.LogError("Failed to open default editor", ex);
notepadFallback = true;
}
if (notepadFallback)
{
try
{
Process.Start(new ProcessStartInfo("notepad.exe", HostsFilePath));
}
catch (Exception ex)
{
LoggerInstance.Logger.LogError("Failed to open notepad", ex);
}
}
}
public void RemoveReadOnly()
{
var fileInfo = _fileSystem.FileInfo.FromFileName(HostsFilePath);
if (fileInfo.IsReadOnly)
{
fileInfo.IsReadOnly = false;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{
_fileSystemWatcher.EnableRaisingEvents = false;
FileChanged?.Invoke(this, EventArgs.Empty);
_fileSystemWatcher.EnableRaisingEvents = true;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_asyncLock.Dispose();
_disposed = true;
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Helpers
{
public interface IElevationHelper
{
bool IsElevated { get; }
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using HostsUILib.Models;
namespace HostsUILib.Helpers
{
public interface IHostsService : IDisposable
{
string HostsFilePath { get; }
event EventHandler FileChanged;
Task<HostsData> ReadAsync();
Task WriteAsync(string additionalLines, IEnumerable<Entry> entries);
Task<bool> PingAsync(string address);
void CleanupBackup();
void OpenHostsFile();
void RemoveReadOnly();
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace HostsUILib.Helpers
{
public interface ILogger
{
public void LogError(string message);
public void LogError(string message, Exception ex);
public void LogWarning(string message);
public void LogInfo(string message);
public void LogDebug(string message);
public void LogTrace();
}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Helpers
{
public static class LoggerInstance
{
public static ILogger Logger { get; set; }
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.Windows.ApplicationModel.Resources;
namespace HostsUILib.Helpers
{
internal static class ResourceLoaderInstance
{
internal static ResourceLoader ResourceLoader { get; private set; }
static ResourceLoaderInstance()
{
ResourceLoader = new Microsoft.Windows.ApplicationModel.Resources.ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Helpers
{
public static class StringHelper
{
public static string GetHostsWithComment(string hosts, string comment)
{
return string.IsNullOrWhiteSpace(comment) ? hosts : string.Concat(hosts, " - ", comment);
}
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.RegularExpressions;
namespace HostsUILib.Helpers
{
public static class ValidationHelper
{
/// <summary>
/// Determines whether the address is a valid IPv4
/// </summary>
public static bool ValidIPv4(string address)
{
if (string.IsNullOrWhiteSpace(address))
{
return false;
}
var regex = new Regex("^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return regex.IsMatch(address);
}
/// <summary>
/// Determines whether the address is a valid IPv6
/// </summary>
public static bool ValidIPv6(string address)
{
if (string.IsNullOrWhiteSpace(address))
{
return false;
}
var regex = new Regex("^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$");
return regex.IsMatch(address);
}
/// <summary>
/// Determines whether the hosts are valid
/// </summary>
public static bool ValidHosts(string hosts, bool validateHostsLength)
{
if (string.IsNullOrWhiteSpace(hosts))
{
return false;
}
var splittedHosts = hosts.Split(' ');
if (validateHostsLength && splittedHosts.Length > Consts.MaxHostsCount)
{
return false;
}
foreach (var host in splittedHosts)
{
if (Uri.CheckHostName(host) == UriHostNameType.Unknown)
{
return false;
}
}
return true;
}
}
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
namespace HostsUILib.Helpers
{
// Taken from https://github.com/microsoft/microsoft-ui-xaml/blob/main/test/MUXControlsTestApp/Utilities/VisualTreeUtils.cs
// Original copyright header:
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
public static class VisualTreeUtils
{
public static T FindVisualChildByType<T>(this DependencyObject element)
where T : DependencyObject
{
if (element == null)
{
return null;
}
if (element is T elementAsT)
{
return elementAsT;
}
int childrenCount = VisualTreeHelper.GetChildrenCount(element);
for (int i = 0; i < childrenCount; i++)
{
var result = VisualTreeHelper.GetChild(element, i).FindVisualChildByType<T>();
if (result != null)
{
return result;
}
}
return null;
}
public static FrameworkElement FindVisualChildByName(this DependencyObject element, string name)
{
if (element == null || string.IsNullOrWhiteSpace(name))
{
return null;
}
if (element is FrameworkElement elementAsFE && elementAsFE.Name == name)
{
return elementAsFE;
}
int childrenCount = VisualTreeHelper.GetChildrenCount(element);
for (int i = 0; i < childrenCount; i++)
{
var result = VisualTreeHelper.GetChild(element, i).FindVisualChildByName(name);
if (result != null)
{
return result;
}
}
return null;
}
}
}

View File

@@ -0,0 +1,725 @@
<Page
x:Class="HostsUILib.Views.HostsMainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:HostsUILib.Helpers"
xmlns:i="using:Microsoft.Xaml.Interactivity"
xmlns:ic="using:Microsoft.Xaml.Interactions.Core"
xmlns:local="using:Hosts.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:HostsUILib.Models"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
xmlns:ui="using:CommunityToolkit.WinUI"
x:Name="Page"
Loaded="Page_Loaded"
mc:Ignorable="d">
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="Loaded">
<ic:InvokeCommandAction Command="{x:Bind ViewModel.ReadHostsCommand}" />
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
<Page.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" />
<StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" />
<StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" />
<StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" />
<StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" />
<StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" />
<StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" />
<StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" />
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" />
<StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" />
<StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" />
<StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" />
<StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" />
<StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" />
<StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" />
<StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" />
<StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" />
<StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" />
<StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" />
<StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" />
<StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" />
<StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" />
<StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" />
<StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" />
<StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" />
<StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" />
<StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" />
<StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<Style x:Key="SubtleButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" />
<Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" />
<Setter Property="Padding" Value="{StaticResource ButtonPadding}" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter
x:Name="ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AnimatedIcon.State="Normal"
AutomationProperties.AccessibilityView="Raw"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}"
CornerRadius="{TemplateBinding CornerRadius}"
Foreground="{TemplateBinding Foreground}">
<ContentPresenter.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</ContentPresenter.BackgroundTransition>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<!-- DisabledVisual Should be handled by the control, not the animated icon. -->
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</ContentPresenter>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<tkconverters:StringVisibilityConverter
x:Key="StringVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
<tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" />
<tkconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<tkconverters:BoolToVisibilityConverter
x:Key="BoolToInvertedVisibilityConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
<tkconverters:DoubleToVisibilityConverter
x:Key="DoubleToVisibilityConverter"
FalseValue="Visible"
GreaterThan="0"
TrueValue="Collapsed" />
</ResourceDictionary>
</Page.Resources>
<Grid Margin="16" RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<!-- Buttons -->
<RowDefinition Height="*" />
<!-- Content -->
</Grid.RowDefinitions>
<Grid>
<!-- Buttons -->
<Button x:Uid="AddEntryBtn" Command="{x:Bind NewDialogCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
x:Name="Icon"
FontSize="16"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xe710;" />
<TextBlock x:Uid="AddEntry" />
</StackPanel>
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="N" Modifiers="Control" />
</Button.KeyboardAccelerators>
</Button>
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="4">
<Button
x:Uid="AdditionalLinesBtn"
Height="32"
Command="{x:Bind AdditionalLinesDialogCommand}"
Content="{ui:FontIcon Glyph=&#xe8a5;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Uid="FilterBtn"
Height="32"
Style="{StaticResource SubtleButtonStyle}">
<Button.Content>
<Grid>
<FontIcon
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="&#xe71c;" />
<InfoBadge
Width="10"
Height="10"
Margin="0,-4,-4,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Visibility="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</Grid>
</Button.Content>
<Button.Flyout>
<Flyout ShouldConstrainToRootBounds="False">
<StackPanel
Width="320"
HorizontalAlignment="Stretch"
Spacing="12">
<AutoSuggestBox
x:Uid="AddressFilter"
PlaceholderText=""
QueryIcon="Find"
Text="{x:Bind ViewModel.AddressFilter, Mode=TwoWay}">
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="TextChanged">
<ic:InvokeCommandAction Command="{x:Bind ViewModel.ApplyFiltersCommand}" />
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
</AutoSuggestBox>
<AutoSuggestBox
x:Uid="HostsFilter"
QueryIcon="Find"
Text="{x:Bind ViewModel.HostsFilter, Mode=TwoWay}">
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="TextChanged">
<ic:InvokeCommandAction Command="{x:Bind ViewModel.ApplyFiltersCommand}" />
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
</AutoSuggestBox>
<AutoSuggestBox
x:Uid="CommentFilter"
QueryIcon="Find"
Text="{x:Bind ViewModel.CommentFilter, Mode=TwoWay}">
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="TextChanged">
<ic:InvokeCommandAction Command="{x:Bind ViewModel.ApplyFiltersCommand}" />
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
</AutoSuggestBox>
<ToggleSwitch x:Uid="ShowOnlyDuplicates" IsOn="{x:Bind ViewModel.ShowOnlyDuplicates, Mode=TwoWay}" />
<Button
x:Uid="ClearFiltersBtn"
HorizontalAlignment="Right"
Command="{x:Bind ViewModel.ClearFiltersCommand}"
IsEnabled="{x:Bind ViewModel.Filtered, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button
x:Uid="OpenHostsFileBtn"
Height="32"
Command="{x:Bind ViewModel.OpenHostsFileCommand}"
Content="{ui:FontIcon Glyph=&#xe8a7;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Uid="SettingsBtn"
Height="32"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
Content="{ui:FontIcon Glyph=&#xe713;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
</StackPanel>
</Grid>
<!--
https://github.com/microsoft/microsoft-ui-xaml/issues/7690
AllowDrop="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
CanDragItems="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
CanReorderItems="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
-->
<Grid Grid.Row="1" Visibility="{x:Bind ViewModel.IsLoading, Converter={StaticResource BoolToInvertedVisibilityConverter}, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView
x:Name="Entries"
x:Uid="Entries"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}"
GotFocus="Entries_GotFocus"
IsItemClickEnabled="True"
ItemClick="Entries_ItemClick"
ItemsSource="{x:Bind ViewModel.Entries, Mode=TwoWay}"
RightTapped="Entries_RightTapped"
SelectedItem="{x:Bind ViewModel.Selected, Mode=TwoWay}">
<ListView.ContextFlyout>
<MenuFlyout>
<MenuFlyoutItem
x:Uid="Edit"
Click="Edit_Click"
Icon="Edit">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator
Key="E"
Modifiers="Control"
ScopeOwner="{x:Bind Entries}" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
<MenuFlyoutItem x:Uid="Duplicate" Click="Duplicate_Click">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xF413;" />
</MenuFlyoutItem.Icon>
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator
Key="D"
Modifiers="Control"
ScopeOwner="{x:Bind Entries}" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Uid="Ping"
Click="Ping_Click"
Icon="TwoBars">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator
Key="P"
Modifiers="Control"
ScopeOwner="{x:Bind Entries}" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Uid="MoveUp"
Click="ReorderButtonUp_Click"
IsEnabled="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE74A;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Uid="MoveDown"
Click="ReorderButtonDown_Click"
IsEnabled="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE74B;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutSeparator />
<MenuFlyoutItem
x:Uid="Delete"
Click="Delete_Click"
Icon="Delete">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="Delete" ScopeOwner="{x:Bind Entries}" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
</MenuFlyout>
</ListView.ContextFlyout>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:Entry">
<Grid
AutomationProperties.Name="{x:Bind Address, Mode=OneWay}"
Background="Transparent"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="256" />
<!-- Address -->
<ColumnDefinition Width="*" />
<!-- Comment -->
<ColumnDefinition Width="Auto" />
<!-- Status -->
<ColumnDefinition Width="Auto" />
<!-- Duplicate -->
<ColumnDefinition Width="Auto" />
<!-- ToggleSwitch -->
<ColumnDefinition Width="Auto" />
<!-- DeleteEntry -->
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="{x:Bind Address, Mode=OneWay}"
TextWrapping="Wrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind helpers:StringHelper.GetHostsWithComment(Hosts, Comment), Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<ProgressRing
Grid.Column="2"
Width="20"
Height="20"
IsActive="{x:Bind Pinging, Mode=OneWay}" />
<FontIcon
x:Name="PingIcon"
x:Uid="PingIcon"
Grid.Column="2"
FontSize="16"
Visibility="Collapsed">
<i:Interaction.Behaviors>
<ic:DataTriggerBehavior
Binding="{x:Bind Ping, Mode=OneWay}"
ComparisonCondition="Equal"
Value="True">
<ic:ChangePropertyAction
PropertyName="Glyph"
TargetObject="{Binding ElementName=PingIcon}"
Value="&#xe8fb;" />
<ic:ChangePropertyAction
PropertyName="Visibility"
TargetObject="{Binding ElementName=PingIcon}"
Value="Visible" />
<ic:ChangePropertyAction
PropertyName="Foreground"
TargetObject="{Binding ElementName=PingIcon}"
Value="{StaticResource SystemFillColorSuccessBrush}" />
</ic:DataTriggerBehavior>
<ic:DataTriggerBehavior
Binding="{x:Bind Ping, Mode=OneWay}"
ComparisonCondition="Equal"
Value="False">
<ic:ChangePropertyAction
PropertyName="Glyph"
TargetObject="{Binding ElementName=PingIcon}"
Value="&#xe894;" />
<ic:ChangePropertyAction
PropertyName="Visibility"
TargetObject="{Binding ElementName=PingIcon}"
Value="Visible" />
<ic:ChangePropertyAction
PropertyName="Foreground"
TargetObject="{Binding ElementName=PingIcon}"
Value="{StaticResource SystemFillColorCriticalBrush}" />
</ic:DataTriggerBehavior>
<ic:DataTriggerBehavior
Binding="{x:Bind Ping, Mode=OneWay}"
ComparisonCondition="Equal"
Value="{x:Null}">
<ic:ChangePropertyAction
PropertyName="Visibility"
TargetObject="{Binding ElementName=PingIcon}"
Value="Collapsed" />
</ic:DataTriggerBehavior>
</i:Interaction.Behaviors>
</FontIcon>
<FontIcon
x:Uid="DuplicateEntryIcon"
Grid.Column="3"
FontSize="16"
Foreground="{StaticResource SystemControlErrorTextForegroundBrush}"
Glyph="&#xe7BA;"
Visibility="{x:Bind Duplicate, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<ToggleSwitch
x:Uid="ActiveToggle"
Grid.Column="4"
Width="40"
MinWidth="0"
HorizontalAlignment="Right"
GotFocus="Entries_GotFocus"
IsOn="{x:Bind Active, Mode=TwoWay}"
OffContent=""
OnContent="" />
<Button
x:Uid="DeleteEntryBtn"
Grid.Column="5"
Height="32"
Click="Delete_Click"
CommandParameter="{x:Bind (models:Entry)}"
Content="{ui:FontIcon Glyph=&#xE74D;,
FontSize=16}"
GotFocus="Entries_GotFocus"
Style="{StaticResource SubtleButtonStyle}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{x:Bind ViewModel.Entries.Count, Mode=OneWay, Converter={StaticResource DoubleToVisibilityConverter}}">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8"
Visibility="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<FontIcon FontSize="32" Glyph="&#xe774;" />
<TextBlock
x:Uid="EmptyHosts"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<HyperlinkButton
x:Uid="AddEntryLink"
HorizontalAlignment="Center"
Command="{x:Bind NewDialogCommand}" />
</StackPanel>
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8"
Visibility="{x:Bind ViewModel.Filtered, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<FontIcon FontSize="32" Glyph="&#xf78b;" />
<TextBlock
x:Uid="EmptyFilterResults"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<HyperlinkButton
x:Uid="ClearFiltersLink"
HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ClearFiltersCommand}" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Row="1">
<InfoBar
x:Uid="FileSaveError"
Margin="0,8,0,0"
IsOpen="{x:Bind ViewModel.Error, Mode=TwoWay}"
Message="{x:Bind ViewModel.ErrorMessage, Mode=TwoWay}"
Severity="Error"
Visibility="{x:Bind ViewModel.Error, Mode=TwoWay, Converter={StaticResource BoolToVisibilityConverter}}">
<InfoBar.ActionButton>
<Button
x:Uid="MakeWritable"
HorizontalAlignment="Right"
Command="{x:Bind ViewModel.OverwriteHostsCommand}"
Visibility="{x:Bind ViewModel.IsReadOnly, Mode=TwoWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</InfoBar.ActionButton>
</InfoBar>
<InfoBar
x:Uid="FileChanged"
Margin="0,8,0,0"
IsOpen="{x:Bind ViewModel.FileChanged, Mode=TwoWay}"
Severity="Informational"
Visibility="{x:Bind ViewModel.FileChanged, Mode=TwoWay, Converter={StaticResource BoolToVisibilityConverter}}">
<InfoBar.ActionButton>
<Button
x:Uid="Reload"
HorizontalAlignment="Right"
Command="{x:Bind ViewModel.ReadHostsCommand}" />
</InfoBar.ActionButton>
</InfoBar>
</StackPanel>
</Grid>
<ProgressRing
Grid.Row="1"
Width="48"
Height="48"
IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />
<ContentDialog
x:Name="EntryDialog"
x:Uid="EntryDialog"
IsPrimaryButtonEnabled="{Binding Valid, Mode=OneWay}"
Loaded="ContentDialog_Loaded_ApplyMargin"
PrimaryButtonStyle="{StaticResource AccentButtonStyle}">
<ContentDialog.DataContext>
<models:Entry />
</ContentDialog.DataContext>
<ScrollViewer>
<StackPanel
MinWidth="320"
HorizontalAlignment="Stretch"
Spacing="12">
<TextBox
x:Uid="Address"
IsSpellCheckEnabled="False"
Text="{Binding Address, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Description>
<StackPanel
Margin="0,4"
Orientation="Horizontal"
Spacing="4"
Visibility="{Binding IsAddressValid, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<FontIcon
Margin="0,0,0,0"
AutomationProperties.AccessibilityView="Raw"
FontSize="14"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Glyph="&#xE7BA;" />
<TextBlock x:Uid="EntryAddressIsInvalidWarning" />
</StackPanel>
</TextBox.Description>
</TextBox>
<TextBox
x:Uid="Hosts"
AcceptsReturn="False"
IsSpellCheckEnabled="False"
ScrollViewer.IsVerticalRailEnabled="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.VerticalScrollMode="Enabled"
Text="{Binding Hosts, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap">
<TextBox.Description>
<StackPanel
Margin="0,4"
Orientation="Horizontal"
Spacing="4"
Visibility="{Binding IsHostsValid, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<FontIcon
AutomationProperties.AccessibilityView="Raw"
FontSize="14"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Glyph="&#xE7BA;" />
<TextBlock x:Uid="EntryHostsIsInvalidWarning" />
</StackPanel>
</TextBox.Description>
</TextBox>
<TextBox
x:Uid="Comment"
AcceptsReturn="False"
IsSpellCheckEnabled="False"
ScrollViewer.IsVerticalRailEnabled="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.VerticalScrollMode="Enabled"
Text="{Binding Comment, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" />
<ToggleSwitch
x:Uid="Active"
IsOn="{Binding Active, Mode=TwoWay}"
OffContent=""
OnContent="" />
</StackPanel>
</ScrollViewer>
</ContentDialog>
<ContentDialog
x:Name="DeleteDialog"
x:Uid="DeleteDialog"
PrimaryButtonCommand="{x:Bind DeleteCommand}"
PrimaryButtonStyle="{StaticResource AccentButtonStyle}">
<TextBlock x:Uid="DeleteDialogAreYouSure" />
</ContentDialog>
<ContentDialog
x:Name="AdditionalLinesDialog"
x:Uid="AdditionalLinesDialog"
Loaded="ContentDialog_Loaded_ApplyMargin"
PrimaryButtonCommand="{x:Bind UpdateAdditionalLinesCommand}"
PrimaryButtonStyle="{StaticResource AccentButtonStyle}">
<TextBox
x:Name="AdditionalLines"
MinHeight="40"
Padding="16,0"
HorizontalAlignment="Stretch"
AcceptsReturn="True"
ScrollViewer.IsVerticalRailEnabled="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.VerticalScrollMode="Enabled"
TextWrapping="Wrap" />
</ContentDialog>
<TeachingTip
x:Uid="TooManyHostsTeachingTip"
IconSource="{ui:FontIconSource Glyph=&#xe946;}"
IsOpen="{x:Bind ViewModel.ShowSplittedEntriesTooltip, Mode=OneWay}"
PlacementMargin="20"
PreferredPlacement="Top">
<TeachingTip.Content>
<TextBlock x:Uid="TooManyHostsTeachingTipContent" TextWrapping="Wrap" />
</TeachingTip.Content>
</TeachingTip>
</Grid>
</Page>

View File

@@ -0,0 +1,234 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using HostsUILib.Helpers;
using HostsUILib.Models;
using HostsUILib.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace HostsUILib.Views
{
public partial class HostsMainPage : Page
{
public MainViewModel ViewModel { get; private set; }
public ICommand NewDialogCommand => new AsyncRelayCommand(OpenNewDialogAsync);
public ICommand AdditionalLinesDialogCommand => new AsyncRelayCommand(OpenAdditionalLinesDialogAsync);
public ICommand AddCommand => new RelayCommand(Add);
public ICommand UpdateCommand => new RelayCommand(Update);
public ICommand DeleteCommand => new RelayCommand(Delete);
public ICommand UpdateAdditionalLinesCommand => new RelayCommand(UpdateAdditionalLines);
public ICommand ExitCommand => new RelayCommand(() => { Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread().TryEnqueue(Application.Current.Exit); });
public HostsMainPage(MainViewModel viewModel)
{
InitializeComponent();
ViewModel = viewModel;
DataContext = ViewModel;
}
private async Task OpenNewDialogAsync()
{
await ShowAddDialogAsync();
}
private async Task OpenAdditionalLinesDialogAsync()
{
AdditionalLines.Text = ViewModel.AdditionalLines;
await AdditionalLinesDialog.ShowAsync();
}
private async void Entries_ItemClick(object sender, ItemClickEventArgs e)
{
Entry entry = e.ClickedItem as Entry;
ViewModel.Selected = entry;
await ShowEditDialogAsync(entry);
}
public async Task ShowEditDialogAsync(Entry entry)
{
var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
EntryDialog.Title = resourceLoader.GetString("UpdateEntry_Title");
EntryDialog.PrimaryButtonText = resourceLoader.GetString("UpdateBtn");
EntryDialog.PrimaryButtonCommand = UpdateCommand;
var clone = ViewModel.Selected.Clone();
EntryDialog.DataContext = clone;
await EntryDialog.ShowAsync();
}
private void Add()
{
ViewModel.Add(EntryDialog.DataContext as Entry);
}
private void Update()
{
ViewModel.Update(Entries.SelectedIndex, EntryDialog.DataContext as Entry);
}
private void Delete()
{
ViewModel.DeleteSelected();
}
private void UpdateAdditionalLines()
{
ViewModel.UpdateAdditionalLines(AdditionalLines.Text);
}
private async void Delete_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
ViewModel.Selected = entry;
DeleteDialog.Title = entry.Address;
await DeleteDialog.ShowAsync();
}
}
private async void Edit_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
await ShowEditDialogAsync(entry);
}
}
private async void Duplicate_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
await ShowAddDialogAsync(entry);
}
}
private async void Ping_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
ViewModel.Selected = entry;
await ViewModel.PingSelectedAsync();
}
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
{
ViewModel.ReadHosts();
var userSettings = ViewModel.UserSettings;
if (userSettings.ShowStartupWarning)
{
var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog();
dialog.XamlRoot = XamlRoot;
dialog.Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style;
dialog.Title = resourceLoader.GetString("WarningDialog_Title");
dialog.Content = new TextBlock
{
Text = resourceLoader.GetString("WarningDialog_Text"),
TextWrapping = TextWrapping.Wrap,
};
dialog.PrimaryButtonText = resourceLoader.GetString("WarningDialog_AcceptBtn");
dialog.PrimaryButtonStyle = Application.Current.Resources["AccentButtonStyle"] as Style;
dialog.CloseButtonText = resourceLoader.GetString("WarningDialog_QuitBtn");
dialog.CloseButtonCommand = ExitCommand;
await dialog.ShowAsync();
}
}
private void ReorderButtonUp_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
var index = ViewModel.Entries.IndexOf(entry);
if (index > 0)
{
ViewModel.Move(index, index - 1);
}
}
}
private void ReorderButtonDown_Click(object sender, RoutedEventArgs e)
{
if (Entries.SelectedItem is Entry entry)
{
var index = ViewModel.Entries.IndexOf(entry);
if (index < ViewModel.Entries.Count - 1)
{
ViewModel.Move(index, index + 1);
}
}
}
/// <summary>
/// Focus the first item when the list view gets the focus with keyboard
/// </summary>
private void Entries_GotFocus(object sender, RoutedEventArgs e)
{
var element = sender as FrameworkElement;
var entry = element.DataContext as Entry;
if (entry != null)
{
ViewModel.Selected = entry;
}
else if (Entries.SelectedItem == null && Entries.Items.Count > 0)
{
Entries.SelectedIndex = 0;
}
}
private void Entries_RightTapped(object sender, RightTappedRoutedEventArgs e)
{
var entry = (e.OriginalSource as FrameworkElement).DataContext as Entry;
ViewModel.Selected = entry;
}
private async Task ShowAddDialogAsync(Entry template = null)
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
EntryDialog.Title = resourceLoader.GetString("AddNewEntryDialog_Title");
EntryDialog.PrimaryButtonText = resourceLoader.GetString("AddBtn");
EntryDialog.PrimaryButtonCommand = AddCommand;
EntryDialog.DataContext = template == null
? new Entry(ViewModel.NextId, string.Empty, string.Empty, string.Empty, true)
: new Entry(ViewModel.NextId, template.Address, template.Hosts, template.Comment, template.Active);
await EntryDialog.ShowAsync();
}
private void ContentDialog_Loaded_ApplyMargin(object sender, RoutedEventArgs e)
{
try
{
// Based on the template from dev/CommonStyles/ContentDialog_themeresources.xaml in https://github.com/microsoft/microsoft-ui-xaml
var border = VisualTreeUtils.FindVisualChildByName(sender as ContentDialog, "BackgroundElement") as Border;
if (border is not null)
{
border.Margin = new Thickness(0, 32, 0, 0); // Should be the size reserved for the title bar as in MainWindow.
}
}
catch (Exception ex)
{
LoggerInstance.Logger.LogError("Couldn't set the margin for a content dialog. It will appear on top of the title bar.", ex);
}
}
}
}

View File

@@ -0,0 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Condition="exists('..\..\..\Version.props')" Project="..\..\..\Version.props" />
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0-windows10.0.20348</TargetFramework>
<RootNamespace>HostsUILib</RootNamespace>
<UseWinUI>true</UseWinUI>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<AssemblyName>PowerToys.HostsUILib</AssemblyName>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>PowerToys.HostsUILib.pri</ProjectPriFileName>
<GenerateLibraryLayout>true</GenerateLibraryLayout>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutDir)\PowerToys.HostsUILib.pri" Pack="True" PackageCopyToOutput="true" PackagePath="lib/$(TargetFramework)" />
<None Include="$(OutDir)\PowerToys.HostsUILib.pri" Pack="True" PackageCopyToOutput="True" PackagePath="contentFiles\any\$(TargetFramework)" />
<XBFFile Include="$(OutDir)**\*.xbf" />
<None Include="@(XBFFile)" Pack="True" PackageCopyToOutput="True" PackagePath="contentFiles\any\$(TargetFramework)" />
<None Include="$(OutDir)\PowerToys.HostsUILib.pdb" Pack="True" PackageCopyToOutput="true" PackagePath="lib/$(TargetFramework)" />
<None Include="Assets\**\*.png" Pack="true" PackageCopyToOutput="true" PackagePath="contentFiles\any\$(TargetFramework)\Assets" />
<None Include="Assets\**\*.ico" Pack="true" PackageCopyToOutput="true" PackagePath="contentFiles\any\$(TargetFramework)\Assets" />
</ItemGroup>
<ItemGroup>
<Content Remove="Assets\**\*.png" Pack="false" />
<Content Remove="Assets\**\*.ico" Pack="false" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<DebugSymbols>true</DebugSymbols>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Optimize>false</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
<PackageReference Include="CommunityToolkit.WinUI.Collections" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Xaml.Behaviors.WinUI.Managed" />
<PackageReference Include="System.IO.Abstractions" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnablePreviewMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Models
{
public enum AddressType
{
Invalid = 0,
IPv4 = 1,
IPv6 = 2,
}
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Net;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using HostsUILib.Helpers;
namespace HostsUILib.Models
{
public partial class Entry : ObservableObject
{
private static readonly char[] _spaceCharacters = new char[] { ' ', '\t' };
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Valid))]
[NotifyPropertyChangedFor(nameof(IsAddressValid))]
private string _address;
partial void OnAddressChanged(string value)
{
if (ValidationHelper.ValidIPv4(value))
{
Type = AddressType.IPv4;
}
else if (ValidationHelper.ValidIPv6(value))
{
Type = AddressType.IPv6;
}
else
{
Type = AddressType.Invalid;
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Valid))]
[NotifyPropertyChangedFor(nameof(IsHostsValid))]
private string _hosts;
partial void OnHostsChanged(string value)
{
SplittedHosts = value.Split(' ');
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Valid))]
private string _comment;
[ObservableProperty]
private bool _active;
[ObservableProperty]
private bool? _ping;
[ObservableProperty]
private bool _pinging;
[ObservableProperty]
private bool _duplicate;
public bool Valid => Validate(true);
public bool IsAddressValid => ValidateAddressField();
public bool IsHostsValid => ValidateHostsField(true);
public string Line { get; private set; }
public AddressType Type { get; private set; }
public string[] SplittedHosts { get; private set; }
public int Id { get; set; }
public Entry()
{
}
public Entry(int id, string line)
{
Id = id;
Line = line.Trim();
Parse();
}
public Entry(int id, string address, string hosts, string comment, bool active)
{
Id = id;
Address = address.Trim();
Hosts = hosts.Trim();
Comment = comment.Trim();
Active = active;
}
public void Parse()
{
Active = !Line.StartsWith("#", StringComparison.InvariantCultureIgnoreCase);
var lineSplit = Line.TrimStart(' ', '#').Split('#');
if (lineSplit.Length == 0)
{
return;
}
var addressHost = lineSplit[0];
var addressHostSplit = addressHost.Split(_spaceCharacters, StringSplitOptions.RemoveEmptyEntries);
var hostsBuilder = new StringBuilder();
var commentBuilder = new StringBuilder();
for (var i = 0; i < addressHostSplit.Length; i++)
{
var element = addressHostSplit[i].Trim();
if (i == 0 && IPAddress.TryParse(element, out var _) && (element.Contains(':') || element.Contains('.')))
{
Address = element;
}
else
{
if (hostsBuilder.Length > 0)
{
hostsBuilder.Append(' ');
}
hostsBuilder.Append(element);
}
}
Hosts = hostsBuilder.ToString();
for (var i = 1; i < lineSplit.Length; i++)
{
if (commentBuilder.Length > 0)
{
commentBuilder.Append('#');
}
commentBuilder.Append(lineSplit[i]);
}
Comment = commentBuilder.ToString().Trim();
}
public Entry Clone()
{
return new Entry
{
Line = Line,
Address = Address,
Hosts = Hosts,
Comment = Comment,
Active = Active,
};
}
private bool ValidateAddressField()
{
return Type != AddressType.Invalid;
}
private bool ValidateHostsField(bool validateHostsLength)
{
return ValidationHelper.ValidHosts(Hosts, validateHostsLength);
}
public bool Validate(bool validateHostsLength)
{
if (Equals("102.54.94.97", "rhino.acme.com", "source server") || Equals("38.25.63.10", "x.acme.com", "x client host"))
{
return false;
}
return ValidateAddressField() && ValidateHostsField(validateHostsLength);
}
private bool Equals(string address, string hosts, string comment)
{
return string.Equals(Address, address, StringComparison.Ordinal)
&& string.Equals(Hosts, hosts, StringComparison.Ordinal)
&& string.Equals(Comment, comment, StringComparison.Ordinal);
}
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace HostsUILib.Models
{
/// <summary>
/// Represents the parsed hosts file
/// </summary>
public class HostsData
{
/// <summary>
/// Gets the parsed entries
/// </summary>
public ReadOnlyCollection<Entry> Entries { get; }
/// <summary>
/// Gets the lines that couldn't be parsed
/// </summary>
public string AdditionalLines { get; }
/// <summary>
/// Gets a value indicating whether some entries been splitted
/// </summary>
public bool SplittedEntries { get; }
public HostsData(List<Entry> entries, string additionalLines, bool splittedEntries)
{
Entries = entries.AsReadOnly();
AdditionalLines = additionalLines;
SplittedEntries = splittedEntries;
}
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Settings
{
public enum HostsAdditionalLinesPosition
{
Top = 0,
Bottom = 1,
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace HostsUILib.Settings
{
public enum HostsEncoding
{
Utf8 = 0,
Utf8Bom = 1,
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Net;
namespace HostsUILib.Settings
{
public interface IUserSettings
{
public bool ShowStartupWarning { get; }
public bool LoopbackDuplicates { get; }
public HostsAdditionalLinesPosition AdditionalLinesPosition { get; }
public HostsEncoding Encoding { get; }
event EventHandler LoopbackDuplicatesChanged;
public delegate void OpenSettingsFunction();
}
}

View File

@@ -0,0 +1,342 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Active.Header" xml:space="preserve">
<value>Active</value>
</data>
<data name="ActiveToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Active</value>
</data>
<data name="AddBtn" xml:space="preserve">
<value>Add</value>
</data>
<data name="AddEntry.Text" xml:space="preserve">
<value>New entry</value>
</data>
<data name="AddEntryBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>New entry</value>
</data>
<data name="AddEntryBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>New entry (Ctrl+N)</value>
</data>
<data name="AddEntryLink.Content" xml:space="preserve">
<value>Add an entry</value>
</data>
<data name="AdditionalLinesBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Additional content</value>
</data>
<data name="AdditionalLinesBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Additional content</value>
</data>
<data name="AdditionalLinesDialog.CloseButtonText" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="AdditionalLinesDialog.PrimaryButtonText" xml:space="preserve">
<value>Save</value>
</data>
<data name="AdditionalLinesDialog.Title" xml:space="preserve">
<value>Additional content</value>
</data>
<data name="AddNewEntryDialog_Title" xml:space="preserve">
<value>Add new entry</value>
</data>
<data name="Address.Header" xml:space="preserve">
<value>Address</value>
<comment>"Address" refers to the IP address of the entry</comment>
</data>
<data name="AddressFilter.Header" xml:space="preserve">
<value>Address</value>
<comment>"Address" refers to the IP address of the entry</comment>
</data>
<data name="ClearFiltersBtn.Content" xml:space="preserve">
<value>Clear filters</value>
</data>
<data name="ClearFiltersBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Clear filters</value>
</data>
<data name="ClearFiltersLink.Content" xml:space="preserve">
<value>Clear filters</value>
</data>
<data name="Comment.Header" xml:space="preserve">
<value>Comment</value>
<comment>"Comment" refers to the comment of the entry</comment>
</data>
<data name="CommentFilter.Header" xml:space="preserve">
<value>Comment</value>
<comment>"Comment" refers to the comment of the entry</comment>
</data>
<data name="Delete.Text" xml:space="preserve">
<value>Delete</value>
</data>
<data name="DeleteDialog.CloseButtonText" xml:space="preserve">
<value>No</value>
</data>
<data name="DeleteDialog.PrimaryButtonText" xml:space="preserve">
<value>Yes</value>
</data>
<data name="DeleteDialogAreYouSure.Text" xml:space="preserve">
<value>Are you sure you want to delete this entry?</value>
</data>
<data name="DeleteEntryBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Delete</value>
</data>
<data name="DeleteEntryBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Delete (Delete)</value>
<comment>"Delete" between parentheses refers to the Delete keyboard key</comment>
</data>
<data name="Duplicate.Text" xml:space="preserve">
<value>Duplicate</value>
<comment>Refers to the action of duplicate an existing entry</comment>
</data>
<data name="DuplicateEntryIcon.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Duplicate entry</value>
</data>
<data name="Edit.Text" xml:space="preserve">
<value>Edit</value>
</data>
<data name="EmptyFilterResults.Text" xml:space="preserve">
<value>No filter results</value>
</data>
<data name="EmptyHosts.Text" xml:space="preserve">
<value>No entries in the hosts file</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="Entries.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Entries</value>
</data>
<data name="EntryDialog.CloseButtonText" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="FileChanged.Message" xml:space="preserve">
<value>Hosts file was modified externally.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="FileSaveError_FileInUse" xml:space="preserve">
<value>The hosts file cannot be saved because it is being used by another process.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="FileSaveError_Generic" xml:space="preserve">
<value>Unable to save the hosts file.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="FileSaveError_NotElevated" xml:space="preserve">
<value>The hosts file cannot be saved because the program isn't running as administrator.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="FileSaveError_ReadOnly" xml:space="preserve">
<value>The hosts file cannot be saved because it is read-only.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="FilterBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Filters</value>
</data>
<data name="FilterBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Filters</value>
</data>
<data name="Hosts.Header" xml:space="preserve">
<value>Hosts</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="Hosts.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Separate multiple hosts by space (e.g. server server.local). Maximum 9 hosts per entry.</value>
<comment>Do not localize "server" and "server.local"</comment>
</data>
<data name="HostsFilter.Header" xml:space="preserve">
<value>Hosts</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="MakeWritable.Content" xml:space="preserve">
<value>Make writable</value>
</data>
<data name="MoveDown.Text" xml:space="preserve">
<value>Move down</value>
</data>
<data name="MoveUp.Text" xml:space="preserve">
<value>Move up</value>
</data>
<data name="OpenHostsFileBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Open hosts file</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="OpenHostsFileBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Open hosts file</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="Ping.Text" xml:space="preserve">
<value>Ping</value>
<comment>"Ping" refers to the command-line utility, do not loc</comment>
</data>
<data name="PingIcon.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Ping response</value>
<comment>"Ping" refers to the command-line utility, do not loc</comment>
</data>
<data name="Reload.Content" xml:space="preserve">
<value>Reload</value>
</data>
<data name="SettingsBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Settings</value>
</data>
<data name="SettingsBtn.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Settings</value>
</data>
<data name="ShowOnlyDuplicates.Header" xml:space="preserve">
<value>Show only duplicates</value>
</data>
<data name="TooManyHostsTeachingTip.Subtitle" xml:space="preserve">
<value>Only 9 hosts per entry are supported. The affected entries have been split. This will take effect on next change.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="TooManyHostsTeachingTip.Title" xml:space="preserve">
<value>Entries contain too many hosts</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="EntryAddressIsInvalidWarning.Text" xml:space="preserve">
<value>Has to be a valid IPv4 or IPv6 address</value>
</data>
<data name="EntryHostsIsInvalidWarning.Text" xml:space="preserve">
<value>Has to be one or more valid host names separated by spaces</value>
</data>
<data name="UpdateBtn" xml:space="preserve">
<value>Update</value>
</data>
<data name="UpdateEntry_Title" xml:space="preserve">
<value>Update the entry</value>
</data>
<data name="WarningDialog_AcceptBtn" xml:space="preserve">
<value>Accept</value>
</data>
<data name="WarningDialog_QuitBtn" xml:space="preserve">
<value>Quit</value>
</data>
<data name="WarningDialog_Text" xml:space="preserve">
<value>Altering hosts file has direct real world impact of how this computer resolves domain names.</value>
<comment>"Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="WarningDialog_Title" xml:space="preserve">
<value>Warning</value>
</data>
<data name="WindowAdminTitle" xml:space="preserve">
<value>Administrator: Hosts File Editor</value>
<comment>Title of the window when running as administrator. "Hosts File Editor" is the name of the utility. "Hosts" refers to the system hosts file, do not loc</comment>
</data>
<data name="WindowTitle" xml:space="preserve">
<value>Hosts File Editor</value>
<comment>Title of the window when running as user. "Hosts File Editor" is the name of the utility. "Hosts" refers to the system hosts file, do not loc</comment>
</data>
</root>

View File

@@ -0,0 +1,460 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Collections;
using HostsUILib.Exceptions;
using HostsUILib.Helpers;
using HostsUILib.Models;
using HostsUILib.Settings;
using Microsoft.UI.Dispatching;
using static HostsUILib.Settings.IUserSettings;
namespace HostsUILib.ViewModels
{
public partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IHostsService _hostsService;
private readonly IUserSettings _userSettings;
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private readonly string[] _loopbackAddresses =
{
"127.0.0.1",
"::1",
"0:0:0:0:0:0:0:1",
};
private bool _readingHosts;
private bool _disposed;
private CancellationTokenSource _tokenSource;
[ObservableProperty]
private Entry _selected;
[ObservableProperty]
private bool _error;
[ObservableProperty]
private string _errorMessage;
[ObservableProperty]
private bool _isReadOnly;
[ObservableProperty]
private bool _fileChanged;
[ObservableProperty]
private string _addressFilter;
[ObservableProperty]
private string _hostsFilter;
[ObservableProperty]
private string _commentFilter;
[ObservableProperty]
private string _additionalLines;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private bool _filtered;
[ObservableProperty]
private bool _showOnlyDuplicates;
[ObservableProperty]
private bool _showSplittedEntriesTooltip;
partial void OnShowOnlyDuplicatesChanged(bool value)
{
ApplyFilters();
}
private ObservableCollection<Entry> _entries;
public AdvancedCollectionView Entries { get; set; }
public int NextId => _entries?.Count > 0 ? _entries.Max(e => e.Id) + 1 : 0;
public IUserSettings UserSettings => _userSettings;
public static MainViewModel Instance { get; set; }
private OpenSettingsFunction _openSettingsFunction;
public MainViewModel(IHostsService hostService, IUserSettings userSettings, ILogger logger, OpenSettingsFunction openSettingsFunction)
{
_hostsService = hostService;
_userSettings = userSettings;
_hostsService.FileChanged += (s, e) => _dispatcherQueue.TryEnqueue(() => FileChanged = true);
_userSettings.LoopbackDuplicatesChanged += (s, e) => ReadHosts();
LoggerInstance.Logger = logger;
_openSettingsFunction = openSettingsFunction;
}
public void Add(Entry entry)
{
entry.PropertyChanged += Entry_PropertyChanged;
_entries.Add(entry);
FindDuplicates(entry.Address, entry.SplittedHosts);
}
public void Update(int index, Entry entry)
{
var existingEntry = Entries[index] as Entry;
var oldAddress = existingEntry.Address;
var oldHosts = existingEntry.SplittedHosts;
existingEntry.Address = entry.Address;
existingEntry.Comment = entry.Comment;
existingEntry.Hosts = entry.Hosts;
existingEntry.Active = entry.Active;
FindDuplicates(oldAddress, oldHosts);
FindDuplicates(entry.Address, entry.SplittedHosts);
}
public void DeleteSelected()
{
var address = Selected.Address;
var hosts = Selected.SplittedHosts;
_entries.Remove(Selected);
FindDuplicates(address, hosts);
}
public void UpdateAdditionalLines(string lines)
{
AdditionalLines = lines;
_ = Task.Run(SaveAsync);
}
public void Move(int oldIndex, int newIndex)
{
if (Filtered)
{
return;
}
// Swap the IDs
var entry1 = _entries[oldIndex];
var entry2 = _entries[newIndex];
(entry2.Id, entry1.Id) = (entry1.Id, entry2.Id);
// Move entries in the UI
_entries.Move(oldIndex, newIndex);
}
[RelayCommand]
public void DeleteEntry(Entry entry)
{
if (entry is not null)
{
var address = entry.Address;
var hosts = entry.SplittedHosts;
_entries.Remove(entry);
FindDuplicates(address, hosts);
}
}
[RelayCommand]
public void ReadHosts()
{
if (_readingHosts)
{
return;
}
_dispatcherQueue.TryEnqueue(() =>
{
FileChanged = false;
IsLoading = true;
});
Task.Run(async () =>
{
_readingHosts = true;
var data = await _hostsService.ReadAsync();
await _dispatcherQueue.EnqueueAsync(() =>
{
ShowSplittedEntriesTooltip = data.SplittedEntries;
AdditionalLines = data.AdditionalLines;
_entries = new ObservableCollection<Entry>(data.Entries);
foreach (var e in _entries)
{
e.PropertyChanged += Entry_PropertyChanged;
}
_entries.CollectionChanged += Entries_CollectionChanged;
Entries = new AdvancedCollectionView(_entries, true);
Entries.SortDescriptions.Add(new SortDescription(nameof(Entry.Id), SortDirection.Ascending));
ApplyFilters();
OnPropertyChanged(nameof(Entries));
IsLoading = false;
});
_readingHosts = false;
_tokenSource?.Cancel();
_tokenSource = new CancellationTokenSource();
FindDuplicates(_tokenSource.Token);
});
}
[RelayCommand]
public void ApplyFilters()
{
var expressions = new List<Expression<Func<object, bool>>>(4);
if (!string.IsNullOrWhiteSpace(AddressFilter))
{
expressions.Add(e => ((Entry)e).Address.Contains(AddressFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(HostsFilter))
{
expressions.Add(e => ((Entry)e).Hosts.Contains(HostsFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(CommentFilter))
{
expressions.Add(e => ((Entry)e).Comment.Contains(CommentFilter, StringComparison.OrdinalIgnoreCase));
}
if (ShowOnlyDuplicates)
{
expressions.Add(e => ((Entry)e).Duplicate);
}
Expression<Func<object, bool>> filterExpression = null;
foreach (var e in expressions)
{
filterExpression = filterExpression == null ? e : filterExpression.And(e);
}
Filtered = filterExpression != null;
Entries.Filter = Filtered ? filterExpression.Compile().Invoke : null;
Entries.RefreshFilter();
}
[RelayCommand]
public void ClearFilters()
{
AddressFilter = null;
HostsFilter = null;
CommentFilter = null;
ShowOnlyDuplicates = false;
ApplyFilters();
}
public async Task PingSelectedAsync()
{
var selected = Selected;
selected.Ping = null;
selected.Pinging = true;
selected.Ping = await _hostsService.PingAsync(Selected.Address);
selected.Pinging = false;
}
[RelayCommand]
public void OpenSettings()
{
_openSettingsFunction();
}
[RelayCommand]
public void OpenHostsFile()
{
_hostsService.OpenHostsFile();
}
[RelayCommand]
public void OverwriteHosts()
{
_hostsService.RemoveReadOnly();
_ = Task.Run(SaveAsync);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void Entry_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (Filtered && (e.PropertyName == nameof(Entry.Hosts)
|| e.PropertyName == nameof(Entry.Address)
|| e.PropertyName == nameof(Entry.Comment)
|| e.PropertyName == nameof(Entry.Duplicate)))
{
Entries.RefreshFilter();
}
// Ping and duplicate should not trigger a file save
if (e.PropertyName == nameof(Entry.Ping)
|| e.PropertyName == nameof(Entry.Pinging)
|| e.PropertyName == nameof(Entry.Duplicate))
{
return;
}
_ = Task.Run(SaveAsync);
}
private void Entries_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
_ = Task.Run(SaveAsync);
}
private void FindDuplicates(CancellationToken cancellationToken)
{
foreach (var entry in _entries)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
if (!_userSettings.LoopbackDuplicates && _loopbackAddresses.Contains(entry.Address))
{
continue;
}
SetDuplicate(entry);
}
catch (OperationCanceledException)
{
LoggerInstance.Logger.LogInfo("FindDuplicates cancelled");
return;
}
}
}
private void FindDuplicates(string address, IEnumerable<string> hosts)
{
var entries = _entries.Where(e =>
string.Equals(e.Address, address, StringComparison.OrdinalIgnoreCase)
|| hosts.Intersect(e.SplittedHosts, StringComparer.OrdinalIgnoreCase).Any());
foreach (var entry in entries)
{
SetDuplicate(entry);
}
}
private void SetDuplicate(Entry entry)
{
if (!_userSettings.LoopbackDuplicates && _loopbackAddresses.Contains(entry.Address))
{
_dispatcherQueue.TryEnqueue(() =>
{
entry.Duplicate = false;
});
return;
}
var duplicate = false;
/*
* Duplicate are based on the following criteria:
* Entries with the same type and at least one host in common
* Entries with the same type and address, except when there is only one entry with less than 9 hosts for that type and address
*/
if (_entries.Any(e => e != entry
&& e.Type == entry.Type
&& entry.SplittedHosts.Intersect(e.SplittedHosts, StringComparer.OrdinalIgnoreCase).Any()))
{
duplicate = true;
}
else if (_entries.Any(e => e != entry
&& e.Type == entry.Type
&& string.Equals(e.Address, entry.Address, StringComparison.OrdinalIgnoreCase)))
{
duplicate = entry.SplittedHosts.Length < Consts.MaxHostsCount
&& _entries.Count(e => e.Type == entry.Type
&& string.Equals(e.Address, entry.Address, StringComparison.OrdinalIgnoreCase)
&& e.SplittedHosts.Length < Consts.MaxHostsCount) > 1;
}
_dispatcherQueue.TryEnqueue(() =>
{
entry.Duplicate = duplicate;
});
}
private async Task SaveAsync()
{
bool error = true;
string errorMessage = string.Empty;
bool isReadOnly = false;
try
{
await _hostsService.WriteAsync(AdditionalLines, _entries);
error = false;
}
catch (NotRunningElevatedException)
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
errorMessage = resourceLoader.GetString("FileSaveError_NotElevated");
}
catch (ReadOnlyHostsException)
{
isReadOnly = true;
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
errorMessage = resourceLoader.GetString("FileSaveError_ReadOnly");
}
catch (IOException ex) when ((ex.HResult & 0x0000FFFF) == 32)
{
// There are some edge cases where a big hosts file is being locked by svchost.exe https://github.com/microsoft/PowerToys/issues/28066
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
errorMessage = resourceLoader.GetString("FileSaveError_FileInUse");
}
catch (Exception ex)
{
LoggerInstance.Logger.LogError("Failed to save hosts file", ex);
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
errorMessage = resourceLoader.GetString("FileSaveError_Generic");
}
await _dispatcherQueue.EnqueueAsync(() =>
{
Error = error;
ErrorMessage = errorMessage;
IsReadOnly = isReadOnly;
});
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_hostsService?.Dispose();
_disposed = true;
}
}
}
}
}