Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ jobs:
- name: Test UI against native backend
if: runner.os == 'Linux'
run: npm --prefix ui run test:browser
- name: Install browser for screenshot comparison
if: matrix.os == 'macos-latest'
- name: Install browser
if: runner.os != 'Linux'
working-directory: ui
run: npx playwright install chromium
- name: Exercise the real features end to end
run: npm --prefix ui run test:e2e
- name: Compare screenshots
if: matrix.os == 'macos-latest'
run: npm --prefix ui run test:visual
Expand All @@ -96,10 +98,10 @@ jobs:
path: artifacts/dotnet-cockpit-${{ matrix.rid }}.*
if-no-files-found: error
- name: Upload browser failure traces
if: failure() && runner.os == 'Linux'
if: failure()
uses: actions/upload-artifact@v7
with:
name: browser-test-results
name: browser-test-results-${{ matrix.rid }}
path: ui/test-results/
if-no-files-found: ignore

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
- Support dates, end-of-life state and the timeline come only from the release catalog. When it is unavailable, say the status is unknown instead of guessing.
- Preferences live in `SettingsStore`, not browser storage, because the free port changes the origin on every launch.
- Styles use the tokens in `ui/src/styles/tokens.css`: six type sizes, three radii, semantic colors. Add no literal colors, other sizes or `!important`. Palettes in `palettes.css` override color roles only and must pass the contrast test in both themes.
- `/setup` may detect, install, update, and remove only the official global `dotnet-core-uninstall` installation for Windows and macOS. Global installs use the platform paths and must surface administrator approval. Release discovery must follow every page of the official GitHub Releases API, filter stable releases to those with a compatible asset, and treat an incomplete/failed response as unavailable rather than presenting a partial list. Do not download arbitrary URLs or claim Linux support.
- `/setup` may detect, install, update, and remove only the official global `dotnet-core-uninstall` installation for Windows and macOS. Global installs use the platform paths and must surface administrator approval. Release discovery must follow every page of the official GitHub Releases API, filter stable releases to those with a compatible asset, and treat an incomplete/failed response as unavailable rather than presenting a partial list. Do not download arbitrary URLs or claim Linux support. Install .NET offers `.pkg` on macOS and `.exe` on Windows; on Linux it only links to Microsoft's install guide.
- The CLI's bracketed path is a base directory. Display `InstallationPath` (base path plus version) for an individual SDK or runtime; retain `Path` in the JSON API for compatibility.
- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs <binary>`, and run the browser tests for UI changes. For visual changes also run `npm --prefix ui run test:visual`; after an intended change, update the baselines with `npm --prefix ui run test:visual:update` and review them.
- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs <binary>`, and run the browser tests for UI changes. `npm --prefix ui run test:e2e` runs the real features against the native binary; it keeps tools in a temporary CLI home, and only touches .NET itself on a Windows CI runner. For visual changes also run `npm --prefix ui run test:visual`; after an intended change, update the baselines with `npm --prefix ui run test:visual:update` and review them.
- Pages read machine state through `LiveData`, which answers from memory and revalidates in the background; never make a page wait for a process or the network. Refresh it after every change. Measure with `npm --prefix ui run bench` against the native AOT binary; results from other builds do not count.
- Use `gh` for GitHub operations. Commit messages follow Conventional Commits and contain no AI attribution.
- Keep changes focused and avoid speculative abstractions or functionality.
2 changes: 1 addition & 1 deletion docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A local declaration is not proof that its package has been restored. Cockpit nev

On macOS and Windows, Cockpit can detect, install, update, and remove the official global `dotnet-core-uninstall` installation. These operations request administrator approval where the platform requires it. Linux is not supported by the official tool.

On macOS, **Versions → Install .NET** lists official `.pkg` installers for the host architecture. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation.
**Versions → Install .NET** lists official installers for the host architecture: `.pkg` on macOS and `.exe` on Windows. Microsoft offers no installer for Linux, so there Cockpit shows versions, support status and tools, and links to the official install guide. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation.

