diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3861ec8..a5705fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Verify, build, test, and package shell: powershell - run: .\build\Build.ps1 -Configuration Release -Version 2.0.0-beta.1-ci + run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.1-ci - name: Upload test results if: always() diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d9e95bc..1ffec52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,7 +34,7 @@ jobs: - name: Build analyzed source shell: powershell - run: .\build\Build.ps1 -Configuration Release -Version 2.0.0-beta.1-codeql -SkipTests + run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.1-codeql -SkipTests - name: Analyze uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c44c18..504dcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,42 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) an ## [Unreleased] -- Replace the PSWindowsUpdate wrapper with an independent typed Windows Update Agent engine. -- Add same-executable GUI and CLI modes with versioned JSON results and stable exit codes. -- Add GUID-and-revision update selection, async WUA progress/cancellation, offline scans, services, policy, jobs, reporting, maintenance, and payload export. -- Stage the same verified executable for remote WinRM operations instead of installing a module. -- Remove the embedded PSWindowsUpdate package and all runtime module extraction/import code. -- Preserve the former 19-command feature surface as an explicit operation-migration matrix. +## [3.0.0-beta.1] - 2026-07-22 + +### Changed + +- Migrate the complete desktop shell from WPF/.NET Framework 4.8 to WinUI 3 on .NET + 10 and Windows App SDK 2.3.1. +- Publish an unpackaged, framework-self-contained, single-file Windows 11 x64 EXE; + no permanent runtime, package, or update module installation is required. +- Replace the WPF dispatcher dependency in the WUA worker with an owning native STA + message pump and move fixed WinRM transport to built-in Windows PowerShell 5.1. +- Redesign History & Status with compact status cards, an inline typed history table, + selected-entry details, and exact-revision removal or driver rollback verification. +- Add native System, Light, and Dark WinUI themes, Mica, themed title bar, modern + controls, semantic resources, and Windows high-contrast fallback. ### Added -- Portable elevated Windows 11 WPF application. -- Guided local and native remote update workflows. -- Metadata-driven coverage of all PSWindowsUpdate 2.2.1.5 public cmdlets. -- Signed vendor verification, redacted logging, tests, SBOM, CI, and release automation. +- Add a production-assembly WinUI smoke mode with a fake WUA adapter, test-only + non-elevated manifest, dark-theme screenshot, and no machine mutations. +- Pin .NET 10, Windows App SDK 2.3.1, and Windows SDK BuildTools 10.0.28000.2270. + +### Removed + +- Remove WPF, .NET Framework, GAC PowerShell automation assembly, and the obsolete + cross-assembly UI smoke host from the product and build. -## [1.0.0] - Unreleased -- Initial public release after clean local and remote VM acceptance. +## [2.0.0-beta.1] - 2026-07-21 + +### Added + +- Portable, elevated Windows 11 x64 WPF application and same-executable CLI. +- Typed Windows Update Agent engine with asynchronous search, download, install, and + uninstall operations selected by update GUID and revision. +- Software and driver workflows, update history and status, service management, + Microsoft-signed offline catalogs, payload export, policy backup and restore, + component maintenance, scheduled jobs, SMTP reporting, and secure remote execution. +- Versioned JSON results, stable exit codes, mutation plans, explicit confirmation + gates, redacted logs, locked dependencies, tests, SPDX SBOM, CI, and release provenance. diff --git a/PSWindowsUpdateGUI.sln b/PSWindowsUpdateGUI.sln index ce3f305..5322dbb 100644 --- a/PSWindowsUpdateGUI.sln +++ b/PSWindowsUpdateGUI.sln @@ -11,10 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSWindowsUpdateGui.Acceptance", "tests\PSWindowsUpdateGui.Acceptance\PSWindowsUpdateGui.Acceptance.csproj", "{909A2504-A985-4D80-B925-3A104B7E0B04}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSWindowsUpdateGui.UiSmoke", "tests\PSWindowsUpdateGui.UiSmoke\PSWindowsUpdateGui.UiSmoke.csproj", "{D467FC9B-27B2-4E66-B582-CC94D6F39950}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -61,24 +57,11 @@ Global {909A2504-A985-4D80-B925-3A104B7E0B04}.Release|Any CPU.Build.0 = Release|x64 {909A2504-A985-4D80-B925-3A104B7E0B04}.Release|x86.ActiveCfg = Release|x64 {909A2504-A985-4D80-B925-3A104B7E0B04}.Release|x86.Build.0 = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|x64.ActiveCfg = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|x64.Build.0 = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|Any CPU.ActiveCfg = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|Any CPU.Build.0 = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|x86.ActiveCfg = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Debug|x86.Build.0 = Debug|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|x64.ActiveCfg = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|x64.Build.0 = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|Any CPU.ActiveCfg = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|Any CPU.Build.0 = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|x86.ActiveCfg = Release|x64 - {D467FC9B-27B2-4E66-B582-CC94D6F39950}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {909A2504-A985-4D80-B925-3A104B7E0B04} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - {D467FC9B-27B2-4E66-B582-CC94D6F39950} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index ac35aa8..1b59e23 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # PSWindowsUpdate GUI -PSWindowsUpdate GUI 2 is an independent, portable Windows 11 x64 graphical and -command-line interface for Windows Update Agent (WUA). It does **not** install, -load, or wrap the PSWindowsUpdate module. +PSWindowsUpdate GUI 3 is a portable Windows 11 x64 graphical and command-line +administrator tool built directly on Windows Update Agent (WUA). The WinUI 3 release +is a self-contained single executable; it does not install a runtime or update module. > [!WARNING] > This is an administrator tool. Installing or uninstalling updates, changing @@ -14,11 +14,17 @@ load, or wrap the PSWindowsUpdate module. _Screenshot uses the repository's non-mutating UI smoke adapter and synthetic host identity._ +![Dark History and Status page](docs/images/gui-history-dark.png) + ## Highlights -- One elevated .NET Framework 4.8 executable; Windows 11 already includes its runtime. +- One elevated, unpackaged, self-contained WinUI 3 executable for Windows 11 x64. - Direct typed WUA engine with GUID-and-revision update selection. - Software and driver scan, download, install, uninstall, hide, and unhide workflows. +- Structured status cards and update history with exact-revision removal checks and + guarded uninstall or driver rollback when Windows reports the update as removable. +- Native WinUI 3 shell with NavigationView, Mica, modern controls, persisted System, + Light, and Dark themes, title-bar theming, keyboard focus, and high-contrast fallback. - Asynchronous WUA progress, timeout, abort requests, structured HRESULTs, and reboot state. - Online Windows Update, Microsoft Update, managed WSUS, registered service, and Microsoft-signed `wsusscn2.cab` offline scan sources. @@ -50,7 +56,9 @@ new process from the calling console: .\PSWindowsUpdateGUI.exe status --computer workstation.contoso.test --use-ssl --output json ``` -Launching without arguments opens WPF. See [CLI reference](docs/CLI.md), +Launching without arguments opens WinUI 3. Select `System`, `Light`, or `Dark` from the +header; `System` follows Windows app-theme and high-contrast changes. See the +[interface and theming notes](docs/UI.md), [CLI reference](docs/CLI.md), [remote administration](docs/REMOTE.md), and [security model](docs/SECURITY.md). The implementation follows Microsoft's [WUA object model](https://learn.microsoft.com/windows/win32/wua_sdk/windows-update-agent-object-model), @@ -67,25 +75,29 @@ and [remote interface restrictions](https://learn.microsoft.com/windows/win32/wu - GUI mutations show a summary. CLI mutations prompt for `YES`; redirected input requires `--yes`. - Offline CABs only assess security-update applicability and do not contain payloads. - WUA-driven operations may not appear in the Windows Settings orchestrator UI. +- Update history is an immutable audit record. Removing an installed update does not + delete its history, and a driver downgrade requires a previous Windows-managed package. ## Build ```powershell $env:DOTNET_EXE = (Resolve-Path .\.tools\dotnet\dotnet.exe) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build\Build.ps1 -Version 2.0.0-beta.1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build\Build.ps1 -Version 3.0.0-beta.1 ``` The build verifies Microsoft Authenticode on the system WUA DLL, generates an interop reference from its installed type library, embeds only referenced COM types, -runs tests, and emits the EXE, checksum, SPDX SBOM, and notices under -`artifacts\release`. No interop DLL or PSWindowsUpdate package is released. +runs tests plus a production-XAML smoke capture, and emits the single-file EXE, +checksum, SPDX SBOM, and notices under `artifacts\release`. The first start extracts +self-contained framework files to the .NET bundle cache; it does not install them. +No interop DLL or third-party update engine is released. ## Project status -Version 2 is a major prerelease until local and remote snapshot-backed Windows 11 VM -acceptance is complete. The physical-machine acceptance runner is read-only unless -both an exact driver identity and `--confirm-machine-mutation` are supplied. +Version `3.0.0-beta.1` is the current major prerelease. Promotion to a stable release +remains gated on local and remote snapshot-backed Windows 11 VM acceptance. The +physical-machine acceptance runner is read-only unless both an exact driver identity +and `--confirm-machine-mutation` are supplied. -This project is MIT licensed. Repository history before 2.0 used PSWindowsUpdate -2.2.1.5 under its separate MIT license; 2.x contains none of its files. This project -is independent and is not affiliated with the upstream PSWindowsUpdate project. +The application source is MIT licensed. Release contents and their licenses are +recorded in the generated SPDX SBOM and `THIRD-PARTY-NOTICES.txt`. diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index 4080174..23e1637 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -1,9 +1,16 @@ -PSWindowsUpdateGUI 2.x does not bundle PSWindowsUpdate or another update engine. +PSWindowsUpdateGUI does not redistribute a third-party update engine. + +The self-contained executable redistributes the Microsoft .NET 10 runtime, Windows +App SDK 2.3.1 runtime files, and System.ServiceProcess.ServiceController 10.0. These +Microsoft open-source components are distributed under the MIT License. Their exact +package versions and relationships are recorded in the accompanying SPDX SBOM. The application uses Windows Update Agent (wuapi.dll), Windows PowerShell 5.1 for authenticated WinRM transport, and other Windows components already present on the supported operating system. Those operating-system components are not redistributed. -Repository history prior to 2.0 contains an embedded copy of PSWindowsUpdate 2.2.1.5, -copyright Michal Gajda and licensed under the MIT License. No PSWindowsUpdate files are -present in 2.x source or release assets. +Project and license information: + +- https://github.com/dotnet/runtime +- https://github.com/microsoft/WindowsAppSDK +- https://licenses.nuget.org/MIT diff --git a/build/Build.ps1 b/build/Build.ps1 index e0a5e5e..365aeaf 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [ValidateSet('Debug','Release')] [string] $Configuration = 'Release', - [string] $Version = '2.0.0-beta.1', + [string] $Version = '3.0.0-beta.1', [switch] $SkipTests ) @@ -17,25 +17,42 @@ function Invoke-DotNet { & (Join-Path $PSScriptRoot 'Verify-Native.ps1') Invoke-DotNet restore (Join-Path $repoRoot 'PSWindowsUpdateGUI.sln') --use-lock-file '-p:Platform=x64' -Invoke-DotNet build (Join-Path $repoRoot 'PSWindowsUpdateGUI.sln') -c $Configuration "-p:Version=$Version" '-p:Platform=x64' --no-restore +Invoke-DotNet clean (Join-Path $repoRoot 'PSWindowsUpdateGUI.sln') -c $Configuration '-p:Platform=x64' --verbosity minimal +$generatedManifest = Join-Path $repoRoot "src\PSWindowsUpdateGui\obj\x64\$Configuration\net10.0-windows10.0.26100.0\win-x64\Manifests\app.manifest" +if (Test-Path -LiteralPath $generatedManifest) { Remove-Item -LiteralPath $generatedManifest -Force } +Invoke-DotNet build (Join-Path $repoRoot 'PSWindowsUpdateGUI.sln') -c $Configuration "-p:Version=$Version" '-p:Platform=x64' --no-restore --no-incremental if (-not $SkipTests) { - Invoke-DotNet test (Join-Path $repoRoot 'tests\PSWindowsUpdateGui.Tests\PSWindowsUpdateGui.Tests.csproj') -c $Configuration '-p:Platform=x64' --logger 'trx;LogFileName=PSWindowsUpdateGui.Tests.trx' - & (Join-Path $PSScriptRoot 'Test-GuiSmoke.ps1') -Configuration $Configuration + Invoke-DotNet test (Join-Path $repoRoot 'tests\PSWindowsUpdateGui.Tests\PSWindowsUpdateGui.Tests.csproj') -c $Configuration '-p:Platform=x64' --no-build --no-restore --logger 'trx;LogFileName=PSWindowsUpdateGui.Tests.trx' } $releaseRoot = Join-Path $repoRoot 'artifacts\release' -if (Test-Path -LiteralPath $releaseRoot) { - $resolvedRelease = [System.IO.Path]::GetFullPath($releaseRoot).TrimEnd('\') - $expectedRelease = [System.IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts\release')).TrimEnd('\') - if ($resolvedRelease -ne $expectedRelease) { throw "Refusing to clean unexpected release path: $resolvedRelease" } - Remove-Item -LiteralPath $resolvedRelease -Recurse -Force +$publishRoot = Join-Path $repoRoot 'artifacts\publish' +foreach ($directory in @($releaseRoot, $publishRoot)) { + if (Test-Path -LiteralPath $directory) { + $resolvedDirectory = [System.IO.Path]::GetFullPath($directory).TrimEnd('\') + $expectedDirectory = [System.IO.Path]::GetFullPath($directory).TrimEnd('\') + if ($resolvedDirectory -ne $expectedDirectory -or -not $resolvedDirectory.StartsWith([System.IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts')).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean unexpected artifact path: $resolvedDirectory" + } + Remove-Item -LiteralPath $resolvedDirectory -Recurse -Force + } } New-Item -ItemType Directory -Path $releaseRoot -Force | Out-Null -$source = Join-Path $repoRoot "src\PSWindowsUpdateGui\bin\x64\$Configuration\net48\PSWindowsUpdateGUI.exe" +New-Item -ItemType Directory -Path $publishRoot -Force | Out-Null + +Invoke-DotNet restore (Join-Path $repoRoot 'src\PSWindowsUpdateGui\PSWindowsUpdateGui.csproj') -r win-x64 --locked-mode '-p:Platform=x64' '-p:SelfContained=true' +Invoke-DotNet publish (Join-Path $repoRoot 'src\PSWindowsUpdateGui\PSWindowsUpdateGui.csproj') -c $Configuration -r win-x64 --self-contained true --no-restore '-p:Platform=x64' "-p:Version=$Version" '-p:WindowsPackageType=None' '-p:WindowsAppSDKSelfContained=true' '-p:PublishSingleFile=true' '-p:IncludeAllContentForSelfExtract=true' '-p:EnableMsixTooling=true' '-p:PublishTrimmed=false' '-p:PublishReadyToRun=false' '-o' $publishRoot + +$source = Join-Path $publishRoot 'PSWindowsUpdateGUI.exe' +if (-not (Test-Path -LiteralPath $source)) { throw "Single-file publish did not create $source." } +& (Join-Path $PSScriptRoot 'Verify-Release.ps1') -Executable $source $destination = Join-Path $releaseRoot 'PSWindowsUpdateGUI.exe' Copy-Item -LiteralPath $source -Destination $destination -Force $checksum = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash.ToLowerInvariant() + ' PSWindowsUpdateGUI.exe' Set-Content -LiteralPath (Join-Path $releaseRoot 'PSWindowsUpdateGUI.exe.sha256') -Value $checksum -Encoding ASCII Copy-Item -LiteralPath (Join-Path $repoRoot 'THIRD-PARTY-NOTICES.txt') -Destination $releaseRoot -Force & (Join-Path $PSScriptRoot 'New-Sbom.ps1') -Executable $destination -OutputPath (Join-Path $releaseRoot 'PSWindowsUpdateGUI.spdx.json') -Version $Version +if (-not $SkipTests) { + & (Join-Path $PSScriptRoot 'Test-GuiSmoke.ps1') -Configuration $Configuration +} Write-Host "Release artifacts: $releaseRoot" diff --git a/build/Test-GuiSmoke.ps1 b/build/Test-GuiSmoke.ps1 index e2c5a89..2eebcba 100644 --- a/build/Test-GuiSmoke.ps1 +++ b/build/Test-GuiSmoke.ps1 @@ -5,29 +5,41 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot -$executable = Join-Path $repoRoot "tests\PSWindowsUpdateGui.UiSmoke\bin\x64\$Configuration\net48\PSWindowsUpdateGui.UiSmoke.exe" +$dotnet = if ($env:DOTNET_EXE) { $env:DOTNET_EXE } else { 'dotnet' } +$project = Join-Path $repoRoot 'src\PSWindowsUpdateGui\PSWindowsUpdateGui.csproj' +$smokeRoot = Join-Path $repoRoot 'artifacts\ui-smoke-build' +$darkCapture = Join-Path $repoRoot 'artifacts\ui-smoke-history-dark.png' +$lightCapture = Join-Path $repoRoot 'artifacts\ui-smoke-updates-light.png' +$executable = Join-Path $smokeRoot 'PSWindowsUpdateGUI.exe' $errorLog = Join-Path (Split-Path -Parent $executable) 'ui-smoke-error.log' -if (-not (Test-Path -LiteralPath $executable)) { throw "GUI smoke harness was not built: $executable" } -if (Test-Path -LiteralPath $errorLog) { Remove-Item -LiteralPath $errorLog -Force } +& $dotnet build $project -c UiSmoke '-p:Platform=x64' '-p:UiSmokeBuild=true' "-p:OutputPath=$smokeRoot\" --no-restore +if ($LASTEXITCODE -ne 0) { throw "WinUI smoke build failed with exit code $LASTEXITCODE." } +if (-not (Test-Path -LiteralPath $executable)) { throw "WinUI smoke executable was not built: $executable" } -$process = Start-Process -FilePath $executable -PassThru -WindowStyle Hidden +foreach ($file in @($errorLog, $darkCapture, $lightCapture)) { + if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force } +} + +$previousDotnetRootX64 = $env:DOTNET_ROOT_X64 try { - $deadline = [DateTime]::UtcNow.AddSeconds(20) - do { - Start-Sleep -Milliseconds 200 - $process.Refresh() - if ($process.HasExited) { + if ($dotnet -ne 'dotnet') { $env:DOTNET_ROOT_X64 = [System.IO.Path]::GetFullPath((Split-Path -Parent $dotnet)) } + $scenarios = @( + @{ Theme = 'Dark'; Page = 'history'; Capture = $darkCapture }, + @{ Theme = 'Light'; Page = 'updates'; Capture = $lightCapture } + ) + foreach ($scenario in $scenarios) { + $process = Start-Process -FilePath $executable -ArgumentList @('--ui-smoke', '--theme', $scenario.Theme, '--page', $scenario.Page, '--capture', "`"$($scenario.Capture)`"") -PassThru -Wait + if ($process.ExitCode -ne 0) { $detail = if (Test-Path -LiteralPath $errorLog) { Get-Content -LiteralPath $errorLog -Raw } else { "Exit code $($process.ExitCode)." } - throw "GUI smoke harness exited before creating a window. $detail" + throw "WinUI $($scenario.Theme) $($scenario.Page) smoke test failed. $detail" } - } while ($process.MainWindowHandle -eq [IntPtr]::Zero -and [DateTime]::UtcNow -lt $deadline) - - if ($process.MainWindowHandle -eq [IntPtr]::Zero) { throw 'GUI smoke harness did not create a WPF window within 20 seconds.' } - Write-Host "GUI smoke window created successfully (PID $($process.Id))." + } } finally { - if (-not $process.HasExited) { - [void] $process.CloseMainWindow() - if (-not $process.WaitForExit(5000)) { Stop-Process -Id $process.Id -Force } - } + $env:DOTNET_ROOT_X64 = $previousDotnetRootX64 +} + +foreach ($capture in @($darkCapture, $lightCapture)) { + if (-not (Test-Path -LiteralPath $capture) -or (Get-Item -LiteralPath $capture).Length -lt 1024) { throw "WinUI smoke test did not create a valid screenshot: $capture" } } +Write-Host "WinUI production-assembly smoke tests passed: $darkCapture, $lightCapture" diff --git a/build/Verify-Native.ps1 b/build/Verify-Native.ps1 index 6a798b4..4921948 100644 --- a/build/Verify-Native.ps1 +++ b/build/Verify-Native.ps1 @@ -13,12 +13,14 @@ if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notm } $projectRoot = Split-Path -Parent $PSScriptRoot -$forbidden = @( - (Join-Path $projectRoot 'src\PSWindowsUpdateGui\Resources\PSWindowsUpdate.2.2.1.5.nupkg'), - (Join-Path $projectRoot 'src\PSWindowsUpdateGui\Resources\vendor-manifest.json') -) -foreach ($path in $forbidden) { - if (Test-Path -LiteralPath $path) { throw "Removed PSWindowsUpdate runtime asset is still present: $path" } +$resourceRoot = Join-Path $projectRoot 'src\PSWindowsUpdateGui\Resources' +if (Test-Path -LiteralPath $resourceRoot) { + $unexpectedAssets = Get-ChildItem -LiteralPath $resourceRoot -Recurse -File | + Where-Object { $_.Extension -in @('.nupkg', '.psd1', '.psm1') } + if ($unexpectedAssets) { + $paths = ($unexpectedAssets.FullName -join ', ') + throw "Bundled update-engine package or PowerShell module assets are not allowed: $paths" + } } Write-Host "Verified native Windows Update Agent: $wuapi" diff --git a/build/Verify-Release.ps1 b/build/Verify-Release.ps1 new file mode 100644 index 0000000..077669f --- /dev/null +++ b/build/Verify-Release.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Executable +) + +$ErrorActionPreference = 'Stop' +$resolvedExecutable = (Resolve-Path -LiteralPath $Executable).Path +$buildToolsRoot = Join-Path $env:USERPROFILE '.nuget\packages\microsoft.windows.sdk.buildtools' +$manifestTool = Get-ChildItem -LiteralPath $buildToolsRoot -Recurse -Filter 'mt.exe' -File | + Where-Object { $_.FullName -match '\\x64\\mt\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 +if ($null -eq $manifestTool) { throw "Could not locate the x64 Windows SDK manifest tool under $buildToolsRoot." } + +$inspectionPath = Join-Path ([System.IO.Path]::GetTempPath()) ("PSWindowsUpdateGUI-manifest-{0}.xml" -f [Guid]::NewGuid().ToString('N')) +try { + & $manifestTool.FullName "-inputresource:$resolvedExecutable;#1" "-out:$inspectionPath" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "mt.exe could not inspect the published executable (exit $LASTEXITCODE)." } + $manifest = Get-Content -LiteralPath $inspectionPath -Raw + if ($manifest -notmatch 'name="PSWindowsUpdateGUI\.app"') { throw 'Published executable does not contain the production application identity.' } + if ($manifest -notmatch 'level="requireAdministrator"') { throw 'Published executable does not require administrator elevation.' } + if ($manifest -match 'UiSmoke|level="asInvoker"') { throw 'Published executable contains the test-only smoke manifest.' } +} +finally { + if (Test-Path -LiteralPath $inspectionPath) { Remove-Item -LiteralPath $inspectionPath -Force } +} + +Write-Host "Verified release manifest: elevated production identity in $resolvedExecutable" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d572aa6..daccd5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -`PSWindowsUpdateGUI.exe` has one entry point. With no arguments it starts WPF; with a +`PSWindowsUpdateGUI.exe` has one entry point. With no arguments it starts WinUI 3; with a subcommand it attaches to the parent console and runs headlessly. The typed domain boundary is `IWindowsUpdateEngine`. `WuaWindowsUpdateEngine` owns a @@ -14,10 +14,22 @@ Build-time interop is generated from `%SystemRoot%\System32\wuapi.dll` by `build/Generate-WuaInterop.ps1`. The generated assembly exists only under `obj`; C# embeds referenced interop types in the EXE. -The WPF view model, CLI, scheduled job runner, and remote worker all use the same +The WinUI view model, CLI, scheduled job runner, and remote worker all use the same request models and validation. Update mutations re-scan and match `UpdateID` plus revision before constructing a WUA update collection. +The WinUI 3 shell uses Windows App SDK controls, NavigationView, Mica, and semantic +theme resources rather than fixed page colors. `AppThemeService` resolves the +persisted System, Light, or Dark choice and applies the matching element and app title +bar themes; high contrast continues to resolve through WinUI system resources. Theme +selection is a presentation concern and cannot alter update requests or privileged +execution. + +Normal builds use the elevated application manifest. The production-assembly UI smoke +build defines `UI_SMOKE`, substitutes a test-only `asInvoker` manifest and fake WUA +adapter, loads the same compiled XAML resources, captures a page, and exits. That test +path is excluded from release builds. + Remote mode uses built-in Windows PowerShell only as WinRM transport. A fixed embedded script receives arguments separately, verifies the staged EXE hash, and invokes its CLI on the target. PowerShell is never the update engine. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index bd455f3..fbab325 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -6,14 +6,20 @@ The repository-local SDK can be selected through `DOTNET_EXE`. ```powershell $env:DOTNET_EXE = (Resolve-Path .\.tools\dotnet\dotnet.exe) .\build\Verify-Native.ps1 -.\build\Build.ps1 -Configuration Release -Version 2.0.0-beta.1 +.\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.1 ``` `Build.ps1` restores locked NuGet dependencies, builds x64 with warnings as errors, -runs unit tests and a non-elevated WPF startup smoke harness, copies the single EXE, -generates SHA-256 and SPDX 2.3 output, and copies notices. WUA interop generation reads the installed Microsoft type library; it +runs unit tests and a non-elevated production-assembly WinUI smoke capture, publishes +an unpackaged self-contained single EXE, generates SHA-256 and SPDX 2.3 output, and +copies notices. WUA interop generation reads the installed Microsoft type library; it does not download code and its generated DLL is not a release asset. +The publish pins .NET 10, Windows App SDK 2.3.1, and the Windows SDK build tools in +source and lock files. `PublishSingleFile` plus `IncludeAllContentForSelfExtract` +produces the portable EXE. At runtime .NET extracts its bundled native framework files +to the per-user bundle cache; no Windows App Runtime registration is required. + CI additionally verifies formatting. Release tags use the same build and attach GitHub artifact provenance. Optional Authenticode signing remains in `build/Sign-Release.ps1`. diff --git a/docs/PARAMETER-COVERAGE.md b/docs/PARAMETER-COVERAGE.md index 5ff94cb..6b6d78f 100644 --- a/docs/PARAMETER-COVERAGE.md +++ b/docs/PARAMETER-COVERAGE.md @@ -1,22 +1,21 @@ -# Legacy capability migration +# Native operation coverage -Version 2 preserves user workflows rather than PSWindowsUpdate cmdlet syntax. +The GUI, CLI, scheduled runner, and remote worker share typed request models, +validation, and the operation catalog below. -| Former command | Native replacement | -| --- | --- | -| `Get-WindowsUpdate` | `scan`, `download`, `install`, `hide`, `unhide` | -| `Remove-WindowsUpdate` | `uninstall`, with explicit DISM/WUSA package fallback | -| `Get-WUOfflineMSU` | `download` then `export-payload` | -| `Get-WUApiVersion`, `Get-WUInstallerStatus`, `Get-WURebootStatus` | `status` | -| `Get-WUHistory`, `Get-WULastResults` | `history` and operation results | -| `Get-WUJob`, `Invoke-WUJob` | `job list/create/run/cancel/cleanup` | -| `Add/Get/Remove-WUServiceManager` | `services add-microsoft-update/list/remove` | -| `Get-WUSettings`, `Set-WUSettings` | `policy get/set/restore` | -| `Set-PSWUSettings` | `report configure/test/send` | -| `Reset-WUComponents` | `maintenance reset-components` | -| `Enable-WURemoting` | secure preflight and documentation; configuration is never changed automatically | -| `Update-WUModule` | application release, checksum, and provenance workflow | +| Area | Operations | Identification and safety controls | +| --- | --- | --- | +| Discovery | `scan` | Typed source and update-type filters, validated WUA criteria, structured results | +| Update actions | `download`, `install`, `hide`, `unhide` | Exact update GUID and revision, immediate applicability re-scan, EULA gate, plan and confirmation | +| Removal | `uninstall` | Exact WUA identity and uninstall capability check; explicit package path for DISM/WUSA fallback | +| Health | `history`, `status` | Structured WUA audit history, exact-revision removal planning, API version, installer activity, last result, reboot state, service health, target clock | +| Sources | `services` | List sources, register Microsoft Update, or remove an explicitly identified service | +| Offline | `offline-scan`, `export-payload` | Microsoft signature validation, metadata-only notice, empty export directory, hashes and signature results | +| Administration | `policy`, `maintenance` | Allowlisted policy schema, preview, backup/restore, recoverable component reset | +| Automation | `job` | Versioned fixed-action manifest, ACL-restricted storage, EXE hash, exact update identities | +| Reporting | `report` | Validated SMTP settings, redaction, memory-only or explicitly confirmed Credential Manager secret | +| Remote execution | Common `--computer` and `--use-ssl` options | Secure WinRM preflight, exact staged EXE hash, ownership marker, no automatic security-policy changes | -The test catalog contains all 19 former manifest exports and fails on a missing mapping. -Arbitrary scheduled scripts are deliberately removed; scheduled jobs contain only a -versioned action, exact update identities, source, timing, EULA choice, and EXE hash. +Catalog tests require all 15 public operation groups to remain unique, documented, +and non-empty. Scheduled jobs accept only schema-validated fixed operations; executable +script text is never accepted. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e2034c6..65e176e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -11,10 +11,15 @@ - The build verifies Microsoft Authenticode on `wuapi.dll`; WUA validates the Microsoft signature on registered `wsusscn2.cab` catalogs. - Update mutations require exact GUID and revision identities and an immediate re-scan. +- History-driven removal first runs a non-mutating uninstall plan for the exact installed + GUID and revision. The modifying control stays disabled until Windows reports that + revision as uninstallable, and changing the target or update source invalidates the plan. - A machine-wide mutex plus WUA `IsBusy` prevents overlapping app modifications. - Search criteria have a length-limited allowlisted grammar. Regex evaluation has a timeout. - CLI arguments are parsed as data. No update input is interpolated into executable code. -- Remote scripts are fixed resources with external values passed through `ArgumentList`. +- The WinRM transport script is fixed and encoded by the application. Typed operation + data is serialized as JSON over the child process standard-input stream; user values + are never appended to the PowerShell command line or interpolated into script text. - Staged remote files and scheduled manifests use SYSTEM/Administrators-only ACLs, ownership markers, executable hashes, schema validation, and narrow paths. - Policy names, types, and ranges are allowlisted. A backup is written before application. diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..f8d7b11 --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,43 @@ +# WinUI 3 interface and theming + +Version 3 moves the desktop application to WinUI 3 on the Windows App SDK. The GUI +and CLI still ship in the same elevated, unpackaged, self-contained executable. + +## Design system + +- A compact command header keeps the target, identity, elevation, portable-storage + state, connection check, theme, and About context visible. +- Top NavigationView destinations separate Updates, History & Status, Services & + Policies, Offline & Maintenance, the operation catalog, and logs. +- Semantic WinUI theme resources provide layered Mica-backed surfaces, status cards, + restrained borders, table headers, keyboard focus, accent actions, warnings, and + destructive actions. +- History is a first-class typed list beneath the status controls. Selecting an entry + shows its exact WUA identity and enables a plan-first removal check. History itself + remains an immutable audit record. +- Controls expose AutomationProperties names where context is not otherwise clear. + WinUI's system resources retain Windows high-contrast behavior. + +The theme selector offers `System`, `Light`, and `Dark`. Its non-secret preference is +stored in `PSWindowsUpdateGUI.Data/settings.json` beside the EXE when portable storage +is writable. `System` follows Windows app theme; explicit modes also set the WinUI +title-bar theme. + +## Deployment behavior + +The project uses an unpackaged, self-contained Windows App SDK deployment. The release +contains one `PSWindowsUpdateGUI.exe` and does not register an MSIX package or require +a separately installed Windows App Runtime. .NET single-file extraction places native +framework content in the current user's temporary bundle cache on first launch. This +is runtime extraction, not a permanent module or runtime installation. + +The release manifest requires administrator elevation. A separate `asInvoker` +manifest exists only behind the `UI_SMOKE` build property, allowing CI to load the +production XAML with a fake engine and capture it without modifying Windows Update. + +Microsoft references: + +- [WinUI 3](https://learn.microsoft.com/windows/apps/winui/winui3/) +- [Unpackaged WinUI deployment](https://learn.microsoft.com/windows/apps/package-and-deploy/unpackage-winui-app) +- [WinUI theming](https://learn.microsoft.com/windows/apps/develop/ui/theming) +- [WinUI title bars](https://learn.microsoft.com/windows/apps/develop/title-bar) diff --git a/docs/VM-ACCEPTANCE.md b/docs/VM-ACCEPTANCE.md index 961aae2..af036a9 100644 --- a/docs/VM-ACCEPTANCE.md +++ b/docs/VM-ACCEPTANCE.md @@ -5,7 +5,8 @@ physical machine. ## Local VM -- Verify a machine with no PSWindowsUpdate installation launches the single EXE. +- Verify a clean Windows 11 machine launches the single EXE without installing an + application runtime, update engine, or supporting files. - Run status, default/software/driver scans, history, services, validated criteria, and cancellation. - Plan then install one exact software update; verify per-update result and reboot state. - Plan then install one exact driver; compare provider/version/date before and after and test snapshot rollback. diff --git a/docs/images/gui-history-dark.png b/docs/images/gui-history-dark.png new file mode 100644 index 0000000..a321b60 Binary files /dev/null and b/docs/images/gui-history-dark.png differ diff --git a/docs/images/gui-updates.png b/docs/images/gui-updates.png index 05557ff..a11f822 100644 Binary files a/docs/images/gui-updates.png and b/docs/images/gui-updates.png differ diff --git a/src/PSWindowsUpdateGui/App.xaml b/src/PSWindowsUpdateGui/App.xaml index 7c57d49..fb82b28 100644 --- a/src/PSWindowsUpdateGui/App.xaml +++ b/src/PSWindowsUpdateGui/App.xaml @@ -1,36 +1,17 @@ - + - - - - - - - - - - - - + + + + + + + + diff --git a/src/PSWindowsUpdateGui/App.xaml.cs b/src/PSWindowsUpdateGui/App.xaml.cs index 447f981..14b8680 100644 --- a/src/PSWindowsUpdateGui/App.xaml.cs +++ b/src/PSWindowsUpdateGui/App.xaml.cs @@ -1,5 +1,7 @@ using System; -using System.Windows; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; using Microsoft.Win32; using PSWindowsUpdateGui.Models; using PSWindowsUpdateGui.Services; @@ -11,45 +13,83 @@ namespace PSWindowsUpdateGui; public partial class App : Application { private IWindowsUpdateEngine? _engine; + private AppThemeService? _themeService; + private MainWindow? _window; - protected override void OnStartup(StartupEventArgs e) + internal static Func? EngineFactoryOverride { get; set; } + internal static string? ThemeOverride { get; set; } + internal static Action? WindowCreatedForTest { get; set; } + internal static int ExitCode { get; private set; } + + public App() { - base.OnStartup(e); - DispatcherUnhandledException += (_, args) => + InitializeComponent(); + DebugSettings.XamlResourceReferenceFailed += (_, args) => TraceXaml("Resource", args.Message); + DebugSettings.BindingFailed += (_, args) => TraceXaml("Binding", args.Message); + UnhandledException += (_, args) => { - MessageBox.Show(args.Exception.ToString(), "Unhandled PSWindowsUpdate GUI error", MessageBoxButton.OK, MessageBoxImage.Error); +#if UI_SMOKE + try { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-error.log"), args.Exception.ToString()); } + catch { } +#endif + NativeDialog.Show("Unhandled PSWindowsUpdate GUI error", args.Exception.ToString()); args.Handled = true; }; + } + + private static void TraceXaml(string category, string message) + { + try { File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "xaml-diagnostics.log"), $"{DateTime.UtcNow:O} {category}: {message}{Environment.NewLine}"); } + catch { } + } + protected override void OnLaunched(LaunchActivatedEventArgs args) + { try { var windowsBuild = GetWindowsBuildNumber(); if (!Environment.Is64BitOperatingSystem || windowsBuild < 22000) - { - throw new PlatformNotSupportedException( - $"PSWindowsUpdate GUI supports Windows 11 x64 (build 22000 or newer). Detected build: {windowsBuild}."); - } + throw new PlatformNotSupportedException($"PSWindowsUpdate GUI supports Windows 11 x64 (build 22000 or newer). Detected build: {windowsBuild}."); var settingsService = new PortableSettingsService(); var settings = settingsService.Load(); + if (ThemeOverride != null) settings.ThemePreference = AppThemeService.NormalizePreference(ThemeOverride); + _themeService = new AppThemeService(); + _themeService.Apply(settings.ThemePreference); var log = new PortableLogService(settingsService.DataDirectory, settings); - _engine = new WuaWindowsUpdateEngine(); - var viewModel = new MainViewModel(_engine, settingsService, settings, log); - var window = new MainWindow(viewModel); - MainWindow = window; - window.Show(); + _engine = EngineFactoryOverride?.Invoke() ?? new WuaWindowsUpdateEngine(); + var dialogs = new WinUiDialogService(() => _window?.Content?.XamlRoot); +#if UI_SMOKE + var viewModel = new MainViewModel(_engine, settingsService, settings, log, _themeService, dialogs, + identityOverride: @"CONTOSO\UpdateAdmin", elevationOverride: false, + persistenceNoticeOverride: @"Portable data: C:\Tools\PSWindowsUpdateGUI.Data", localComputerOverride: "WIN11-LAB"); +#else + var viewModel = new MainViewModel(_engine, settingsService, settings, log, _themeService, dialogs); +#endif + _window = new MainWindow(viewModel, _themeService, dialogs); + _window.Closed += (_, __) => Cleanup(); + WindowCreatedForTest?.Invoke(_window); + _window.Activate(); } catch (Exception exception) { - MessageBox.Show(exception.ToString(), "PSWindowsUpdate GUI could not start", MessageBoxButton.OK, MessageBoxImage.Error); - Shutdown(1); + ExitCode = 1; +#if UI_SMOKE + try { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-error.log"), exception.ToString()); } + catch { } +#endif + NativeDialog.Show("PSWindowsUpdate GUI could not start", exception.ToString()); + Cleanup(); + Exit(); } } - protected override void OnExit(ExitEventArgs e) + private void Cleanup() { _engine?.Dispose(); - base.OnExit(e); + _engine = null; + _themeService?.Dispose(); + _themeService = null; } internal static int GetWindowsBuildNumber() @@ -59,4 +99,12 @@ internal static int GetWindowsBuildNumber() var value = currentVersion?.GetValue("CurrentBuildNumber")?.ToString(); return int.TryParse(value, out var build) ? build : Environment.OSVersion.Version.Build; } + + private static class NativeDialog + { + public static void Show(string title, string message) => _ = MessageBox(IntPtr.Zero, message, title, 0x00000010); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "MessageBoxW")] + private static extern int MessageBox(IntPtr window, string text, string caption, uint type); + } } diff --git a/src/PSWindowsUpdateGui/Infrastructure/BoolToVisibilityConverter.cs b/src/PSWindowsUpdateGui/Infrastructure/BoolToVisibilityConverter.cs index ddde45b..b544979 100644 --- a/src/PSWindowsUpdateGui/Infrastructure/BoolToVisibilityConverter.cs +++ b/src/PSWindowsUpdateGui/Infrastructure/BoolToVisibilityConverter.cs @@ -1,19 +1,18 @@ using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; namespace PSWindowsUpdateGui.Infrastructure; -internal sealed class BoolToVisibilityConverter : IValueConverter +public sealed class BoolToVisibilityConverter : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + public object Convert(object value, Type targetType, object parameter, string language) { var visible = value is bool flag && flag; if (string.Equals(parameter?.ToString(), "Invert", StringComparison.OrdinalIgnoreCase)) visible = !visible; return visible ? Visibility.Visible : Visibility.Collapsed; } - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); } diff --git a/src/PSWindowsUpdateGui/Models/OperationCatalog.cs b/src/PSWindowsUpdateGui/Models/OperationCatalog.cs index c3bc9e6..3d58c9d 100644 --- a/src/PSWindowsUpdateGui/Models/OperationCatalog.cs +++ b/src/PSWindowsUpdateGui/Models/OperationCatalog.cs @@ -14,16 +14,6 @@ public OperationDefinition(string name, string category, bool mutating, string d public string Description { get; } } -internal sealed class LegacyCapabilityMapping -{ - public LegacyCapabilityMapping(string legacyCommand, string replacement) - { - LegacyCommand = legacyCommand; Replacement = replacement; - } - public string LegacyCommand { get; } - public string Replacement { get; } -} - internal static class OperationCatalog { public static readonly IReadOnlyList Operations = new[] @@ -44,27 +34,4 @@ internal static class OperationCatalog new OperationDefinition("job", "Automation", true, "Create, run, inspect, cancel, or clean schema-validated scheduled update jobs."), new OperationDefinition("report", "Reporting", true, "Configure and send SMTP reports without storing passwords in portable state.") }; - - public static readonly IReadOnlyList LegacyMappings = new[] - { - new LegacyCapabilityMapping("Add-WUServiceManager", "services add-microsoft-update"), - new LegacyCapabilityMapping("Enable-WURemoting", "secure remote preflight and documentation; never changes WinRM automatically"), - new LegacyCapabilityMapping("Get-WindowsUpdate", "scan/download/install/hide/unhide"), - new LegacyCapabilityMapping("Get-WUApiVersion", "status"), - new LegacyCapabilityMapping("Get-WUHistory", "history"), - new LegacyCapabilityMapping("Get-WUInstallerStatus", "status"), - new LegacyCapabilityMapping("Get-WUJob", "job list"), - new LegacyCapabilityMapping("Get-WULastResults", "history/status and operation journal"), - new LegacyCapabilityMapping("Get-WUOfflineMSU", "download plus export-payload"), - new LegacyCapabilityMapping("Get-WURebootStatus", "status"), - new LegacyCapabilityMapping("Get-WUServiceManager", "services list"), - new LegacyCapabilityMapping("Get-WUSettings", "policy get"), - new LegacyCapabilityMapping("Invoke-WUJob", "job create with a fixed manifest; arbitrary scripts intentionally removed"), - new LegacyCapabilityMapping("Remove-WindowsUpdate", "uninstall with WUA capability checks"), - new LegacyCapabilityMapping("Remove-WUServiceManager", "services remove"), - new LegacyCapabilityMapping("Reset-WUComponents", "maintenance reset-components"), - new LegacyCapabilityMapping("Set-PSWUSettings", "report configure/test/send"), - new LegacyCapabilityMapping("Set-WUSettings", "policy set/restore"), - new LegacyCapabilityMapping("Update-WUModule", "application release/checksum update workflow; no runtime module exists") - }; } diff --git a/src/PSWindowsUpdateGui/Models/PortableSettings.cs b/src/PSWindowsUpdateGui/Models/PortableSettings.cs index 152ea32..6fd488b 100644 --- a/src/PSWindowsUpdateGui/Models/PortableSettings.cs +++ b/src/PSWindowsUpdateGui/Models/PortableSettings.cs @@ -20,4 +20,7 @@ internal sealed class PortableSettings [DataMember(Name = "logSizeLimitMb", Order = 5)] public int LogSizeLimitMb { get; set; } = 25; + + [DataMember(Name = "theme", Order = 6)] + public string ThemePreference { get; set; } = "System"; } diff --git a/src/PSWindowsUpdateGui/Models/UpdateModels.cs b/src/PSWindowsUpdateGui/Models/UpdateModels.cs index 488b0aa..5daa48f 100644 --- a/src/PSWindowsUpdateGui/Models/UpdateModels.cs +++ b/src/PSWindowsUpdateGui/Models/UpdateModels.cs @@ -271,6 +271,12 @@ internal sealed class UpdateSystemStatus [DataMember(Name = "updateServiceStatus", Order = 5)] public string UpdateServiceStatus { get; set; } = string.Empty; [DataMember(Name = "orchestratorNotice", Order = 6)] public string OrchestratorNotice { get; set; } = string.Empty; [DataMember(Name = "localTime", Order = 7)] public DateTimeOffset LocalTime { get; set; } + + [IgnoreDataMember] public bool IsLoaded => LocalTime != default || !string.IsNullOrWhiteSpace(AgentVersion); + [IgnoreDataMember] public string AgentVersionDisplay => IsLoaded ? AgentVersion : "Not loaded"; + [IgnoreDataMember] public string InstallerState => !IsLoaded ? "Not checked" : InstallerBusy ? "Busy" : "Idle"; + [IgnoreDataMember] public string RebootState => !IsLoaded ? "Not checked" : RebootRequired ? "Required" : "Not required"; + [IgnoreDataMember] public string LocalTimeDisplay => LocalTime == default ? "Not loaded" : LocalTime.ToString("g"); } [DataContract] diff --git a/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj b/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj index d1ce805..5cc63b8 100644 --- a/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj +++ b/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj @@ -1,39 +1,44 @@ - + WinExe PSWindowsUpdateGui.Program - net48 - true + net10.0-windows10.0.26100.0 + 10.0.22000.0 + true + false + None + true + false + win-x64 + false + true + true + false + false + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + $(DefineConstants);UI_SMOKE PSWindowsUpdateGUI PSWindowsUpdateGui x64 x64 - false enable true true true - app.manifest - 2.0.0-beta.1 - 2.0.0.0 - 2.0.0.0 + app.manifest + app.smoke.manifest + 3.0.0-beta.1 + 3.0.0.0 + 3.0.0.0 PSWindowsUpdate GUI - Independent portable Windows Update Agent GUI and CLI. + Independent portable Windows Update Agent GUI and CLI built with WinUI 3. Copyright (c) 2026 PSWindowsUpdateGUI contributors - - - $(SystemRoot)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll - false - - - - - - - + + + $(MSBuildProjectDirectory)\$(IntermediateOutputPath)Interop.WUApiLib.dll false diff --git a/src/PSWindowsUpdateGui/Program.cs b/src/PSWindowsUpdateGui/Program.cs index a8409ee..73bfecc 100644 --- a/src/PSWindowsUpdateGui/Program.cs +++ b/src/PSWindowsUpdateGui/Program.cs @@ -3,6 +3,9 @@ using System.Runtime.InteropServices; using System.Runtime.Serialization.Json; using System.Text; +using System.Threading; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; using PSWindowsUpdateGui.Cli; using PSWindowsUpdateGui.Services; @@ -18,11 +21,30 @@ internal static class Program [STAThread] public static int Main(string[] args) { - if (args.Length == 0 || (args.Length == 1 && string.Equals(args[0], "gui", StringComparison.OrdinalIgnoreCase))) + var isGui = args.Length == 0 || (args.Length == 1 && string.Equals(args[0], "gui", StringComparison.OrdinalIgnoreCase)); +#if UI_SMOKE + isGui = isGui || UiSmokeBootstrap.TryConfigure(args); + File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Program configured; isGui={isGui}{Environment.NewLine}"); +#endif + if (isGui) { - var application = new App(); - application.InitializeComponent(); - return application.Run(); +#if UI_SMOKE + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Starting WinUI{Environment.NewLine}"); +#endif + WinRT.ComWrappersSupport.InitializeComWrappers(); + Application.Start(initialization => + { +#if UI_SMOKE + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Application callback{Environment.NewLine}"); +#endif + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + new App(); + }); +#if UI_SMOKE + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Application exited; code={App.ExitCode}{Environment.NewLine}"); +#endif + return App.ExitCode; } AttachParentConsole(); diff --git a/src/PSWindowsUpdateGui/Services/AppThemeService.cs b/src/PSWindowsUpdateGui/Services/AppThemeService.cs new file mode 100644 index 0000000..d6a6216 --- /dev/null +++ b/src/PSWindowsUpdateGui/Services/AppThemeService.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; + +namespace PSWindowsUpdateGui.Services; + +internal sealed class AppThemeService : IDisposable +{ + internal const string SystemPreference = "System"; + internal const string LightPreference = "Light"; + internal const string DarkPreference = "Dark"; + + public event EventHandler? ThemeChanged; + + public string Preference { get; private set; } = SystemPreference; + + public ElementTheme ElementTheme => Preference switch + { + LightPreference => ElementTheme.Light, + DarkPreference => ElementTheme.Dark, + _ => ElementTheme.Default + }; + + public TitleBarTheme TitleBarTheme => Preference switch + { + LightPreference => TitleBarTheme.Light, + DarkPreference => TitleBarTheme.Dark, + _ => TitleBarTheme.UseDefaultAppMode + }; + + public static string NormalizePreference(string? preference) + { + if (string.Equals(preference, LightPreference, StringComparison.OrdinalIgnoreCase)) return LightPreference; + if (string.Equals(preference, DarkPreference, StringComparison.OrdinalIgnoreCase)) return DarkPreference; + return SystemPreference; + } + + public void Apply(string? preference) + { + Preference = NormalizePreference(preference); + ThemeChanged?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() { } +} diff --git a/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs b/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs index 100e679..ba807ae 100644 --- a/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs +++ b/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs @@ -44,7 +44,9 @@ public PortableSettings Load() { using var stream = File.OpenRead(_settingsPath); var serializer = new DataContractJsonSerializer(typeof(PortableSettings)); - return (PortableSettings)(serializer.ReadObject(stream) ?? new PortableSettings()); + var settings = (PortableSettings)(serializer.ReadObject(stream) ?? new PortableSettings()); + settings.ThemePreference = AppThemeService.NormalizePreference(settings.ThemePreference); + return settings; } catch { diff --git a/src/PSWindowsUpdateGui/Services/RemoteCliClient.cs b/src/PSWindowsUpdateGui/Services/RemoteCliClient.cs index ab1f7e2..5316247 100644 --- a/src/PSWindowsUpdateGui/Services/RemoteCliClient.cs +++ b/src/PSWindowsUpdateGui/Services/RemoteCliClient.cs @@ -3,12 +3,12 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Management.Automation; using System.Net; using System.Security.Cryptography; using System.Security.AccessControl; using System.Security.Principal; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Runtime.Serialization.Json; using System.Threading.Tasks; @@ -20,6 +20,28 @@ internal sealed class RemoteCliClient private const string MarkerName = "PSWindowsUpdateGUI.owner"; private static readonly Regex DnsName = new Regex(@"^(?=.{1,253}$)(?!-)(?:[A-Za-z0-9-]{1,63}\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})$", RegexOptions.Compiled); + private const string TransportScript = @" +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::InputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +try { + $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json -ErrorAction Stop + $scriptText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$payload.ScriptBase64)) + $invoke = @{ + ComputerName = [string]$payload.ComputerName + ScriptBlock = [ScriptBlock]::Create($scriptText) + ArgumentList = @($payload.Arguments) + ErrorAction = 'Stop' + } + if ([bool]$payload.UseSsl) { $invoke.UseSSL = $true } + $values = @(Invoke-Command @invoke) + [Console]::Out.Write(($values | ConvertTo-Json -Compress -Depth 12)) +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +}"; + private const string PreflightScript = @" param($useSsl) $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -58,7 +80,8 @@ public static void ValidateComputerName(string computerName) private static int Execute(string[] rawArguments, string computerName, bool useSsl) { var preflight = Invoke(computerName, useSsl, PreflightScript, useSsl); - var remote = preflight.FirstOrDefault() ?? throw new InvalidOperationException("Remote preflight returned no data."); + if (preflight.Count == 0) throw new InvalidOperationException("Remote preflight returned no data."); + var remote = preflight[0]; if (!Read(remote, "Is64Bit") || Read(remote, "OsBuild") < 22000) throw new PlatformNotSupportedException("The remote target must be Windows 11 x64."); if (!Read(remote, "IsAdministrator")) throw new UnauthorizedAccessException("The remote WinRM session does not have an administrator token."); @@ -99,8 +122,9 @@ private static int Execute(string[] rawArguments, string computerName, bool useS { forwarded = forwarded.Concat(new[] { "--output", "json" }).ToArray(); } - var result = Invoke(computerName, useSsl, ExecuteScript, remoteDirectory + "\\PSWindowsUpdateGUI.exe", forwarded, hash).FirstOrDefault() - ?? throw new InvalidOperationException("The remote CLI returned no result."); + var execution = Invoke(computerName, useSsl, ExecuteScript, remoteDirectory + "\\PSWindowsUpdateGUI.exe", forwarded, hash); + if (execution.Count == 0) throw new InvalidOperationException("The remote CLI returned no result."); + var result = execution[0]; var output = Read(result, "Output") ?? string.Empty; if (output.Length > 0) Console.WriteLine(output); var exitCode = Read(result, "ExitCode"); @@ -121,24 +145,53 @@ private static IEnumerable RemoveTransportOptions(IList argument } } - private static IReadOnlyList Invoke(string computerName, bool useSsl, string script, params object[] arguments) + internal static IReadOnlyList Invoke(string computerName, bool useSsl, string script, params object[] arguments) { - using var powerShell = PowerShell.Create(); - powerShell.AddCommand("Invoke-Command") - .AddParameter("ComputerName", computerName) - .AddParameter("ScriptBlock", ScriptBlock.Create(script)) - .AddParameter("ArgumentList", arguments) - .AddParameter("ErrorAction", ActionPreference.Stop); - if (useSsl) powerShell.AddParameter("UseSSL", true); - var output = powerShell.Invoke(); - if (powerShell.HadErrors) throw new InvalidOperationException(string.Join(Environment.NewLine, powerShell.Streams.Error.Select(error => error.Exception.Message))); - return output; + ValidateComputerName(computerName); + var powerShellPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + if (!File.Exists(powerShellPath)) throw new PlatformNotSupportedException("Windows PowerShell 5.1 was not found for the fixed WinRM transport."); + + var encodedTransport = Convert.ToBase64String(Encoding.Unicode.GetBytes(TransportScript)); + var payload = JsonSerializer.Serialize(new + { + ComputerName = computerName, + UseSsl = useSsl, + ScriptBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(script)), + Arguments = arguments + }); + var start = new ProcessStartInfo + { + FileName = powerShellPath, + Arguments = "-NoLogo -NoProfile -NonInteractive -EncodedCommand " + encodedTransport, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), + StandardOutputEncoding = new UTF8Encoding(false), + StandardErrorEncoding = new UTF8Encoding(false) + }; + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start the fixed Windows PowerShell transport."); + process.StandardInput.Write(payload); + process.StandardInput.Close(); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? "The WinRM transport failed." : error.Trim()); + if (string.IsNullOrWhiteSpace(output)) return Array.Empty(); + + using var document = JsonDocument.Parse(output.Trim()); + if (document.RootElement.ValueKind == JsonValueKind.Array) + return document.RootElement.EnumerateArray().Select(value => value.Clone()).ToArray(); + if (document.RootElement.ValueKind == JsonValueKind.Null) return Array.Empty(); + return new[] { document.RootElement.Clone() }; } - private static T Read(PSObject value, string property) + internal static T Read(JsonElement value, string property) { - var raw = value.Properties[property]?.Value; - return raw == null ? default! : (T)LanguagePrimitives.ConvertTo(raw, typeof(T)); + if (!value.TryGetProperty(property, out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return default!; + return raw.Deserialize()!; } private static string Hash(string path) @@ -164,7 +217,7 @@ private static void RestrictDirectory(string path) InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); - Directory.SetAccessControl(path, security); + new DirectoryInfo(path).SetAccessControl(security); } private static void ReconcileStaleOwned(string remoteRoot, string jobRoot, string currentHash) diff --git a/src/PSWindowsUpdateGui/Services/RemoteGuiBridge.cs b/src/PSWindowsUpdateGui/Services/RemoteGuiBridge.cs index a37839f..6cf10e4 100644 --- a/src/PSWindowsUpdateGui/Services/RemoteGuiBridge.cs +++ b/src/PSWindowsUpdateGui/Services/RemoteGuiBridge.cs @@ -26,14 +26,21 @@ public static async Task> ScanAsync(ScanRequest requ } public static async Task ExecuteAsync(UpdateActionRequest request, string computerName, bool useSsl, CancellationToken cancellationToken) + { + var arguments = BuildExecuteArguments(request, computerName, useSsl); + var envelope = await RunAsync(arguments, cancellationToken).ConfigureAwait(false); + return envelope.Data ?? throw new InvalidDataException("The remote update operation returned no result data."); + } + + internal static List BuildExecuteArguments(UpdateActionRequest request, string computerName, bool useSsl) { var arguments = new List { request.Action.ToString().ToLowerInvariant(), "--computer", computerName, "--source", FormatSource(request.Source), "--output", "json", "--yes" }; if (useSsl) arguments.Add("--use-ssl"); if (request.AcceptEulas) arguments.Add("--accept-eula"); if (request.Force) arguments.Add("--force"); + if (request.PlanOnly) arguments.Add("--plan"); foreach (var update in request.Updates) { arguments.Add("--update"); arguments.Add(update.ToString()); } - var envelope = await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return envelope.Data ?? throw new InvalidDataException("The remote update operation returned no result data."); + return arguments; } public static async Task StatusAsync(string computerName, bool useSsl, CancellationToken cancellationToken) diff --git a/src/PSWindowsUpdateGui/Services/RemotePreflightService.cs b/src/PSWindowsUpdateGui/Services/RemotePreflightService.cs index e206eb9..a734e1e 100644 --- a/src/PSWindowsUpdateGui/Services/RemotePreflightService.cs +++ b/src/PSWindowsUpdateGui/Services/RemotePreflightService.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Management.Automation; using System.Net; +using System.Text.Json; namespace PSWindowsUpdateGui.Services; @@ -46,25 +46,11 @@ public RemotePreflightResult Run(string computerName, bool useSsl) return result; } - PSObject remote; + JsonElement remote; try { - using var powerShell = PowerShell.Create(); - powerShell.AddCommand("Invoke-Command") - .AddParameter("ComputerName", computerName) - .AddParameter("ScriptBlock", ScriptBlock.Create(ProbeScript)) - .AddParameter("ErrorAction", ActionPreference.Stop); - if (useSsl) - { - powerShell.AddParameter("UseSSL", true); - } - - remote = powerShell.Invoke().FirstOrDefault() ?? throw new InvalidOperationException("The remote probe returned no data."); - if (powerShell.HadErrors) - { - throw new InvalidOperationException(string.Join(Environment.NewLine, powerShell.Streams.Error.Select(error => error.ToString()))); - } - + remote = RemoteCliClient.Invoke(computerName, useSsl, ProbeScript).FirstOrDefault(); + if (remote.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) throw new InvalidOperationException("The remote probe returned no data."); result.Add(useSsl ? "WinRM HTTPS" : "WinRM Kerberos/Negotiate", true, "Connected with normal certificate and authentication validation."); } catch (Exception exception) @@ -110,16 +96,7 @@ public RemotePreflightResult Run(string computerName, bool useSsl) return result; } - private static T Read(PSObject value, string property) - { - var raw = value.Properties[property]?.Value; - if (raw == null) - { - return default!; - } - - return (T)LanguagePrimitives.ConvertTo(raw, typeof(T)); - } + private static T Read(JsonElement value, string property) => RemoteCliClient.Read(value, property); private static string FormatBytes(long bytes) => $"{bytes / 1024d / 1024d / 1024d:N1} GiB"; } diff --git a/src/PSWindowsUpdateGui/Services/ReportService.cs b/src/PSWindowsUpdateGui/Services/ReportService.cs index 68b49d0..9fbe9e2 100644 --- a/src/PSWindowsUpdateGui/Services/ReportService.cs +++ b/src/PSWindowsUpdateGui/Services/ReportService.cs @@ -198,7 +198,7 @@ public static void Write(string target, string userName, SecureString password) if (!CredRead(target, GenericCredential, 0, out var pointer)) return null; try { - var credential = (NativeCredential)Marshal.PtrToStructure(pointer, typeof(NativeCredential)); + var credential = Marshal.PtrToStructure(pointer); var password = credential.CredentialBlob == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2) ?? string.Empty; return new NetworkCredential(credential.UserName, password); } diff --git a/src/PSWindowsUpdateGui/Services/ScheduledJobService.cs b/src/PSWindowsUpdateGui/Services/ScheduledJobService.cs index 57f7bf7..395faa9 100644 --- a/src/PSWindowsUpdateGui/Services/ScheduledJobService.cs +++ b/src/PSWindowsUpdateGui/Services/ScheduledJobService.cs @@ -183,7 +183,7 @@ private void EnsureRoot() security.SetAccessRuleProtection(true, false); security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); - Directory.SetAccessControl(_jobRoot, security); + new DirectoryInfo(_jobRoot).SetAccessControl(security); } catch (PlatformNotSupportedException) { } } diff --git a/src/PSWindowsUpdateGui/Services/StaComWorker.cs b/src/PSWindowsUpdateGui/Services/StaComWorker.cs index 0d0989c..6b6d745 100644 --- a/src/PSWindowsUpdateGui/Services/StaComWorker.cs +++ b/src/PSWindowsUpdateGui/Services/StaComWorker.cs @@ -1,24 +1,27 @@ using System; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using System.Windows.Threading; namespace PSWindowsUpdateGui.Services; internal sealed class StaComWorker : IDisposable { + private const uint WorkMessage = 0x8001; + private const uint QuitMessage = 0x0012; + private const uint PeekNoRemove = 0; + private readonly Thread _thread; - private readonly ManualResetEventSlim _ready = new ManualResetEventSlim(false); - private Dispatcher? _dispatcher; + private readonly ManualResetEventSlim _ready = new(false); + private readonly ConcurrentQueue _work = new(); + private uint _threadId; private bool _disposed; public StaComWorker(string name) { - _thread = new Thread(Run) - { - IsBackground = true, - Name = name - }; + _thread = new Thread(Run) { IsBackground = true, Name = name }; _thread.SetApartmentState(ApartmentState.STA); _thread.Start(); _ready.Wait(); @@ -26,38 +29,121 @@ public StaComWorker(string name) public Task InvokeAsync(Func> operation) { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (_disposed || _dispatcher == null) throw new ObjectDisposedException(nameof(StaComWorker)); - return _dispatcher.InvokeAsync(operation, DispatcherPriority.Normal).Task.Unwrap(); + ArgumentNullException.ThrowIfNull(operation); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(async () => + { + try { completion.TrySetResult(await operation().ConfigureAwait(true)); } + catch (OperationCanceledException exception) { completion.TrySetCanceled(exception.CancellationToken); } + catch (Exception exception) { completion.TrySetException(exception); } + }); + return completion.Task; } public Task InvokeAsync(Func operation) { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (_disposed || _dispatcher == null) throw new ObjectDisposedException(nameof(StaComWorker)); - return _dispatcher.InvokeAsync(operation, DispatcherPriority.Normal).Task.Unwrap(); + ArgumentNullException.ThrowIfNull(operation); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(async () => + { + try { await operation().ConfigureAwait(true); completion.TrySetResult(); } + catch (OperationCanceledException exception) { completion.TrySetCanceled(exception.CancellationToken); } + catch (Exception exception) { completion.TrySetException(exception); } + }); + return completion.Task; } public Task InvokeAsync(Func operation) { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (_disposed || _dispatcher == null) throw new ObjectDisposedException(nameof(StaComWorker)); - return _dispatcher.InvokeAsync(operation, DispatcherPriority.Normal).Task; + ArgumentNullException.ThrowIfNull(operation); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(() => + { + try { completion.TrySetResult(operation()); } + catch (Exception exception) { completion.TrySetException(exception); } + }); + return completion.Task; + } + + private void Post(Action action) + { + if (_disposed) throw new ObjectDisposedException(nameof(StaComWorker)); + _work.Enqueue(action); + if (!PostThreadMessage(_threadId, WorkMessage, UIntPtr.Zero, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not wake the WUA STA dispatcher."); } private void Run() { - _dispatcher = Dispatcher.CurrentDispatcher; + _threadId = GetCurrentThreadId(); + _ = PeekMessage(out _, IntPtr.Zero, 0, 0, PeekNoRemove); + SynchronizationContext.SetSynchronizationContext(new PumpSynchronizationContext(Post)); _ready.Set(); - Dispatcher.Run(); + + while (true) + { + var result = GetMessage(out var message, IntPtr.Zero, 0, 0); + if (result == 0 || message.Message == QuitMessage) break; + if (result == -1) throw new Win32Exception(Marshal.GetLastWin32Error(), "The WUA STA message pump failed."); + if (message.Message == WorkMessage) + { + while (_work.TryDequeue(out var action)) action(); + continue; + } + _ = TranslateMessage(ref message); + _ = DispatchMessage(ref message); + } } public void Dispose() { if (_disposed) return; _disposed = true; - _dispatcher?.BeginInvokeShutdown(DispatcherPriority.Send); - if (!_thread.Join(TimeSpan.FromSeconds(10))) _thread.Interrupt(); + _ = PostThreadMessage(_threadId, QuitMessage, UIntPtr.Zero, IntPtr.Zero); + _ = _thread.Join(TimeSpan.FromSeconds(10)); _ready.Dispose(); } + + private sealed class PumpSynchronizationContext : SynchronizationContext + { + private readonly Action _post; + public PumpSynchronizationContext(Action post) => _post = post; + public override void Post(SendOrPostCallback callback, object? state) => _post(() => callback(state)); + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeMessage + { + public IntPtr Window; + public uint Message; + public UIntPtr WParam; + public IntPtr LParam; + public uint Time; + public Point Point; + public uint Private; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point { public int X; public int Y; } + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PostThreadMessage(uint threadId, uint message, UIntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern int GetMessage(out NativeMessage message, IntPtr window, uint minimum, uint maximum); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PeekMessage(out NativeMessage message, IntPtr window, uint minimum, uint maximum, uint remove); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TranslateMessage(ref NativeMessage message); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref NativeMessage message); } diff --git a/src/PSWindowsUpdateGui/Services/UserDialogService.cs b/src/PSWindowsUpdateGui/Services/UserDialogService.cs new file mode 100644 index 0000000..705ce4b --- /dev/null +++ b/src/PSWindowsUpdateGui/Services/UserDialogService.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace PSWindowsUpdateGui.Services; + +internal interface IUserDialogService +{ + Task ShowMessageAsync(string title, string message); + Task ConfirmAsync(string title, string message, string primaryButtonText = "Continue"); +} + +internal sealed class WinUiDialogService : IUserDialogService +{ + private readonly Func _xamlRoot; + + public WinUiDialogService(Func xamlRoot) => _xamlRoot = xamlRoot; + + public async Task ShowMessageAsync(string title, string message) + { + var dialog = Create(title, message); + dialog.CloseButtonText = "Close"; + await dialog.ShowAsync(); + } + + public async Task ConfirmAsync(string title, string message, string primaryButtonText = "Continue") + { + var dialog = Create(title, message); + dialog.PrimaryButtonText = primaryButtonText; + dialog.CloseButtonText = "Cancel"; + dialog.DefaultButton = ContentDialogButton.Close; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + + private ContentDialog Create(string title, string message) + { + var root = _xamlRoot() ?? throw new InvalidOperationException("The application window is not ready to show a dialog."); + return new ContentDialog + { + XamlRoot = root, + Title = title, + Content = new ScrollViewer + { + MaxHeight = 520, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true } + } + }; + } +} + +internal sealed class NonInteractiveDialogService : IUserDialogService +{ + public Task ShowMessageAsync(string title, string message) => Task.CompletedTask; + public Task ConfirmAsync(string title, string message, string primaryButtonText = "Continue") => Task.FromResult(false); +} diff --git a/src/PSWindowsUpdateGui/Themes/Fluent.xaml b/src/PSWindowsUpdateGui/Themes/Fluent.xaml new file mode 100644 index 0000000..4eb1f24 --- /dev/null +++ b/src/PSWindowsUpdateGui/Themes/Fluent.xaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PSWindowsUpdateGui/UiSmokeBootstrap.cs b/src/PSWindowsUpdateGui/UiSmokeBootstrap.cs new file mode 100644 index 0000000..36afe13 --- /dev/null +++ b/src/PSWindowsUpdateGui/UiSmokeBootstrap.cs @@ -0,0 +1,104 @@ +#if UI_SMOKE +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media.Imaging; +using PSWindowsUpdateGui.Models; +using PSWindowsUpdateGui.Services; +using PSWindowsUpdateGui.ViewModels; +using Windows.Graphics.Imaging; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace PSWindowsUpdateGui; + +internal static class UiSmokeBootstrap +{ + public static bool TryConfigure(IList args) + { + if (!args.Contains("--ui-smoke", StringComparer.OrdinalIgnoreCase)) return false; + var engine = new SmokeWindowsUpdateEngine(); + App.EngineFactoryOverride = () => engine; + App.ThemeOverride = Value(args, "--theme") ?? "System"; + var capturePath = Value(args, "--capture"); + var page = Value(args, "--page") ?? "history"; + App.WindowCreatedForTest = window => + { + foreach (var update in engine.SampleUpdates) window.ViewModel.Updates.Add(update); + foreach (var history in engine.SampleHistory) window.ViewModel.HistoryEntries.Add(new HistoryItemViewModel(history)); + window.ViewModel.SelectedHistoryEntry = window.ViewModel.HistoryEntries[0]; + window.ShowPage(string.Equals(page, "updates", StringComparison.OrdinalIgnoreCase) ? 0 : 1); + if (capturePath == null) return; + var captured = false; + window.Activated += async (_, __) => + { + if (captured) return; + captured = true; + try + { + await Task.Delay(700); + await CaptureAsync(window.RootElement, Path.GetFullPath(capturePath)); + } + catch (Exception exception) + { + File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-error.log"), exception.ToString()); + } + finally { window.Close(); } + }; + }; + return true; + } + + private static async Task CaptureAsync(FrameworkElement root, string path) + { + var bitmap = new RenderTargetBitmap(); + await bitmap.RenderAsync(root); + var pixels = await bitmap.GetPixelsAsync(); + var bytes = new byte[checked((int)pixels.Length)]; + using (var reader = DataReader.FromBuffer(pixels)) reader.ReadBytes(bytes); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using (File.Create(path)) { } + var file = await StorageFile.GetFileFromPathAsync(path); + using var output = await file.OpenAsync(FileAccessMode.ReadWrite); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, output); + encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied, (uint)bitmap.PixelWidth, (uint)bitmap.PixelHeight, 96, 96, bytes); + await encoder.FlushAsync(); + } + + private static string? Value(IList args, string name) + { + for (var index = 0; index < args.Count; index++) + if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase) && index + 1 < args.Count) return args[index + 1]; + return null; + } +} + +internal sealed class SmokeWindowsUpdateEngine : IWindowsUpdateEngine +{ + private readonly IReadOnlyList _updates = new[] + { + new UpdateRecord { Identity = UpdateKey.Parse("dac82a19-f9ba-459d-a30a-f56d4b326faa:3"), Title = "Intel - System - 11.7.0.1000", Type = "Driver", DriverProvider = "Intel", DriverVersion = "11.7.0.1000", MaximumDownloadBytes = 12_582_912 }, + new UpdateRecord { Identity = UpdateKey.Parse("8d6a8a0e-f3b4-43e7-b4da-40d63d0ffea9:1"), Title = "2026-07 Cumulative Update for Windows 11 Version 24H2", Type = "Software", KbArticleIds = new[] { "5062553" }, MaximumDownloadBytes = 1_234_567_890 } + }; + private readonly IReadOnlyList _history = new[] + { + new HistoryRecord { DateUtc = new DateTime(2026, 7, 22, 10, 48, 5, DateTimeKind.Utc), Title = "Lenovo System Driver Update (10.2.5.3)", Operation = "uoInstallation", Result = "orcSucceeded", Client = "UpdateOrchestrator", Identity = UpdateKey.Parse("dac82a19-f9ba-459d-a30a-f56d4b326faa:3") }, + new HistoryRecord { DateUtc = new DateTime(2026, 7, 22, 10, 35, 1, DateTimeKind.Utc), Title = "2026-07 Cumulative Update for Windows 11 Version 24H2 (KB5062553)", Operation = "uoInstallation", Result = "orcSucceededWithErrors", HResult = unchecked((int)0x8024200D), Client = "UpdateOrchestrator", Identity = UpdateKey.Parse("8d6a8a0e-f3b4-43e7-b4da-40d63d0ffea9:1") } + }; + public IReadOnlyList SampleUpdates => _updates; + public IReadOnlyList SampleHistory => _history; + public Task> ScanAsync(ScanRequest request, IProgress? progress, CancellationToken cancellationToken) => Task.FromResult(_updates); + public Task ExecuteAsync(UpdateActionRequest request, IProgress? progress, CancellationToken cancellationToken) => Task.FromResult(new UpdateActionResult { Action = request.Action, Result = "Planned" }); + public Task> GetHistoryAsync(int limit, CancellationToken cancellationToken) => Task.FromResult(_history); + public Task GetStatusAsync(CancellationToken cancellationToken) => Task.FromResult(new UpdateSystemStatus { ComputerName = "WIN11-LAB", AgentVersion = "WinUI smoke adapter", UpdateServiceStatus = "Running", LocalTime = DateTimeOffset.Now }); + public Task> GetServicesAsync(CancellationToken cancellationToken) => Task.FromResult>(new[] { new UpdateServiceRecord { Name = "Windows Update", ServiceId = "9482f4b4-e343-43b6-b170-9a65bc822c77" } }); + public Task AddMicrosoftUpdateServiceAsync(bool planOnly, CancellationToken cancellationToken) => Task.FromResult("Planned"); + public Task RemoveServiceAsync(string serviceId, bool planOnly, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ExportPayloadsAsync(IList updates, string destination, CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } +} +#endif diff --git a/src/PSWindowsUpdateGui/ViewModels/HistoryItemViewModel.cs b/src/PSWindowsUpdateGui/ViewModels/HistoryItemViewModel.cs new file mode 100644 index 0000000..b16331e --- /dev/null +++ b/src/PSWindowsUpdateGui/ViewModels/HistoryItemViewModel.cs @@ -0,0 +1,102 @@ +using System; +using System.Globalization; +using PSWindowsUpdateGui.Infrastructure; +using PSWindowsUpdateGui.Models; + +namespace PSWindowsUpdateGui.ViewModels; + +internal sealed class HistoryItemViewModel : ObservableObject +{ + private bool _canUninstall; + private string _removalStatus; + private string _verifiedSource = string.Empty; + + public HistoryItemViewModel(HistoryRecord record) + { + Record = record ?? throw new ArgumentNullException(nameof(record)); + _removalStatus = CanCheckRemoval + ? "Select Check removal to ask Windows whether this exact installed revision can be removed." + : "Only completed installation entries can be checked for removal."; + } + + public HistoryRecord Record { get; } + public DateTime DateUtc => Record.DateUtc; + public string DateDisplay => DateUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); + public string Title => Record.Title; + public string Client => Record.Client; + public UpdateKey Identity => Record.Identity; + public string IdentityDisplay => Identity.ToString(); + public string HResultDisplay => $"0x{unchecked((uint)Record.HResult):X8}"; + public string OperationDisplay => DisplayOperation(Record.Operation); + public string ResultDisplay => DisplayResult(Record.Result); + + public bool CanCheckRemoval => IsCompletedInstallation(Record); + public bool CanUninstall { get => _canUninstall; private set => SetProperty(ref _canUninstall, value); } + public string RemovalStatus { get => _removalStatus; private set => SetProperty(ref _removalStatus, value); } + public string VerifiedSource { get => _verifiedSource; private set => SetProperty(ref _verifiedSource, value); } + + public void MarkRemovalVerified(string source) + { + VerifiedSource = source; + CanUninstall = true; + RemovalStatus = "Windows reports this exact installed revision as uninstallable. A driver may roll back only when Windows has a previous package available."; + } + + public void MarkRemovalUnavailable(string message) + { + VerifiedSource = string.Empty; + CanUninstall = false; + RemovalStatus = string.IsNullOrWhiteSpace(message) + ? "Windows did not report this exact revision as removable." + : message; + } + + public void MarkRemovalCompleted(bool rebootRequired) + { + VerifiedSource = string.Empty; + CanUninstall = false; + RemovalStatus = rebootRequired + ? "Removal completed and Windows requires a restart. The audit-history entry remains." + : "Removal completed. The audit-history entry remains."; + } + + public void ResetRemovalVerification(string reason) + { + VerifiedSource = string.Empty; + CanUninstall = false; + RemovalStatus = CanCheckRemoval + ? reason + : "Only completed installation entries can be checked for removal."; + } + + internal static bool IsCompletedInstallation(HistoryRecord record) + { + var installed = string.Equals(record.Operation, "uoInstallation", StringComparison.OrdinalIgnoreCase) || + string.Equals(record.Operation, "Installation", StringComparison.OrdinalIgnoreCase) || + string.Equals(record.Operation, "Install", StringComparison.OrdinalIgnoreCase); + var succeeded = string.Equals(record.Result, "orcSucceeded", StringComparison.OrdinalIgnoreCase) || + string.Equals(record.Result, "orcSucceededWithErrors", StringComparison.OrdinalIgnoreCase) || + string.Equals(record.Result, "Succeeded", StringComparison.OrdinalIgnoreCase) || + string.Equals(record.Result, "SucceededWithErrors", StringComparison.OrdinalIgnoreCase); + return installed && succeeded && Guid.TryParse(record.Identity.UpdateId, out _) && record.Identity.Revision >= 0; + } + + private static string DisplayOperation(string value) + { + if (string.Equals(value, "uoInstallation", StringComparison.OrdinalIgnoreCase)) return "Installed"; + if (string.Equals(value, "uoUninstallation", StringComparison.OrdinalIgnoreCase)) return "Uninstalled"; + if (string.Equals(value, "uoOther", StringComparison.OrdinalIgnoreCase)) return "Other"; + return value; + } + + private static string DisplayResult(string value) + { + if (string.Equals(value, "orcNotStarted", StringComparison.OrdinalIgnoreCase)) return "Not started"; + if (string.Equals(value, "orcInProgress", StringComparison.OrdinalIgnoreCase)) return "In progress"; + if (string.Equals(value, "orcSucceeded", StringComparison.OrdinalIgnoreCase)) return "Succeeded"; + if (string.Equals(value, "orcSucceededWithErrors", StringComparison.OrdinalIgnoreCase)) return "Succeeded with errors"; + if (string.Equals(value, "orcFailed", StringComparison.OrdinalIgnoreCase)) return "Failed"; + if (string.Equals(value, "orcAborted", StringComparison.OrdinalIgnoreCase)) return "Aborted"; + return value; + } +} diff --git a/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs b/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs index 54462e7..93243ed 100644 --- a/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs +++ b/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs @@ -6,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Windows; using System.Windows.Input; using PSWindowsUpdateGui.Infrastructure; using PSWindowsUpdateGui.Models; @@ -20,6 +19,9 @@ internal sealed class MainViewModel : ObservableObject, IDisposable private readonly PortableSettingsService _settingsService; private readonly PortableSettings _settings; private readonly PortableLogService _log; + private readonly AppThemeService _themeService; + private readonly IUserDialogService _dialogs; + private readonly SynchronizationContext? _uiContext; private readonly string? _identityOverride; private readonly bool? _elevationOverride; private readonly string? _persistenceNoticeOverride; @@ -35,6 +37,7 @@ internal sealed class MainViewModel : ObservableObject, IDisposable private bool _isBusy; private int _progress; private string _selectedSource = "Default"; + private string _selectedTheme = AppThemeService.SystemPreference; private string _titleFilter = string.Empty; private string _kbFilter = string.Empty; private bool _driversOnly; @@ -42,20 +45,28 @@ internal sealed class MainViewModel : ObservableObject, IDisposable private bool _acceptEulas; private string _offlineCabPath = string.Empty; private string _policyChanges = string.Empty; + private UpdateSystemStatus _currentStatus = CreateEmptyStatus(); + private HistoryItemViewModel? _selectedHistoryEntry; + private int _selectedMainTabIndex; + private string _registeredServicesSummary = "Select Registered services to load the target's update sources."; private bool _disposed; - public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settingsService, PortableSettings settings, PortableLogService log, + public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settingsService, PortableSettings settings, PortableLogService log, AppThemeService themeService, IUserDialogService dialogs, string? identityOverride = null, bool? elevationOverride = null, string? persistenceNoticeOverride = null, string? localComputerOverride = null) { _engine = engine; _settingsService = settingsService; _settings = settings; _log = log; + _themeService = themeService; + _dialogs = dialogs; + _uiContext = SynchronizationContext.Current; _identityOverride = identityOverride; _elevationOverride = elevationOverride; _persistenceNoticeOverride = persistenceNoticeOverride; _localComputerOverride = localComputerOverride; _selectedSource = settings.LastUpdateSource; + _selectedTheme = AppThemeService.NormalizePreference(settings.ThemePreference); _log.EntryWritten += OnLogEntry; ScanCommand = new AsyncRelayCommand(ScanAsync, () => !IsBusy); @@ -67,20 +78,24 @@ public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settin UninstallCommand = new AsyncRelayCommand(() => RunActionAsync(UpdateActionKind.Uninstall), () => !IsBusy); RefreshStatusCommand = new AsyncRelayCommand(RefreshStatusAsync, () => !IsBusy); HistoryCommand = new AsyncRelayCommand(HistoryAsync, () => !IsBusy); + CheckHistoryRemovalCommand = new AsyncRelayCommand(CheckHistoryRemovalAsync, () => !IsBusy && SelectedHistoryEntry?.CanCheckRemoval == true); + UninstallHistoryCommand = new AsyncRelayCommand(UninstallHistoryAsync, () => !IsBusy && SelectedHistoryEntry?.CanUninstall == true); ServicesCommand = new AsyncRelayCommand(ServicesAsync, () => !IsBusy); AddMicrosoftUpdateCommand = new AsyncRelayCommand(AddMicrosoftUpdateAsync, () => !IsBusy); OfflineScanCommand = new AsyncRelayCommand(OfflineScanAsync, () => !IsBusy); DownloadOfflineCatalogCommand = new AsyncRelayCommand(DownloadOfflineCatalogAsync, () => !IsBusy); - PreviewPolicyCommand = new RelayCommand(PreviewPolicy); + PreviewPolicyCommand = new AsyncRelayCommand(PreviewPolicyAsync); ApplyPolicyCommand = new AsyncRelayCommand(ApplyPolicyAsync, () => !IsBusy); ResetComponentsCommand = new AsyncRelayCommand(ResetComponentsAsync, () => !IsBusy); CancelCommand = new RelayCommand(Cancel, () => IsBusy); - ClearLogsCommand = new RelayCommand(ClearLogs); + ClearLogsCommand = new AsyncRelayCommand(ClearLogsAsync); } public ObservableCollection Updates { get; } = new ObservableCollection(); + public ObservableCollection HistoryEntries { get; } = new ObservableCollection(); public IReadOnlyList Operations => OperationCatalog.Operations; public string[] SourceOptions { get; } = { "Default", "Windows Update", "Microsoft Update", "Managed Server" }; + public string[] ThemeOptions { get; } = { AppThemeService.SystemPreference, AppThemeService.LightPreference, AppThemeService.DarkPreference }; public ICommand ScanCommand { get; } public ICommand TestTargetCommand { get; } public ICommand InstallCommand { get; } @@ -90,6 +105,8 @@ public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settin public ICommand UninstallCommand { get; } public ICommand RefreshStatusCommand { get; } public ICommand HistoryCommand { get; } + public ICommand CheckHistoryRemovalCommand { get; } + public ICommand UninstallHistoryCommand { get; } public ICommand ServicesCommand { get; } public ICommand AddMicrosoftUpdateCommand { get; } public ICommand OfflineScanCommand { get; } @@ -101,24 +118,30 @@ public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settin public ICommand ClearLogsCommand { get; } public string ProductTitle => "PSWindowsUpdate GUI"; - public string EngineVersion => "Native WUA engine 2.0.0-beta.1"; + public string EngineVersion => "Native WUA engine 3.0.0-beta.1"; public string Identity => _identityOverride ?? WindowsIdentity.GetCurrent().Name; public bool IsElevated { get { if (_elevationOverride.HasValue) return _elevationOverride.Value; using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } } public string ElevationDisplay => IsElevated ? "Administrator" : "Not elevated (UI smoke test only)"; public bool IsEphemeral => _settingsService.IsEphemeral; public string PersistenceNotice => _persistenceNoticeOverride ?? (IsEphemeral ? "The EXE directory is not writable. This session is ephemeral." : $"Portable data: {_settingsService.DataDirectory}"); - public bool IsRemote { get => _isRemote; set { if (SetProperty(ref _isRemote, value)) { RaisePropertyChanged(nameof(TargetDisplay)); RaisePropertyChanged(nameof(IsLocal)); } } } + public bool IsRemote { get => _isRemote; set { if (SetProperty(ref _isRemote, value)) { RaisePropertyChanged(nameof(TargetDisplay)); RaisePropertyChanged(nameof(IsLocal)); ResetTargetSnapshot(); } } } public bool IsLocal => !IsRemote; public bool UseWinRmHttps { get => _useWinRmHttps; set => SetProperty(ref _useWinRmHttps, value); } - public string ComputerName { get => _computerName; set { if (SetProperty(ref _computerName, value)) RaisePropertyChanged(nameof(TargetDisplay)); } } + public string ComputerName { get => _computerName; set { if (SetProperty(ref _computerName, value)) { RaisePropertyChanged(nameof(TargetDisplay)); if (IsRemote) ResetTargetSnapshot(); } } } public string TargetDisplay => IsRemote ? (string.IsNullOrWhiteSpace(ComputerName) ? "Remote target" : ComputerName) : (_localComputerOverride ?? Environment.MachineName); public string StatusText { get => _statusText; private set => SetProperty(ref _statusText, value); } public string OutputText { get => _outputText; private set => SetProperty(ref _outputText, value); } public string LogText { get => _logText; private set => SetProperty(ref _logText, value); } + public UpdateSystemStatus CurrentStatus { get => _currentStatus; private set => SetProperty(ref _currentStatus, value); } + public HistoryItemViewModel? SelectedHistoryEntry { get => _selectedHistoryEntry; set { if (SetProperty(ref _selectedHistoryEntry, value)) RaiseCommandStates(); } } + public int SelectedMainTabIndex { get => _selectedMainTabIndex; set { if (SetProperty(ref _selectedMainTabIndex, value)) RaisePropertyChanged(nameof(IsOperationOutputVisible)); } } + public bool IsOperationOutputVisible => SelectedMainTabIndex != 1; + public string RegisteredServicesSummary { get => _registeredServicesSummary; private set => SetProperty(ref _registeredServicesSummary, value); } public bool IsBusy { get => _isBusy; private set { if (SetProperty(ref _isBusy, value)) RaiseCommandStates(); } } public int Progress { get => _progress; private set => SetProperty(ref _progress, value); } - public string SelectedSource { get => _selectedSource; set { if (SetProperty(ref _selectedSource, value)) _settings.LastUpdateSource = value; } } + public string SelectedSource { get => _selectedSource; set { if (SetProperty(ref _selectedSource, value)) { _settings.LastUpdateSource = value; SelectedHistoryEntry?.ResetRemovalVerification("The verification source changed. Check removal again before modifying Windows."); RaiseCommandStates(); } } } + public string SelectedTheme { get => _selectedTheme; set { var normalized = AppThemeService.NormalizePreference(value); if (SetProperty(ref _selectedTheme, normalized)) { _settings.ThemePreference = normalized; _themeService.Apply(normalized); } } } public string TitleFilter { get => _titleFilter; set => SetProperty(ref _titleFilter, value); } public string KBFilter { get => _kbFilter; set => SetProperty(ref _kbFilter, value); } public bool DriversOnly { get => _driversOnly; set => SetProperty(ref _driversOnly, value); } @@ -152,9 +175,9 @@ private async Task ScanAsync() private async Task RunActionAsync(UpdateActionKind action) { var selected = Updates.Where(update => update.IsSelected).ToList(); - if (selected.Count == 0) { MessageBox.Show("Select at least one update first.", action.ToString(), MessageBoxButton.OK, MessageBoxImage.Information); return; } + if (selected.Count == 0) { await _dialogs.ShowMessageAsync(action.ToString(), "Select at least one update first."); return; } var summary = $"{action} {selected.Count} update(s) on {TargetDisplay}? Updates are revalidated by GUID and revision. Automatic reboot is disabled."; - if (MessageBox.Show(summary, $"Confirm {action}", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; + if (!await _dialogs.ConfirmAsync($"Confirm {action}", summary, action.ToString())) return; var request = new UpdateActionRequest { Action = action, @@ -189,12 +212,9 @@ private async Task RefreshStatusAsync() { await RunAsync("Reading Windows Update status", false, async token => { - var status = IsRemote + CurrentStatus = IsRemote ? await RemoteGuiBridge.StatusAsync(ComputerName, UseWinRmHttps, token).ConfigureAwait(true) : await _engine.GetStatusAsync(token).ConfigureAwait(true); - OutputText = $"Computer: {status.ComputerName}{Environment.NewLine}WUA: {status.AgentVersion}{Environment.NewLine}" + - $"Service: {status.UpdateServiceStatus}{Environment.NewLine}Installer busy: {status.InstallerBusy}{Environment.NewLine}" + - $"Reboot required: {status.RebootRequired}{Environment.NewLine}Target-local time: {status.LocalTime:O}{Environment.NewLine}{status.OrchestratorNotice}"; StatusText = $"Ready — {EngineVersion}, {ElevationDisplay} as {Identity}"; }).ConfigureAwait(true); } @@ -204,22 +224,103 @@ private async Task HistoryAsync() => await RunAsync("Reading update history", fa var history = IsRemote ? await RemoteGuiBridge.HistoryAsync(ComputerName, UseWinRmHttps, 200, token).ConfigureAwait(true) : await _engine.GetHistoryAsync(200, token).ConfigureAwait(true); - OutputText = string.Join(Environment.NewLine, history.Select(item => $"{item.DateUtc:u} {item.Operation,-10} {item.Result,-24} {item.Title}")); + SelectedHistoryEntry = null; + HistoryEntries.Clear(); + foreach (var item in history) HistoryEntries.Add(new HistoryItemViewModel(item)); StatusText = $"History complete — {history.Count} entries"; }).ConfigureAwait(true); + private async Task CheckHistoryRemovalAsync() + { + var selected = SelectedHistoryEntry; + if (selected == null || !selected.CanCheckRemoval) return; + var sourceName = SelectedSource; + var source = ParseSource(sourceName); + + await RunAsync("Checking selected update removal capability", false, async token => + { + var request = new UpdateActionRequest + { + Action = UpdateActionKind.Uninstall, + Updates = new List { selected.Identity }, + Source = source, + PlanOnly = true + }; + + try + { + var result = IsRemote + ? await RemoteGuiBridge.ExecuteAsync(request, ComputerName, UseWinRmHttps, token).ConfigureAwait(true) + : await _engine.ExecuteAsync(request, CreateProgress(), token).ConfigureAwait(true); + if (!string.Equals(result.Result, "Planned", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Windows did not return a removable plan for this update."); + selected.MarkRemovalVerified(sourceName); + StatusText = "Removal check complete — exact installed revision is uninstallable"; + } + catch (Exception exception) + { + selected.MarkRemovalUnavailable("This exact installed revision is not removable from the selected source: " + exception.Message); + throw; + } + finally { RaiseCommandStates(); } + }).ConfigureAwait(true); + } + + private async Task UninstallHistoryAsync() + { + var selected = SelectedHistoryEntry; + if (selected == null || !selected.CanUninstall) return; + if (!string.Equals(selected.VerifiedSource, SelectedSource, StringComparison.Ordinal)) + { + selected.ResetRemovalVerification("The update source changed. Check removal again before modifying Windows."); + RaiseCommandStates(); + return; + } + var source = ParseSource(selected.VerifiedSource); + + var summary = $"Uninstall the currently installed update below from {TargetDisplay}?{Environment.NewLine}{Environment.NewLine}" + + $"{selected.Title}{Environment.NewLine}{selected.IdentityDisplay}{Environment.NewLine}{Environment.NewLine}" + + "The history entry is an audit record and will remain. A driver rolls back only if Windows has a previous package available. " + + "Automatic reboot is disabled."; + if (!await _dialogs.ConfirmAsync("Confirm uninstall / rollback", summary, "Uninstall")) return; + + await RunAsync("Uninstalling selected history update", true, async token => + { + var request = new UpdateActionRequest + { + Action = UpdateActionKind.Uninstall, + Updates = new List { selected.Identity }, + Source = source + }; + var result = IsRemote + ? await RemoteGuiBridge.ExecuteAsync(request, ComputerName, UseWinRmHttps, token).ConfigureAwait(true) + : await _engine.ExecuteAsync(request, CreateProgress(), token).ConfigureAwait(true); + OutputText = $"{result.Action}: {result.Result}; reboot required: {result.RebootRequired}" + Environment.NewLine + + string.Join(Environment.NewLine, result.Updates.Select(item => $"{item.Identity} {item.Result} {item.Title}")); + selected.MarkRemovalCompleted(result.RebootRequired); + CurrentStatus = IsRemote + ? await RemoteGuiBridge.StatusAsync(ComputerName, UseWinRmHttps, token).ConfigureAwait(true) + : await _engine.GetStatusAsync(token).ConfigureAwait(true); + StatusText = result.RebootRequired ? "Removal complete — restart required" : "Removal complete"; + RaiseCommandStates(); + }).ConfigureAwait(true); + } + private async Task ServicesAsync() => await RunAsync("Reading update services", false, async token => { var services = IsRemote ? await RemoteGuiBridge.ServicesAsync(ComputerName, UseWinRmHttps, token).ConfigureAwait(true) : await _engine.GetServicesAsync(token).ConfigureAwait(true); + RegisteredServicesSummary = services.Count == 0 + ? "Registered services: none reported" + : "Registered services: " + string.Join("; ", services.Select(item => item.Name)); OutputText = string.Join(Environment.NewLine, services.Select(item => $"{item.ServiceId} {item.Name} Managed={item.IsManaged} AU={item.IsRegisteredWithAutomaticUpdates}")); StatusText = $"Found {services.Count} update service(s)"; }).ConfigureAwait(true); private async Task AddMicrosoftUpdateAsync() { - if (MessageBox.Show("Register Microsoft Update with Windows Update Agent?", "Confirm service registration", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; + if (!await _dialogs.ConfirmAsync("Confirm service registration", "Register Microsoft Update with Windows Update Agent?", "Register")) return; await RunAsync("Registering Microsoft Update", true, async token => OutputText = IsRemote ? await RemoteGuiBridge.AddMicrosoftUpdateServiceAsync(ComputerName, UseWinRmHttps, token).ConfigureAwait(true) : await _engine.AddMicrosoftUpdateServiceAsync(false, token).ConfigureAwait(true)).ConfigureAwait(true); @@ -227,7 +328,7 @@ await RunAsync("Registering Microsoft Update", true, async token => OutputText = private async Task OfflineScanAsync() { - if (!RequireLocalTarget("Offline scan CAB paths are local to this computer. Use the CLI with --computer for a CAB already present on the remote target.")) return; + if (!await RequireLocalTargetAsync("Offline scan CAB paths are local to this computer. Use the CLI with --computer for a CAB already present on the remote target.")) return; var request = BuildScanRequest(); request.Source = UpdateSourceKind.Offline; request.OfflineCabPath = OfflineCabPath; @@ -240,7 +341,7 @@ await RunAsync("Running offline security scan", false, async token => private async Task DownloadOfflineCatalogAsync() { - if (!RequireLocalTarget("Download the catalog on the remote target with the CLI, or switch to this computer.")) return; + if (!await RequireLocalTargetAsync("Download the catalog on the remote target with the CLI, or switch to this computer.")) return; if (string.IsNullOrWhiteSpace(OfflineCabPath)) OfflineCabPath = System.IO.Path.Combine(_settingsService.DataDirectory, "wsusscn2.cab"); await RunAsync("Downloading Microsoft offline scan catalog", false, async token => @@ -250,10 +351,10 @@ await RunAsync("Downloading Microsoft offline scan catalog", false, async token }).ConfigureAwait(true); } - private void PreviewPolicy() + private async Task PreviewPolicyAsync() { try { OutputText = new WindowsUpdatePolicyService().Preview(SplitLines(PolicyChanges)); } - catch (Exception exception) { MessageBox.Show(exception.Message, "Policy validation", MessageBoxButton.OK, MessageBoxImage.Error); } + catch (Exception exception) { await _dialogs.ShowMessageAsync("Policy validation", exception.Message); } } private async Task ApplyPolicyAsync() @@ -261,8 +362,8 @@ private async Task ApplyPolicyAsync() var service = new WindowsUpdatePolicyService(); IList changes; try { changes = SplitLines(PolicyChanges); OutputText = service.Preview(changes); } - catch (Exception exception) { MessageBox.Show(exception.Message, "Policy validation", MessageBoxButton.OK, MessageBoxImage.Error); return; } - if (MessageBox.Show(OutputText, "Confirm policy changes", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; + catch (Exception exception) { await _dialogs.ShowMessageAsync("Policy validation", exception.Message); return; } + if (!await _dialogs.ConfirmAsync("Confirm policy changes", OutputText, "Apply policy")) return; await RunAsync("Applying Windows Update policy", true, async token => { if (IsRemote) @@ -282,7 +383,7 @@ private async Task ResetComponentsAsync() { var service = new WindowsUpdateMaintenanceService(); var preview = await service.ResetAsync(true, CancellationToken.None).ConfigureAwait(true); - if (MessageBox.Show(string.Join(Environment.NewLine, preview), "Confirm component reset", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; + if (!await _dialogs.ConfirmAsync("Confirm component reset", string.Join(Environment.NewLine, preview), "Reset components")) return; await RunAsync("Resetting Windows Update components", true, async token => OutputText = IsRemote ? await RemoteGuiBridge.ResetComponentsAsync(ComputerName, UseWinRmHttps, token).ConfigureAwait(true) : string.Join(Environment.NewLine, await service.ResetAsync(false, token).ConfigureAwait(true))).ConfigureAwait(true); @@ -295,14 +396,33 @@ private ScanRequest BuildScanRequest() return request; } - private UpdateSourceKind ParseSource() + private UpdateSourceKind ParseSource() => ParseSource(SelectedSource); + + private static UpdateSourceKind ParseSource(string source) { - if (SelectedSource == "Windows Update") return UpdateSourceKind.WindowsUpdate; - if (SelectedSource == "Microsoft Update") return UpdateSourceKind.MicrosoftUpdate; - if (SelectedSource == "Managed Server") return UpdateSourceKind.ManagedServer; + if (source == "Windows Update") return UpdateSourceKind.WindowsUpdate; + if (source == "Microsoft Update") return UpdateSourceKind.MicrosoftUpdate; + if (source == "Managed Server") return UpdateSourceKind.ManagedServer; return UpdateSourceKind.Default; } + private void ResetTargetSnapshot() + { + SelectedHistoryEntry = null; + HistoryEntries.Clear(); + CurrentStatus = CreateEmptyStatus(TargetDisplay); + RegisteredServicesSummary = "Select Registered services to load the target's update sources."; + StatusText = "Target changed — refresh status and history"; + } + + private static UpdateSystemStatus CreateEmptyStatus(string computerName = "Not loaded") => new UpdateSystemStatus + { + ComputerName = computerName, + AgentVersion = string.Empty, + UpdateServiceStatus = "Not checked", + OrchestratorNotice = "Refresh status to read the current Windows Update state." + }; + private void Populate(IEnumerable updates) { Updates.Clear(); foreach (var update in updates) Updates.Add(update); } private IProgress CreateProgress() => new Progress(value => { Progress = Math.Max(0, Math.Min(100, value.PercentComplete)); StatusText = value.Activity; }); @@ -313,14 +433,18 @@ private async Task RunAsync(string activity, bool modifying, Func _cancellation?.Cancel(); - private void ClearLogs() { if (MessageBox.Show("Delete all portable log files?", "Clear logs", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; _log.Clear(); LogText = string.Empty; } - private bool RequireLocalTarget(string message) { if (!IsRemote) return true; MessageBox.Show(message, "Remote target", MessageBoxButton.OK, MessageBoxImage.Information); return false; } - private void OnLogEntry(object? sender, string entry) => Application.Current.Dispatcher.BeginInvoke(new Action(() => { var text = new StringBuilder(LogText).AppendLine(entry); if (text.Length > 250000) text.Remove(0, text.Length - 200000); LogText = text.ToString(); })); + private async Task ClearLogsAsync() { if (!await _dialogs.ConfirmAsync("Clear logs", "Delete all portable log files?", "Delete logs")) return; _log.Clear(); LogText = string.Empty; } + private async Task RequireLocalTargetAsync(string message) { if (!IsRemote) return true; await _dialogs.ShowMessageAsync("Remote target", message); return false; } + private void OnLogEntry(object? sender, string entry) + { + void Append() { var text = new StringBuilder(LogText).AppendLine(entry); if (text.Length > 250000) text.Remove(0, text.Length - 200000); LogText = text.ToString(); } + if (_uiContext == null) Append(); else _uiContext.Post(_ => Append(), null); + } private void RaiseCommandStates() { foreach (var command in GetType().GetProperties().Where(property => typeof(ICommand).IsAssignableFrom(property.PropertyType)).Select(property => property.GetValue(this))) { if (command is RelayCommand relay) relay.RaiseCanExecuteChanged(); if (command is AsyncRelayCommand asyncRelay) asyncRelay.RaiseCanExecuteChanged(); } } private static string[] Split(string value) => value.Split(new[] { ',', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Select(item => item.Trim()).Where(item => item.Length > 0).ToArray(); private static IList SplitLines(string value) => value.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Select(item => item.Trim()).Where(item => item.Length > 0).ToList(); diff --git a/src/PSWindowsUpdateGui/Views/MainWindow.xaml b/src/PSWindowsUpdateGui/Views/MainWindow.xaml index b632b45..7116674 100644 --- a/src/PSWindowsUpdateGui/Views/MainWindow.xaml +++ b/src/PSWindowsUpdateGui/Views/MainWindow.xaml @@ -1,36 +1,106 @@ - - - - - - - -