Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:

- name: Verify, build, test, and package
shell: powershell
run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.1-ci
run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.2-ci

- name: Upload test results
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:

- name: Build analyzed source
shell: powershell
run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.1-codeql -SkipTests
run: .\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.2-codeql -SkipTests

- name: Analyze
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) an

## [Unreleased]

## [3.0.0-beta.2] - 2026-07-23

### Fixed

- Fix the published single-file EXE failing before the first window with WinUI
activation error `0x80040111` by regenerating the registration-free activation
manifest after folder builds and before every single-file publish.
- Resolve registration-free WinUI resources from the extracted runtime directory
and display a native diagnostic dialog for failures that occur before `App` starts.
- Keep portable settings and logs beside the downloaded EXE rather than inside the
temporary .NET bundle extraction directory.
- Serialize scan, history, and service collections correctly in CLI JSON output and
report the current 3.0 prerelease in CLI help.
- Identify new WUA operations as `PSWindowsUpdateGUI/3`, and return the documented
validation exit code and message for malformed WUA search criteria.

### Added

- Add a release-blocking extracted-single-file GUI smoke test in addition to the
existing folder-based dark and light WinUI smoke captures.

## [3.0.0-beta.1] - 2026-07-22

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ and [remote interface restrictions](https://learn.microsoft.com/windows/win32/wu

```powershell
$env:DOTNET_EXE = (Resolve-Path .\.tools\dotnet\dotnet.exe)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build\Build.ps1 -Version 3.0.0-beta.1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build\Build.ps1 -Version 3.0.0-beta.2
```

The build verifies Microsoft Authenticode on the system WUA DLL, generates an
Expand All @@ -94,7 +94,7 @@ No interop DLL or third-party update engine is released.

## Project status

Version `3.0.0-beta.1` is the current major prerelease. Promotion to a stable release
Version `3.0.0-beta.2` 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.
Expand Down
3 changes: 2 additions & 1 deletion build/Build.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[ValidateSet('Debug','Release')] [string] $Configuration = 'Release',
[string] $Version = '3.0.0-beta.1',
[string] $Version = '3.0.0-beta.2',
[switch] $SkipTests
)

Expand Down Expand Up @@ -41,6 +41,7 @@ New-Item -ItemType Directory -Path $releaseRoot -Force | Out-Null
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'
if (Test-Path -LiteralPath $generatedManifest) { Remove-Item -LiteralPath $generatedManifest -Force }
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'
Expand Down
33 changes: 31 additions & 2 deletions build/Test-GuiSmoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ $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'
$singleFileRoot = Join-Path $repoRoot 'artifacts\ui-smoke-single-file'
$singleFileCapture = Join-Path $repoRoot 'artifacts\ui-smoke-single-file-history-dark.png'
$executable = Join-Path $smokeRoot 'PSWindowsUpdateGUI.exe'
$errorLog = Join-Path (Split-Path -Parent $executable) 'ui-smoke-error.log'
& $dotnet build $project -c UiSmoke '-p:Platform=x64' '-p:UiSmokeBuild=true' "-p:OutputPath=$smokeRoot\" --no-restore
& $dotnet build $project -c UiSmoke -r win-x64 --self-contained true '-p:Platform=x64' '-p:SelfContained=true' '-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" }

