From b2e1337d4e6350189cadf0c8b992f099695c40d4 Mon Sep 17 00:00:00 2001 From: Davide Giacometti Date: Thu, 13 Oct 2022 13:05:43 +0200 Subject: [PATCH] Hosts file editor (#20462) --- .pipelines/ESRPSigning_core.json | 5 + .../ci/templates/build-powertoys-steps.yml | 1 + PowerToys.sln | 48 +++ installer/PowerToysSetup/Product.wxs | 54 ++- .../CustomAction.cpp | 16 +- src/common/Common.UI/SettingsDeepLink.cs | 3 + src/common/logger/logger_settings.h | 2 + src/modules/Hosts/Hosts.Tests/EntryTest.cs | 83 ++++ .../Hosts/Hosts.Tests/Hosts.Tests.csproj | 30 ++ .../Hosts/Hosts.Tests/HostsServiceTest.cs | 255 ++++++++++++ .../Hosts.Tests/TestFileSystemWatcher.cs | 49 +++ .../TestFileSystemWatcherFactory.cs | 19 + src/modules/Hosts/Hosts/App.xaml | 115 ++++++ src/modules/Hosts/Hosts/App.xaml.cs | 113 ++++++ .../Hosts/Hosts/Helpers/ElevationHelper.cs | 20 + .../Hosts/Hosts/Helpers/HostsService.cs | 238 ++++++++++++ .../Hosts/Hosts/Helpers/IElevationHelper.cs | 11 + .../Hosts/Hosts/Helpers/IHostsService.cs | 26 ++ src/modules/Hosts/Hosts/Helpers/Logger.cs | 80 ++++ .../Hosts/Hosts/Helpers/NativeMethods.cs | 24 ++ .../Hosts/Hosts/Helpers/StringHelper.cs | 14 + .../Hosts/Hosts/Helpers/ValidationHelper.cs | 60 +++ src/modules/Hosts/Hosts/Hosts.csproj | 55 +++ src/modules/Hosts/Hosts/MainWindow.xaml | 37 ++ src/modules/Hosts/Hosts/MainWindow.xaml.cs | 71 ++++ src/modules/Hosts/Hosts/Models/Entry.cs | 149 +++++++ src/modules/Hosts/Hosts/Program.cs | 38 ++ .../Hosts/Hosts/Settings/IUserSettings.cs | 15 + .../Hosts/Hosts/Settings/UserSettings.cs | 81 ++++ .../Hosts/Hosts/Strings/en-us/Resources.resw | 261 +++++++++++++ .../Hosts/Hosts/ViewModels/MainViewModel.cs | 242 ++++++++++++ src/modules/Hosts/Hosts/Views/MainPage.xaml | 366 ++++++++++++++++++ .../Hosts/Hosts/Views/MainPage.xaml.cs | 185 +++++++++ src/modules/Hosts/Hosts/app.manifest | 15 + src/modules/Hosts/Hosts/icon.ico | Bin 0 -> 53114 bytes .../HostsModuleInterface.base.rc | 40 ++ .../HostsModuleInterface.vcxproj | 101 +++++ .../HostsModuleInterface.vcxproj.filters | 59 +++ .../Hosts/HostsModuleInterface/RCa04224 | Bin 0 -> 2294 bytes .../Hosts/HostsModuleInterface/Resource.resx | 124 ++++++ .../Hosts/HostsModuleInterface/dllmain.cpp | 199 ++++++++++ .../HostsModuleInterface/packages.config | 4 + .../Hosts/HostsModuleInterface/pch.cpp | 1 + src/modules/Hosts/HostsModuleInterface/pch.h | 5 + .../HostsModuleInterface/resource.base.h | 13 + .../Hosts/HostsModuleInterface/trace.cpp | 19 + .../Hosts/HostsModuleInterface/trace.h | 8 + src/runner/main.cpp | 2 +- src/runner/settings_window.cpp | 6 + src/runner/settings_window.h | 3 +- .../Settings.UI.Library/EnabledModules.cs | 16 + .../Enumerations/AdditionalLinesPosition.cs | 12 + .../Settings.UI.Library/HostsProperties.cs | 27 ++ .../Settings.UI.Library/HostsSettings.cs | 46 +++ .../ViewModels/HostsViewModel.cs | 115 ++++++ src/settings-ui/Settings.UI/App.xaml.cs | 1 + .../OOBE/Enums/PowerToysModules.cs | 1 + .../Settings.UI/OOBE/Views/OobeHosts.xaml | 28 ++ .../Settings.UI/OOBE/Views/OobeHosts.xaml.cs | 58 +++ .../Settings.UI/OOBE/Views/OobeShellPage.xaml | 7 + .../OOBE/Views/OobeShellPage.xaml.cs | 7 + .../Settings.UI/PowerToys.Settings.csproj | 7 + .../Settings.UI/Strings/en-us/Resources.resw | 68 ++++ .../Settings.UI/Views/HostsPage.xaml | 79 ++++ .../Settings.UI/Views/HostsPage.xaml.cs | 22 ++ .../Settings.UI/Views/ShellPage.xaml | 9 +- .../BugReportTool/ProcessesList.cpp | 3 +- 67 files changed, 3858 insertions(+), 13 deletions(-) create mode 100644 src/modules/Hosts/Hosts.Tests/EntryTest.cs create mode 100644 src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj create mode 100644 src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs create mode 100644 src/modules/Hosts/Hosts.Tests/TestFileSystemWatcher.cs create mode 100644 src/modules/Hosts/Hosts.Tests/TestFileSystemWatcherFactory.cs create mode 100644 src/modules/Hosts/Hosts/App.xaml create mode 100644 src/modules/Hosts/Hosts/App.xaml.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/ElevationHelper.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/HostsService.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/IElevationHelper.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/IHostsService.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/Logger.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/NativeMethods.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/StringHelper.cs create mode 100644 src/modules/Hosts/Hosts/Helpers/ValidationHelper.cs create mode 100644 src/modules/Hosts/Hosts/Hosts.csproj create mode 100644 src/modules/Hosts/Hosts/MainWindow.xaml create mode 100644 src/modules/Hosts/Hosts/MainWindow.xaml.cs create mode 100644 src/modules/Hosts/Hosts/Models/Entry.cs create mode 100644 src/modules/Hosts/Hosts/Program.cs create mode 100644 src/modules/Hosts/Hosts/Settings/IUserSettings.cs create mode 100644 src/modules/Hosts/Hosts/Settings/UserSettings.cs create mode 100644 src/modules/Hosts/Hosts/Strings/en-us/Resources.resw create mode 100644 src/modules/Hosts/Hosts/ViewModels/MainViewModel.cs create mode 100644 src/modules/Hosts/Hosts/Views/MainPage.xaml create mode 100644 src/modules/Hosts/Hosts/Views/MainPage.xaml.cs create mode 100644 src/modules/Hosts/Hosts/app.manifest create mode 100644 src/modules/Hosts/Hosts/icon.ico create mode 100644 src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.base.rc create mode 100644 src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj create mode 100644 src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj.filters create mode 100644 src/modules/Hosts/HostsModuleInterface/RCa04224 create mode 100644 src/modules/Hosts/HostsModuleInterface/Resource.resx create mode 100644 src/modules/Hosts/HostsModuleInterface/dllmain.cpp create mode 100644 src/modules/Hosts/HostsModuleInterface/packages.config create mode 100644 src/modules/Hosts/HostsModuleInterface/pch.cpp create mode 100644 src/modules/Hosts/HostsModuleInterface/pch.h create mode 100644 src/modules/Hosts/HostsModuleInterface/resource.base.h create mode 100644 src/modules/Hosts/HostsModuleInterface/trace.cpp create mode 100644 src/modules/Hosts/HostsModuleInterface/trace.h create mode 100644 src/settings-ui/Settings.UI.Library/Enumerations/AdditionalLinesPosition.cs create mode 100644 src/settings-ui/Settings.UI.Library/HostsProperties.cs create mode 100644 src/settings-ui/Settings.UI.Library/HostsSettings.cs create mode 100644 src/settings-ui/Settings.UI.Library/ViewModels/HostsViewModel.cs create mode 100644 src/settings-ui/Settings.UI/OOBE/Views/OobeHosts.xaml create mode 100644 src/settings-ui/Settings.UI/OOBE/Views/OobeHosts.xaml.cs create mode 100644 src/settings-ui/Settings.UI/Views/HostsPage.xaml create mode 100644 src/settings-ui/Settings.UI/Views/HostsPage.xaml.cs diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 9d64083237..fcd5e5c03c 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -65,6 +65,10 @@ "modules\\FileExplorerPreview\\PowerToys.SvgThumbnailProvider.dll", "modules\\FileExplorerPreview\\PowerToys.SvgThumbnailProvider.comhost.dll", + "modules\\Hosts\\PowerToys.HostsModuleInterface.dll", + "modules\\Hosts\\PowerToys.Hosts.dll", + "modules\\Hosts\\PowerToys.Hosts.exe", + "modules\\ImageResizer\\PowerToys.ImageResizer.exe", "modules\\ImageResizer\\PowerToys.ImageResizer.dll", "modules\\ImageResizer\\PowerToys.ImageResizerExt.dll", @@ -217,6 +221,7 @@ "modules\\FileExplorerPreview\\Microsoft.Web.WebView2.WinForms.dll", "modules\\FileExplorerPreview\\Microsoft.Web.WebView2.Wpf.dll", "modules\\FileExplorerPreview\\WebView2Loader.dll", + "modules\\Hosts\\Microsoft.Graphics.Canvas.Interop.dll", "modules\\launcher\\e_sqlite3.dll", "modules\\launcher\\LazyCache.dll", "modules\\launcher\\SQLitePCLRaw.batteries_v2.dll", diff --git a/.pipelines/ci/templates/build-powertoys-steps.yml b/.pipelines/ci/templates/build-powertoys-steps.yml index 8ac45cce1b..7377106c81 100644 --- a/.pipelines/ci/templates/build-powertoys-steps.yml +++ b/.pipelines/ci/templates/build-powertoys-steps.yml @@ -200,6 +200,7 @@ steps: **\PreviewPaneUnitTests.dll **\UnitTests-SvgThumbnailProvider.dll **\UnitTests-SvgPreviewHandler.dll + **\Hosts.Tests.dll !**\obj\** !**\ref\** diff --git a/PowerToys.sln b/PowerToys.sln index 831a4daa97..437de874b2 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -449,6 +449,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MeasureToolUI", "src\module EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerAccentKeyboardService", "src\modules\poweraccent\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj", "{C97D9A5D-206C-454E-997E-009E227D7F02}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hosts", "src\modules\Hosts\Hosts\Hosts.csproj", "{31D1C81D-765F-4446-AA62-E743F6325049}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hosts", "Hosts", "{F05E590D-AD46-42BE-9C25-6A63ADD2E3EA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hosts.Tests", "src\modules\Hosts\Hosts.Tests\Hosts.Tests.csproj", "{E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "HostsModuleInterface", "src\modules\Hosts\HostsModuleInterface\HostsModuleInterface.vcxproj", "{B41B888C-7DB8-4747-B262-4062E05A230D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -1804,6 +1812,42 @@ Global {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|x64.Build.0 = Release|x64 {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|x86.ActiveCfg = Release|x64 {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|x86.Build.0 = Release|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|ARM64.Build.0 = Debug|ARM64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x64.ActiveCfg = Debug|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x64.Build.0 = Debug|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x86.ActiveCfg = Debug|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x86.Build.0 = Debug|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|ARM64.ActiveCfg = Release|ARM64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|ARM64.Build.0 = Release|ARM64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x64.ActiveCfg = Release|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x64.Build.0 = Release|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x86.ActiveCfg = Release|x64 + {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x86.Build.0 = Release|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|ARM64.Build.0 = Debug|ARM64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x64.ActiveCfg = Debug|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x64.Build.0 = Debug|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x86.ActiveCfg = Debug|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x86.Build.0 = Debug|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|ARM64.ActiveCfg = Release|ARM64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|ARM64.Build.0 = Release|ARM64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x64.ActiveCfg = Release|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x64.Build.0 = Release|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x86.ActiveCfg = Release|x64 + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x86.Build.0 = Release|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|ARM64.Build.0 = Debug|ARM64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x64.ActiveCfg = Debug|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x64.Build.0 = Debug|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x86.ActiveCfg = Debug|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x86.Build.0 = Debug|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|ARM64.ActiveCfg = Release|ARM64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|ARM64.Build.0 = Release|ARM64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x64.ActiveCfg = Release|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x64.Build.0 = Release|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x86.ActiveCfg = Release|x64 + {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1956,6 +2000,10 @@ Global {92C39820-9F84-4529-BC7D-22AAE514D63B} = {7AC943C9-52E8-44CF-9083-744D8049667B} {515554D1-D004-4F7F-A107-2211FC0F6B2C} = {7AC943C9-52E8-44CF-9083-744D8049667B} {C97D9A5D-206C-454E-997E-009E227D7F02} = {0F14491C-6369-4C45-AAA8-135814E66E6B} + {31D1C81D-765F-4446-AA62-E743F6325049} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} + {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} + {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} + {B41B888C-7DB8-4747-B262-4062E05A230D} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index 76d05703f8..f85fd046b5 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -15,6 +15,7 @@ + @@ -121,13 +122,17 @@ - + + + + + @@ -543,7 +548,14 @@ - + + + + + + + + @@ -1079,6 +1091,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1227,6 +1261,13 @@ + + + + + + + @@ -1580,7 +1621,7 @@ - + @@ -1899,6 +1940,13 @@ + + + + diff --git a/installer/PowerToysSetupCustomActions/CustomAction.cpp b/installer/PowerToysSetupCustomActions/CustomAction.cpp index debffba212..b05a4bb216 100644 --- a/installer/PowerToysSetupCustomActions/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActions/CustomAction.cpp @@ -1049,7 +1049,7 @@ UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall) { HRESULT hr = S_OK; UINT er = ERROR_SUCCESS; - std::wstring installationFolder, winAppSDKFilesSrcDir, settingsDir, powerRenameDir, measureToolDir; + std::wstring installationFolder, winAppSDKFilesSrcDir, settingsDir, powerRenameDir, measureToolDir, hostsFileEditorDir; hr = WcaInitialize(hInstall, "CreateWinAppSDKHardlinksCA"); ExitOnFailure(hr, "Failed to initialize"); @@ -1058,6 +1058,7 @@ UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall) ExitOnFailure(hr, "Failed to get installation folder"); winAppSDKFilesSrcDir = installationFolder + L"dll\\WinAppSDK\\"; + hostsFileEditorDir = installationFolder + L"modules\\Hosts\\"; settingsDir = installationFolder + L"Settings\\"; powerRenameDir = installationFolder + L"modules\\PowerRename\\"; measureToolDir = installationFolder + L"modules\\MeasureTool\\"; @@ -1065,6 +1066,7 @@ UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall) for (auto file : winAppSdkFiles) { std::error_code ec; + std::filesystem::create_hard_link((winAppSDKFilesSrcDir + file).c_str(), (hostsFileEditorDir + file).c_str(), ec); std::filesystem::create_hard_link((winAppSDKFilesSrcDir + file).c_str(), (settingsDir + file).c_str(), ec); std::filesystem::create_hard_link((winAppSDKFilesSrcDir + file).c_str(), (powerRenameDir + file).c_str(), ec); std::filesystem::create_hard_link((winAppSDKFilesSrcDir + file).c_str(), (measureToolDir + file).c_str(), ec); @@ -1088,7 +1090,7 @@ UINT __stdcall CreatePTInteropHardlinksCA(MSIHANDLE hInstall) HRESULT hr = S_OK; UINT er = ERROR_SUCCESS; std::wstring installationFolder, interopFilesSrcDir, colorPickerDir, powerOCRDir, launcherDir, fancyZonesDir, - imageResizerDir, settingsDir, awakeDir, measureToolDir, powerAccentDir; + imageResizerDir, settingsDir, awakeDir, measureToolDir, powerAccentDir, hostsFileEditorDir; hr = WcaInitialize(hInstall, "CreatePTInteropHardlinksCA"); ExitOnFailure(hr, "Failed to initialize"); @@ -1101,6 +1103,7 @@ UINT __stdcall CreatePTInteropHardlinksCA(MSIHANDLE hInstall) powerOCRDir = installationFolder + L"modules\\PowerOCR\\"; launcherDir = installationFolder + L"modules\\launcher\\"; fancyZonesDir = installationFolder + L"modules\\FancyZones\\"; + hostsFileEditorDir = installationFolder + L"modules\\Hosts\\"; imageResizerDir = installationFolder + L"modules\\ImageResizer\\"; settingsDir = installationFolder + L"Settings\\"; awakeDir = installationFolder + L"modules\\Awake\\"; @@ -1114,6 +1117,7 @@ UINT __stdcall CreatePTInteropHardlinksCA(MSIHANDLE hInstall) std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (powerOCRDir + file).c_str(), ec); std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (launcherDir + file).c_str(), ec); std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (fancyZonesDir + file).c_str(), ec); + std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (hostsFileEditorDir + file).c_str(), ec); std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (imageResizerDir + file).c_str(), ec); std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (settingsDir + file).c_str(), ec); std::filesystem::create_hard_link((interopFilesSrcDir + file).c_str(), (awakeDir + file).c_str(), ec); @@ -1138,7 +1142,7 @@ UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall) { HRESULT hr = S_OK; UINT er = ERROR_SUCCESS; - std::wstring installationFolder, settingsDir, powerRenameDir, measureToolDir; + std::wstring installationFolder, settingsDir, powerRenameDir, measureToolDir, hostsFileEditorDir; hr = WcaInitialize(hInstall, "DeleteWinAppSDKHardlinksCA"); ExitOnFailure(hr, "Failed to initialize"); @@ -1146,6 +1150,7 @@ UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall) hr = getInstallFolder(hInstall, installationFolder); ExitOnFailure(hr, "Failed to get installation folder"); + hostsFileEditorDir = installationFolder + L"modules\\Hosts\\"; settingsDir = installationFolder + L"Settings\\"; powerRenameDir = installationFolder + L"modules\\PowerRename\\"; measureToolDir = installationFolder + L"modules\\MeasureTool\\"; @@ -1154,6 +1159,7 @@ UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall) { for (auto file : winAppSdkFiles) { + DeleteFile((hostsFileEditorDir + file).c_str()); DeleteFile((settingsDir + file).c_str()); DeleteFile((powerRenameDir + file).c_str()); DeleteFile((measureToolDir + file).c_str()); @@ -1178,7 +1184,7 @@ UINT __stdcall DeletePTInteropHardlinksCA(MSIHANDLE hInstall) HRESULT hr = S_OK; UINT er = ERROR_SUCCESS; std::wstring installationFolder, interopFilesSrcDir, colorPickerDir, powerOCRDir, launcherDir, fancyZonesDir, - imageResizerDir, settingsDir, awakeDir, measureToolDir, powerAccentDir; + imageResizerDir, settingsDir, awakeDir, measureToolDir, powerAccentDir, hostsFileEditorDir; hr = WcaInitialize(hInstall, "DeletePTInteropHardlinksCA"); ExitOnFailure(hr, "Failed to initialize"); @@ -1190,6 +1196,7 @@ UINT __stdcall DeletePTInteropHardlinksCA(MSIHANDLE hInstall) powerOCRDir = installationFolder + L"modules\\PowerOCR\\"; launcherDir = installationFolder + L"modules\\launcher\\"; fancyZonesDir = installationFolder + L"modules\\FancyZones\\"; + hostsFileEditorDir = installationFolder + L"modules\\Hosts\\"; imageResizerDir = installationFolder + L"modules\\ImageResizer\\"; settingsDir = installationFolder + L"Settings\\"; awakeDir = installationFolder + L"modules\\Awake\\"; @@ -1204,6 +1211,7 @@ UINT __stdcall DeletePTInteropHardlinksCA(MSIHANDLE hInstall) DeleteFile((powerOCRDir + file).c_str()); DeleteFile((launcherDir + file).c_str()); DeleteFile((fancyZonesDir + file).c_str()); + DeleteFile((hostsFileEditorDir + file).c_str()); DeleteFile((imageResizerDir + file).c_str()); DeleteFile((settingsDir + file).c_str()); DeleteFile((awakeDir + file).c_str()); diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index fce6dc0c3a..42da58b8a0 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -23,6 +23,7 @@ namespace Common.UI FileExplorer, ShortcutGuide, VideoConference, + Hosts, } private static string SettingsWindowNameToString(SettingsWindow value) @@ -53,6 +54,8 @@ namespace Common.UI return "ShortcutGuide"; case SettingsWindow.VideoConference: return "VideoConference"; + case SettingsWindow.Hosts: + return "Hosts"; default: { return string.Empty; diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index cc18f04513..d5364d47f4 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -35,6 +35,8 @@ struct LogSettings inline const static std::string alwaysOnTopLoggerName = "always-on-top"; inline const static std::string powerOcrLoggerName = "TextExtractor"; inline const static std::wstring alwaysOnTopLogPath = L"always-on-top-log.txt"; + inline const static std::string hostsLoggerName = "hosts"; + inline const static std::wstring hostsLogPath = L"Logs\\hosts-log.txt"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/modules/Hosts/Hosts.Tests/EntryTest.cs b/src/modules/Hosts/Hosts.Tests/EntryTest.cs new file mode 100644 index 0000000000..7a61fe147c --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/EntryTest.cs @@ -0,0 +1,83 @@ +// 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 Hosts.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hosts.Tests +{ + [TestClass] + public class EntryTest + { + [DataTestMethod] + [DataRow("\t\t10.1.1.1\t\thost\t\t", "10.1.1.1", "host", "", true)] + [DataRow(" 10.1.1.1 host ", "10.1.1.1", "host", "", true)] + [DataRow("10.1.1.1 host", "10.1.1.1", "host", "", true)] + [DataRow("\t\t#\t\t10.1.1.1\thost\t\t", "10.1.1.1", "host", "", false)] + [DataRow(" # 10.1.1.1 host ", "10.1.1.1", "host", "", false)] + [DataRow("#10.1.1.1 host", "10.1.1.1", "host", "", false)] + [DataRow("\t\t10.1.1.1\t\thost\t\t#\t\tcomment\t\t", "10.1.1.1", "host", "comment", true)] + [DataRow(" 10.1.1.1 host # comment ", "10.1.1.1", "host", "comment", true)] + [DataRow("10.1.1.1 host#comment", "10.1.1.1", "host", "comment", true)] + [DataRow("\t\t#\t\t10.1.1.1\thost\t\t#\t\tcomment\t\t", "10.1.1.1", "host", "comment", false)] + [DataRow(" # 10.1.1.1 host # comment ", "10.1.1.1", "host", "comment", false)] + [DataRow("#10.1.1.1 host#comment", "10.1.1.1", "host", "comment", false)] + [DataRow("# #10.1.1.1 host#comment", "10.1.1.1", "host", "comment", false)] + [DataRow("# #\t10.1.1.1 host#comment", "10.1.1.1", "host", "comment", false)] + [DataRow("# # \t10.1.1.1 host#comment", "10.1.1.1", "host", "comment", false)] + public void Valid_Entry_SingleHost(string line, string address, string host, string comment, bool active) + { + var entry = new Entry(line); + + Assert.AreEqual(entry.Address, address); + Assert.AreEqual(entry.Hosts, host); + Assert.AreEqual(entry.Comment, comment); + Assert.AreEqual(entry.Active, active); + Assert.IsTrue(entry.Valid); + } + + [DataTestMethod] + [DataRow("\t\t10.1.1.1\t\thost host.local\t\t", "10.1.1.1", "host host.local", "", true)] + [DataRow(" 10.1.1.1 host host.local ", "10.1.1.1", "host host.local", "", true)] + [DataRow("10.1.1.1 host host.local", "10.1.1.1", "host host.local", "", true)] + [DataRow("\t\t#\t\t10.1.1.1\thost\t\thost.local\t\t", "10.1.1.1", "host host.local", "", false)] + [DataRow(" # 10.1.1.1 host host.local ", "10.1.1.1", "host host.local", "", false)] + [DataRow("#10.1.1.1 host host.local", "10.1.1.1", "host host.local", "", false)] + [DataRow("\t\t10.1.1.1\t\thost\t\thost.local\t\t#\t\tcomment\t\t", "10.1.1.1", "host host.local", "comment", true)] + [DataRow(" 10.1.1.1 host host.local # comment ", "10.1.1.1", "host host.local", "comment", true)] + [DataRow("10.1.1.1 host host.local#comment", "10.1.1.1", "host host.local", "comment", true)] + [DataRow("\t\t#\t\t10.1.1.1\thost\t\thost.local\t\t#\t\tcomment\t\t", "10.1.1.1", "host host.local", "comment", false)] + [DataRow(" # 10.1.1.1 host host.local # comment ", "10.1.1.1", "host host.local", "comment", false)] + [DataRow("#10.1.1.1 host host.local#comment", "10.1.1.1", "host host.local", "comment", false)] + public void Valid_Entry_MultipleHosts(string line, string address, string host, string comment, bool active) + { + var entry = new Entry(line); + + Assert.AreEqual(entry.Address, address); + Assert.AreEqual(entry.Hosts, host); + Assert.AreEqual(entry.Comment, comment); + Assert.AreEqual(entry.Active, active); + Assert.IsTrue(entry.Valid); + } + + [DataTestMethod] + [DataRow("\t\t10.1.1.1\t\t")] + [DataRow(" 10.1.1.1 ")] + [DataRow("10.1.1.1")] + [DataRow("\t\thost\t\t")] + [DataRow(" host ")] + [DataRow("host")] + [DataRow("\t\t10\t\thost")] + [DataRow(" 10 host ")] + [DataRow("10 host")] + [DataRow("\t\thost\t\t10.1.1.1")] + [DataRow(" host 10.1.1.1")] + [DataRow("host 10.1.1.1")] + public void Not_Valid_Entry(string line) + { + var entry = new Entry(line); + Assert.IsFalse(entry.Valid); + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj b/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj new file mode 100644 index 0000000000..4611541e11 --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj @@ -0,0 +1,30 @@ + + + + + net6.0-windows10.0.19041.0 + win10-x64;win10-arm64 + false + false + false + $(SolutionDir)$(Platform)\$(Configuration)\modules\Hosts\Hosts.Tests\ + $(SolutionDir)$(Platform)\$(Configuration)\obj\$(AssemblyName)\ + Hosts.Tests + PowerToys.Hosts.Tests + + + + + + + + + + + + + + + + + diff --git a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs new file mode 100644 index 0000000000..b3d032ca29 --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs @@ -0,0 +1,255 @@ +// 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.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using Hosts.Helpers; +using Hosts.Models; +using Hosts.Settings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Settings.UI.Library.Enumerations; + +namespace Hosts.Tests +{ + [TestClass] + public class HostsServiceTest + { + private static Mock _elevationHelper; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _elevationHelper = new Mock(); + _elevationHelper.Setup(m => m.IsElevated).Returns(true); + } + + [TestMethod] + public void Hosts_Exists() + { + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); + var result = service.Exists(); + + Assert.IsTrue(result); + } + + [TestMethod] + public void Hosts_Not_Exists() + { + var fileSystem = new MockFileSystem(new Dictionary + { + }) + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var result = service.Exists(); + + Assert.IsFalse(result); + } + + [TestMethod] + public async Task Host_Added() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var contentResult = +@" 10.1.1.1 host host.local # comment + 10.1.1.2 host2 host2.local # another comment +# 10.1.1.30 host30 host30.local # new entry +"; + + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var (_, entries) = await service.ReadAsync(); + entries.Add(new Entry("10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(string.Empty, entries); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, contentResult); + } + + [TestMethod] + public async Task Host_Deleted() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var contentResult = +@"10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var (_, entries) = await service.ReadAsync(); + entries.RemoveAt(0); + await service.WriteAsync(string.Empty, entries); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, contentResult); + } + + [TestMethod] + public async Task Host_Updated() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var contentResult = +@"# 10.1.1.10 host host.local host1.local # updated comment + 10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var (_, entries) = await service.ReadAsync(); + var entry = entries[0]; + entry.Address = "10.1.1.10"; + entry.Hosts = "host host.local host1.local"; + entry.Comment = "updated comment"; + entry.Active = false; + await service.WriteAsync(string.Empty, entries); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, contentResult); + } + + [TestMethod] + public async Task Empty_Hosts() + { + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); + + await service.WriteAsync(string.Empty, Enumerable.Empty()); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, string.Empty); + } + + [TestMethod] + public async Task AdditionalLines_Top() + { + var content = +@"# header +10.1.1.1 host host.local # comment +# comment +10.1.1.2 host2 host2.local # another comment +# footer +"; + + var contentResult = +@"# header +# comment +# footer +10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + userSettings.Setup(m => m.AdditionalLinesPosition).Returns(AdditionalLinesPosition.Top); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var (additionalLines, entries) = await service.ReadAsync(); + await service.WriteAsync(additionalLines, entries); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, contentResult); + } + + [TestMethod] + public async Task AdditionalLines_Bottom() + { + var content = +@"# header +10.1.1.1 host host.local # comment +# comment +10.1.1.2 host2 host2.local # another comment +# footer +"; + + var contentResult = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +# header +# comment +# footer +"; + + var fileSystem = new MockFileSystem + { + FileSystemWatcher = new TestFileSystemWatcherFactory(), + }; + + var userSettings = new Mock(); + userSettings.Setup(m => m.AdditionalLinesPosition).Returns(AdditionalLinesPosition.Bottom); + + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var (additionalLines, entries) = await service.ReadAsync(); + await service.WriteAsync(additionalLines, entries); + + var result = fileSystem.GetFile(service.HostsFilePath); + Assert.AreEqual(result.TextContents, contentResult); + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcher.cs b/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcher.cs new file mode 100644 index 0000000000..050c931cfa --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcher.cs @@ -0,0 +1,49 @@ +// 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.ComponentModel; +using System.IO; +using System.IO.Abstractions; + +namespace Hosts.Tests +{ + public class TestFileSystemWatcher : FileSystemWatcherBase + { + public override bool IncludeSubdirectories { get; set; } + + public override bool EnableRaisingEvents { get; set; } + + public override string Filter { get; set; } + + public override int InternalBufferSize { get; set; } + + public override NotifyFilters NotifyFilter { get; set; } + + public override string Path { get; set; } + + public override ISite Site { get; set; } + + public override ISynchronizeInvoke SynchronizingObject { get; set; } + + public override WaitForChangedResult WaitForChanged(WatcherChangeTypes changeType) => default(WaitForChangedResult); + + public override WaitForChangedResult WaitForChanged(WatcherChangeTypes changeType, int timeout) => default(WaitForChangedResult); + + public TestFileSystemWatcher(string path) => Path = path; + + public TestFileSystemWatcher(string path, string filter) + { + Path = path; + Filter = filter; + } + + public override void BeginInit() + { + } + + public override void EndInit() + { + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcherFactory.cs b/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcherFactory.cs new file mode 100644 index 0000000000..cb62129cfc --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/TestFileSystemWatcherFactory.cs @@ -0,0 +1,19 @@ +// 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.IO.Abstractions; + +namespace Hosts.Tests +{ + public class TestFileSystemWatcherFactory : IFileSystemWatcherFactory + { + public IFileSystemWatcher CreateNew() => new TestFileSystemWatcher(null); + + public IFileSystemWatcher CreateNew(string path) => new TestFileSystemWatcher(path); + + public IFileSystemWatcher CreateNew(string path, string filter) => new TestFileSystemWatcher(path, filter); + + public IFileSystemWatcher FromPath(string path) => new TestFileSystemWatcher(path); + } +} diff --git a/src/modules/Hosts/Hosts/App.xaml b/src/modules/Hosts/Hosts/App.xaml new file mode 100644 index 0000000000..d4fa24303e --- /dev/null +++ b/src/modules/Hosts/Hosts/App.xaml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/Hosts/Hosts/App.xaml.cs b/src/modules/Hosts/Hosts/App.xaml.cs new file mode 100644 index 0000000000..9bd942db23 --- /dev/null +++ b/src/modules/Hosts/Hosts/App.xaml.cs @@ -0,0 +1,113 @@ +// 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.IO.Abstractions; +using System.Threading; +using Hosts.Helpers; +using Hosts.Settings; +using Hosts.ViewModels; +using Hosts.Views; +using ManagedCommon; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace Hosts +{ + public partial class App : Application + { + private Window _window; + + public IHost Host + { + get; + } + + public static T GetService() + where T : class + { + if ((App.Current as App)!.Host.Services.GetService(typeof(T)) is not T service) + { + throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices within App.xaml.cs."); + } + + return service; + } + + public App() + { + InitializeComponent(); + + Host = Microsoft.Extensions.Hosting.Host. + CreateDefaultBuilder(). + UseContentRoot(AppContext.BaseDirectory). + ConfigureServices((context, services) => + { + // Core Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Views and ViewModels + services.AddTransient(); + services.AddTransient(); + }). + Build(); + + UnhandledException += App_UnhandledException; + + new Thread(() => + { + // Delete old backups only if running elevated + if (!GetService().IsElevated) + { + return; + } + + try + { + GetService().CleanupBackup(); + } + catch (Exception ex) + { + Logger.LogError("Failed to delete backup", ex); + } + }).Start(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var cmdArgs = Environment.GetCommandLineArgs(); + if (cmdArgs?.Length > 1) + { + if (int.TryParse(cmdArgs[cmdArgs.Length - 1], out int powerToysRunnerPid)) + { + Logger.LogInfo($"Hosts started from the PowerToys Runner. Runner pid={powerToysRunnerPid}"); + + var dispatcher = DispatcherQueue.GetForCurrentThread(); + RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => + { + Logger.LogInfo("PowerToys Runner exited. Exiting Hosts"); + dispatcher.TryEnqueue(App.Current.Exit); + }); + } + } + else + { + Logger.LogInfo($"Hosts started detached from PowerToys Runner."); + } + + _window = new MainWindow(); + _window.Activate(); + } + + private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) + { + Logger.LogError("Unhandled exception", e.Exception); + } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/ElevationHelper.cs b/src/modules/Hosts/Hosts/Helpers/ElevationHelper.cs new file mode 100644 index 0000000000..306ba02bea --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/ElevationHelper.cs @@ -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 Hosts.Helpers +{ + public class ElevationHelper : IElevationHelper + { + private readonly bool _isElevated; + + public bool IsElevated => _isElevated; + + public ElevationHelper() + { + _isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/HostsService.cs b/src/modules/Hosts/Hosts/Helpers/HostsService.cs new file mode 100644 index 0000000000..e7b6eb1ca9 --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/HostsService.cs @@ -0,0 +1,238 @@ +// 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.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 Hosts.Models; +using Hosts.Settings; +using Settings.UI.Library.Enumerations; + +namespace Hosts.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 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<(string Unparsed, List Entries)> ReadAsync() + { + var entries = new List(); + var unparsedBuilder = new StringBuilder(); + + if (!Exists()) + { + return (unparsedBuilder.ToString(), entries); + } + + var lines = await _fileSystem.File.ReadAllLinesAsync(HostsFilePath); + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var entry = new Entry(line); + + if (entry.Valid) + { + entries.Add(entry); + } + else + { + if (unparsedBuilder.Length > 0) + { + unparsedBuilder.Append(Environment.NewLine); + } + + unparsedBuilder.Append(line); + } + } + + return (unparsedBuilder.ToString(), entries); + } + + public async Task WriteAsync(string additionalLines, IEnumerable entries) + { + if (!_elevationHelper.IsElevated) + { + return false; + } + + var lines = new List(); + + 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.GetLine()); + } + 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 == AdditionalLinesPosition.Top) + { + lines.Insert(0, additionalLines); + } + else if (_userSettings.AdditionalLinesPosition == AdditionalLinesPosition.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); + } + catch (Exception ex) + { + Logger.LogError("Failed to write hosts file", ex); + return false; + } + finally + { + _fileSystemWatcher.EnableRaisingEvents = true; + _asyncLock.Release(); + } + + return true; + } + + public async Task 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 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; + } + } + } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/IElevationHelper.cs b/src/modules/Hosts/Hosts/Helpers/IElevationHelper.cs new file mode 100644 index 0000000000..122949e6a2 --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/IElevationHelper.cs @@ -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 Hosts.Helpers +{ + public interface IElevationHelper + { + bool IsElevated { get; } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/IHostsService.cs b/src/modules/Hosts/Hosts/Helpers/IHostsService.cs new file mode 100644 index 0000000000..06e1c6017a --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/IHostsService.cs @@ -0,0 +1,26 @@ +// 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 Hosts.Models; + +namespace Hosts.Helpers +{ + public interface IHostsService : IDisposable + { + string HostsFilePath { get; } + + event EventHandler FileChanged; + + Task<(string Unparsed, List Entries)> ReadAsync(); + + Task WriteAsync(string additionalLines, IEnumerable entries); + + Task PingAsync(string address); + + void CleanupBackup(); + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/Logger.cs b/src/modules/Hosts/Hosts/Helpers/Logger.cs new file mode 100644 index 0000000000..8a4cd558ce --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/Logger.cs @@ -0,0 +1,80 @@ +// 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.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Abstractions; +using interop; + +namespace Hosts.Helpers +{ + // TODO: use centralized logger https://github.com/microsoft/PowerToys/issues/19650 + public static class Logger + { + private static readonly IFileSystem _fileSystem = new FileSystem(); + private static readonly string ApplicationLogPath = Path.Combine(Constants.AppDataPath(), "Hosts\\Logs"); + + static Logger() + { + if (!_fileSystem.Directory.Exists(ApplicationLogPath)) + { + _fileSystem.Directory.CreateDirectory(ApplicationLogPath); + } + + // Using InvariantCulture since this is used for a log file name + var logFilePath = _fileSystem.Path.Combine(ApplicationLogPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".txt"); + + Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); + + Trace.AutoFlush = true; + } + + public static void LogError(string message) + { + Log(message, "ERROR"); + } + + public static void LogError(string message, Exception ex) + { + Log( + message + Environment.NewLine + + ex?.Message + Environment.NewLine + + "Inner exception: " + Environment.NewLine + + ex?.InnerException?.Message + Environment.NewLine + + "Stack trace: " + Environment.NewLine + + ex?.StackTrace, + "ERROR"); + } + + public static void LogWarning(string message) + { + Log(message, "WARNING"); + } + + public static void LogInfo(string message) + { + Log(message, "INFO"); + } + + private static void Log(string message, string type) + { + Trace.WriteLine(type + ": " + DateTime.Now.TimeOfDay); + Trace.Indent(); + Trace.WriteLine(GetCallerInfo()); + Trace.WriteLine(message); + Trace.Unindent(); + } + + private static string GetCallerInfo() + { + StackTrace stackTrace = new StackTrace(); + + var methodName = stackTrace.GetFrame(3)?.GetMethod(); + var className = methodName?.DeclaringType.Name; + return "[Method]: " + methodName?.Name + " [Class]: " + className; + } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/NativeMethods.cs b/src/modules/Hosts/Hosts/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..e35884560c --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/NativeMethods.cs @@ -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. + +namespace Hosts.Helpers +{ + using System; + using System.Runtime.InteropServices; + + internal class NativeMethods + { + [DllImport("user32.dll", SetLastError = true)] + internal static extern IntPtr SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + internal static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr processId); + + [DllImport("user32.dll")] + internal static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/StringHelper.cs b/src/modules/Hosts/Hosts/Helpers/StringHelper.cs new file mode 100644 index 0000000000..56992d54e5 --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/StringHelper.cs @@ -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 Hosts.Helpers +{ + public static class StringHelper + { + public static string GetHostsWithComment(string hosts, string comment) + { + return string.IsNullOrWhiteSpace(comment) ? hosts : string.Concat(hosts, " - ", comment); + } + } +} diff --git a/src/modules/Hosts/Hosts/Helpers/ValidationHelper.cs b/src/modules/Hosts/Hosts/Helpers/ValidationHelper.cs new file mode 100644 index 0000000000..d93c9438ef --- /dev/null +++ b/src/modules/Hosts/Hosts/Helpers/ValidationHelper.cs @@ -0,0 +1,60 @@ +// 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.Text.RegularExpressions; + +namespace Hosts.Helpers +{ + public static class ValidationHelper + { + /// + /// Determines whether the address is a valid IPv4 + /// + 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); + } + + /// + /// Determines whether the address is a valid IPv6 + /// + 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); + } + + /// + /// Determines whether the hosts are valid + /// + public static bool ValidHosts(string hosts) + { + if (string.IsNullOrWhiteSpace(hosts)) + { + return false; + } + + foreach (var host in hosts.Split(' ')) + { + if (System.Uri.CheckHostName(host) == System.UriHostNameType.Unknown) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/modules/Hosts/Hosts/Hosts.csproj b/src/modules/Hosts/Hosts/Hosts.csproj new file mode 100644 index 0000000000..7a45f9450a --- /dev/null +++ b/src/modules/Hosts/Hosts/Hosts.csproj @@ -0,0 +1,55 @@ + + + + + WinExe + net6.0-windows10.0.19041.0 + 10.0.17763.0 + Hosts + app.manifest + win10-x64;win10-arm64 + true + true + None + false + false + true + $(SolutionDir)$(Platform)\$(Configuration)\modules\$(AssemblyName) + $(SolutionDir)$(Platform)\$(Configuration)\obj\$(AssemblyName) + Hosts + PowerToys.Hosts + DISABLE_XAML_GENERATED_MAIN,TRACE + icon.ico + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MSBuild:Compile + + + diff --git a/src/modules/Hosts/Hosts/MainWindow.xaml b/src/modules/Hosts/Hosts/MainWindow.xaml new file mode 100644 index 0000000000..e4583f1956 --- /dev/null +++ b/src/modules/Hosts/Hosts/MainWindow.xaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/modules/Hosts/Hosts/MainWindow.xaml.cs b/src/modules/Hosts/Hosts/MainWindow.xaml.cs new file mode 100644 index 0000000000..3a1cb13803 --- /dev/null +++ b/src/modules/Hosts/Hosts/MainWindow.xaml.cs @@ -0,0 +1,71 @@ +// 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 Hosts.Helpers; +using ManagedCommon; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using WinUIEx; + +namespace Hosts +{ + public sealed partial class MainWindow : WindowEx + { + public MainWindow() + { + InitializeComponent(); + + if (AppWindowTitleBar.IsCustomizationSupported()) + { + SetTitleBar(); + } + else + { + titleBar.Visibility = Visibility.Collapsed; + + // Set window icon + var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd); + AppWindow appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.SetIcon("icon.ico"); + + if (ThemeHelpers.GetAppTheme() == AppTheme.Dark) + { + ThemeHelpers.SetImmersiveDarkMode(hWnd, true); + } + } + + BringToForeground(); + } + + private void SetTitleBar() + { + AppWindow window = this.GetAppWindow(); + window.TitleBar.ExtendsContentIntoTitleBar = true; + window.TitleBar.ButtonBackgroundColor = Colors.Transparent; + SetTitleBar(titleBar); + } + + private void BringToForeground() + { + var handle = this.GetWindowHandle(); + var fgHandle = NativeMethods.GetForegroundWindow(); + + var threadId1 = NativeMethods.GetWindowThreadProcessId(handle, System.IntPtr.Zero); + var threadId2 = NativeMethods.GetWindowThreadProcessId(fgHandle, System.IntPtr.Zero); + + if (threadId1 != threadId2) + { + NativeMethods.AttachThreadInput(threadId1, threadId2, true); + NativeMethods.SetForegroundWindow(handle); + NativeMethods.AttachThreadInput(threadId1, threadId2, false); + } + else + { + NativeMethods.SetForegroundWindow(handle); + } + } + } +} diff --git a/src/modules/Hosts/Hosts/Models/Entry.cs b/src/modules/Hosts/Hosts/Models/Entry.cs new file mode 100644 index 0000000000..0c1634ea27 --- /dev/null +++ b/src/modules/Hosts/Hosts/Models/Entry.cs @@ -0,0 +1,149 @@ +// 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 Hosts.Helpers; + +namespace Hosts.Models +{ + public partial class Entry : ObservableObject + { + private string _line; + + private string _address; + + public string Address + { + get => _address; + set + { + SetProperty(ref _address, value); + OnPropertyChanged(nameof(Valid)); + } + } + + private string _hosts; + + public string Hosts + { + get => _hosts; + set + { + SetProperty(ref _hosts, value); + OnPropertyChanged(nameof(Valid)); + } + } + + [ObservableProperty] + private string _comment; + + [ObservableProperty] + private bool _active; + + [ObservableProperty] + private bool? _ping; + + [ObservableProperty] + private bool _pinging; + + public bool Valid => ValidationHelper.ValidHosts(_hosts) && (ValidationHelper.ValidIPv4(_address) || ValidationHelper.ValidIPv6(_address)); + + public Entry() + { + } + + public Entry(string line) + { + _line = line.Trim(); + Parse(); + } + + public Entry(string address, string hosts, string comment, bool active) + { + 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(' ', '\t'); + var hostsBuilder = new StringBuilder(); + var commentBuilder = new StringBuilder(); + + for (var i = 0; i < addressHostSplit.Length; i++) + { + var element = addressHostSplit[i].Trim(); + + if (string.IsNullOrWhiteSpace(element)) + { + continue; + } + + if (Address == null) + { + if (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, + }; + } + + public string GetLine() + { + return _line; + } + } +} diff --git a/src/modules/Hosts/Hosts/Program.cs b/src/modules/Hosts/Hosts/Program.cs new file mode 100644 index 0000000000..1754261d51 --- /dev/null +++ b/src/modules/Hosts/Hosts/Program.cs @@ -0,0 +1,38 @@ +// 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; +using Hosts.Helpers; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.AppLifecycle; + +namespace Hosts +{ + public static class Program + { + [STAThread] + public static void Main(string[] args) + { + WinRT.ComWrappersSupport.InitializeComWrappers(); + var instanceKey = AppInstance.FindOrRegisterForKey("PowerToys_Hosts_Instance"); + + if (instanceKey.IsCurrent) + { + Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _ = new App(); + }); + } + else + { + Logger.LogWarning("Another instance of Hosts running. Exiting Hosts"); + } + + return; + } + } +} diff --git a/src/modules/Hosts/Hosts/Settings/IUserSettings.cs b/src/modules/Hosts/Hosts/Settings/IUserSettings.cs new file mode 100644 index 0000000000..5278d60a95 --- /dev/null +++ b/src/modules/Hosts/Hosts/Settings/IUserSettings.cs @@ -0,0 +1,15 @@ +// 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 Settings.UI.Library.Enumerations; + +namespace Hosts.Settings +{ + public interface IUserSettings + { + public bool ShowStartupWarning { get; } + + public AdditionalLinesPosition AdditionalLinesPosition { get; } + } +} diff --git a/src/modules/Hosts/Hosts/Settings/UserSettings.cs b/src/modules/Hosts/Hosts/Settings/UserSettings.cs new file mode 100644 index 0000000000..26a8e931e0 --- /dev/null +++ b/src/modules/Hosts/Hosts/Settings/UserSettings.cs @@ -0,0 +1,81 @@ +// 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.IO.Abstractions; +using System.Threading; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Settings.UI.Library.Enumerations; + +namespace Hosts.Settings +{ + public class UserSettings : IUserSettings + { + private const string HostsModuleName = "Hosts"; + private const int MaxNumberOfRetry = 5; + + private readonly ISettingsUtils _settingsUtils; + private readonly IFileSystemWatcher _watcher; + private readonly object _loadingSettingsLock = new object(); + + public bool ShowStartupWarning { get; private set; } + + public AdditionalLinesPosition AdditionalLinesPosition { get; private set; } + + public UserSettings() + { + _settingsUtils = new SettingsUtils(); + ShowStartupWarning = true; + AdditionalLinesPosition = AdditionalLinesPosition.Top; + + LoadSettingsFromJson(); + + _watcher = Helper.GetFileWatcher(HostsModuleName, "settings.json", () => LoadSettingsFromJson()); + } + + private void LoadSettingsFromJson() + { + lock (_loadingSettingsLock) + { + var retry = true; + var retryCount = 0; + + while (retry) + { + try + { + retryCount++; + + if (!_settingsUtils.SettingsExists(HostsModuleName)) + { + Logger.LogInfo("Hosts settings.json was missing, creating a new one"); + var defaultSettings = new HostsSettings(); + defaultSettings.Save(_settingsUtils); + } + + var settings = _settingsUtils.GetSettingsOrDefault(HostsModuleName); + if (settings != null) + { + ShowStartupWarning = settings.Properties.ShowStartupWarning; + AdditionalLinesPosition = settings.Properties.AdditionalLinesPosition; + } + + retry = false; + } + catch (Exception ex) + { + if (retryCount > MaxNumberOfRetry) + { + retry = false; + } + + Logger.LogError("Failed to read changed settings", ex); + Thread.Sleep(500); + } + } + } + } + } +} diff --git a/src/modules/Hosts/Hosts/Strings/en-us/Resources.resw b/src/modules/Hosts/Hosts/Strings/en-us/Resources.resw new file mode 100644 index 0000000000..5a057a99ce --- /dev/null +++ b/src/modules/Hosts/Hosts/Strings/en-us/Resources.resw @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Active + + + Active + + + Add + + + New entry + + + New entry + + + Additional lines + + + Additional lines + + + Cancel + + + Save + + + Additional lines + + + Add new entry + + + Address + "Address" refers to the IP address of the entry + + + Address + "Address" refers to the IP address of the entry + + + Clear filters + + + Clear filters + + + Comment + "Comment" refers to the comment of the entry + + + Comment + "Comment" refers to the comment of the entry + + + Delete + + + No + + + Yes + + + Are you sure you want to delete this entry? + + + Entries + + + Cancel + + + Hosts file has changed by another application. Do you want to reload it? + "Hosts" refers to the system hosts file, do not loc + + + Failed to save hosts file. + "Hosts" refers to the system hosts file, do not loc + + + Filters + + + Filters + + + Hosts + "Hosts" refers to the system hosts file, do not loc + + + Seperate multiple hosts by space (e.g. server server.local). + Do not localize "server" and "server.local" + + + Hosts + "Hosts" refers to the system hosts file, do not loc + + + Move down + + + Move up + + + Ping + "Ping" refers to the command-line utility, do not loc + + + Reload + + + Settings + + + Settings + + + Update + + + Update the entry + + + Accept + + + Quit + + + Altering hosts file has direct real world impact of how this computer resolves domain names. + "Hosts" refers to the system hosts file, do not loc + + + Warning + + + Hosts File Editor + "Hosts File Editor" is the name of the utility. "Hosts" refers to the system hosts file, do not loc + + \ No newline at end of file diff --git a/src/modules/Hosts/Hosts/ViewModels/MainViewModel.cs b/src/modules/Hosts/Hosts/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..6b59ab4779 --- /dev/null +++ b/src/modules/Hosts/Hosts/ViewModels/MainViewModel.cs @@ -0,0 +1,242 @@ +// 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.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using Common.UI; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.WinUI; +using Hosts.Helpers; +using Hosts.Models; +using Hosts.Settings; +using Microsoft.UI.Dispatching; + +namespace Hosts.ViewModels +{ + public partial class MainViewModel : ObservableObject, IDisposable + { + private readonly IHostsService _hostsService; + private readonly IUserSettings _userSettings; + private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + private bool _disposed; + + [ObservableProperty] + private Entry _selected; + + [ObservableProperty] + private bool _error; + + [ObservableProperty] + private bool _fileChanged; + + [ObservableProperty] + private string _addressFilter; + + [ObservableProperty] + private string _hostsFilter; + + [ObservableProperty] + private string _commentFilter; + + [ObservableProperty] + private bool _filtered; + + [ObservableProperty] + private string _additionalLines; + + private ObservableCollection _entries; + + public ObservableCollection Entries + { + get + { + if (_filtered) + { + var filter = _entries.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(_addressFilter)) + { + filter = filter.Where(e => e.Address.Contains(_addressFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(_hostsFilter)) + { + filter = filter.Where(e => e.Hosts.Contains(_hostsFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(_commentFilter)) + { + filter = filter.Where(e => e.Comment.Contains(_commentFilter, StringComparison.OrdinalIgnoreCase)); + } + + return new ObservableCollection(filter); + } + else + { + return _entries; + } + } + + set + { + _entries = value; + OnPropertyChanged(nameof(Entries)); + } + } + + public ICommand ReadHostsCommand => new RelayCommand(ReadHosts); + + public ICommand ApplyFiltersCommand => new RelayCommand(ApplyFilters); + + public ICommand ClearFiltersCommand => new RelayCommand(ClearFilters); + + public ICommand OpenSettingsCommand => new RelayCommand(OpenSettings); + + public MainViewModel( + IHostsService hostService, + IUserSettings userSettings) + { + _hostsService = hostService; + _userSettings = userSettings; + + _hostsService.FileChanged += (s, e) => + { + _dispatcherQueue.TryEnqueue(() => FileChanged = true); + }; + } + + public void Add(Entry entry) + { + entry.PropertyChanged += Entry_PropertyChanged; + _entries.Add(entry); + } + + public void Update(int index, Entry entry) + { + var existingEntry = _entries.ElementAt(index); + existingEntry.Address = entry.Address; + existingEntry.Comment = entry.Comment; + existingEntry.Hosts = entry.Hosts; + existingEntry.Active = entry.Active; + } + + public void DeleteSelected() + { + _entries.Remove(Selected); + if (Filtered) + { + OnPropertyChanged(nameof(Entries)); + } + } + + public void UpdateAdditionalLines(string lines) + { + _additionalLines = lines; + + Task.Run(async () => + { + var error = !await _hostsService.WriteAsync(_additionalLines, _entries); + await _dispatcherQueue.EnqueueAsync(() => Error = error); + }); + } + + public void ReadHosts() + { + FileChanged = false; + + Task.Run(async () => + { + (_additionalLines, var entries) = await _hostsService.ReadAsync(); + + await _dispatcherQueue.EnqueueAsync(() => + { + Entries = new ObservableCollection(entries); + + foreach (var e in _entries) + { + e.PropertyChanged += Entry_PropertyChanged; + } + + _entries.CollectionChanged += Entries_CollectionChanged; + }); + }); + } + + public void ApplyFilters() + { + if (_entries != null) + { + Filtered = !string.IsNullOrWhiteSpace(_addressFilter) || !string.IsNullOrWhiteSpace(_hostsFilter) || !string.IsNullOrWhiteSpace(_commentFilter); + OnPropertyChanged(nameof(Entries)); + } + } + + public void ClearFilters() + { + AddressFilter = null; + HostsFilter = null; + CommentFilter = null; + } + + public async Task PingSelectedAsync() + { + var selected = _selected; + selected.Ping = null; + selected.Pinging = true; + selected.Ping = await _hostsService.PingAsync(_selected.Address); + selected.Pinging = false; + } + + public void OpenSettings() + { + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.Hosts); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void Entry_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // Ping should't trigger a file save + if (e.PropertyName == nameof(Entry.Ping) || e.PropertyName == nameof(Entry.Pinging)) + { + return; + } + + Task.Run(async () => + { + var error = !await _hostsService.WriteAsync(_additionalLines, _entries); + await _dispatcherQueue.EnqueueAsync(() => Error = error); + }); + } + + private void Entries_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + Task.Run(async () => + { + var error = !await _hostsService.WriteAsync(_additionalLines, _entries); + await _dispatcherQueue.EnqueueAsync(() => Error = error); + }); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _hostsService?.Dispose(); + _disposed = true; + } + } + } + } +} diff --git a/src/modules/Hosts/Hosts/Views/MainPage.xaml b/src/modules/Hosts/Hosts/Views/MainPage.xaml new file mode 100644 index 0000000000..7a4a200f42 --- /dev/null +++ b/src/modules/Hosts/Hosts/Views/MainPage.xaml @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/Views/HostsPage.xaml.cs new file mode 100644 index 0000000000..7ddd63592e --- /dev/null +++ b/src/settings-ui/Settings.UI/Views/HostsPage.xaml.cs @@ -0,0 +1,22 @@ +// 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.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Settings.UI.Library.ViewModels; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class HostsPage : Page + { + private HostsViewModel ViewModel { get; } + + public HostsPage() + { + InitializeComponent(); + var settingsUtils = new SettingsUtils(); + ViewModel = new HostsViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + } + } +} diff --git a/src/settings-ui/Settings.UI/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/Views/ShellPage.xaml index 6637e5f7f7..79379d9081 100644 --- a/src/settings-ui/Settings.UI/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/Views/ShellPage.xaml @@ -76,6 +76,12 @@ + + + + + + @@ -137,9 +143,6 @@ - - - diff --git a/tools/BugReportTool/BugReportTool/ProcessesList.cpp b/tools/BugReportTool/BugReportTool/ProcessesList.cpp index cedab1652b..93a618a393 100644 --- a/tools/BugReportTool/BugReportTool/ProcessesList.cpp +++ b/tools/BugReportTool/BugReportTool/ProcessesList.cpp @@ -20,5 +20,6 @@ std::vector processes = L"PowerToys.ImageResizer.exe", L"PowerToys.Update.exe", L"PowerToys.ActionRunner.exe", - L"PowerToys.AlwaysOnTop.exe" + L"PowerToys.AlwaysOnTop.exe", + L"PowerToys.Hosts.exe" };