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