Expand Down Expand Up @@ -42,4 +44,31 @@ finally {
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"

if (Test-Path -LiteralPath $singleFileRoot) {
$resolvedSingleFileRoot = [System.IO.Path]::GetFullPath($singleFileRoot).TrimEnd('\')
$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts')).TrimEnd('\')
if (-not $resolvedSingleFileRoot.StartsWith($artifactsRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to clean unexpected single-file smoke path: $resolvedSingleFileRoot"
}
Remove-Item -LiteralPath $resolvedSingleFileRoot -Recurse -Force
}
if (Test-Path -LiteralPath $singleFileCapture) { Remove-Item -LiteralPath $singleFileCapture -Force }

$singleFileManifest = Join-Path $repoRoot 'src\PSWindowsUpdateGui\obj\x64\UiSmoke\net10.0-windows10.0.26100.0\win-x64\Manifests\app.manifest'
if (Test-Path -LiteralPath $singleFileManifest) { Remove-Item -LiteralPath $singleFileManifest -Force }
& $dotnet publish $project -c UiSmoke -r win-x64 --self-contained true --no-restore '-p:Platform=x64' '-p:UiSmokeBuild=true' '-p:WindowsPackageType=None' '-p:WindowsAppSDKSelfContained=true' '-p:PublishSingleFile=true' '-p:IncludeAllContentForSelfExtract=true' '-p:EnableMsixTooling=true' '-p:PublishTrimmed=false' '-p:PublishReadyToRun=false' -o $singleFileRoot
if ($LASTEXITCODE -ne 0) { throw "WinUI single-file smoke publish failed with exit code $LASTEXITCODE." }
$singleFileExecutable = Join-Path $singleFileRoot 'PSWindowsUpdateGUI.exe'
if (-not (Test-Path -LiteralPath $singleFileExecutable)) { throw "WinUI single-file smoke executable was not published: $singleFileExecutable" }
$singleFileErrorLog = Join-Path $singleFileRoot 'ui-smoke-error.log'
$process = Start-Process -FilePath $singleFileExecutable -ArgumentList @('--ui-smoke', '--theme', 'Dark', '--page', 'history', '--capture', "`"$singleFileCapture`"") -PassThru -Wait
if ($process.ExitCode -ne 0) {
$detail = if (Test-Path -LiteralPath $singleFileErrorLog) { Get-Content -LiteralPath $singleFileErrorLog -Raw } else { "Exit code $($process.ExitCode)." }
throw "WinUI extracted single-file smoke test failed. $detail"
}
if (-not (Test-Path -LiteralPath $singleFileCapture) -or (Get-Item -LiteralPath $singleFileCapture).Length -lt 1024) {
throw "WinUI single-file smoke test did not create a valid screenshot: $singleFileCapture"
}

Write-Host "WinUI production-assembly and extracted single-file smoke tests passed: $darkCapture, $lightCapture, $singleFileCapture"
3 changes: 3 additions & 0 deletions build/Verify-Release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ try {
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.' }
if ($manifest -notmatch 'loadFrom="%MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY%Microsoft\.ui\.xaml\.dll"') {
throw 'Published executable is missing the extracted single-file WinUI activation mapping.'
}
}
finally {
if (Test-Path -LiteralPath $inspectionPath) { Remove-Item -LiteralPath $inspectionPath -Force }
Expand Down
4 changes: 2 additions & 2 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ 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 3.0.0-beta.1
.\build\Build.ps1 -Configuration Release -Version 3.0.0-beta.2
```

`Build.ps1` restores locked NuGet dependencies, builds x64 with warnings as errors,
runs unit tests and a non-elevated production-assembly WinUI smoke capture, publishes
runs unit tests plus folder-based and extracted-single-file WinUI smoke captures, 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.
Expand Down
41 changes: 33 additions & 8 deletions src/PSWindowsUpdateGui/Cli/CliApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace PSWindowsUpdateGui.Cli;

internal sealed class CliApplication
{
private const int WuaInvalidCriteria = unchecked((int)0x80240032);

public async Task<int> RunAsync(string[] rawArguments)
{
var arguments = CliArguments.Parse(rawArguments);
Expand Down Expand Up @@ -42,7 +44,7 @@ public async Task<int> RunAsync(string[] rawArguments)
case "unhide":
return await ModifyAsync(engine, arguments, command).ConfigureAwait(false);
case "history":
return await ReadAsync(() => engine.GetHistoryAsync(arguments.GetInt("limit", 100), CancellationToken.None), arguments).ConfigureAwait(false);
return await ReadAsync(async () => (await engine.GetHistoryAsync(arguments.GetInt("limit", 100), CancellationToken.None).ConfigureAwait(false)).ToList(), arguments).ConfigureAwait(false);
case "status":
return await ReadAsync(() => engine.GetStatusAsync(CancellationToken.None), arguments).ConfigureAwait(false);
case "services":
Expand Down Expand Up @@ -81,7 +83,7 @@ private static async Task<int> ScanAsync(IWindowsUpdateEngine engine, CliArgumen
}

return await RunEnvelopeAsync(
() => engine.ScanAsync(request, CreateProgress(arguments), CancellationToken.None),
async () => (await engine.ScanAsync(request, CreateProgress(arguments), CancellationToken.None).ConfigureAwait(false)).ToList(),
arguments,
data => $"{data.Count} update(s) found.{Environment.NewLine}" + string.Join(Environment.NewLine, data.Select(FormatUpdate))).ConfigureAwait(false);
}
Expand Down Expand Up @@ -124,7 +126,7 @@ private static async Task<int> ModifyAsync(IWindowsUpdateEngine engine, CliArgum
private static async Task<int> ServicesAsync(IWindowsUpdateEngine engine, CliArguments arguments)
{
var verb = arguments.Positionals.Count > 1 ? arguments.Positionals[1].ToLowerInvariant() : "list";
if (verb == "list") return await ReadAsync(() => engine.GetServicesAsync(CancellationToken.None), arguments).ConfigureAwait(false);
if (verb == "list") return await ReadAsync(async () => (await engine.GetServicesAsync(CancellationToken.None).ConfigureAwait(false)).ToList(), arguments).ConfigureAwait(false);
if (verb == "add-microsoft-update")
{
var plan = arguments.Has("plan");
Expand Down Expand Up @@ -270,6 +272,7 @@ private static async Task<int> RunEnvelopeAsync<T>(
Func<T, OperationState>? classify = null)
{
var envelope = new OperationEnvelope<T>();
var validationFailure = false;
try
{
envelope.Data = await action().ConfigureAwait(false);
Expand All @@ -283,23 +286,37 @@ private static async Task<int> RunEnvelopeAsync<T>(
catch (Exception exception)
{
envelope.Status = OperationState.Failed;
validationFailure = IsValidationFailure(exception);
envelope.Errors.Add(ToError(exception));
}
envelope.CompletedUtc = DateTime.UtcNow;

if (IsJson(arguments)) Serialize(Console.Out, envelope);
else if (envelope.Data != null) Console.WriteLine(render(envelope.Data));
foreach (var error in envelope.Errors) Console.Error.WriteLine($"ERROR {error.Code}: {error.Message}");
return ExitCode(envelope.Status);
return validationFailure ? 2 : ExitCode(envelope.Status);
}

private static OperationError ToError(Exception exception) => new OperationError
{
Code = $"0x{exception.HResult:X8}",
HResult = exception.HResult,
Message = PortableLogService.Redact(exception.Message)
Message = exception.HResult == WuaInvalidCriteria
? "The WUA search criteria is invalid."
: PortableLogService.Redact(exception.Message)
};

internal static bool IsValidationFailure(Exception exception) =>
exception is FormatException ||
exception is ArgumentException ||
exception is PlatformNotSupportedException ||
exception is UnauthorizedAccessException ||
exception.HResult == WuaInvalidCriteria ||
(exception is InvalidOperationException &&
(exception.Message.IndexOf("requires --yes", StringComparison.OrdinalIgnoreCase) >= 0 ||
exception.Message.IndexOf("noninteractively", StringComparison.OrdinalIgnoreCase) >= 0 ||
exception.Message.IndexOf("preflight", StringComparison.OrdinalIgnoreCase) >= 0));

private static int ExitCode(OperationState state)
{
if (state == OperationState.Success || state == OperationState.Planned) return 0;
Expand Down Expand Up @@ -345,10 +362,18 @@ private static void WriteValue<T>(CliArguments arguments, T value, string text)

private static void Serialize<T>(TextWriter writer, T value)
{
var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true });
writer.WriteLine(SerializeJson(value));
}

internal static string SerializeJson<T>(T value)
{
var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings
{
UseSimpleDictionaryFormat = true
});
using var stream = new MemoryStream();
serializer.WriteObject(stream, value);
writer.WriteLine(Encoding.UTF8.GetString(stream.ToArray()));
return Encoding.UTF8.GetString(stream.ToArray());
}

private static string FormatUpdate(UpdateRecord update)
Expand Down Expand Up @@ -377,7 +402,7 @@ private static string RenderObject(object? value)

private static void WriteHelp()
{
Console.WriteLine("PSWindowsUpdateGUI 2 - independent Windows Update Agent GUI and CLI");
Console.WriteLine("PSWindowsUpdateGUI 3.0.0-beta.2 - independent Windows Update Agent GUI and CLI");
Console.WriteLine();
Console.WriteLine("Usage: PSWindowsUpdateGUI.exe <command> [options]");
Console.WriteLine("Commands: scan, download, install, uninstall, hide, unhide, history, status,");
Expand Down
2 changes: 1 addition & 1 deletion src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ApplicationManifest Condition="'$(UiSmokeBuild)' != 'true'">app.manifest</ApplicationManifest>
<ApplicationManifest Condition="'$(UiSmokeBuild)' == 'true'">app.smoke.manifest</ApplicationManifest>
<Version>3.0.0-beta.1</Version>
<Version>3.0.0-beta.2</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<Product>PSWindowsUpdate GUI</Product>
Expand Down
Loading
Loading