From 8462e0371c9425d19f23c36c6d80d887bc4ff37f Mon Sep 17 00:00:00 2001 From: Redth Date: Wed, 29 Jul 2026 14:21:27 -0400 Subject: [PATCH 1/2] Make dotnetup's managed root authoritative for Doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor and the .NET SDK Manager could disagree about the active .NET SDK. On macOS with dotnetup opted in, Doctor reported 11.0.100-preview.5 from /usr/local/share/dotnet while the SDK Manager reported preview.6 from the dotnetup-managed root, and the resulting feature-band mismatch silently downgraded Doctor's workload check to a NuGet-only path. Two root causes: 1. Prerelease-blind ordering. SdkVersion exposed only Major/Minor/Patch, so every OrderByDescending tied across 11.0.100-preview.4/5/6 and stable 11.0.100, letting directory/feed enumeration order pick the winner. SdkVersion now implements IComparable over a full NuGetVersion and all consumers sort through the shared SdkVersion.SortDescending helper. 2. Doctor never treated dotnetup as authoritative — it scanned the system root and merely merged managed versions in by version string, keeping the system install root, architecture, manifests and workload-set files. Adds DotnetSdkSourceResolver as the single decision point for which install root wins: repo-local .dotnet, then the dotnetup-managed root whenever dotnetup manages at least one valid SDK (preferring the process architecture, then the newest SDK), then the machine scan. When the managed root wins it is the sole source of truth — DoctorService pins a LocalSdkService to it so manifests and workload sets read the same place, and ResolveDotNetExecutable invokes the matching dotnet. Doctor's ".NET SDK" check now reuses dotnetup's tracked-channel update preview when managed, so it shows exactly what the SDK Manager shows, and emits dotnetup-update-sdk: (specific channels preferred over the latest/lts/sts/preview aliases; pinned specs skipped). DoctorContext exposes UsesDotnetUpManagedSdk and the Doctor page badges the ".NET SDK Path" row. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38513608-bbd3-48e1-8ac5-7a5a304654ca --- docs/dotnet-sdk-management.md | 61 ++++- src/MauiSherpa.Core/Interfaces.cs | 3 +- src/MauiSherpa.Core/Services/DoctorService.cs | 244 +++++++++++------- src/MauiSherpa.Workloads/Models/SdkVersion.cs | 25 +- .../Services/DotnetSdkSourceResolver.cs | 131 ++++++++++ .../Services/LocalSdkService.cs | 24 +- .../Services/SdkVersionService.cs | 10 +- src/MauiSherpa/Pages/Doctor.razor | 13 +- .../Services/DoctorDotnetUpTests.cs | 189 +++++++++++++- .../Models/SdkVersionTests.cs | 51 ++++ .../Services/DotnetSdkSourceResolverTests.cs | 133 ++++++++++ 11 files changed, 750 insertions(+), 134 deletions(-) create mode 100644 src/MauiSherpa.Workloads/Services/DotnetSdkSourceResolver.cs create mode 100644 tests/MauiSherpa.Workloads.Tests/Services/DotnetSdkSourceResolverTests.cs diff --git a/docs/dotnet-sdk-management.md b/docs/dotnet-sdk-management.md index 21c841e5..30f476fa 100644 --- a/docs/dotnet-sdk-management.md +++ b/docs/dotnet-sdk-management.md @@ -30,15 +30,47 @@ https://aka.ms/dotnet/dotnetup/{quality}/dotnetup-{rid}[.exe] (+ ".sha512") - Installed into `~/.dotnetup` (matches the official installer, so an existing install is reused). ### Doctor integration -- The `.NET SDK` "Update available" check is now **fixable**. Its `FixAction` is - `dotnetup-update-sdk:`; applying it bootstraps `dotnetup` if missing, then runs - `sdk install --set-default-install` (Terminal Mode). + +#### Which install root wins + +Doctor and the SDK Manager must agree on *one* .NET install root, otherwise they report different +active SDKs, feature bands, and workload sets. `DotnetSdkSourceResolver` +(`MauiSherpa.Workloads/Services`) is the single decision point. Precedence: + +1. A repo-local `.dotnet/sdk` next to the scanned project directory. +2. The **dotnetup-managed root**, whenever dotnetup manages at least one valid SDK. When several + managed roots exist, it prefers the one matching the process architecture, then the one with the + newest SDK, then ordinal install-root order. +3. The machine-discovered root (`/etc/dotnet/install_location*`, `DOTNET_ROOT`, `PATH`, defaults). + +When the managed root wins it is the **sole** source of truth: installed SDK list, active SDK, +feature band, manifests, workload set, and the `dotnet` executable Doctor invokes all come from it, +and system SDKs are ignored. `DoctorService` pins a `LocalSdkService` to that root +(`GetSdkServiceForRoot`) so manifest and workload-set probing reads the same place. + +This matters because the GUI Doctor does not inherit shell `PATH`/`DOTNET_ROOT`, and on macOS the +managed root defaults to `~/Library/Application Support/dotnet` — which the machine scan never finds. +`DoctorContext.UsesDotnetUpManagedSdk` reports the outcome, and the Doctor page shows a `dotnetup` +badge on the ".NET SDK Path" row. + +#### Prerelease-aware ordering + +`SdkVersion` implements `IComparable` over a full `NuGetVersion`, and every consumer +sorts through `SdkVersion.SortDescending`. Without this, `11.0.100-preview.4`, `-preview.5`, +`-preview.6` and stable `11.0.100` all compare equal on `Major`/`Minor`/`Patch` and directory or feed +enumeration order silently picks the "active" SDK — which then produces a feature band that matches +no dotnetup workload target and downgrades Doctor to a NuGet-only workload path. + +#### Checks and fixes + +- The `.NET SDK` "Update available" check is **fixable**. When the managed root is in use, Doctor + reuses dotnetup's own tracked-channel update preview — the same data the SDK Manager shows — and + emits `FixAction: dotnetup-update-sdk:` (e.g. `11.0.1xx`). Specific channels are preferred + over moving aliases (`latest`, `lts`, `sts`, `preview`), and pinned specs are skipped. Otherwise it + falls back to `dotnetup-update-sdk:`. Applying the fix bootstraps `dotnetup` if missing, + then runs `sdk install --set-default-install` (Terminal Mode). - A **dotnetup presence** check (Info) shows the installed version, or offers an `install-dotnetup` fix when it is missing. -- Doctor reconciles **dotnetup-managed SDKs** into the installed-SDK set - (`MergeManagedSdks`) so an applied update actually clears the warning. This matters because - the GUI Doctor does not read shell `PATH`, and on macOS the managed root defaults to - `~/Library/Application Support/dotnet` — which `LocalSdkService` does not otherwise scan. ### SDK Manager page (`/dotnet-sdk`) @@ -185,6 +217,8 @@ participate in resolution. - `Services/WorkloadInstallationStateResolver.cs` — overlays direct SDK installation records on the active transitive workload graph to classify explicit, included, and available IDs without inferring state from shared pack folders. + - `Services/DotnetSdkSourceResolver.cs` — pure resolution of the authoritative install root and + SDK set from a local machine scan plus a `DotnetUpListResult`. - `Services/GlobalJsonWorkloadPinEditor.cs` — JSONC-preserving atomic `sdk.workloadVersion` edits. - **`MauiSherpa.Core`** — `IDotnetUpService` (`Interfaces.cs`) + `DotnetUpService`: resolves the @@ -192,11 +226,16 @@ participate in resolution. builds `ProcessRequest`s for install/update/uninstall. `IDotnetWorkloadService` discovers feature-band targets, orchestrates inventory queries, builds workload process requests, and invalidates per-root caches after writes. Both app heads register these services. -- **Doctor** consumes the same `IDotnetWorkloadService` target, availability result, environment, - command builder, and refresh path as the SDK Manager so the two surfaces cannot disagree. +- **Doctor** resolves its install root through `DotnetSdkSourceResolver`, then consumes the same + `IDotnetWorkloadService` target, availability result, environment, command builder, update + previews, and refresh path as the SDK Manager. Because both surfaces start from the same root and + the same prerelease-aware ordering, they cannot disagree. ## Testing Pure helpers are covered by unit tests in `tests/MauiSherpa.Workloads.Tests` (RID/URL building, -SHA-512 verify, `list`/`--info` parsing, argument building). Doctor reconciliation and the new -dependency-status shapes are covered in `tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs`. +SHA-512 verify, `list`/`--info` parsing, argument building, install-root resolution in +`Services/DotnetSdkSourceResolverTests.cs`, and prerelease ordering in +`Models/SdkVersionTests.cs`). Doctor source-of-truth selection, channel-preview matching, and the +dependency-status shapes are covered in +`tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs`. diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 2db0b701..00c9e458 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -1730,7 +1730,8 @@ public record DoctorContext( bool DotnetUpInstalled = false, string? DotnetUpVersion = null, string? DotnetUpManagedInstallRoot = null, - string? DotNetArchitecture = null + string? DotNetArchitecture = null, + bool UsesDotnetUpManagedSdk = false ); /// diff --git a/src/MauiSherpa.Core/Services/DoctorService.cs b/src/MauiSherpa.Core/Services/DoctorService.cs index d4c87885..060734a8 100644 --- a/src/MauiSherpa.Core/Services/DoctorService.cs +++ b/src/MauiSherpa.Core/Services/DoctorService.cs @@ -27,6 +27,9 @@ public class DoctorService : IDoctorService // MauiSherpa.Workloads services - instantiated on demand private LocalSdkService? _localSdkService; + private LocalSdkService? _rootedSdkService; + private string? _rootedSdkServiceRoot; + private string? _authoritativeSdkRoot; private GlobalJsonService? _globalJsonService; private NuGetClient? _nugetClient; private WorkloadSetService? _workloadSetService; @@ -54,6 +57,27 @@ public DoctorService( } private LocalSdkService GetLocalSdkService() => _localSdkService ??= new LocalSdkService(_loggerFactory.CreateLogger()); + + /// + /// Returns a pinned to so manifest, + /// workload-set, and dependency reads come from the same root Doctor decided is authoritative + /// (which is the dotnetup-managed root whenever the user has opted into dotnetup). + /// + private LocalSdkService GetSdkServiceForRoot(string? installRoot) + { + if (string.IsNullOrWhiteSpace(installRoot)) + return GetLocalSdkService(); + + if (_rootedSdkService != null && + string.Equals(_rootedSdkServiceRoot, installRoot, StringComparison.OrdinalIgnoreCase)) + return _rootedSdkService; + + _rootedSdkServiceRoot = installRoot; + _rootedSdkService = new LocalSdkService( + _loggerFactory.CreateLogger(), installRoot); + return _rootedSdkService; + } + private GlobalJsonService GetGlobalJsonService() => _globalJsonService ??= new GlobalJsonService(); private NuGetClient GetNuGetClient() => _nugetClient ??= new NuGetClient(); private WorkloadSetService GetWorkloadSetService() => _workloadSetService ??= new WorkloadSetService(GetNuGetClient()); @@ -61,17 +85,22 @@ public DoctorService( /// /// Resolves the full path to the dotnet executable. /// GUI apps on macOS don't inherit the user's shell PATH, so bare "dotnet" won't resolve. + /// Prefers the root Doctor last resolved as authoritative (the dotnetup-managed root when the + /// user opted into dotnetup) so muxer-based commands run against the SDK Doctor reported on. /// private string ResolveDotNetExecutable() { - var sdkPath = GetLocalSdkService().GetDotNetSdkPath(); - if (!string.IsNullOrEmpty(sdkPath)) + var exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + foreach (var root in new[] { _authoritativeSdkRoot, GetLocalSdkService().GetDotNetSdkPath() }) { - var exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - var fullPath = Path.Combine(sdkPath, exeName); + if (string.IsNullOrEmpty(root)) + continue; + var fullPath = Path.Combine(root, exeName); if (File.Exists(fullPath)) return fullPath; } + // Fallback to bare name (works if dotnet is on PATH) return "dotnet"; } @@ -83,7 +112,6 @@ private string ResolveDotNetExecutable() public async Task GetContextAsync(string? workingDirectory = null) { var globalJsonService = GetGlobalJsonService(); - var localSdkService = GetLocalSdkService(); // Determine working directory var effectiveDir = workingDirectory ?? Environment.CurrentDirectory; @@ -102,29 +130,19 @@ public async Task GetContextAsync(string? workingDirectory = null dotnetUpList = await TryGetDotnetUpListAsync(); } - // Get SDK path - LocalSdkService already checks for .dotnet/, DOTNET_ROOT, etc. - string? sdkPath = null; - var sdkArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); - - // Check for local .dotnet in working directory - var localDotnet = Path.Combine(effectiveDir, ".dotnet"); - if (Directory.Exists(localDotnet) && Directory.Exists(Path.Combine(localDotnet, "sdk"))) - { - sdkPath = localDotnet; - } - else - { - sdkPath = localSdkService.GetDotNetSdkPath(); - } - - // Resolve against local and dotnetup-managed SDKs before choosing the exact root. + // Pick the authoritative install root before anything else — when the user opted into + // dotnetup, its managed root is what the shell actually resolves, so Doctor must not mix + // in machine-wide SDKs from /usr/local/share/dotnet (or Program Files). + var source = ResolveSdkSource(effectiveDir, dotnetUpList); + var sdkPath = source.InstallRoot; + var sdkArchitecture = source.Architecture + ?? RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + var sdks = source.Sdks; + string? featureBand = null; bool isPreviewSdk = false; string? activeSdkVersion = null; string? resolvedSdkVersion = null; - var sdks = localSdkService.GetInstalledSdkVersions(); - if (dotnetUpList != null) - sdks = MergeManagedSdks(sdks, dotnetUpList); if (sdks.Count > 0) { SdkVersion effectiveSdk; @@ -143,42 +161,6 @@ public async Task GetContextAsync(string? workingDirectory = null featureBand = effectiveSdk.FeatureBand; isPreviewSdk = effectiveSdk.IsPreview; activeSdkVersion = effectiveSdk.Version; - - var localRootContainsSdk = sdkPath != null && - Directory.Exists(Path.Combine(sdkPath, "sdk", effectiveSdk.Version)); - var matchingSpec = globalJson?.Path == null || dotnetUpList == null - ? null - : dotnetUpList.InstallSpecs.FirstOrDefault(spec => - spec.Component == DotnetUpComponent.Sdk && - string.Equals(spec.GlobalJsonPath, globalJson.Path, StringComparison.OrdinalIgnoreCase)); - if (dotnetUpList != null && (matchingSpec != null || !localRootContainsSdk)) - { - var managedInstallation = dotnetUpList.Installations - .Where(installation => - installation.Component == DotnetUpComponent.Sdk && - installation.IsValid && - string.Equals( - installation.Version, - effectiveSdk.Version, - StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(installation => - matchingSpec != null && - string.Equals( - installation.InstallRoot, - matchingSpec.InstallRoot, - StringComparison.OrdinalIgnoreCase) && - (string.IsNullOrWhiteSpace(matchingSpec.Architecture) || - string.Equals( - installation.Architecture, - matchingSpec.Architecture, - StringComparison.OrdinalIgnoreCase))) - .FirstOrDefault(); - if (managedInstallation != null) - { - sdkPath = managedInstallation.InstallRoot; - sdkArchitecture = managedInstallation.Architecture ?? sdkArchitecture; - } - } } return new DoctorContext( @@ -194,11 +176,44 @@ public async Task GetContextAsync(string? workingDirectory = null ResolvedSdkVersion: resolvedSdkVersion, DotnetUpInstalled: dotnetUpInstalled, DotnetUpVersion: dotnetUpVersion, - DotnetUpManagedInstallRoot: dotnetUpList?.InstallRoots.FirstOrDefault(), - DotNetArchitecture: sdkArchitecture + DotnetUpManagedInstallRoot: source.IsDotnetUpManaged + ? source.InstallRoot + : dotnetUpList?.InstallRoots.FirstOrDefault(), + DotNetArchitecture: sdkArchitecture, + UsesDotnetUpManagedSdk: source.IsDotnetUpManaged ); } + /// + /// Resolves which .NET install root Doctor should inspect, in priority order: + /// a repo-local .dotnet (an explicit per-project override), then the dotnetup-managed + /// root when the user opted into dotnetup, then the machine's discovered install. + /// + private DotnetSdkSource ResolveSdkSource(string effectiveDir, DotnetUpListResult? dotnetUpList) + { + var repoLocalRoot = Path.Combine(effectiveDir, ".dotnet"); + if (Directory.Exists(Path.Combine(repoLocalRoot, "sdk"))) + { + var repoLocalService = GetSdkServiceForRoot(repoLocalRoot); + _authoritativeSdkRoot = repoLocalRoot; + return new DotnetSdkSource + { + Sdks = repoLocalService.GetInstalledSdkVersions(), + InstallRoot = repoLocalRoot, + Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + IsDotnetUpManaged = false + }; + } + + var machineService = GetLocalSdkService(); + var resolved = DotnetSdkSourceResolver.Resolve( + machineService.GetInstalledSdkVersions(), + machineService.GetDotNetSdkPath(), + dotnetUpList); + _authoritativeSdkRoot = resolved.InstallRoot; + return resolved; + } + private async Task TryGetDotnetUpInfoAsync() { if (_dotnetUpService is null) @@ -229,30 +244,48 @@ public async Task GetContextAsync(string? workingDirectory = null } } - /// - /// Merges dotnetup-managed SDK versions into the locally discovered set, de-duplicating by - /// version string and re-sorting descending so the "latest installed" reflects dotnetup installs. - /// - internal static IReadOnlyList MergeManagedSdks( - IReadOnlyList localSdks, MauiSherpa.Workloads.Models.DotnetUpListResult dotnetUpList) + private async Task?> TryGetDotnetUpUpdatePreviewAsync( + DotnetUpListResult list) { - var byVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var sdk in localSdks) - byVersion[sdk.Version] = sdk; - - foreach (var managed in DotnetUpParser.GetManagedSdkVersions(dotnetUpList)) + if (_dotnetUpService is null) + return null; + try { - if (byVersion.ContainsKey(managed)) - continue; - if (SdkVersion.TryParse(managed, out var parsed) && parsed != null) - byVersion[managed] = parsed; + return await _dotnetUpService.GetUpdatePreviewAsync(list); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to resolve dotnetup update preview: {ex.Message}"); + return null; } + } + + private static readonly string[] AliasChannels = ["latest", "lts", "sts", "preview"]; + + /// + /// Finds the dotnetup tracked SDK channel that currently resolves to , + /// preferring a specific channel (e.g. 11.0.1xx) over a moving alias (e.g. preview) + /// so the offered fix targets the narrowest channel that owns the SDK. + /// + internal static DotnetUpdatePreview? FindSdkChannelPreview( + IReadOnlyList? previews, SdkVersion activeSdk) + { + if (previews == null || previews.Count == 0) + return null; - return byVersion.Values - .OrderByDescending(v => v.Major) - .ThenByDescending(v => v.Minor) - .ThenByDescending(v => v.Patch) - .ToList(); + return previews + .Where(preview => + preview.Component == DotnetUpComponent.Sdk && + !preview.IsPinned && + string.Equals( + preview.InstalledVersion, + activeSdk.Version, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(preview => AliasChannels.Contains( + preview.Channel, StringComparer.OrdinalIgnoreCase) ? 1 : 0) + .ThenByDescending(preview => preview.UpdateAvailable) + .ThenBy(preview => preview.Channel, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); } /// @@ -318,20 +351,24 @@ public async Task RunDoctorAsync(DoctorContext? context = null, IP progress?.Report("Checking .NET SDK installation..."); - var localSdkService = GetLocalSdkService(); + // Read everything from the root the context settled on. When dotnetup manages the active + // SDK that is the dotnetup root, so Doctor and the .NET SDK Manager inspect the same files. + var localSdkService = GetSdkServiceForRoot(context.DotNetSdkPath); var dependencies = new List(); // Get installed SDKs var sdkVersions = localSdkService.GetInstalledSdkVersions(); - // Merge in SDKs managed by dotnetup. dotnetup installs to a user-level root - // (e.g. ~/Library/Application Support/dotnet on macOS) that LocalSdkService does not - // scan, so without this the GUI Doctor would never "see" a dotnetup-applied update. var dotnetUpList = await TryGetDotnetUpListAsync(); var dotnetUpInstalled = _dotnetUpService is { IsInstalled: true }; - if (dotnetUpList != null) + + // When dotnetup owns the active SDK, reuse the same tracked-channel update preview the + // .NET SDK Manager renders so the two pages cannot report different versions. + IReadOnlyList? updatePreviews = null; + if (context.UsesDotnetUpManagedSdk && dotnetUpList != null) { - sdkVersions = MergeManagedSdks(sdkVersions, dotnetUpList); + progress?.Report("Checking dotnetup tracked channels..."); + updatePreviews = await TryGetDotnetUpUpdatePreviewAsync(dotnetUpList); } var sdkInfos = sdkVersions.Select(s => new SdkVersionInfo( @@ -381,8 +418,33 @@ public async Task RunDoctorAsync(DoctorContext? context = null, IP else { var latestSdk = sdkVersions[0]; - - if (latestSdk.IsPreview) + var managedChannel = FindSdkChannelPreview(updatePreviews, latestSdk); + + if (managedChannel != null) + { + // dotnetup owns this SDK — report exactly what its tracked channel resolves to. + var hasUpdate = managedChannel.UpdateAvailable; + var available = managedChannel.AvailableVersion; + + dependencies.Add(new DependencyStatus( + ".NET SDK", + DependencyCategory.DotNetSdk, + null, + hasUpdate ? available : null, + latestSdk.Version, + hasUpdate + ? DependencyStatusType.Warning + : latestSdk.IsPreview ? DependencyStatusType.Info : DependencyStatusType.Ok, + hasUpdate + ? $"Update available: {available} (dotnetup channel {managedChannel.Channel})" + : latestSdk.IsPreview + ? $"Preview SDK ({latestSdk.Version}) — managed by dotnetup" + : $"{sdkVersions.Count} SDK(s) managed by dotnetup, using {latestSdk.Version}", + IsFixable: hasUpdate, + FixAction: hasUpdate ? $"dotnetup-update-sdk:{managedChannel.Channel}" : null + )); + } + else if (latestSdk.IsPreview) { // Active SDK is a preview — find the latest available for the SAME major version var latestAvailableForMajor = availableSdkVersions? @@ -603,7 +665,7 @@ private async Task CheckWorkloadDependenciesAsync( { if (context.EffectiveFeatureBand == null) return; - var localSdkService = GetLocalSdkService(); + var localSdkService = GetSdkServiceForRoot(context.DotNetSdkPath); // Collect all dependencies from installed manifests var manifestIds = localSdkService.GetInstalledWorkloadManifests(context.EffectiveFeatureBand); diff --git a/src/MauiSherpa.Workloads/Models/SdkVersion.cs b/src/MauiSherpa.Workloads/Models/SdkVersion.cs index 32b8656b..97cb6924 100644 --- a/src/MauiSherpa.Workloads/Models/SdkVersion.cs +++ b/src/MauiSherpa.Workloads/Models/SdkVersion.cs @@ -1,9 +1,11 @@ +using NuGet.Versioning; + namespace MauiSherpa.Workloads.Models; /// /// Represents a .NET SDK version with its components parsed. /// -public record SdkVersion +public record SdkVersion : IComparable { /// /// The full version string (e.g., "9.0.100"). @@ -48,6 +50,27 @@ public record SdkVersion /// public string? PreviewLabel { get; init; } + /// + /// The full semantic version, including prerelease labels. Used for ordering so that + /// 11.0.100-preview.6 correctly sorts above 11.0.100-preview.5. + /// + public NuGetVersion SemanticVersion => + NuGetVersion.TryParse(Version, out var parsed) + ? parsed + : new NuGetVersion(Major, Minor, Patch); + + /// + /// Compares by full semantic version so prerelease labels participate in ordering. + /// + public int CompareTo(SdkVersion? other) => + other is null ? 1 : SemanticVersion.CompareTo(other.SemanticVersion); + + /// + /// Orders SDK versions newest-first, honouring prerelease labels. + /// + public static IReadOnlyList SortDescending(IEnumerable versions) => + versions.OrderByDescending(v => v.SemanticVersion).ToList(); + /// /// Parses an SDK version string into an SdkVersion object. /// diff --git a/src/MauiSherpa.Workloads/Services/DotnetSdkSourceResolver.cs b/src/MauiSherpa.Workloads/Services/DotnetSdkSourceResolver.cs new file mode 100644 index 00000000..c69c17dd --- /dev/null +++ b/src/MauiSherpa.Workloads/Services/DotnetSdkSourceResolver.cs @@ -0,0 +1,131 @@ +using System.Runtime.InteropServices; +using MauiSherpa.Workloads.Models; + +namespace MauiSherpa.Workloads.Services; + +/// +/// The .NET install root that surfaces such as Doctor should treat as authoritative, along with +/// the SDKs it contains. +/// +public sealed record DotnetSdkSource +{ + /// SDK versions found in , newest first. + public IReadOnlyList Sdks { get; init; } = []; + + /// The install root the SDKs belong to, or null when none could be resolved. + public string? InstallRoot { get; init; } + + /// The architecture of the install root (e.g. arm64). + public string? Architecture { get; init; } + + /// True when the root is managed by dotnetup rather than discovered on the machine. + public bool IsDotnetUpManaged { get; init; } +} + +/// +/// Decides which .NET install root is authoritative. +/// +/// When the user has opted into dotnetup (the tool is installed and manages at least one valid +/// SDK), the dotnetup-managed root wins outright: dotnetup's Terminal Mode points PATH and +/// DOTNET_ROOT at it, so it is the SDK the user actually builds with. Mixing it with a +/// machine-wide install at /usr/local/share/dotnet produces an SDK/feature-band pair that +/// matches neither root, which in turn breaks workload-state lookups. +/// +public static class DotnetSdkSourceResolver +{ + /// + /// Resolves the authoritative source from a machine scan and dotnetup's reported state. + /// + /// SDKs discovered by scanning the machine's default install root. + /// The machine's default install root, if one was found. + /// dotnetup's list --format Json result, if available. + /// + /// Architecture to prefer when dotnetup manages more than one; defaults to the process architecture. + /// + public static DotnetSdkSource Resolve( + IReadOnlyList localSdks, + string? localInstallRoot, + DotnetUpListResult? dotnetUpList, + string? preferredArchitecture = null) + { + var managed = ResolveManaged(dotnetUpList, preferredArchitecture); + if (managed != null) + return managed; + + return new DotnetSdkSource + { + Sdks = SdkVersion.SortDescending(localSdks), + InstallRoot = localInstallRoot, + Architecture = preferredArchitecture ?? CurrentArchitecture, + IsDotnetUpManaged = false + }; + } + + private static DotnetSdkSource? ResolveManaged( + DotnetUpListResult? dotnetUpList, string? preferredArchitecture) + { + if (dotnetUpList == null) + return null; + + var wanted = string.IsNullOrWhiteSpace(preferredArchitecture) + ? CurrentArchitecture + : preferredArchitecture; + + var groups = dotnetUpList.Installations + .Where(installation => + installation.Component == DotnetUpComponent.Sdk && + installation.IsValid && + !string.IsNullOrWhiteSpace(installation.InstallRoot) && + SdkVersion.TryParse(installation.Version, out _)) + .GroupBy( + installation => ( + Root: installation.InstallRoot, + Architecture: installation.Architecture ?? string.Empty), + TupleComparer) + .Select(group => new DotnetSdkSource + { + Sdks = SdkVersion.SortDescending( + group + .Select(installation => + SdkVersion.TryParse(installation.Version, out var parsed) ? parsed : null) + .Where(version => version != null) + .Select(version => version!) + .DistinctBy(version => version.Version, StringComparer.OrdinalIgnoreCase)), + InstallRoot = group.Key.Root, + Architecture = string.IsNullOrWhiteSpace(group.Key.Architecture) + ? wanted + : group.Key.Architecture, + IsDotnetUpManaged = true + }) + .Where(source => source.Sdks.Count > 0) + .ToList(); + + if (groups.Count == 0) + return null; + + return groups + .OrderByDescending(source => string.Equals( + source.Architecture, wanted, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(source => source.Sdks[0].SemanticVersion) + .ThenBy(source => source.InstallRoot, StringComparer.OrdinalIgnoreCase) + .First(); + } + + private static string CurrentArchitecture => + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + private static readonly IEqualityComparer<(string Root, string Architecture)> TupleComparer = + new RootArchitectureComparer(); + + private sealed class RootArchitectureComparer : IEqualityComparer<(string Root, string Architecture)> + { + public bool Equals((string Root, string Architecture) x, (string Root, string Architecture) y) => + StringComparer.OrdinalIgnoreCase.Equals(x.Root, y.Root) && + StringComparer.OrdinalIgnoreCase.Equals(x.Architecture, y.Architecture); + + public int GetHashCode((string Root, string Architecture) obj) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Root), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Architecture)); + } +} diff --git a/src/MauiSherpa.Workloads/Services/LocalSdkService.cs b/src/MauiSherpa.Workloads/Services/LocalSdkService.cs index 5df07ebb..c53a1f96 100644 --- a/src/MauiSherpa.Workloads/Services/LocalSdkService.cs +++ b/src/MauiSherpa.Workloads/Services/LocalSdkService.cs @@ -14,7 +14,8 @@ namespace MauiSherpa.Workloads.Services; public class LocalSdkService : ILocalSdkService { private readonly ILogger _logger; - + private readonly string? _installRootOverride; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -25,13 +26,28 @@ public class LocalSdkService : ILocalSdkService public LocalSdkService() : this(NullLogger.Instance) { } public LocalSdkService(ILogger logger) + : this(logger, installRootOverride: null) { } + + /// + /// When set, every lookup is rooted at this .NET install directory instead of discovering one + /// from DOTNET_ROOT/registered install locations. Used to inspect a specific install root + /// (for example a dotnetup-managed one) rather than whatever the machine resolves by default. + /// + public LocalSdkService(ILogger logger, string? installRootOverride) { _logger = logger; + _installRootOverride = string.IsNullOrWhiteSpace(installRootOverride) ? null : installRootOverride; } /// public string? GetDotNetSdkPath() { + if (_installRootOverride != null) + { + _logger.LogDebug("Using explicit SDK install root: {Path}", _installRootOverride); + return Directory.Exists(_installRootOverride) ? _installRootOverride : null; + } + _logger.LogDebug("Starting SDK path detection"); // Try common installation paths @@ -118,11 +134,7 @@ public IReadOnlyList GetInstalledSdkVersions() } } - return versions - .OrderByDescending(v => v.Major) - .ThenByDescending(v => v.Minor) - .ThenByDescending(v => v.Patch) - .ToList(); + return SdkVersion.SortDescending(versions); } /// diff --git a/src/MauiSherpa.Workloads/Services/SdkVersionService.cs b/src/MauiSherpa.Workloads/Services/SdkVersionService.cs index adc67317..8be2b2e2 100644 --- a/src/MauiSherpa.Workloads/Services/SdkVersionService.cs +++ b/src/MauiSherpa.Workloads/Services/SdkVersionService.cs @@ -39,11 +39,7 @@ public async Task> GetAvailableSdkVersionsAsync( } } - return sdkVersions - .OrderByDescending(v => v.Major) - .ThenByDescending(v => v.Minor) - .ThenByDescending(v => v.Patch) - .ToList(); + return SdkVersion.SortDescending(sdkVersions); } /// @@ -88,9 +84,7 @@ public async Task> GetSdkVersionsForRuntimeAsync( } } - return sdkVersions - .OrderByDescending(v => v.Patch) - .ToList(); + return SdkVersion.SortDescending(sdkVersions); } /// diff --git a/src/MauiSherpa/Pages/Doctor.razor b/src/MauiSherpa/Pages/Doctor.razor index eb2c966c..e9eb788a 100644 --- a/src/MauiSherpa/Pages/Doctor.razor +++ b/src/MauiSherpa/Pages/Doctor.razor @@ -96,7 +96,15 @@
- .NET SDK Path + + .NET SDK Path + @if (report.Context.UsesDotnetUpManagedSdk) + { + + dotnetup + + } + @(report.Context.DotNetSdkPath ?? "Not found") @if (!string.IsNullOrEmpty(report.Context.DotNetSdkPath)) @@ -476,7 +484,8 @@ else .context-item { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.625rem 1.25rem; border-bottom: 1px solid var(--border-color); } .context-item:last-child { border-bottom: none; } .context-item.full-width { grid-column: 1 / -1; } - .context-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } + .context-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; gap: 0.5rem; } + .context-label-badge { text-transform: none; letter-spacing: 0; font-family: monospace; } .context-value { font-size: 0.875rem; color: var(--text-primary); } .context-value.path-value { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 0.375rem; } .mono { font-family: monospace; font-size: 0.8125rem; word-break: break-all; } diff --git a/tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs b/tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs index 50ffa0cd..dc157b4d 100644 --- a/tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs @@ -22,6 +22,7 @@ public void DoctorContext_DotnetUpFields_DefaultToNotInstalled() context.DotnetUpInstalled.Should().BeFalse(); context.DotnetUpVersion.Should().BeNull(); context.DotnetUpManagedInstallRoot.Should().BeNull(); + context.UsesDotnetUpManagedSdk.Should().BeFalse(); } [Fact] @@ -31,11 +32,13 @@ public void DoctorContext_DotnetUpFields_RoundTrip() "/test", "/dotnet", null, null, null, "10.0.100", DotnetUpInstalled: true, DotnetUpVersion: "0.1.4-preview.6.26323.4", - DotnetUpManagedInstallRoot: "/Users/x/Library/Application Support/dotnet"); + DotnetUpManagedInstallRoot: "/Users/x/Library/Application Support/dotnet", + UsesDotnetUpManagedSdk: true); context.DotnetUpInstalled.Should().BeTrue(); context.DotnetUpVersion.Should().Be("0.1.4-preview.6.26323.4"); context.DotnetUpManagedInstallRoot.Should().Be("/Users/x/Library/Application Support/dotnet"); + context.UsesDotnetUpManagedSdk.Should().BeTrue(); } [Fact] @@ -94,37 +97,195 @@ public void OutOfDateSdkStatus_WithDotnetUp_EncodesChannelInFixAction() } [Fact] - public void MergeManagedSdks_AddsDotnetUpVersionsAndSortsDescending() + public void SdkSource_PrefersDotnetUpManagedRoot_OverMachineInstall() { + // The machine root has an older .NET 11 preview than dotnetup's managed root. var local = new List { - SdkVersion.Parse("9.0.305") + SdkVersion.Parse("11.0.100-preview.5.26302.115"), + SdkVersion.Parse("11.0.100-preview.4.26230.115"), + SdkVersion.Parse("10.0.300") }; var dotnetUpList = DotnetUpParser.ParseList(""" { "installations": [ - { "component": "SDK", "version": "10.0.300", "installRoot": "/u/dotnet", "architecture": "arm64", "isValid": true }, - { "component": "SDK", "version": "9.0.305", "installRoot": "/u/dotnet", "architecture": "arm64", "isValid": true }, - { "component": "Runtime", "version": "10.0.8", "installRoot": "/u/dotnet", "architecture": "arm64", "isValid": true } + { "component": "SDK", "version": "11.0.100-preview.6.26359.118", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true }, + { "component": "Runtime", "version": "10.0.10", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true } ] } """); - var merged = DoctorService.MergeManagedSdks(local, dotnetUpList); + var source = DotnetSdkSourceResolver.Resolve( + local, "/usr/local/share/dotnet", dotnetUpList, "arm64"); - merged.Select(s => s.Version).Should().ContainInOrder("10.0.300", "9.0.305"); - merged.Should().HaveCount(2, "the duplicate 9.0.305 is de-duplicated and runtimes are excluded"); - merged[0].Version.Should().Be("10.0.300", "newest SDK should sort first"); + source.IsDotnetUpManaged.Should().BeTrue(); + source.InstallRoot.Should().Be("/u/managed"); + source.Architecture.Should().Be("arm64"); + source.Sdks.Select(s => s.Version).Should().Equal( + "11.0.100-preview.6.26359.118", "10.0.302"); + source.Sdks.Should().NotContain( + s => s.Version == "11.0.100-preview.5.26302.115", + "machine-wide SDKs are ignored once dotnetup owns the toolchain"); } [Fact] - public void MergeManagedSdks_IgnoresInvalidManaged_AndEmptyList() + public void SdkSource_SortsPreviewsByPrereleaseLabel() + { + var dotnetUpList = DotnetUpParser.ParseList(""" + { "installations": [ + { "component": "SDK", "version": "11.0.100-preview.5.26302.115", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "11.0.100-preview.6.26359.118", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "11.0.100-preview.4.26230.115", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve([], null, dotnetUpList, "arm64"); + + source.Sdks[0].Version.Should().Be( + "11.0.100-preview.6.26359.118", + "prerelease labels must participate in ordering, not just major.minor.patch"); + } + + [Fact] + public void SdkSource_PrefersManagedRootMatchingProcessArchitecture() + { + var dotnetUpList = DotnetUpParser.ParseList(""" + { "installations": [ + { "component": "SDK", "version": "10.0.400", "installRoot": "/u/x64", "architecture": "x64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/u/arm64", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve([], null, dotnetUpList, "arm64"); + + source.InstallRoot.Should().Be( + "/u/arm64", "architecture match wins over a newer SDK in a foreign-architecture root"); + } + + [Fact] + public void SdkSource_WithoutManagedSdks_FallsBackToMachineInstall() { var local = new List { SdkVersion.Parse("10.0.103") }; - var empty = new DotnetUpListResult(); - var merged = DoctorService.MergeManagedSdks(local, empty); + // dotnetup is present but only tracks runtimes / has invalid SDK entries. + var dotnetUpList = DotnetUpParser.ParseList(""" + { "installations": [ + { "component": "Runtime", "version": "10.0.10", "installRoot": "/u/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/u/managed", "architecture": "arm64", "isValid": false } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve( + local, "/usr/local/share/dotnet", dotnetUpList, "arm64"); + + source.IsDotnetUpManaged.Should().BeFalse(); + source.InstallRoot.Should().Be("/usr/local/share/dotnet"); + source.Sdks.Should().ContainSingle().Which.Version.Should().Be("10.0.103"); + } + + [Fact] + public void SdkSource_WithoutDotnetUp_UsesMachineInstall() + { + var local = new List + { + SdkVersion.Parse("11.0.100-preview.4.26230.115"), + SdkVersion.Parse("11.0.100-preview.5.26302.115") + }; + + var source = DotnetSdkSourceResolver.Resolve( + local, "/usr/local/share/dotnet", dotnetUpList: null, "arm64"); + + source.IsDotnetUpManaged.Should().BeFalse(); + source.Sdks[0].Version.Should().Be("11.0.100-preview.5.26302.115"); + } + + [Fact] + public void FindSdkChannelPreview_PrefersSpecificChannelOverMovingAlias() + { + var active = SdkVersion.Parse("11.0.100-preview.6.26359.118"); + var previews = new List + { + new() + { + Component = DotnetUpComponent.Sdk, + Channel = "preview", + InstalledVersion = active.Version, + AvailableVersion = active.Version + }, + new() + { + Component = DotnetUpComponent.Sdk, + Channel = "11.0.1xx", + InstalledVersion = active.Version, + AvailableVersion = active.Version + }, + new() + { + Component = DotnetUpComponent.Sdk, + Channel = "10.0.3xx", + InstalledVersion = "10.0.302", + AvailableVersion = "10.0.302" + } + }; + + var match = DoctorService.FindSdkChannelPreview(previews, active); + + match.Should().NotBeNull(); + match!.Channel.Should().Be("11.0.1xx"); + } + + [Fact] + public void FindSdkChannelPreview_WhenNoChannelOwnsTheActiveSdk_ReturnsNull() + { + var active = SdkVersion.Parse("11.0.100-preview.6.26359.118"); + var previews = new List + { + new() + { + Component = DotnetUpComponent.Sdk, + Channel = "10.0.3xx", + InstalledVersion = "10.0.302", + AvailableVersion = "10.0.302" + } + }; + + DoctorService.FindSdkChannelPreview(previews, active).Should().BeNull(); + } + + [Fact] + public void FindSdkChannelPreview_IgnoresPinnedSpecs() + { + var active = SdkVersion.Parse("10.0.302"); + var previews = new List + { + new() + { + Component = DotnetUpComponent.Sdk, + Channel = "10.0.302", + InstalledVersion = "10.0.302", + AvailableVersion = "10.0.302", + IsPinned = true + } + }; + + DoctorService.FindSdkChannelPreview(previews, active).Should().BeNull( + "a pinned exact version has no channel update to offer"); + } + + [Fact] + public void ManagedSdkStatus_UsesChannelInFixAction() + { + // The managed branch offers the tracked channel, not an exact version, so applying the + // fix installs whatever that channel resolves to — the same thing the SDK Manager does. + var dep = new DependencyStatus( + ".NET SDK", DependencyCategory.DotNetSdk, + null, "11.0.100-preview.7.26400.1", "11.0.100-preview.6.26359.118", + DependencyStatusType.Warning, + "Update available: 11.0.100-preview.7.26400.1 (dotnetup channel 11.0.1xx)", + IsFixable: true, + FixAction: "dotnetup-update-sdk:11.0.1xx"); - merged.Should().ContainSingle().Which.Version.Should().Be("10.0.103"); + dep.FixAction!["dotnetup-update-sdk:".Length..].Should().Be("11.0.1xx"); } private static DoctorReport MakeReport(params DependencyStatus[] deps) => diff --git a/tests/MauiSherpa.Workloads.Tests/Models/SdkVersionTests.cs b/tests/MauiSherpa.Workloads.Tests/Models/SdkVersionTests.cs index ed21d052..793b25cd 100644 --- a/tests/MauiSherpa.Workloads.Tests/Models/SdkVersionTests.cs +++ b/tests/MauiSherpa.Workloads.Tests/Models/SdkVersionTests.cs @@ -122,4 +122,55 @@ public void TryParse_InvalidVersion_ReturnsFalse(string version) success.Should().BeFalse(); result.Should().BeNull(); } + + [Fact] + public void SortDescending_OrdersPreviewsByPrereleaseLabel() + { + // Every 11.0.100-preview.* shares major.minor.patch, so ordering must fall through to the + // prerelease labels rather than leaving the newest install to enumeration order. + var versions = new[] + { + SdkVersion.Parse("11.0.100-preview.5.26302.115"), + SdkVersion.Parse("11.0.100-preview.4.26230.115"), + SdkVersion.Parse("11.0.100-preview.6.26359.118"), + }; + + var sorted = SdkVersion.SortDescending(versions); + + sorted.Select(v => v.Version).Should().Equal( + "11.0.100-preview.6.26359.118", + "11.0.100-preview.5.26302.115", + "11.0.100-preview.4.26230.115"); + } + + [Fact] + public void SortDescending_RanksStableAboveItsOwnPreviews() + { + var versions = new[] + { + SdkVersion.Parse("11.0.100-rc.1.25451.107"), + SdkVersion.Parse("11.0.100"), + SdkVersion.Parse("11.0.100-preview.6.26359.118"), + SdkVersion.Parse("10.0.302"), + }; + + var sorted = SdkVersion.SortDescending(versions); + + sorted.Select(v => v.Version).Should().Equal( + "11.0.100", + "11.0.100-rc.1.25451.107", + "11.0.100-preview.6.26359.118", + "10.0.302"); + } + + [Fact] + public void CompareTo_UsesFullSemanticVersion() + { + var newer = SdkVersion.Parse("11.0.100-preview.6.26359.118"); + var older = SdkVersion.Parse("11.0.100-preview.5.26302.115"); + + newer.CompareTo(older).Should().BePositive(); + older.CompareTo(newer).Should().BeNegative(); + newer.CompareTo(null).Should().BePositive(); + } } diff --git a/tests/MauiSherpa.Workloads.Tests/Services/DotnetSdkSourceResolverTests.cs b/tests/MauiSherpa.Workloads.Tests/Services/DotnetSdkSourceResolverTests.cs new file mode 100644 index 00000000..d0b609fa --- /dev/null +++ b/tests/MauiSherpa.Workloads.Tests/Services/DotnetSdkSourceResolverTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using MauiSherpa.Workloads.Models; +using MauiSherpa.Workloads.Services; + +namespace MauiSherpa.Workloads.Tests.Services; + +/// +/// The resolver decides which .NET install root a surface such as Doctor should trust. Once a user +/// opts into dotnetup, the managed root wins outright — mixing it with a machine-wide install +/// produces an SDK/feature-band pair that belongs to neither root. +/// +public class DotnetSdkSourceResolverTests +{ + [Fact] + public void Resolve_WithNoDotnetUpList_UsesMachineInstall() + { + var local = new[] { SdkVersion.Parse("10.0.302"), SdkVersion.Parse("10.0.204") }; + + var source = DotnetSdkSourceResolver.Resolve(local, "/usr/local/share/dotnet", null, "arm64"); + + source.IsDotnetUpManaged.Should().BeFalse(); + source.InstallRoot.Should().Be("/usr/local/share/dotnet"); + source.Architecture.Should().Be("arm64"); + source.Sdks.Select(s => s.Version).Should().Equal("10.0.302", "10.0.204"); + } + + [Fact] + public void Resolve_WithManagedSdks_IgnoresMachineInstallEntirely() + { + var local = new[] + { + SdkVersion.Parse("11.0.100-preview.5.26302.115"), + SdkVersion.Parse("10.0.300") + }; + var list = Parse(""" + { "installations": [ + { "component": "SDK", "version": "11.0.100-preview.6.26359.118", "installRoot": "/managed", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve(local, "/usr/local/share/dotnet", list, "arm64"); + + source.IsDotnetUpManaged.Should().BeTrue(); + source.InstallRoot.Should().Be("/managed"); + source.Sdks.Select(s => s.Version).Should().Equal("11.0.100-preview.6.26359.118"); + } + + [Fact] + public void Resolve_DeduplicatesRepeatedManagedVersions() + { + var list = Parse(""" + { "installations": [ + { "component": "SDK", "version": "10.0.302", "installRoot": "/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/managed", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve([], null, list, "arm64"); + + source.Sdks.Should().ContainSingle().Which.Version.Should().Be("10.0.302"); + } + + [Fact] + public void Resolve_SkipsRuntimesAndInvalidInstallations() + { + var list = Parse(""" + { "installations": [ + { "component": "Runtime", "version": "10.0.10", "installRoot": "/managed", "architecture": "arm64", "isValid": true }, + { "component": "ASPNETCore", "version": "10.0.10", "installRoot": "/managed", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/managed", "architecture": "arm64", "isValid": false } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve( + [SdkVersion.Parse("9.0.305")], "/usr/local/share/dotnet", list, "arm64"); + + source.IsDotnetUpManaged.Should().BeFalse(); + source.Sdks.Should().ContainSingle().Which.Version.Should().Be("9.0.305"); + } + + [Fact] + public void Resolve_PrefersArchitectureMatchOverNewerSdk() + { + var list = Parse(""" + { "installations": [ + { "component": "SDK", "version": "11.0.100-preview.6.26359.118", "installRoot": "/managed-x64", "architecture": "x64", "isValid": true }, + { "component": "SDK", "version": "10.0.302", "installRoot": "/managed-arm64", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve([], null, list, "arm64"); + + source.InstallRoot.Should().Be("/managed-arm64"); + source.Architecture.Should().Be("arm64"); + } + + [Fact] + public void Resolve_WithSameArchitecture_PrefersRootWithNewestSdk() + { + var list = Parse(""" + { "installations": [ + { "component": "SDK", "version": "10.0.204", "installRoot": "/managed-a", "architecture": "arm64", "isValid": true }, + { "component": "SDK", "version": "11.0.100-preview.6.26359.118", "installRoot": "/managed-b", "architecture": "arm64", "isValid": true } + ] } + """); + + var source = DotnetSdkSourceResolver.Resolve([], null, list, "arm64"); + + source.InstallRoot.Should().Be("/managed-b"); + } + + [Fact] + public void Resolve_WithEmptyList_FallsBackToMachineInstall() + { + var source = DotnetSdkSourceResolver.Resolve( + [SdkVersion.Parse("10.0.103")], "/usr/local/share/dotnet", new DotnetUpListResult(), "arm64"); + + source.IsDotnetUpManaged.Should().BeFalse(); + source.InstallRoot.Should().Be("/usr/local/share/dotnet"); + } + + [Fact] + public void Resolve_WithNothingInstalled_ReturnsEmptySource() + { + var source = DotnetSdkSourceResolver.Resolve([], null, null, "arm64"); + + source.Sdks.Should().BeEmpty(); + source.InstallRoot.Should().BeNull(); + source.IsDotnetUpManaged.Should().BeFalse(); + } + + private static DotnetUpListResult Parse(string json) => DotnetUpParser.ParseList(json); +} From f79a535eb99f5699ca2668af0745f8e1954b98e1 Mon Sep 17 00:00:00 2001 From: Redth Date: Wed, 29 Jul 2026 14:45:44 -0400 Subject: [PATCH 2/2] Drop the Mac Catalyst head and fix duplicate native references The Mac Catalyst app head is superseded by src/MauiSherpa.MacOS (net10.0-macos, AppKit), which is what CI publishes. Remove net10.0-maccatalyst from src/MauiSherpa so it is now a Windows-only head, and no-op Build/Rebuild/Publish there when not on Windows so solution builds succeed on macOS and Linux. Building any Apple head also failed with: install_name_tool: cannot rename ... libcopilot_runtime.dylib.tmp The "InstallNameTool" task failed unexpectedly GitHub.Copilot.SDK stages its native runtime once per referencing project, and the diamond (app head -> AppInspector -> Core, plus app head -> Core) produced three _FileNativeReference items pointing at the same destination. The Apple SDK's InstallNameTool task processes items in parallel and derives its scratch file from the destination, so those items raced on the same .tmp path. build/DedupeNativeReferences.targets collapses _FileNativeReference items that share a RelativePath down to one before _ComputeDynamicLibrariesToReidentify runs. Deduping has to match on RelativePath rather than ItemSpec because the duplicates can share an identical ItemSpec and differ only in MSBuildSourceProjectFile. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38513608-bbd3-48e1-8ac5-7a5a304654ca --- AGENTS.md | 33 ++++++------ README.md | 9 ++-- build/DedupeNativeReferences.targets | 56 ++++++++++++++++++++ src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj | 1 + src/MauiSherpa/MauiSherpa.csproj | 53 ++++++------------ 5 files changed, 93 insertions(+), 59 deletions(-) create mode 100644 build/DedupeNativeReferences.targets diff --git a/AGENTS.md b/AGENTS.md index eced722a..ad2f65e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ MauiSherpa is a .NET 10 MAUI Blazor Hybrid desktop application for managing deve - .NET SDK management via `dotnetup` (install/update SDKs & runtimes — see `docs/dotnet-sdk-management.md`) - GitHub Copilot integration -**Platforms:** Mac Catalyst, Windows +**Platforms:** macOS (AppKit), Windows, Linux (GTK) **Bundle Identifier:** `codes.redth.mauisherpa` ## Project Structure @@ -24,12 +24,14 @@ MAUI.Sherpa/ │ │ ├── Services/ # Service implementations │ │ ├── ViewModels/ # MVVM ViewModels │ │ └── Interfaces.cs # All interface definitions -│ ├── MauiSherpa/ # MAUI app with Blazor UI +│ ├── MauiSherpa/ # Shared MAUI app with Blazor UI (Windows head) │ │ ├── Components/ # Reusable Blazor components │ │ ├── Pages/ # Blazor page components │ │ ├── Services/ # Platform-specific service implementations -│ │ ├── Platforms/ # Platform-specific code (MacCatalyst, Windows) +│ │ ├── Platforms/ # Platform-specific code (Windows) │ │ └── wwwroot/ # Static assets (CSS, JS, index.html) +│ ├── MauiSherpa.MacOS/ # macOS AppKit app head (net10.0-macos) +│ ├── MauiSherpa.LinuxGtk/ # Linux GTK app head │ └── MauiSherpa.Workloads/ # .NET SDK workload querying library │ ├── Models/ # Workload data models │ ├── Services/ # Workload services @@ -43,8 +45,8 @@ MAUI.Sherpa/ ## Build Commands ```bash -# Build for Mac Catalyst -dotnet build src/MauiSherpa -f net10.0-maccatalyst +# Build for macOS (AppKit head) +dotnet build src/MauiSherpa.MacOS -f net10.0-macos # Build for Windows (on Windows only) dotnet build src/MauiSherpa -f net10.0-windows10.0.19041.0 @@ -55,23 +57,26 @@ dotnet build MauiSherpa.sln # Run all tests dotnet test MauiSherpa.sln -# Publish Mac Catalyst app -dotnet publish src/MauiSherpa -f net10.0-maccatalyst -c Release +# Publish macOS app +dotnet publish src/MauiSherpa.MacOS -f net10.0-macos -c Release # Publish Windows app dotnet publish src/MauiSherpa -f net10.0-windows10.0.19041.0 -c Release ``` +`src/MauiSherpa` is the shared UI project and the Windows head. It only builds on Windows — on +macOS and Linux its `Build`/`Rebuild`/`Publish` targets are no-ops so solution builds still work. + ### Launching the App **IMPORTANT:** `dotnet run` does NOT work for .NET MAUI apps (until .NET 11). Use one of: ```bash # Option 1: Build with -t:Run target (keeps process alive until app exits) -dotnet build src/MauiSherpa -f net10.0-maccatalyst -t:Run +dotnet build src/MauiSherpa.MacOS -f net10.0-macos -t:Run # Option 2: Build then manually open the .app bundle -dotnet build src/MauiSherpa -f net10.0-maccatalyst -open "src/MauiSherpa/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/MAUI Sherpa.app" +dotnet build src/MauiSherpa.MacOS -f net10.0-macos +open "src/MauiSherpa.MacOS/bin/Debug/net10.0-macos/osx-arm64/MAUI Sherpa.app" ``` **Always launch from `bin/` path**, NOT `artifacts/`. The `artifacts/` copy may be stale and missing DLLs. @@ -169,7 +174,7 @@ All modals use `modalInterop.js` (`wwwroot/js/modalInterop.js`) for focus trappi - Escape closes the modal - Auto-focuses `.btn-primary:not([disabled])` on open -**CRITICAL:** In Blazor WebView (Mac Catalyst), browser default Tab navigation does NOT work. All Tab keypresses must be intercepted with `preventDefault()` and explicit `.focus()` calls via JS interop. +**CRITICAL:** In Blazor WebView (macOS), browser default Tab navigation does NOT work. All Tab keypresses must be intercepted with `preventDefault()` and explicit `.focus()` calls via JS interop. ### Text Selection Prevention Global `user-select: none` is applied on `*` to prevent accidental text selection in the hybrid app. Selectively re-enabled on: `input`, `textarea`, `select`, `code`, `pre`, `.mono`, `.terminal-output`, `.log-entry`, `.error-message`, `.chat-message`, and `.text-selectable`. @@ -205,7 +210,7 @@ When making or reviewing UI changes, always verify: ## Platform-Specific Notes -### Mac Catalyst +### macOS **App data path:** Use `AppDataPath.GetAppDataDirectory()` which returns `~/Library/Application Support/MauiSherpa/`. Do NOT use `SpecialFolder.ApplicationData` (resolves to `~/Documents/.config/` which is TCC-protected). @@ -213,9 +218,7 @@ When making or reviewing UI changes, always verify: **File save dialogs:** `PickSaveFileAsync` (native `NSSavePanel`) creates an empty file at the chosen path. Tools like `keytool` that refuse to overwrite existing files need the empty file deleted first. -**Mac Catalyst delegate:** Use `DidPickDocumentAtUrls` (plural), NOT `DidPickDocument` (singular). The singular form doesn't fire on modern Mac Catalyst. - -**Hardened runtime:** MSBuild `EnableHardenedRuntime=true` does NOT work for .NET MAUI Mac Catalyst. Must re-sign with `codesign --force --options runtime --timestamp` after publish. +**Duplicate native libraries:** The app heads reference both `MauiSherpa.AppInspector` and `MauiSherpa.Core`, and AppInspector also references Core, so `GitHub.Copilot.SDK` stages `libcopilot_runtime.dylib` several times over. The Apple SDK's `InstallNameTool` task runs its items in parallel against a shared `.tmp` path and crashes on duplicates, so `build/DedupeNativeReferences.targets` collapses each destination to one entry. Import it from any new Apple app head. **Logging:** Logs saved to `~/Library/Application Support/MauiSherpa/logs/maui-sherpa-{yyyy-MM-dd}.log`. diff --git a/README.md b/README.md index 926d0cd8..b7ee5aba 100644 --- a/README.md +++ b/README.md @@ -284,10 +284,7 @@ dotnet restore # Build for macOS (AppKit) dotnet build src/MauiSherpa.MacOS -f net10.0-macos -# Build for Mac Catalyst -dotnet build src/MauiSherpa -f net10.0-maccatalyst - -# Build for Windows +# Build for Windows (Windows only) dotnet build src/MauiSherpa -f net10.0-windows10.0.19041.0 # Run tests @@ -299,11 +296,11 @@ dotnet test ``` MAUI.Sherpa/ ├── src/ -│ ├── MauiSherpa/ # Main MAUI Blazor Hybrid app +│ ├── MauiSherpa/ # Shared MAUI Blazor Hybrid UI + Windows app head │ │ ├── Components/ # Reusable Blazor components │ │ ├── Pages/ # Blazor page components │ │ ├── Services/ # Platform-specific services -│ │ └── Platforms/ # Platform code (MacCatalyst, Windows) +│ │ └── Platforms/ # Platform code (Windows) │ ├── MauiSherpa.MacOS/ # macOS AppKit app head │ ├── MauiSherpa.LinuxGtk/ # Linux GTK4 app head │ ├── MauiSherpa.Core/ # Business logic library diff --git a/build/DedupeNativeReferences.targets b/build/DedupeNativeReferences.targets new file mode 100644 index 00000000..61c143e9 --- /dev/null +++ b/build/DedupeNativeReferences.targets @@ -0,0 +1,56 @@ + + + + + + + <_NativeReferencesForPath Include="@(_FileNativeReference)" /> + + + + <_NativeReferencesForPathCount>@(_NativeReferencesForPath->Count()) + <_NativeReferenceToKeep /> + + + + + <_NativeReferenceToKeep>%(_NativeReferencesForPath.Identity) + + + + + + <_FileNativeReference Remove="@(_NativeReferencesForPath)" MatchOnMetadata="RelativePath" /> + <_FileNativeReference Include="@(_NativeReferencesForPath)" + Condition="'%(Identity)' == '$(_NativeReferenceToKeep)'" /> + + + + <_NativeReferencesForPath Remove="@(_NativeReferencesForPath)" /> + + + + diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index 022c2f51..4c993490 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -133,5 +133,6 @@ + diff --git a/src/MauiSherpa/MauiSherpa.csproj b/src/MauiSherpa/MauiSherpa.csproj index 0a52f5b0..e2f92a63 100644 --- a/src/MauiSherpa/MauiSherpa.csproj +++ b/src/MauiSherpa/MauiSherpa.csproj @@ -1,8 +1,11 @@ - + + + - net10.0-maccatalyst - $(TargetFrameworks);net10.0-windows10.0.19041.0 + + net10.0-windows10.0.19041.0 Exe MauiSherpa @@ -19,21 +22,10 @@ $(AppVersion) 1 - 15.0 10.0.17763.0 10.0.17763.0 - - true - Platforms/MacCatalyst/Entitlements.plist - true - - - - Platforms/MacCatalyst/Entitlements.Debug.plist - - $(RuntimeIdentifierOverride) @@ -81,30 +73,15 @@ - - - - <_CopilotMacCatRid Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">maccatalyst-arm64 - <_CopilotMacCatRid Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">maccatalyst-x64 - <_CopilotOsxRid Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">osx-arm64 - <_CopilotOsxRid Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">osx-x64 - <_CopilotSourceBinary>$(OutDir)runtimes/$(_CopilotOsxRid)/native/copilot - <_CopilotAppBundle>$(OutDir)$(ApplicationTitle).app/Contents/MonoBundle/runtimes/$(_CopilotMacCatRid)/native - - <_CopilotAppBundleOsx>$(OutDir)$(ApplicationTitle).app/Contents/MonoBundle/runtimes/$(_CopilotOsxRid)/native - - - - - - - - - + + + + + + +