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/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.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.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/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 - - - - - - - - - + + + + + + + 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); +}