From fa5eb3a49ddab4c9af14c810743e046c90390874 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 13 Oct 2026 23:45:05 +0800 Subject: [PATCH] feat(app): require WinFsp 2.2.26286 and offer to install it at startup Bump WinFsp to 2026 Beta5 (2.2.26286) in the NuGet package, installer and CI. At startup the app now compares the installed winfsp-msil.dll with the required version. A missing runtime or an older one offers to install the MSI: it is taken from the portable ZIP's prerequisites folder or downloaded, verified against a pinned SHA256 through a handle kept open during the run, then executed elevated with msiexec. A newer WinFsp is accepted, and a declined or failed update of an older one is remembered per installed and required version. Only a missing runtime is fatal. The Inno Setup installer now replaces an older WinFsp with its bundled MSI, and the portable ZIP carries the MSI. --- .github/workflows/ci.yml | 46 ++- CLAUDE.md | 2 +- Directory.Packages.props | 2 +- README.md | 4 +- docs/DEVELOPMENT.md | 8 +- docs/THIRD-PARTY-NOTICES.md | 4 +- installer/ManagedDrive.iss | 16 +- src/ManagedDrive.App/App.xaml.cs | 94 ++++++- .../Localization/Strings.en-US.xaml | 7 +- .../Localization/Strings.zh-CN.xaml | 7 +- .../Models/AppConfiguration.cs | 10 + .../Services/WinFspInstaller.cs | 262 ++++++++++++++++++ .../Services/WinFspPrerequisite.cs | 137 ++++++++- .../Services/WinFspInstallerTests.cs | 234 ++++++++++++++++ .../Services/WinFspPrerequisiteTests.cs | 60 ++++ 15 files changed, 838 insertions(+), 55 deletions(-) create mode 100644 src/ManagedDrive.App/Services/WinFspInstaller.cs create mode 100644 tests/ManagedDrive.Tests/Services/WinFspInstallerTests.cs create mode 100644 tests/ManagedDrive.Tests/Services/WinFspPrerequisiteTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6640ce6..a52c6ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,31 +138,49 @@ jobs: echo "is_prerelease=$($isPrerelease.ToString().ToLower())" >> $env:GITHUB_OUTPUT shell: pwsh - - name: Build portable package - run: | - $tag = "${{ steps.version.outputs.tag }}" - Compress-Archive -Path publish-fx\* -DestinationPath "ManagedDrive-$tag-win-x64-portable.zip" - shell: pwsh - - - name: Prepare installer inputs + - name: Download WinFsp MSI + # Single source of the bundled WinFsp release for the steps below; bump alongside + # WinFspPrerequisite.cs, installer/ManagedDrive.iss and Directory.Packages.props. + env: + WINFSP_TAG: v2.2B5 + WINFSP_MSI: winfsp-2.2.26286.msi + WINFSP_SHA256: 293FFB9BCC0B625C85079FE85FA77F8FE98170ABA51A02AC9C83D58F6C3B0E2F run: | - Copy-Item -Path publish-fx -Destination installer\publish-fx -Recurse + "WINFSP_MSI=$env:WINFSP_MSI" >> $env:GITHUB_ENV + "WINFSP_SHA256=$env:WINFSP_SHA256" >> $env:GITHUB_ENV Invoke-WebRequest ` - -Uri "https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi" ` - -OutFile "installer\winfsp-2.2.26215.msi" + -Uri "https://github.com/winfsp/winfsp/releases/download/$env:WINFSP_TAG/$env:WINFSP_MSI" ` + -OutFile $env:WINFSP_MSI shell: pwsh - name: Verify WinFsp MSI checksum - # Pins the download to the exact bytes we've tested against; bump this hash - # deliberately (alongside the URL above) when upgrading the bundled WinFsp version. + # Pins the download to the exact bytes we've tested against; bump WINFSP_SHA256 + # deliberately (alongside WINFSP_TAG/WINFSP_MSI above) when upgrading the bundled WinFsp version. run: | - $expectedHash = "2ECB5C89405488A95BBD8A01875E02C48534FD37BBDFD84488F7590464D65944" - $actualHash = (Get-FileHash "installer\winfsp-2.2.26215.msi" -Algorithm SHA256).Hash + $expectedHash = $env:WINFSP_SHA256 + $actualHash = (Get-FileHash $env:WINFSP_MSI -Algorithm SHA256).Hash if ($actualHash -ne $expectedHash) { throw "WinFsp MSI checksum mismatch: expected $expectedHash, got $actualHash" } shell: pwsh + - name: Build portable package + # The portable ZIP carries the WinFsp MSI in prerequisites\ so the app can install it offline; + # it is removed again afterwards because the Inno Setup installer bundles its own copy. + run: | + $tag = "${{ steps.version.outputs.tag }}" + New-Item -ItemType Directory -Path publish-fx\prerequisites | Out-Null + Copy-Item $env:WINFSP_MSI publish-fx\prerequisites\ + Compress-Archive -Path publish-fx\* -DestinationPath "ManagedDrive-$tag-win-x64-portable.zip" + Remove-Item publish-fx\prerequisites -Recurse -Force + shell: pwsh + + - name: Prepare installer inputs + run: | + Copy-Item -Path publish-fx -Destination installer\publish-fx -Recurse + Copy-Item $env:WINFSP_MSI installer\ + shell: pwsh + - name: Install Inno Setup # Chocolatey's "innosetup" package tops out at 6.x; JRSoftware.InnoSetup.7 is the # separate winget package for Inno Setup 7. "--scope machine" keeps the install diff --git a/CLAUDE.md b/CLAUDE.md index c001207..34abc8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ dotnet run --project src/ManagedDrive.Cli -- list The solution file is `ManagedDrive.slnx` (Visual Studio 2022+ format). -**WinFsp prerequisite:** `winfsp-msil.dll` must be present at `C:\Program Files (x86)\WinFsp\bin\`. Install exactly [WinFsp 2.2.26215 (2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4) before building or running — download the MSI directly; do not use `winget install WinFsp.WinFsp`, as the winget package lags behind this release. +**WinFsp prerequisite:** `winfsp-msil.dll` must be present at `C:\Program Files (x86)\WinFsp\bin\`. Install exactly [WinFsp 2.2.26286 (2026 Beta5)](https://github.com/winfsp/winfsp/releases/tag/v2.2B5) before building or running — download the MSI directly; do not use `winget install WinFsp.WinFsp`, as the winget package lags behind this release. At startup `WinFspPrerequisite.Check` compares the installed `winfsp-msil.dll` with that version (`RequiredVersion`, the release tag and the MSI SHA256 live there too; bump them together with `winfsp.net`, `installer/ManagedDrive.iss` and the `ci.yml` WinFsp env values); a missing or older version offers `WinFspInstaller` (copies the MSI shipped in the portable ZIP's `prerequisites\`, or downloads it, to `%LOCALAPPDATA%\ManagedDrive\updates`; verifies the SHA256 through a handle that stays open during the run; runs `msiexec` elevated). A newer WinFsp is accepted, a declined update for an older one is remembered per installed/required pair (`AppConfiguration.SkippedWinFspVersion`), and only a missing runtime is fatal. ## Architecture diff --git a/Directory.Packages.props b/Directory.Packages.props index a5a494e..f62d8da 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + diff --git a/README.md b/README.md index d1c1a86..c47c294 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The ZIP also includes `mdrive.exe`, a companion CLI (see [CLI Usage](#cli-usage) | Requirement | Notes | |---|---| | **Windows 10 / 11 (64-bit)** | ARM64 is not currently tested | -| **[WinFsp 2.2.26215 (2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)** | Must be installed before running ManagedDrive. Download the installer directly: [winfsp-2.2.26215.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi) — do not use `winget install WinFsp.WinFsp`, as the winget package lags behind the latest release. The managed assembly `winfsp-msil.dll` is installed to `C:\Program Files (x86)\WinFsp\bin\` and is referenced by the project automatically. | +| **[WinFsp 2.2.26286 (2026 Beta5)](https://github.com/winfsp/winfsp/releases/tag/v2.2B5)** | The installer bundles it, and the app offers to download and install it when it is missing or older (a newer version is accepted); the portable ZIP also carries the installer in `prerequisites\`. To install it manually, download the installer directly: [winfsp-2.2.26286.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B5/winfsp-2.2.26286.msi) — do not use `winget install WinFsp.WinFsp`, as the winget package lags behind the latest release. The managed assembly `winfsp-msil.dll` is installed to `C:\Program Files (x86)\WinFsp\bin\` and is referenced by the project automatically. | | **[.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)** | Required for the `-portable` ZIP (framework-dependent). | | **.NET 10 SDK** | Required to build. | @@ -307,7 +307,7 @@ ZIP 中还包含 `mdrive.exe`(配套命令行工具,见下方[命令行用 | 要求 | 说明 | |---|---| | **Windows 10 / 11(64 位)** | 暂未测试 ARM64 | -| **[WinFsp 2.2.26215(2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)** | 必须安装此版本才能运行 ManagedDrive。请直接下载安装包:[winfsp-2.2.26215.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi)——不要使用 `winget install WinFsp.WinFsp` 安装,因为该 winget 包更新不及时,落后于最新发布版本。托管程序集 `winfsp-msil.dll` 将安装至 `C:\Program Files (x86)\WinFsp\bin\`,项目会自动引用。 | +| **[WinFsp 2.2.26286(2026 Beta5)](https://github.com/winfsp/winfsp/releases/tag/v2.2B5)** | 安装程序已内置,应用在检测到缺失或版本过旧时也会提示自动下载安装(更新版本可直接使用),便携版压缩包的 `prerequisites\` 目录同样附带安装包。如需手动安装,请直接下载安装包:[winfsp-2.2.26286.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B5/winfsp-2.2.26286.msi)——不要使用 `winget install WinFsp.WinFsp` 安装,因为该 winget 包更新不及时,落后于最新发布版本。托管程序集 `winfsp-msil.dll` 将安装至 `C:\Program Files (x86)\WinFsp\bin\`,项目会自动引用。 | | **[.NET 10 桌面运行时](https://dotnet.microsoft.com/download/dotnet/10.0)** | "绿色版"(框架依赖型)ZIP 需要。 | | **.NET 10 SDK** | 编译所需。 | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2368f0b..a8b1d76 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,8 +11,8 @@ Developer-facing documentation for ManagedDrive — building from source, soluti ### Getting Started ```powershell -# 1. Download and install WinFsp 2.2.26215 (2026 Beta4) -# https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi +# 1. Download and install WinFsp 2.2.26286 (2026 Beta5) - or let ManagedDrive offer to install it on first start +# https://github.com/winfsp/winfsp/releases/download/v2.2B5/winfsp-2.2.26286.msi # 2. Clone the repository git clone https://github.com/coldhighsun/ManagedDrive @@ -100,8 +100,8 @@ BenchmarkDotNet will prompt you to pick which benchmark class(es) to run (`Seque ### 快速开始 ```powershell -# 1. 下载并安装 WinFsp 2.2.26215(2026 Beta4) -# https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi +# 1. 下载并安装 WinFsp 2.2.26286(2026 Beta5)——也可以首次启动时由 ManagedDrive 提示自动安装 +# https://github.com/winfsp/winfsp/releases/download/v2.2B5/winfsp-2.2.26286.msi # 2. 克隆仓库 git clone https://github.com/coldhighsun/ManagedDrive diff --git a/docs/THIRD-PARTY-NOTICES.md b/docs/THIRD-PARTY-NOTICES.md index 9e194b9..44121b8 100644 --- a/docs/THIRD-PARTY-NOTICES.md +++ b/docs/THIRD-PARTY-NOTICES.md @@ -4,10 +4,10 @@ ManagedDrive uses the following open-source software. ## WinFsp -- **Package:** `winfsp.net` 2.2.26215 +- **Package:** `winfsp.net` 2.2.26286 - **Copyright:** © 2015-2026 Bill Zissimopoulos - **License:** Dual-licensed under the GNU General Public License v2 (with a FUSE linking exception) or a commercial license. See [License.txt](https://github.com/winfsp/winfsp/blob/master/License.txt) and [winfsp.dev](https://winfsp.dev/) for the authoritative terms. -- The `ManagedDrive-Setup-*.exe` installer bundles and redistributes the official, unmodified WinFsp installer (`winfsp-2.2.26215.msi`, downloaded from the [WinFsp v2.2B4 release](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)) so it can be installed automatically alongside ManagedDrive. +- The `ManagedDrive-Setup-*.exe` installer bundles and redistributes the official, unmodified WinFsp installer (`winfsp-2.2.26286.msi`, downloaded from the [WinFsp v2.2B5 release](https://github.com/winfsp/winfsp/releases/tag/v2.2B5)) so it can be installed automatically alongside ManagedDrive. ## SharpCompress diff --git a/installer/ManagedDrive.iss b/installer/ManagedDrive.iss index bf7dfd9..43e1ad6 100644 --- a/installer/ManagedDrive.iss +++ b/installer/ManagedDrive.iss @@ -16,7 +16,9 @@ #define AppVersionTag "v" + AppVersion #endif -#define WinFspMsiName "winfsp-2.2.26215.msi" +#define WinFspBuild "26286" +#define WinFspVersion "2.2." + WinFspBuild +#define WinFspMsiName "winfsp-" + WinFspVersion + ".msi" #define HelperServiceName "ManagedDriveHelper" #define HelperServiceExeName "ManagedDriveHelper.exe" ; Must match App.xaml.cs::SingleInstanceMutexName exactly - this is how Setup detects a running @@ -93,13 +95,14 @@ var // LaunchAppAsUser()'s explorer.exe workaround below exists to avoid. LaunchAppCheckBox: TNewCheckBox; -// Mirrors ManagedDrive.App's App.xaml.cs::CheckWinFspPrerequisite() detection: -// HKLM InstallDir -> \bin\winfsp-msil.dll must exist with a 2.2.x file version. +// Mirrors ManagedDrive.App's WinFspPrerequisite.Check() detection: HKLM InstallDir -> +// \bin\winfsp-msil.dll must exist with at least the bundled version ({#WinFspVersion}). +// An older version is replaced by the bundled MSI; a newer one is kept (MSI refuses downgrades). function IsWinFspInstalled(): Boolean; var InstallDir, DllPath: string; VersionMS, VersionLS: Cardinal; - Major, Minor: Integer; + Major, Minor, Build: Cardinal; begin Result := False; @@ -118,7 +121,8 @@ begin Major := VersionMS shr 16; Minor := VersionMS and $FFFF; - Result := (Major = 2) and (Minor = 2); + Build := VersionLS shr 16; + Result := (Major > 2) or ((Major = 2) and ((Minor > 2) or ((Minor = 2) and (Build >= {#WinFspBuild})))); end; // Mirrors the check dotnet's own bootstrapper uses to detect an installed @@ -427,7 +431,7 @@ begin if not IsWinFspInstalled() then InstallWinFspSilently() else - Log('WinFsp 2.2.x already installed; skipping.'); + Log('WinFsp {#WinFspVersion} already installed; skipping.'); // Reinstall/upgrade: stop the running service *before* [Files] copies the new // ManagedDriveHelper.exe over it. CloseApplicationsFilter/Restart Manager only cover diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index b3701e8..2ec5f57 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -145,7 +145,7 @@ private async void App_Startup(object sender, StartupEventArgs e) // Before the splash, so its warning box is not covered by it. Shutdown() only queues the // exit; return so nothing below runs — in particular no MainViewModel gets created, so // App_Exit has no (empty) disk list to save over the user's settings. - if (!CheckWinFspPrerequisite()) + if (!await CheckWinFspPrerequisiteAsync(_settings, config)) { Shutdown(); return; @@ -561,30 +561,102 @@ void CountDiskDone(int index) private const int AutoMountConcurrency = 2; /// - /// Verifies WinFsp is installed; if not, tells the user and offers to open its download page. + /// Verifies the required WinFsp version is installed; if not, offers to download and install it. /// /// /// true if WinFsp is installed and startup can continue; false if the app must exit. /// - private static bool CheckWinFspPrerequisite() + private static async Task CheckWinFspPrerequisiteAsync(SettingsStore settings, AppConfiguration config) { - if (WinFspPrerequisite.IsInstalled()) + var check = WinFspPrerequisite.Check(); + if (!WinFspPrerequisite.ShouldPrompt(check, config.SkippedWinFspVersion)) { return true; } - var result = MessageBox.Show( - Loc.Get("Msg.WinFspMissingBody"), - Loc.Get("Msg.WinFspMissingTitle"), + var missing = check.Status == WinFspStatus.Missing; + + var question = missing + ? Loc.Format("Msg.WinFspMissingBody", WinFspPrerequisite.RequiredVersionText) + : Loc.Format("Msg.WinFspMismatchBody", check.InstalledVersion!, WinFspPrerequisite.RequiredVersionText); + + var answer = MessageBox.Show( + question, + Loc.Get(missing ? "Msg.WinFspMissingTitle" : "Msg.WinFspTitle"), MessageBoxButton.YesNo, - MessageBoxImage.Error); + missing ? MessageBoxImage.Error : MessageBoxImage.Question); + + // An older build still works; only a missing runtime is fatal. When the user says no, or the update + // cannot be done (UAC declined, offline), remember the installed/required pair so it is not asked + // about again on every start. + void RememberSkip() + { + if (!missing) + { + var skipKey = WinFspPrerequisite.SkipKey(check.InstalledVersion!); + settings.Update(current => current with { SkippedWinFspVersion = skipKey }); + } + } - if (result == MessageBoxResult.Yes) + if (answer != MessageBoxResult.Yes) { - Process.Start(new ProcessStartInfo("https://github.com/winfsp/winfsp/releases/tag/v2.2B4") { UseShellExecute = true }); + RememberSkip(); + return !missing; } - return false; + var installer = WinFspInstaller.CreateDefault(); + WinFspInstallResult result; + System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait; + try + { + result = await installer.InstallAsync(); + } + finally + { + System.Windows.Input.Mouse.OverrideCursor = null; + } + + switch (result) + { + case WinFspInstallResult.Success: + return true; + case WinFspInstallResult.RebootRequired: + MessageBox.Show( + Loc.Format("Msg.WinFspRebootRequired", WinFspPrerequisite.RequiredVersionText), + Loc.Get("Msg.WinFspTitle"), + MessageBoxButton.OK, + MessageBoxImage.Information); + return false; + case WinFspInstallResult.Declined: + // Nothing changed; explain why a missing runtime still ends the app instead of closing silently. + if (missing) + { + OfferManualWinFspDownload(Loc.Format("Msg.WinFspDeclined", WinFspPrerequisite.RequiredVersionText)); + } + + RememberSkip(); + return !missing; + default: + Log.Warning("Automatic WinFsp install failed: {Error}", installer.LastError); + OfferManualWinFspDownload(Loc.Format("Msg.WinFspInstallFailed", installer.LastError ?? string.Empty)); + RememberSkip(); + return !missing; + } + } + + /// + /// Shows and opens the WinFsp release page when the user agrees. + /// + private static void OfferManualWinFspDownload(string message) + { + if (MessageBox.Show( + message, + Loc.Get("Msg.WinFspTitle"), + MessageBoxButton.YesNo, + MessageBoxImage.Error) == MessageBoxResult.Yes) + { + Process.Start(new ProcessStartInfo(WinFspPrerequisite.ReleasePageUrl) { UseShellExecute = true }); + } } /// diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml index e6ee7a8..55e3c56 100644 --- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml +++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml @@ -409,7 +409,12 @@ Your Windows Temp directory was pointing to: {0} This RAM disk is not configured to auto-mount, so the directory would not be available. It has been automatically reset to the system default. Your Windows Temp directory was pointing to: {0} This RAM disk is not currently mounted (it may have failed to auto-mount, or its profile no longer exists), so the directory was not available. It has been automatically reset to the system default. ManagedDrive – Missing Prerequisite - WinFsp 2.2.x is required but was not detected on this system. Click Yes to open the download page, or No to exit. + ManagedDrive – WinFsp + WinFsp {0} is required but was not detected on this system. Click Yes to download and install it now (administrator permission is requested), or No to exit. + The installed WinFsp version is {0}, but ManagedDrive is built and tested against {1}. Click Yes to download and install {1} now (administrator permission is requested), or No to continue with the current version. + WinFsp could not be installed automatically: {0} Click Yes to open the download page and install it manually. + WinFsp {0} is required but was not installed because administrator permission was not granted. Click Yes to open the download page and install it manually, or No to exit. + WinFsp {0} was installed, but Windows must be restarted before it can be used. Restart Windows, then start ManagedDrive again. ManagedDrive All mounted RAM disks will be unmounted and any unimaged data will be lost. Exit anyway? Failed to mount disk: {0} diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml index b469a64..f869f68 100644 --- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml +++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml @@ -409,7 +409,12 @@ 您的 Windows 临时目录指向了: {0} 该内存盘未配置自动挂载,临时目录将无法访问,已自动重置为系统默认临时目录。 您的 Windows 临时目录指向了: {0} 该内存盘当前未挂载(可能自动挂载失败,或对应的磁盘配置已不存在),临时目录无法访问,已自动重置为系统默认临时目录。 ManagedDrive – 缺少必要组件 - 此程序需要 WinFsp 2.2.x,但未在系统中检测到。 点击「是」跳转下载页面,点击「否」退出。 + ManagedDrive – WinFsp + 此程序需要 WinFsp {0},但未在系统中检测到。 点击「是」立即下载并安装(需要管理员权限),点击「否」退出。 + 当前安装的 WinFsp 版本为 {0},而 ManagedDrive 基于 {1} 构建和测试。 点击「是」立即下载并安装 {1}(需要管理员权限),点击「否」继续使用当前版本。 + 无法自动安装 WinFsp: {0} 点击「是」打开下载页面手动安装。 + 此程序需要 WinFsp {0},但由于未授予管理员权限,安装未进行。 点击「是」打开下载页面手动安装,点击「否」退出。 + WinFsp {0} 已安装,但需要重启 Windows 后才能使用。请重启 Windows 后再次启动 ManagedDrive。 ManagedDrive 所有已挂载的 RAM 磁盘将被卸载,其中未挂载镜像的数据将丢失。 确认退出? 挂载磁盘失败: {0} diff --git a/src/ManagedDrive.App/Models/AppConfiguration.cs b/src/ManagedDrive.App/Models/AppConfiguration.cs index 75fe2da..b371111 100644 --- a/src/ManagedDrive.App/Models/AppConfiguration.cs +++ b/src/ManagedDrive.App/Models/AppConfiguration.cs @@ -91,6 +91,16 @@ public string? SkippedVersion get; init; } + /// + /// Gets or sets the installed/required WinFsp version pair (WinFspPrerequisite.SkipKey) for which the + /// user declined the "update WinFsp" prompt, so it is not repeated on every start; it is asked again when + /// either version changes. + /// + public string? SkippedWinFspVersion + { + get; init; + } + /// /// Gets or sets a value indicating whether closing the main window (title bar X) minimizes /// to the system tray instead of exiting the application. Defaults to true, matching diff --git a/src/ManagedDrive.App/Services/WinFspInstaller.cs b/src/ManagedDrive.App/Services/WinFspInstaller.cs new file mode 100644 index 0000000..310b427 --- /dev/null +++ b/src/ManagedDrive.App/Services/WinFspInstaller.cs @@ -0,0 +1,262 @@ +using System.Net.Http; +using System.Security.Cryptography; + +namespace ManagedDrive.App.Services; + +/// +/// Outcome of . +/// +public enum WinFspInstallResult +{ + /// The required WinFsp version is installed. + Success, + + /// The install succeeded but Windows needs a restart before WinFsp is usable. + RebootRequired, + + /// The user declined the elevation (UAC) prompt; nothing changed. + Declined, + + /// Download, verification or installation failed; see . + Failed, +} + +/// +/// Downloads the required WinFsp installer, verifies its SHA256 and runs it silently with elevation. +/// The download and the process start are injected so the policy (verify before running, map exit codes, +/// never execute a file whose hash differs) is testable without network or UAC. +/// +/// User-private directory the MSI is stored in (never %TEMP%, which may be on a RAM disk). +/// Downloads to the given path. +/// Runs the MSI elevated and silently and returns the msiexec exit code; throws with code 1223 when UAC is declined. +/// Reads the installed WinFsp state after the install. +/// MSI shipped with the app (portable ZIP); copied and verified before any download is attempted. +/// Hex SHA256 the MSI must have; defaults to (overridden only by tests). +public sealed class WinFspInstaller( + string downloadDirectory, + Func download, + Func> runMsi, + Func verifyInstalled, + string? bundledMsiPath = null, + string expectedSha256 = WinFspPrerequisite.MsiSha256) +{ + private const int ErrorCancelled = 1223; + private const int ExitSuccess = 0; + private const int ExitRebootRequired = 3010; + private const int ExitRebootInitiated = 1641; + private static readonly TimeSpan MsiTimeout = TimeSpan.FromMinutes(10); + + /// + /// Upper bound for the downloaded installer; the real MSI is a few MB, so anything larger is rejected before the hash check. + /// + private const long MaxDownloadBytes = 64L * 1024 * 1024; + + /// + /// A short description of why the last returned . + /// + public string? LastError { get; private set; } + + /// + /// Creates the installer used by the app: downloads over HTTPS and starts msiexec through the UAC prompt. + /// + public static WinFspInstaller CreateDefault() => new( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ManagedDrive", "updates"), + DownloadFileAsync, + RunMsiElevatedAsync, + WinFspPrerequisite.Check, + Path.Combine(AppContext.BaseDirectory, "prerequisites", WinFspPrerequisite.MsiFileName)); + + /// + /// Fetches, verifies and installs the required WinFsp version. + /// + /// Cancels the download or the wait for the installer. + public async Task InstallAsync(CancellationToken cancellationToken = default) + { + LastError = null; + var msiPath = Path.Combine(downloadDirectory, WinFspPrerequisite.MsiFileName); + + try + { + Directory.CreateDirectory(downloadDirectory); + + // Every candidate is hashed through a handle that stays open (read-shared, so nobody can write + // to or delete the file) until msiexec has finished: a same-user process cannot swap the file + // between the check and the elevated run. The copy in the user-private directory is what runs, + // so a file in a writable app folder is covered too. + var handle = await TryOpenVerifiedAsync(msiPath, cancellationToken); + if (handle is null && bundledMsiPath is not null && File.Exists(bundledMsiPath)) + { + File.Copy(bundledMsiPath, msiPath, overwrite: true); + + // A copy of a read-only source stays read-only, which would block the next overwrite or delete. + File.SetAttributes(msiPath, FileAttributes.Normal); + handle = await TryOpenVerifiedAsync(msiPath, cancellationToken); + } + + if (handle is null) + { + await download(msiPath, cancellationToken); + handle = await TryOpenVerifiedAsync(msiPath, cancellationToken); + } + + if (handle is null) + { + TryDelete(msiPath); + LastError = "The downloaded WinFsp installer failed the SHA256 check."; + return WinFspInstallResult.Failed; + } + + int exitCode; + await using (handle) + { + try + { + exitCode = await runMsi(msiPath, cancellationToken); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == ErrorCancelled) + { + return WinFspInstallResult.Declined; + } + } + + switch (exitCode) + { + case ExitSuccess: + break; + case ExitRebootRequired or ExitRebootInitiated: + return WinFspInstallResult.RebootRequired; + default: + LastError = $"The WinFsp installer exited with code {exitCode}."; + return WinFspInstallResult.Failed; + } + + if (verifyInstalled().Status != WinFspStatus.Ok) + { + LastError = "WinFsp is still not at the required version after the install."; + return WinFspInstallResult.Failed; + } + + return WinFspInstallResult.Success; + } + catch (Exception ex) when (ex is IOException or HttpRequestException or UnauthorizedAccessException + or System.ComponentModel.Win32Exception or InvalidOperationException + or TimeoutException + || (ex is OperationCanceledException && !cancellationToken.IsCancellationRequested)) + { + // An OperationCanceledException not caused by our own token is an HttpClient timeout. + LastError = ex.Message; + return WinFspInstallResult.Failed; + } + finally + { + TryDelete(msiPath + ".part"); + } + } + + /// + /// Opens read-shared and returns the open handle when its SHA256 matches; a missing or + /// different file yields (and is deleted). Keep the handle open to keep the file unchangeable. + /// + private async Task TryOpenVerifiedAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return null; + } + + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + try + { + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + if (string.Equals(Convert.ToHexString(hash), expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + return stream; + } + } + catch + { + // Never leave the file locked behind an exception (read error, cancellation). + await stream.DisposeAsync(); + throw; + } + + await stream.DisposeAsync(); + TryDelete(path); + return null; + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: the hash check runs again before the file is ever executed. + } + } + + private static async Task DownloadFileAsync(string destination, CancellationToken cancellationToken) + { + // Written beside the target and moved into place, so an interrupted download never leaves a + // truncated file under the final name. + var partial = destination + ".part"; + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + + await using (var source = await client.GetStreamAsync(WinFspPrerequisite.MsiDownloadUrl, cancellationToken)) + await using (var target = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) + { + total += read; + if (total > MaxDownloadBytes) + { + throw new IOException("The WinFsp download is larger than expected."); + } + + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } + + File.Move(partial, destination, overwrite: true); + } + + /// + /// Runs and converts running past into a + /// ; cancelling still throws + /// . + /// + public static async Task WaitWithTimeoutAsync( + Func wait, TimeSpan timeout, CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(timeout); + try + { + await wait(linked.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The WinFsp installer did not finish in time."); + } + } + + private static async Task RunMsiElevatedAsync(string msiPath, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo("msiexec.exe", $"/i \"{msiPath}\" /qn /norestart") + { + UseShellExecute = true, + Verb = "runas", + }; + + using var process = Process.Start(startInfo) + ?? throw new IOException("Could not start msiexec.exe."); + await WaitWithTimeoutAsync(process.WaitForExitAsync, MsiTimeout, cancellationToken); + return process.ExitCode; + } +} diff --git a/src/ManagedDrive.App/Services/WinFspPrerequisite.cs b/src/ManagedDrive.App/Services/WinFspPrerequisite.cs index 0e2786b..d2384d2 100644 --- a/src/ManagedDrive.App/Services/WinFspPrerequisite.cs +++ b/src/ManagedDrive.App/Services/WinFspPrerequisite.cs @@ -1,27 +1,140 @@ namespace ManagedDrive.App.Services; /// -/// Checks whether a compatible WinFsp 2.2.x runtime is installed. Extracted from -/// as a pure, static check; the caller decides what to -/// do (show a dialog, offer the download page, shut down) when it reports missing. +/// How the installed WinFsp compares with . +/// +public enum WinFspStatus +{ + /// The required version is installed. + Ok, + + /// No WinFsp runtime (winfsp-msil.dll) was found. + Missing, + + /// WinFsp is installed, but older than the required version. + VersionMismatch, + + /// WinFsp is installed and newer than the required version; a downgrade is not offered. + Newer, +} + +/// +/// Result of . +/// +/// How the installed version compares with the required one. +/// The installed version, or when WinFsp is missing. +public readonly record struct WinFspCheckResult(WinFspStatus Status, Version? InstalledVersion); + +/// +/// Checks that the WinFsp runtime ManagedDrive is built and tested against (or a newer one) is installed, and holds +/// the facts about that release (version, download URL, SHA256) in one place. The caller decides what to do +/// (offer an install, warn, shut down) from the result. /// public static class WinFspPrerequisite { /// - /// Returns true when the system-installed winfsp-msil.dll at - /// C:\Program Files (x86)\WinFsp\bin\ is present and reports a 2.2.x file version. + /// The WinFsp version ManagedDrive requires (2026 Beta5). Keep in step with winfsp.net in + /// Directory.Packages.props and installer/ManagedDrive.iss. + /// + public static readonly Version RequiredVersion = new(2, 2, 26286); + + /// + /// GitHub release tag of (it differs from the file version). + /// + private const string ReleaseTag = "v2.2B5"; + + /// + /// Display text of . + /// + public static string RequiredVersionText => RequiredVersion.ToString(); + + /// + /// File name of the required release's installer. + /// + public static readonly string MsiFileName = $"winfsp-{RequiredVersion}.msi"; + + /// + /// Where the required release's installer is downloaded from. + /// + public static readonly string MsiDownloadUrl = + $"https://github.com/winfsp/winfsp/releases/download/{ReleaseTag}/{MsiFileName}"; + + /// + /// SHA256 (hex) of the installer at ; the downloaded file is rejected unless it matches. /// - public static bool IsInstalled() + public const string MsiSha256 = "293FFB9BCC0B625C85079FE85FA77F8FE98170ABA51A02AC9C83D58F6C3B0E2F"; + + /// + /// The release page, opened when the installer cannot be fetched or run automatically. + /// + public static readonly string ReleasePageUrl = $"https://github.com/winfsp/winfsp/releases/tag/{ReleaseTag}"; + + /// + /// The value remembered when the user declines to update: it names both the installed and the required + /// version, so the question is asked again when either changes. + /// + public static string SkipKey(Version installed) => $"{installed}>{RequiredVersion}"; + + /// + /// Whether the user should be asked about : never for an acceptable install, always + /// for a missing one, and for an older one unless the same update was already declined. + /// + /// The result of . + /// The remembered , or . + public static bool ShouldPrompt(WinFspCheckResult check, string? skippedKey) => check.Status switch + { + WinFspStatus.Missing => true, + WinFspStatus.VersionMismatch => skippedKey != SkipKey(check.InstalledVersion!), + _ => false, + }; + + /// + /// Reads the installed WinFsp runtime from disk and compares it with . + /// + public static WinFspCheckResult Check() + { + var installed = GetInstalledVersion(); + return new(Evaluate(installed), installed); + } + + /// + /// Compares an installed version with . + /// + /// The installed version, or when WinFsp is not installed. + public static WinFspStatus Evaluate(Version? installed) + { + if (installed is null) + { + return WinFspStatus.Missing; + } + + var installedCore = new Version(installed.Major, installed.Minor, installed.Build); + var comparison = installedCore.CompareTo(RequiredVersion); + return comparison == 0 ? WinFspStatus.Ok : comparison < 0 ? WinFspStatus.VersionMismatch : WinFspStatus.Newer; + } + + /// + /// Returns the file version of the system-installed winfsp-msil.dll (found through the + /// InstallDir registry value, normally C:\Program Files (x86)\WinFsp\bin\), or + /// when it is not there. + /// + private static Version? GetInstalledVersion() { using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\WinFsp") ?? Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WinFsp"); - var dllPath = key?.GetValue("InstallDir") is string installDir - ? Path.Combine(installDir, "bin", "winfsp-msil.dll") - : null; + if (key?.GetValue("InstallDir") is not string installDir) + { + return null; + } + + var dllPath = Path.Combine(installDir, "bin", "winfsp-msil.dll"); + if (!File.Exists(dllPath)) + { + return null; + } - return dllPath is not null && - File.Exists(dllPath) && - FileVersionInfo.GetVersionInfo(dllPath).FileVersion?.StartsWith("2.2.") == true; + var info = FileVersionInfo.GetVersionInfo(dllPath); + return new(info.FileMajorPart, info.FileMinorPart, info.FileBuildPart); } } diff --git a/tests/ManagedDrive.Tests/Services/WinFspInstallerTests.cs b/tests/ManagedDrive.Tests/Services/WinFspInstallerTests.cs new file mode 100644 index 0000000..a6dfdd4 --- /dev/null +++ b/tests/ManagedDrive.Tests/Services/WinFspInstallerTests.cs @@ -0,0 +1,234 @@ +using System.ComponentModel; +using System.Security.Cryptography; +using ManagedDrive.App.Services; + +namespace ManagedDrive.Tests; + +/// +/// Tests 's policy: verify before running, map exit codes, never execute a +/// file whose hash differs. +/// +public sealed class WinFspInstallerTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "wfi-" + Guid.NewGuid().ToString("N")); + + private int _downloads; + private int _runs; + + /// + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, recursive: true); + } + } + + /// + /// A download whose hash differs is rejected, deleted and never run. + /// + [Fact] + public async Task InstallAsync_HashMismatch_FailsWithoutRunning() + { + var installer = Create(writeValidFile: false, exitCode: 0); + + var result = await installer.InstallAsync(TestContext.Current.CancellationToken); + + Assert.Equal(WinFspInstallResult.Failed, result); + Assert.Equal(1, _downloads); + Assert.Equal(0, _runs); + Assert.False(File.Exists(Path.Combine(_dir, WinFspPrerequisite.MsiFileName))); + } + + /// + /// Exit codes map to results. + /// + [Theory] + [InlineData(0, WinFspInstallResult.Success)] + [InlineData(3010, WinFspInstallResult.RebootRequired)] + [InlineData(1603, WinFspInstallResult.Failed)] + public async Task InstallAsync_MapsExitCode(int exitCode, WinFspInstallResult expected) + { + var installer = Create(writeValidFile: true, exitCode); + + Assert.Equal(expected, await installer.InstallAsync(TestContext.Current.CancellationToken)); + } + + /// + /// Declining UAC yields . + /// + [Fact] + public async Task InstallAsync_UacDeclined_ReturnsDeclined() + { + var installer = Create(writeValidFile: true, exitCode: 0, throwUac: true); + + Assert.Equal(WinFspInstallResult.Declined, await installer.InstallAsync(TestContext.Current.CancellationToken)); + } + + /// + /// A cached file with the right hash is reused without downloading again. + /// + [Fact] + public async Task InstallAsync_CachedValidFile_SkipsDownload() + { + Directory.CreateDirectory(_dir); + await File.WriteAllBytesAsync(Path.Combine(_dir, WinFspPrerequisite.MsiFileName), ValidBytes, TestContext.Current.CancellationToken); + var installer = Create(writeValidFile: true, exitCode: 0); + + Assert.Equal(WinFspInstallResult.Success, await installer.InstallAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, _downloads); + Assert.Equal(1, _runs); + } + + /// + /// A valid MSI shipped with the app is used without downloading. + /// + [Fact] + public async Task InstallAsync_BundledValidFile_SkipsDownload() + { + Directory.CreateDirectory(_dir); + var bundled = Path.Combine(_dir, "bundled.msi"); + await File.WriteAllBytesAsync(bundled, ValidBytes, TestContext.Current.CancellationToken); + var installer = Create(writeValidFile: true, exitCode: 0, bundled: bundled); + + Assert.Equal(WinFspInstallResult.Success, await installer.InstallAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, _downloads); + } + + /// + /// A tampered bundled MSI is ignored and the download is used instead. + /// + [Fact] + public async Task InstallAsync_BundledBadFile_FallsBackToDownload() + { + Directory.CreateDirectory(_dir); + var bundled = Path.Combine(_dir, "bundled.msi"); + await File.WriteAllBytesAsync(bundled, [7, 7], TestContext.Current.CancellationToken); + var installer = Create(writeValidFile: true, exitCode: 0, bundled: bundled); + + Assert.Equal(WinFspInstallResult.Success, await installer.InstallAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, _downloads); + } + + /// + /// A timeout raised by the download (not by the caller's token) is a failure, not an exception. + /// + [Fact] + public async Task InstallAsync_DownloadTimeout_Fails() + { + var installer = new WinFspInstaller( + _dir, + (_, _) => throw new TaskCanceledException("timeout"), + (_, _) => Task.FromResult(0), + () => new(WinFspStatus.Ok, WinFspPrerequisite.RequiredVersion)); + + Assert.Equal(WinFspInstallResult.Failed, await installer.InstallAsync(TestContext.Current.CancellationToken)); + } + + /// + /// A successful msiexec run that leaves the wrong version behind is a failure. + /// + [Fact] + public async Task InstallAsync_StillWrongVersionAfterInstall_Fails() + { + var installer = new WinFspInstaller( + _dir, + async (path, ct) => await File.WriteAllBytesAsync(path, ValidBytes, ct), + (_, _) => Task.FromResult(0), + () => new(WinFspStatus.VersionMismatch, new Version(2, 2, 1)), + expectedSha256: Convert.ToHexString(SHA256.HashData(ValidBytes))); + + Assert.Equal(WinFspInstallResult.Failed, await installer.InstallAsync(TestContext.Current.CancellationToken)); + } + + /// + /// Exit code 1641 (reboot initiated) maps to . + /// + [Fact] + public async Task InstallAsync_RebootInitiated_RequiresReboot() => + Assert.Equal( + WinFspInstallResult.RebootRequired, + await Create(writeValidFile: true, exitCode: 1641).InstallAsync(TestContext.Current.CancellationToken)); + + /// + /// The verified MSI cannot be modified while the installer runs (no swap between hash check and execution). + /// + [Fact] + public async Task InstallAsync_FileIsLockedAgainstWritesWhileInstalling() + { + var msiPath = Path.Combine(_dir, WinFspPrerequisite.MsiFileName); + var writeBlocked = false; + var installer = new WinFspInstaller( + _dir, + async (path, ct) => await File.WriteAllBytesAsync(path, ValidBytes, ct), + (path, _) => + { + try + { + using var probe = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + } + catch (IOException) + { + writeBlocked = true; + } + + return Task.FromResult(0); + }, + () => new(WinFspStatus.Ok, WinFspPrerequisite.RequiredVersion), + expectedSha256: Convert.ToHexString(SHA256.HashData(ValidBytes))); + + await installer.InstallAsync(TestContext.Current.CancellationToken); + + Assert.True(writeBlocked); + Assert.True(File.Exists(msiPath)); + } + + /// + /// A leftover partial download is cleaned up. + /// + [Fact] + public async Task InstallAsync_PartialFileIsRemoved() + { + Directory.CreateDirectory(_dir); + var part = Path.Combine(_dir, WinFspPrerequisite.MsiFileName + ".part"); + await File.WriteAllBytesAsync(part, [1], TestContext.Current.CancellationToken); + var installer = Create(writeValidFile: true, exitCode: 0); + + await installer.InstallAsync(TestContext.Current.CancellationToken); + + Assert.False(File.Exists(part)); + } + + /// + /// A wait that outlives the timeout becomes a ; the caller's own cancellation is not swallowed. + /// + [Fact] + public async Task WaitWithTimeoutAsync_TimeoutAndCancellation() + { + await Assert.ThrowsAsync(() => WinFspInstaller.WaitWithTimeoutAsync( + ct => Task.Delay(Timeout.Infinite, ct), TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => WinFspInstaller.WaitWithTimeoutAsync( + ct => Task.Delay(Timeout.Infinite, ct), TimeSpan.FromSeconds(30), cts.Token)); + } + + private WinFspInstaller Create(bool writeValidFile, int exitCode, bool throwUac = false, string? bundled = null) => new( + _dir, + async (path, ct) => + { + _downloads++; + await File.WriteAllBytesAsync(path, writeValidFile ? ValidBytes : [9, 9, 9], ct); + }, + (_, _) => + { + _runs++; + return throwUac ? throw new Win32Exception(1223) : Task.FromResult(exitCode); + }, + () => new(WinFspStatus.Ok, WinFspPrerequisite.RequiredVersion), + bundled, + Convert.ToHexString(SHA256.HashData(ValidBytes))); + + private static byte[] ValidBytes => [1, 2, 3]; +} diff --git a/tests/ManagedDrive.Tests/Services/WinFspPrerequisiteTests.cs b/tests/ManagedDrive.Tests/Services/WinFspPrerequisiteTests.cs new file mode 100644 index 0000000..0abe3c1 --- /dev/null +++ b/tests/ManagedDrive.Tests/Services/WinFspPrerequisiteTests.cs @@ -0,0 +1,60 @@ +using ManagedDrive.App.Services; + +namespace ManagedDrive.Tests; + +/// +/// Tests the version comparison of . +/// +public sealed class WinFspPrerequisiteTests +{ + /// + /// No installed runtime is reported as missing. + /// + [Fact] + public void Evaluate_NoInstall_IsMissing() => + Assert.Equal(WinFspStatus.Missing, WinFspPrerequisite.Evaluate(null)); + + /// + /// The exact required version is accepted. + /// + [Fact] + public void Evaluate_RequiredVersion_IsOk() => + Assert.Equal(WinFspStatus.Ok, WinFspPrerequisite.Evaluate(new Version(2, 2, 26286))); + + /// + /// The previous beta, other builds and other minor/major versions are mismatches. + /// + [Theory] + [InlineData(2, 2, 26215)] + [InlineData(2, 1, 25156)] + [InlineData(2, 2, 26285)] + public void Evaluate_OlderVersion_IsMismatch(int major, int minor, int build) => + Assert.Equal(WinFspStatus.VersionMismatch, WinFspPrerequisite.Evaluate(new Version(major, minor, build))); + + /// + /// Newer installs are reported as such, so no downgrade is offered. + /// + [Theory] + [InlineData(2, 2, 26287)] + [InlineData(2, 3, 100)] + [InlineData(3, 0, 1)] + public void Evaluate_NewerVersion_IsNewer(int major, int minor, int build) => + Assert.Equal(WinFspStatus.Newer, WinFspPrerequisite.Evaluate(new Version(major, minor, build))); + + /// + /// Only a missing or an older, not yet declined install prompts; a declined pair stays quiet until either version changes. + /// + [Fact] + public void ShouldPrompt_FollowsStatusAndSkipKey() + { + var older = new Version(2, 2, 26215); + var olderCheck = new WinFspCheckResult(WinFspStatus.VersionMismatch, older); + + Assert.True(WinFspPrerequisite.ShouldPrompt(new(WinFspStatus.Missing, null), WinFspPrerequisite.SkipKey(older))); + Assert.True(WinFspPrerequisite.ShouldPrompt(olderCheck, null)); + Assert.False(WinFspPrerequisite.ShouldPrompt(olderCheck, WinFspPrerequisite.SkipKey(older))); + Assert.True(WinFspPrerequisite.ShouldPrompt(olderCheck, WinFspPrerequisite.SkipKey(new Version(2, 1, 25156)))); + Assert.False(WinFspPrerequisite.ShouldPrompt(new(WinFspStatus.Ok, WinFspPrerequisite.RequiredVersion), null)); + Assert.False(WinFspPrerequisite.ShouldPrompt(new(WinFspStatus.Newer, new Version(2, 3, 1)), null)); + } +}