The SDK installer includes the matching .NET Runtime and ASP.NET Core Runtime. Use the standalone Runtime package only when you do not need the SDK.

Expand Down
5 changes: 1 addition & 4 deletions src/DotnetCockpit/Installation/DotnetInstallationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,7 @@ private static bool IsQueryFailure(Exception exception) =>
private static string? FindUninstallTool()
{
var executable = OperatingSystem.IsWindows() ? "dotnet-core-uninstall.exe" : "dotnet-core-uninstall";
var globalDirectory = OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet-core-uninstall")
: "/usr/local/bin";
var candidates = new[] { Path.Combine(globalDirectory, executable) }
var candidates = new[] { Path.Combine(Setup.DotnetUninstallToolService.GetGlobalDirectory(), executable) }
.Concat((Environment.GetEnvironmentVariable("PATH") ?? "")
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Select(directory => Path.Combine(directory.Trim('"'), executable)));
Expand Down
21 changes: 18 additions & 3 deletions src/DotnetCockpit/InstallationPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,19 @@ static bool IsDownloadEol(SdkDownload download, DateOnly date) =>
.ToArray();


var hasInstallers = InstallerPlatform.Current.HasInstallers;
string resultsHtml;
if (sdkSnapshot.Status != "ready" || curated.Length == 0)
if (!hasInstallers)
{
resultsHtml = $"""
<div class="empty-state">
<h3>Install .NET with your distribution's packages</h3>
<p class="muted">Microsoft offers no installer for Linux, so Cockpit shows versions and support status here but does not install or remove them.</p>
<a class="button" id="linux-install-guide" href="{InstallerPlatform.LinuxInstallGuide}" target="_blank" rel="noopener">{Icon("external", "xs")} Official install guide</a>
</div>
""";
}
else if (sdkSnapshot.Status != "ready" || curated.Length == 0)
{
resultsHtml = $"""
<div class="empty-state error-state">
Expand Down Expand Up @@ -702,7 +713,7 @@ static bool IsDownloadEol(SdkDownload download, DateOnly date) =>
<a class="button icon ghost" id="close-install-dotnet-btn" href="/" aria-label="Close install .NET panel">{{Icon("x", "sm")}}</a>
</div>

<div class="sheet-controls">
<div class="sheet-controls" {{(hasInstallers ? "" : "hidden")}}>
<form method="get" action="/" class="filter-form" data-install-search>
<input type="hidden" name="install" value="1">
<input type="hidden" name="product" value="{{(options.InstallProduct == "SDK" ? "sdk" : "runtime")}}">
Expand Down Expand Up @@ -981,7 +992,11 @@ private static string GetInstallerFileName(string? url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) ? System.IO.Path.GetFileName(uri.LocalPath) : "";

private static string FormatVerifyInstallCommand(string? sha512, string fileName) =>
!string.IsNullOrEmpty(sha512)
OperatingSystem.IsWindows()
? !string.IsNullOrEmpty(sha512)
? $"if ((Get-FileHash {fileName} -Algorithm SHA512).Hash -ne \"{sha512}\") {{ throw \"Checksum mismatch\" }}\n.\\{fileName} /install /quiet /norestart"
: $".\\{fileName} /install /quiet /norestart"
: !string.IsNullOrEmpty(sha512)
? $"echo \"{sha512} {fileName}\" \\\n | shasum -a 512 -c - \\\n && sudo installer -pkg {fileName} \\\n -target /"
: $"sudo installer -pkg {fileName} \\\n -target /";

Expand Down
2 changes: 1 addition & 1 deletion src/DotnetCockpit/PageLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public static string Render(string content, string currentPath, string title = "
</a>
<a class="opt" href="/?install=1">
{{Icon("download", "sm")}}
<div><b>Install new .NET SDK or Runtime</b> <small>· Official macOS .pkg catalog</small></div>
<div><b>Install new .NET SDK or Runtime</b> <small>· Official Microsoft releases</small></div>
<span class="badge brand">Download</span>
</a>
<a class="opt" href="/preflight">
Expand Down
32 changes: 28 additions & 4 deletions src/DotnetCockpit/SdkDownloads/SdkDownloadCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@ internal sealed record SdkDownload(string Product, string Version, string Instal

internal sealed record SdkDownloadSnapshot(string Status, SdkDownload[] Downloads, string? Error = null, ChannelLifecycle[]? Channels = null);

/// <summary>Which official installer fits this machine. Microsoft offers none for Linux, only archives and package feeds.</summary>
internal sealed record InstallerPlatform(string Rid, string? Extension)
{
public static readonly InstallerPlatform Mac = new("osx", ".pkg");
public static readonly InstallerPlatform Windows = new("win", ".exe");
public static readonly InstallerPlatform Linux = new("linux", null);

public static InstallerPlatform Current =>
OperatingSystem.IsMacOS() ? Mac : OperatingSystem.IsWindows() ? Windows : Linux;

public bool HasInstallers => Extension is not null;

public const string LinuxInstallGuide = "https://learn.microsoft.com/en-us/dotnet/core/install/linux";
}

internal sealed record StoredCatalog(SdkDownload[] Downloads, ChannelLifecycle[] Channels);

internal sealed class SdkDownloadCatalog(ILogger<SdkDownloadCatalog> logger, HttpClient httpClient, string? storeDirectory = null)
internal sealed class SdkDownloadCatalog(ILogger<SdkDownloadCatalog> logger, HttpClient httpClient, string? storeDirectory = null, InstallerPlatform? platform = null)
{
private readonly InstallerPlatform installerPlatform = platform ?? InstallerPlatform.Current;

private const string IndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json";
private static readonly Uri OfficialHost = new("https://builds.dotnet.microsoft.com/");
private (SdkDownload[] Downloads, ChannelLifecycle[] Channels)? cachedCatalog;
Expand Down Expand Up @@ -193,15 +210,15 @@ private async Task RefreshInBackgroundAsync()
return (downloads.ToArray(), lifecycle);
}

private static void AddPackages(JsonElement release, string productProperty, string productName, string packagePrefix,
private void AddPackages(JsonElement release, string productProperty, string productName, string packagePrefix,
string channel, string releaseType, string supportPhase, string? endOfSupportDate, string releaseDate, SdkCve[] cves, List<SdkDownload> downloads)
{
if (!release.TryGetProperty(productProperty, out var product) || product.ValueKind != JsonValueKind.Object)
return;
AddProductElement(product, productProperty, productName, packagePrefix, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, downloads);
}

private static void AddProductElement(JsonElement product, string productProperty, string productName, string packagePrefix,
private void AddProductElement(JsonElement product, string productProperty, string productName, string packagePrefix,
string channel, string releaseType, string supportPhase, string? endOfSupportDate, string releaseDate, SdkCve[] cves, List<SdkDownload> downloads)
{
if (!product.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
Expand All @@ -212,11 +229,18 @@ private static void AddProductElement(JsonElement product, string productPropert
var installationVersion = GetString(product, "version") ?? version;
var includedRuntimeVersion = productProperty == "sdk" ? GetString(product, "runtime-version") : null;

// Without an installer the release still counts: patches and security fixes are reported the same way.
if (!installerPlatform.HasInstallers)
{
downloads.Add(new(productName, version, installationVersion, includedRuntimeVersion, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, "", ""));
return;
}

var architecture = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
foreach (var file in files.EnumerateArray())
{
var name = GetString(file, "name");
if (!string.Equals(name, $"{packagePrefix}-osx-{architecture}.pkg", StringComparison.Ordinal)) continue;
if (!string.Equals(name, $"{packagePrefix}-{installerPlatform.Rid}-{architecture}{installerPlatform.Extension}", StringComparison.Ordinal)) continue;
var url = GetString(file, "url");
if (!IsOfficialUrl(url)) continue;
downloads.Add(new(productName, version, installationVersion, includedRuntimeVersion, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, url!, GetString(file, "hash") ?? ""));
Expand Down
Loading
Loading