diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5705fe..eb045c8 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 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() diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1ffec52..96a4fa4 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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 504dcd0..9762008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1b59e23..44c5ab3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/build/Build.ps1 b/build/Build.ps1 index 365aeaf..c575482 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -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 ) @@ -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' diff --git a/build/Test-GuiSmoke.ps1 b/build/Test-GuiSmoke.ps1 index 2eebcba..ce0a697 100644 --- a/build/Test-GuiSmoke.ps1 +++ b/build/Test-GuiSmoke.ps1 @@ -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" } @@ -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" diff --git a/build/Verify-Release.ps1 b/build/Verify-Release.ps1 index 077669f..b4261cc 100644 --- a/build/Verify-Release.ps1 +++ b/build/Verify-Release.ps1 @@ -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 } diff --git a/docs/BUILDING.md b/docs/BUILDING.md index fbab325..6e04013 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -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. diff --git a/src/PSWindowsUpdateGui/Cli/CliApplication.cs b/src/PSWindowsUpdateGui/Cli/CliApplication.cs index 1d04952..d412505 100644 --- a/src/PSWindowsUpdateGui/Cli/CliApplication.cs +++ b/src/PSWindowsUpdateGui/Cli/CliApplication.cs @@ -14,6 +14,8 @@ namespace PSWindowsUpdateGui.Cli; internal sealed class CliApplication { + private const int WuaInvalidCriteria = unchecked((int)0x80240032); + public async Task RunAsync(string[] rawArguments) { var arguments = CliArguments.Parse(rawArguments); @@ -42,7 +44,7 @@ public async Task 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": @@ -81,7 +83,7 @@ private static async Task 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); } @@ -124,7 +126,7 @@ private static async Task ModifyAsync(IWindowsUpdateEngine engine, CliArgum private static async Task 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"); @@ -270,6 +272,7 @@ private static async Task RunEnvelopeAsync( Func? classify = null) { var envelope = new OperationEnvelope(); + var validationFailure = false; try { envelope.Data = await action().ConfigureAwait(false); @@ -283,6 +286,7 @@ private static async Task RunEnvelopeAsync( catch (Exception exception) { envelope.Status = OperationState.Failed; + validationFailure = IsValidationFailure(exception); envelope.Errors.Add(ToError(exception)); } envelope.CompletedUtc = DateTime.UtcNow; @@ -290,16 +294,29 @@ private static async Task RunEnvelopeAsync( 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; @@ -345,10 +362,18 @@ private static void WriteValue(CliArguments arguments, T value, string text) private static void Serialize(TextWriter writer, T value) { - var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true }); + writer.WriteLine(SerializeJson(value)); + } + + internal static string SerializeJson(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) @@ -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 [options]"); Console.WriteLine("Commands: scan, download, install, uninstall, hide, unhide, history, status,"); diff --git a/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj b/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj index 5cc63b8..723c301 100644 --- a/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj +++ b/src/PSWindowsUpdateGui/PSWindowsUpdateGui.csproj @@ -27,7 +27,7 @@ true app.manifest app.smoke.manifest - 3.0.0-beta.1 + 3.0.0-beta.2 3.0.0.0 3.0.0.0 PSWindowsUpdate GUI diff --git a/src/PSWindowsUpdateGui/Program.cs b/src/PSWindowsUpdateGui/Program.cs index 73bfecc..a9b58a9 100644 --- a/src/PSWindowsUpdateGui/Program.cs +++ b/src/PSWindowsUpdateGui/Program.cs @@ -18,6 +18,15 @@ internal static class Program [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AttachConsole(uint processId); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr GetModuleHandle(string moduleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetModuleFileName(IntPtr module, StringBuilder fileName, int size); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "MessageBoxW")] + private static extern int MessageBox(IntPtr window, string text, string caption, uint type); + [STAThread] public static int Main(string[] args) { @@ -28,23 +37,37 @@ public static int Main(string[] args) #endif if (isGui) { + try + { + ConfigureWindowsAppRuntimeBaseDirectory(); #if UI_SMOKE - File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Starting WinUI{Environment.NewLine}"); + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), + $"{DateTime.UtcNow:O} Starting WinUI; AppBase={AppContext.BaseDirectory}; RuntimeBase={Environment.GetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY")}{Environment.NewLine}"); #endif - WinRT.ComWrappersSupport.InitializeComWrappers(); - Application.Start(initialization => - { + 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 callback{Environment.NewLine}"); + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Application exited; code={App.ExitCode}{Environment.NewLine}"); #endif - var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); - SynchronizationContext.SetSynchronizationContext(context); - new App(); - }); + return App.ExitCode; + } + catch (Exception exception) + { #if UI_SMOKE - File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-trace.log"), $"{DateTime.UtcNow:O} Application exited; code={App.ExitCode}{Environment.NewLine}"); + try { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "ui-smoke-error.log"), exception.ToString()); } + catch { } #endif - return App.ExitCode; + _ = MessageBox(IntPtr.Zero, exception.ToString(), "PSWindowsUpdate GUI could not start", 0x00000010); + return 1; + } } AttachParentConsole(); @@ -54,7 +77,7 @@ public static int Main(string[] args) } catch (Exception exception) { - var exitCode = IsValidationFailure(exception) ? 2 : 1; + var exitCode = CliApplication.IsValidationFailure(exception) ? 2 : 1; if (RequestsJson(args)) { var envelope = new Models.OperationEnvelope @@ -94,15 +117,25 @@ private static void AttachParentConsole() } } - private static bool IsValidationFailure(Exception exception) => - exception is FormatException || - exception is ArgumentException || - exception is PlatformNotSupportedException || - exception is UnauthorizedAccessException || - (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 void ConfigureWindowsAppRuntimeBaseDirectory() + { + // In an IncludeAllContentForSelfExtract publish, .NET loads this native module and the + // managed app from the bundle extraction directory. Undocked RegFree WinRT must resolve + // its manifest and WinUI DLLs there before Application.Start requests a factory. + var module = GetModuleHandle("Microsoft.WindowsAppRuntime.dll"); + if (module == IntPtr.Zero) return; + + var path = new StringBuilder(32768); + var length = GetModuleFileName(module, path, path.Capacity); + if (length == 0 || length >= path.Capacity) return; + + var directory = Path.GetDirectoryName(path.ToString()); + if (!string.IsNullOrWhiteSpace(directory) && File.Exists(Path.Combine(directory, "Microsoft.ui.xaml.dll"))) + { + var baseDirectory = Path.EndsInDirectorySeparator(directory) ? directory : directory + Path.DirectorySeparatorChar; + Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", baseDirectory); + } + } private static bool RequestsJson(string[] args) { diff --git a/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs b/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs index ba807ae..6917eb7 100644 --- a/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs +++ b/src/PSWindowsUpdateGui/Services/PortableSettingsService.cs @@ -9,9 +9,20 @@ internal sealed class PortableSettingsService { private readonly string _settingsPath; - public PortableSettingsService() + public PortableSettingsService() : this(null) { - DataDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "PSWindowsUpdateGUI.Data"); + } + + internal PortableSettingsService(string? executableDirectory) + { + var directory = executableDirectory; + if (string.IsNullOrWhiteSpace(directory)) + { + directory = Path.GetDirectoryName(Environment.ProcessPath); + if (string.IsNullOrWhiteSpace(directory)) directory = AppContext.BaseDirectory; + } + + DataDirectory = Path.Combine(directory, "PSWindowsUpdateGUI.Data"); _settingsPath = Path.Combine(DataDirectory, "settings.json"); try { diff --git a/src/PSWindowsUpdateGui/Services/WuaWindowsUpdateEngine.cs b/src/PSWindowsUpdateGui/Services/WuaWindowsUpdateEngine.cs index 9968e0d..3aee5b1 100644 --- a/src/PSWindowsUpdateGui/Services/WuaWindowsUpdateEngine.cs +++ b/src/PSWindowsUpdateGui/Services/WuaWindowsUpdateEngine.cs @@ -16,7 +16,7 @@ namespace PSWindowsUpdateGui.Services; internal sealed class WuaWindowsUpdateEngine : IWindowsUpdateEngine { internal const string MicrosoftUpdateServiceId = "7971f918-a847-4430-9279-4a52d1efe18d"; - private const string ClientId = "PSWindowsUpdateGUI/2"; + private const string ClientId = "PSWindowsUpdateGUI/3"; private readonly StaComWorker _worker = new StaComWorker("PSWindowsUpdateGUI WUA COM"); private readonly SemaphoreSlim _operationGate = new SemaphoreSlim(1, 1); private bool _disposed; diff --git a/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs b/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs index 93243ed..3817570 100644 --- a/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs +++ b/src/PSWindowsUpdateGui/ViewModels/MainViewModel.cs @@ -118,7 +118,7 @@ public MainViewModel(IWindowsUpdateEngine engine, PortableSettingsService settin public ICommand ClearLogsCommand { get; } public string ProductTitle => "PSWindowsUpdate GUI"; - public string EngineVersion => "Native WUA engine 3.0.0-beta.1"; + public string EngineVersion => "Native WUA engine 3.0.0-beta.2"; 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)"; diff --git a/src/PSWindowsUpdateGui/Views/MainWindow.xaml.cs b/src/PSWindowsUpdateGui/Views/MainWindow.xaml.cs index 9140d8b..290ebd0 100644 --- a/src/PSWindowsUpdateGui/Views/MainWindow.xaml.cs +++ b/src/PSWindowsUpdateGui/Views/MainWindow.xaml.cs @@ -114,7 +114,7 @@ private async void ExportLogs_Click(object sender, RoutedEventArgs e) private async void About_Click(object sender, RoutedEventArgs e) => await _dialogs.ShowMessageAsync( "About PSWindowsUpdate GUI", - "PSWindowsUpdate GUI 3.0.0-beta.1\n\nPortable Windows 11 x64 GUI and CLI built directly on Windows Update Agent with WinUI 3 and the Windows App SDK.\n\nSingle-file, self-contained distribution. MIT License."); + "PSWindowsUpdate GUI 3.0.0-beta.2\n\nPortable Windows 11 x64 GUI and CLI built directly on Windows Update Agent with WinUI 3 and the Windows App SDK.\n\nSingle-file, self-contained distribution. MIT License."); private void OnClosed(object sender, WindowEventArgs args) { diff --git a/tests/PSWindowsUpdateGui.Tests/CliSerializationTests.cs b/tests/PSWindowsUpdateGui.Tests/CliSerializationTests.cs new file mode 100644 index 0000000..c4472dc --- /dev/null +++ b/tests/PSWindowsUpdateGui.Tests/CliSerializationTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PSWindowsUpdateGui.Cli; +using PSWindowsUpdateGui.Models; + +namespace PSWindowsUpdateGui.Tests; + +[TestClass] +public sealed class CliSerializationTests +{ + [TestMethod] + public void MaterializedHistoryListSerializesInEnvelope() + { + var history = new List + { + new HistoryRecord + { + DateUtc = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc), + Title = "Test update", + Operation = "uoInstallation", + Result = "orcSucceeded" + } + }; + var envelope = new OperationEnvelope> { Data = history }; + + var json = CliApplication.SerializeJson(envelope); + + StringAssert.Contains(json, "Test update"); + StringAssert.Contains(json, "\"data\""); + } + + [TestMethod] + public void InvalidWuaCriteriaIsAValidationFailure() + { + var exception = new COMException("Invalid criteria", unchecked((int)0x80240032)); + + Assert.IsTrue(CliApplication.IsValidationFailure(exception)); + } +} diff --git a/tests/PSWindowsUpdateGui.Tests/PortableSettingsServiceTests.cs b/tests/PSWindowsUpdateGui.Tests/PortableSettingsServiceTests.cs new file mode 100644 index 0000000..ec50de4 --- /dev/null +++ b/tests/PSWindowsUpdateGui.Tests/PortableSettingsServiceTests.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PSWindowsUpdateGui.Services; + +namespace PSWindowsUpdateGui.Tests; + +[TestClass] +public sealed class PortableSettingsServiceTests +{ + [TestMethod] + public void DataDirectoryIsAdjacentToExecutableDirectory() + { + var testRoot = Path.Combine(Path.GetTempPath(), "PSWindowsUpdateGUI.Tests", Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(testRoot); + var service = new PortableSettingsService(testRoot); + + Assert.IsFalse(service.IsEphemeral); + Assert.AreEqual(Path.Combine(testRoot, "PSWindowsUpdateGUI.Data"), service.DataDirectory); + } + finally + { + if (Directory.Exists(testRoot)) Directory.Delete(testRoot, true); + } + } +}