From 4840b8e3be31815959c9af9c0be29489f240c2f5 Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 13:51:49 +0200 Subject: [PATCH 1/4] ci: diagnose Windows SDK pack resolution on the actual runner --- .gitea/workflows/pr-push-build-and-test.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitea/workflows/pr-push-build-and-test.yaml b/.gitea/workflows/pr-push-build-and-test.yaml index c2e5ce3..286c0a2 100644 --- a/.gitea/workflows/pr-push-build-and-test.yaml +++ b/.gitea/workflows/pr-push-build-and-test.yaml @@ -77,6 +77,19 @@ jobs: echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}" "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info + - name: Diagnose Windows SDK targeting-pack resolution + run: | + find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print + "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \ + -p:EnableWindowsTargeting=true \ + -p:TargetFramework=net10.0 \ + -getProperty:NetCoreRoot,NetCoreTargetingPackRoot,PrunePackageDataRoot,PrunePackageTargetingPackRoots,MSBuildSDKsPath,DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR + "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \ + -p:EnableWindowsTargeting=true \ + -p:TargetFramework=net10.0 \ + -t:AddPrunePackageReferences \ + -v:normal + - name: Build Windows desktop target via Wine run: | rm -f MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll From 83726a22338994096f85cf80ea64f26f1be4823f Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 14:17:44 +0200 Subject: [PATCH 2/4] ci: use Wine 11 for .NET 10 targeting-pack enumeration --- .gitea/workflows/pr-push-build-and-test.yaml | 32 +++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/pr-push-build-and-test.yaml b/.gitea/workflows/pr-push-build-and-test.yaml index 286c0a2..44f74a1 100644 --- a/.gitea/workflows/pr-push-build-and-test.yaml +++ b/.gitea/workflows/pr-push-build-and-test.yaml @@ -24,27 +24,23 @@ jobs: - name: Install Wine run: | + set -euo pipefail export DEBIAN_FRONTEND=noninteractive sudo dpkg --add-architecture i386 sudo apt-get update - sudo apt-get install -y --no-install-recommends wine64 wine32 winbind unzip - if [ -d /usr/lib/wine ]; then - echo "/usr/lib/wine" >> "${GITHUB_PATH}" - export PATH="${PATH}:/usr/lib/wine" - fi - if command -v wine >/dev/null 2>&1; then - WINE_BIN="$(command -v wine)" - elif command -v wine64 >/dev/null 2>&1; then - WINE_BIN="$(command -v wine64)" - elif [ -x /usr/lib/wine/wine64 ]; then - WINE_BIN="/usr/lib/wine/wine64" - elif [ -x /usr/lib/wine/wine ]; then - WINE_BIN="/usr/lib/wine/wine" - else - echo "No wine binary found after installation." - ls -la /usr/lib/wine || true - exit 1 - fi + sudo apt-get install -y --no-install-recommends ca-certificates curl gnupg winbind unzip + sudo install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \ + | gpg --dearmor \ + | sudo tee /etc/apt/keyrings/winehq-archive.key >/dev/null + . /etc/os-release + curl -fsSL "https://dl.winehq.org/wine-builds/ubuntu/dists/${VERSION_CODENAME}/winehq-${VERSION_CODENAME}.sources" \ + | sudo tee /etc/apt/sources.list.d/winehq.sources >/dev/null + sudo apt-get update + # Wine 9 cannot enumerate the DOS_DOT patterns used by .NET 10's SDK pack lookup. + sudo apt-get install -y --no-install-recommends "winehq-stable=11.0.0.0~${VERSION_CODENAME}-1" + WINE_BIN="/opt/wine-stable/bin/wine" + test -x "${WINE_BIN}" echo "WINE_BIN=${WINE_BIN}" >> "${GITHUB_ENV}" "${WINE_BIN}" --version From 05e23857ddf9e6990b76660e2ef5c1b429bfe66b Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 15:00:48 +0200 Subject: [PATCH 3/4] ci: provision SDK code-signing roots in the disposable Wine prefix --- .gitea/workflows/pr-push-build-and-test.yaml | 15 ++ docs/wine-sdk-trust.md | 26 ++++ scripts/wine-sdk-trust.cs | 150 +++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 docs/wine-sdk-trust.md create mode 100644 scripts/wine-sdk-trust.cs diff --git a/.gitea/workflows/pr-push-build-and-test.yaml b/.gitea/workflows/pr-push-build-and-test.yaml index 44f74a1..789a0c0 100644 --- a/.gitea/workflows/pr-push-build-and-test.yaml +++ b/.gitea/workflows/pr-push-build-and-test.yaml @@ -73,6 +73,21 @@ jobs: echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}" "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info + - name: Provision NuGet signature trust inside Wine + run: | + SDK_VERSION="$(dotnet --version)" + SDK_TRUST_ROOT="${DOTNET_ROOT}/sdk/${SDK_VERSION}/trustedroots" + dotnet publish scripts/wine-sdk-trust.cs \ + -c Release \ + -p:UseAppHost=false \ + -p:PublishAot=false \ + -o "${RUNNER_TEMP}/wine-sdk-trust" \ + --nologo + "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" \ + "Z:${RUNNER_TEMP}/wine-sdk-trust/WineSdkTrust.dll" --import \ + "Z:${SDK_TRUST_ROOT}/codesignctl.pem" \ + "Z:${SDK_TRUST_ROOT}/timestampctl.pem" + - name: Diagnose Windows SDK targeting-pack resolution run: | find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print diff --git a/docs/wine-sdk-trust.md b/docs/wine-sdk-trust.md new file mode 100644 index 0000000..ad67dc9 --- /dev/null +++ b/docs/wine-sdk-trust.md @@ -0,0 +1,26 @@ +# Wine SDK certificate trust + +`scripts/wine-sdk-trust.cs` is a .NET 10 file-based helper for validating the public root-certificate bundles distributed with the Microsoft SDK and importing them into a disposable Wine prefix's Windows `CurrentUser Root` store. It does not disable NuGet signature verification or certificate checks and does not import private keys. + +```sh +dotnet publish scripts/wine-sdk-trust.cs -c Release -p:UseAppHost=false -p:PublishAot=false -o artifacts/wine-sdk-trust +dotnet artifacts/wine-sdk-trust/WineSdkTrust.dll --validate /path/to/sdk/trustedroots/codesignctl.pem /path/to/sdk/trustedroots/timestampctl.pem +``` + +Validation is read-only on every OS. Both files must exist and contain public PEM certificates. Explicit non-CA certificates are rejected. Historical self-issued SDK roots without `BasicConstraints` remain valid; certificate expiry is not filtered because trusted bundles also support historical signatures. The helper logs each bundle's certificate count and SHA-256, then the unique certificate count. Use bundles from the verified Microsoft SDK archive and retain those hashes with the SDK provenance. + +For the Windows SDK installed inside a disposable Wine prefix, invoke the published DLL with `--import` and the two authoritative Linux SDK bundle paths, for example: + +```sh +"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" artifacts/wine-sdk-trust/WineSdkTrust.dll --import \ + "Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/codesignctl.pem" \ + "Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/timestampctl.pem" +``` + +The import mode first requires `OperatingSystem.IsWindows()` and the Wine-specific `wine_get_version` export from `ntdll.dll`, located with `NativeLibrary.TryLoad` and `TryGetExport`. Native Windows is rejected as well as macOS/Linux. Only after both bundles validate does it open `CurrentUser Root` for writing and call `AddRange`. It closes the store, reopens it read-only and asserts that every imported thumbprint is present. There is no localized shell command, interactive prompt or GUI automation. Missing/unknown arguments and import attempts outside Wine return `2`; validation/import failures return `1`; successful validation or verified import returns `0`. + +The write side effect is confined to the selected Wine prefix's current-user root store. Repeated imports are safe. Run it only against the disposable CI prefix. Native validation never opens any certificate store, and native import attempts are rejected before parsing bundles or constructing a store. The normal PR workflow publishes this DLL and imports the bundles before the Windows desktop restore/build. This repair still requires a real Wine restore/build result to establish that it fixes the observed trust failure. + +[Microsoft documents](https://learn.microsoft.com/en-us/dotnet/core/tools/nuget-signed-package-verification) that these SDK bundles originate from its Trusted Root Program and provide code-signing and timestamping roots. The Windows NuGet restore remains responsible for checking actual package signatures; this helper populates the Wine store used for those checks. + +Local qualification on 2026-10-03 used SDK 10.0.203 to publish the DLL and the authoritative bundles from SDK 10.0.401 to validate it on macOS/.NET 10.0.7. Both bundles passed: codesign contained 307 certificates including seven historical roots without constraints; timestamp contained 327 including two such roots; the union contained 372 unique thumbprints. Their SHA-256 hashes were `AAB671F52E5229906B2100727370007EF4B6D2E360B23F2CF12A6D87773BE611` and `5CCB03367B52F047099F07E1653160E8578A83711CB18560C979FEBB65F8CB5D`, respectively. A native `--import` invocation with those same paths returned `2` before parsing bundles or opening a store; unknown arguments returned `2`, and an empty input returned `1`. No local store import was performed. This is helper qualification, not a passing Wine/NuGet result. diff --git a/scripts/wine-sdk-trust.cs b/scripts/wine-sdk-trust.cs new file mode 100644 index 0000000..09a4c74 --- /dev/null +++ b/scripts/wine-sdk-trust.cs @@ -0,0 +1,150 @@ +#:property PublishAot=false +#:property UseAppHost=false +#:property AssemblyName=WineSdkTrust + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +if (args.Length != 3 || (args[0] != "--validate" && args[0] != "--import")) +{ + Console.Error.WriteLine("Usage: WineSdkTrust <--validate|--import> "); + return 2; +} + +var import = args[0] == "--import"; +if (import && (!OperatingSystem.IsWindows() || !IsWine())) +{ + Console.Error.WriteLine("REFUSED: --import requires Windows under Wine (ntdll.dll!wine_get_version). No certificate store was opened."); + return 2; +} + +Console.WriteLine($"OS: {RuntimeInformation.OSDescription}"); +Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}"); +Console.WriteLine($"Mode: {args[0]}"); +var certificates = new X509Certificate2Collection(); +try +{ + foreach (var path in args.Skip(1)) + { + if (!File.Exists(path) || new FileInfo(path).Length == 0) + { + throw new InvalidDataException($"The SDK certificate bundle is missing or empty: {path}"); + } + + var bundle = new X509Certificate2Collection(); + try + { + bundle.ImportFromPemFile(path); + if (bundle.Count == 0) + { + throw new InvalidDataException($"The SDK bundle contains no PEM certificates: {path}"); + } + + var legacyRoots = 0; + foreach (var certificate in bundle) + { + if (certificate.HasPrivateKey) + { + throw new InvalidDataException($"The SDK bundle must contain public certificates only: {certificate.Thumbprint}"); + } + + var constraints = certificate.Extensions.OfType().SingleOrDefault(); + if (constraints is { CertificateAuthority: false }) + { + throw new InvalidDataException($"The SDK bundle contains a non-CA certificate: {certificate.Thumbprint}"); + } + if (constraints is null) + { + // Microsoft also ships historical roots without the BasicConstraints extension. + if (!certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData)) + { + throw new InvalidDataException($"A certificate without CA constraints is not self-issued: {certificate.Thumbprint}"); + } + legacyRoots++; + } + } + + Console.WriteLine($"Bundle: {Path.GetFullPath(path)}"); + Console.WriteLine($" SHA256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}"); + Console.WriteLine($" Certificates: {bundle.Count}; historical self-issued roots without BasicConstraints: {legacyRoots}"); + certificates.AddRange(bundle); + bundle.Clear(); + } + finally + { + foreach (var certificate in bundle) + { + certificate.Dispose(); + } + } + } + + var thumbprints = certificates.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase); + Console.WriteLine($"Unique SDK certificate thumbprints: {thumbprints.Count}"); + if (!import) + { + Console.WriteLine("PASS: both SDK bundles validated; no certificate store was opened."); + return 0; + } + + using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.AddRange(certificates); + } + + using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadOnly); + var installed = store.Certificates; + try + { + var installedThumbprints = installed.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = thumbprints.Except(installedThumbprints).ToArray(); + if (missing.Length != 0) + { + throw new CryptographicException($"SDK certificates missing after import: {string.Join(", ", missing)}"); + } + } + finally + { + foreach (var certificate in installed) + { + certificate.Dispose(); + } + } + } + + Console.WriteLine($"PASS: all {thumbprints.Count} SDK certificate thumbprints verified in CurrentUser Root."); + return 0; +} +catch (Exception exception) +{ + Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}"); + return 1; +} +finally +{ + foreach (var certificate in certificates) + { + certificate.Dispose(); + } +} + +static bool IsWine() +{ + if (!NativeLibrary.TryLoad("ntdll.dll", out var library)) + { + return false; + } + + try + { + return NativeLibrary.TryGetExport(library, "wine_get_version", out _); + } + finally + { + NativeLibrary.Free(library); + } +} From 1b19b08f2e62398661dc24692ce4b776e90e715f Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 15:33:02 +0200 Subject: [PATCH 4/4] build: use the tray core dependency without unused WinUI tooling --- MeetingAssistant/MeetingAssistant.csproj | 2 +- docs/wine-windows-build.md | 41 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/wine-windows-build.md diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index 6250804..e2e8b95 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -58,7 +58,7 @@ - + diff --git a/docs/wine-windows-build.md b/docs/wine-windows-build.md new file mode 100644 index 0000000..b79f6be --- /dev/null +++ b/docs/wine-windows-build.md @@ -0,0 +1,41 @@ +# Windows desktop build under Wine + +The PR workflow uses the existing `ubuntu-latest` Gitea runner. It builds `net10.0-windows10.0.19041.0` with the Windows .NET SDK under Wine, then runs the portable `net10.0` test suite through that Windows host. The desktop target retains WPF, `WinExe`, x64, and the Windows recording, hotkey, Outlook, screenshot, tray, and notification implementations. + +## Observed failures + +On 2026-10-03, run 4154 at commit `05e23857ddf9e6990b76660e2ef5c1b429bfe66b` used Wine 11 and successfully imported and read back all 372 unique thumbprints from the Microsoft SDK's code-signing and timestamp trust bundles. The Windows SDK targeting-pack diagnostic succeeded, and the Windows desktop restore completed. NuGet signature verification was not disabled. See [the trust helper documentation](wine-sdk-trust.md). + +The desktop build then failed in `WinAppSdkExpandPriContent` from `Microsoft.Windows.SDK.BuildTools.MSIX` 1.7.20250829.1. MakePRI reported `PRI175: 0x80004001 - Dump`, `MakePri failed with error: Not implemented`, and `PRI222`. The following `Root element is missing` exception came from loading the missing dump output; it was a secondary failure. These logs establish a MakePRI compatibility failure under Wine, but do not identify the particular unimplemented Wine API. + +## Dependency correction + +The resolved Windows graph was: + +```text +H.NotifyIcon.Uno.WinUI 2.4.1 + -> H.NotifyIcon 2.4.1 + -> Microsoft.WindowsAppSDK 1.8.251106002 + -> Microsoft.WindowsAppSDK.Base 1.8.250831001 + -> Microsoft.Windows.SDK.BuildTools.MSIX 1.7.20250829.1 +``` + +`UnoTaskbarIconService.Windows.cs` uses `H.NotifyIcon.Core.TrayIconWithContextMenu`, `PopupMenu`, `PopupMenuItem`, `PopupMenuSeparator`, and `PopupSubMenu`. These types come from the already-selected `H.NotifyIcon` 2.4.1 assembly. The application does not use the wrapper's WinUI/Uno XAML controls. The Windows package reference therefore selects `H.NotifyIcon` 2.4.1 directly. This keeps the same core assembly and tray APIs while removing the unused WinUI wrapper and its Windows App SDK graph. `CommunityToolkit.WinUI.Notifications` 7.1.2 remains available for toast notifications and does not introduce Windows App SDK in the selected Windows target. + +The package author's [core-package documentation](https://www.nuget.org/packages/H.NotifyIcon/2.4.1) supports using `H.NotifyIcon` directly, including in console applications. Its `net10.0` dependency group contains `H.GeneratedIcons.System.Drawing` 2.4.1. The [wrapper package](https://www.nuget.org/packages/H.NotifyIcon.Uno.WinUI/2.4.1) adds Windows App SDK for its Windows target. + +PRI expansion discovers referenced asset files that are absent from normal project outputs and adds them to copy-local output. Disabling that target would risk losing real WinUI resource payloads. This correction removes an unused application dependency instead of disabling PRI generation/expansion, creating a dummy PRI file, suppressing signature checks, or changing the desktop target. + +## Qualification and remaining CI evidence + +Local qualification on 2026-10-03 used the existing `mcr.microsoft.com/dotnet/sdk:10.0-noble` container image with SDK 10.0.401. The existing NuGet package cache was a read-only fallback; new package/cache writes went to a separate temporary directory. This command inside the container compiled the complete Windows desktop target: + +```sh +dotnet build MeetingAssistant/MeetingAssistant.csproj \ + -c Release -f net10.0-windows10.0.19041.0 -r win-x64 \ + -p:EnableWindowsTargeting=true -p:RestoreFallbackFolders=/host-nuget --nologo +``` + +The command returned zero with `Build succeeded`, zero errors, and four existing NAudio deprecation warnings. The produced runtime configuration retains `Microsoft.WindowsDesktop.App` alongside the .NET and ASP.NET frameworks. The regenerated assets graph contains `H.NotifyIcon/2.4.1`, no `Microsoft.WindowsAppSDK*` packages, and no `Microsoft.Windows.SDK.BuildTools.MSIX`; generated package imports contain no Windows App SDK or MakePRI entry. The output `H.NotifyIcon.dll` is byte-identical to the previously selected cached core assembly, with SHA-256 `0027e443a6121af8fb616c0200d3c63bf4abb72e3c548e119fee1eae321a8368`. No application source or target suppression was required. This Linux cross-build does not establish that Windows `dotnet.exe` succeeds under Wine. + +Completion requires a new Gitea run at the corrected commit: successful Windows desktop build under Wine with a freshly produced `MeetingAssistant.dll`, followed by actual Windows-host test execution with a fresh `wine.trx`, nonzero executed tests, zero failed tests, and a successful job. The workflow removes the previous DLL/TRX at the expected paths before these commands and requires nonempty replacements. The Wine test project targets `net10.0`, so these tests do not prove execution of Windows-TFM-only or WPF/Outlook UI behavior. Native macOS tests report their explicit platform skip outside macOS.