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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
- `/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.
- 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.
- 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/Privacy-and-Safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ Before removing an installation, confirm that it is not needed by another applic

## Local files

Cockpit stores your appearance and pre-flight choices in `settings.json` in the `dotnet-cockpit` folder of your user configuration directory. Delete the file to reset them.
Cockpit stores your appearance and pre-flight choices in `settings.json`, and the last official release catalog in `release-catalog.json`, in the `dotnet-cockpit` folder of your user configuration directory. Delete the files to reset them.
2 changes: 1 addition & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function runWithoutRepositoryFiles(environment, verify, expectedStatus = 2
assert.equal(page.status, expectedStatus);
const html = await page.text();
assert.match(html, /<title>\.NET Cockpit<\/title>/);
const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(match => match[1]);
const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)(?:\?[^"]*)?"/g)].map(match => match[1]);
assert.ok(assets.some(asset => asset.endsWith('.js')), 'An embedded UI script must be present.');
assert.ok(assets.some(asset => asset.endsWith('.css')), 'An embedded stylesheet must be present.');
for (const asset of new Set(assets)) {
Expand Down
4 changes: 4 additions & 0 deletions src/DotnetCockpit/AppJsonContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using DotnetCockpit.Installation;
using DotnetCockpit.SdkDownloads;
using DotnetCockpit.Tools;

namespace DotnetCockpit;
Expand All @@ -11,7 +12,10 @@ namespace DotnetCockpit;
[JsonSerializable(typeof(ToolSearch))]
[JsonSerializable(typeof(AppSettings))]
[JsonSerializable(typeof(SystemAccentStatus))]
[JsonSerializable(typeof(StoredCatalog))]
[JsonSerializable(typeof(RevisionStatus))]
internal partial class AppJsonContext : JsonSerializerContext;

internal sealed record HealthStatus(string Status);
internal sealed record SystemAccentStatus(string? Accent);
internal sealed record RevisionStatus(string Revision);
2 changes: 1 addition & 1 deletion src/DotnetCockpit/InstallationPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ public static string Render(InstallationSnapshot snapshot, SdkDownloadSnapshot?
</section>
{{RenderInstallDotnetSheet(sdkSnapshot ?? new("ready", []), snapshot, options, currentDate)}}
<p class="scope-note muted">Locations include the version subfolder. When dotnet-core-uninstall is installed, only versions it can remove are shown; runtime frameworks that share a version are grouped together. Separate installations and other architectures may not appear here.</p>
""", "/", ".NET Cockpit", health.Count, total, navTools, WebUtility.HtmlDecode(RefreshLink(options, snapshot.CheckedAt)), snapshot.Signature);
""", "/", ".NET Cockpit", health.Count, total, navTools, WebUtility.HtmlDecode(RefreshLink(options, snapshot.CheckedAt)));
}

private static string RenderVersionsHub(Row[] allRows, Row[] rows, InstallationViewOptions options, Row? selectedRow)
Expand Down
204 changes: 204 additions & 0 deletions src/DotnetCockpit/LiveData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using System.ComponentModel;
using System.Security.Cryptography;
using System.Text;
using DotnetCockpit.Installation;
using DotnetCockpit.SdkDownloads;
using DotnetCockpit.Setup;
using DotnetCockpit.Tools;

namespace DotnetCockpit;

/// <summary>
/// A value that answers from memory at once and checks again in the background. Reading the machine takes
/// external processes, so waiting for them on every page would make each click feel slow.
/// </summary>
internal sealed class StaleWhileRevalidate<T>(Func<Task<T>> load, TimeSpan maxAge, Func<T, bool>? keep = null) where T : class
{
private readonly Lock gate = new();
private T? current;
private DateTimeOffset loadedAt = DateTimeOffset.MinValue;
private Task<T>? inFlight;

public T? Current { get { lock (gate) return current; } }

/// <summary>Returns the last value without waiting and refreshes it in the background once it has aged.</summary>
public Task<T> GetAsync()
{
lock (gate)
{
if (current is null) return StartLoad();
if (DateTimeOffset.UtcNow - loadedAt > maxAge) _ = StartLoad();
return Task.FromResult(current);
}
}

/// <summary>Waits for a value no older than the given age, joining a read that is already running.</summary>
public Task<T> GetFreshAsync(TimeSpan age)
{
lock (gate)
{
return current is not null && DateTimeOffset.UtcNow - loadedAt <= age ? Task.FromResult(current) : StartLoad();
}
}

/// <summary>Forces a read that starts after this call, for use right after a change.</summary>
public async Task<T> RefreshAsync()
{
Task<T>? running;
lock (gate) running = inFlight;
if (running is not null)
{
try { await running; }
catch (Exception exception) when (exception is not OutOfMemoryException) { /* The new read reports its own failure. */ }
}
Task<T> fresh;
lock (gate) fresh = StartLoad();
return await fresh;
}

private Task<T> StartLoad()
{
return inFlight ??= Run();

async Task<T> Run()
{
await Task.Yield();
try
{
var value = await load();
lock (gate)
{
if (keep?.Invoke(value) != false)
{
current = value;
loadedAt = DateTimeOffset.UtcNow;
}
else
{
current = null;
}
}
return value;
}
finally
{
lock (gate) inFlight = null;
}
}
}
}

internal sealed record PageData(InstallationSnapshot Installation, ToolsSnapshot Tools, SdkDownloadSnapshot Catalog)
{
public int? ToolsCount => Tools.Status == "ready" ? Tools.Global.Tools.Length + Tools.Local.Tools.Length : null;
}

/// <summary>Everything the pages read about the machine, kept current without making them wait.</summary>
internal sealed class LiveData
{
private static readonly TimeSpan Revalidate = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ReleaseListAge = TimeSpan.FromMinutes(10);

private readonly SdkDownloadCatalog catalog;
private readonly HttpClient httpClient;
private readonly ILogger<LiveData> logger;
private int metadataRefreshRunning;

public StaleWhileRevalidate<InstallationSnapshot> Installation { get; }
public StaleWhileRevalidate<ToolsSnapshot> Tools { get; }
public StaleWhileRevalidate<ActiveSdk> ActiveSdk { get; }
public StaleWhileRevalidate<SetupSnapshot> Setup { get; }

public LiveData(DotnetInstallationService installation, DotnetToolsService tools, DotnetUninstallToolService setup, SdkDownloadCatalog catalog, HttpClient httpClient, ILogger<LiveData> logger)
{
this.catalog = catalog;
this.httpClient = httpClient;
this.logger = logger;
Installation = new(() => ReadInstallationAsync(installation), Revalidate, snapshot => snapshot.Status != "error");
Tools = new(() => ReadToolsAsync(tools), Revalidate, snapshot => snapshot.Status != "error");
ActiveSdk = new(() => installation.ReadActiveSdkAsync(CancellationToken.None), Revalidate);
Setup = new(() => setup.ReadAsync(CancellationToken.None), ReleaseListAge);
}

/// <summary>Starts every read in the background, so the first page finds them finished or nearly so.</summary>
public void Warm()
{
_ = Installation.GetAsync();
_ = Tools.GetAsync();
_ = ActiveSdk.GetAsync();
_ = Setup.GetAsync();
_ = catalog.ReadAsync(CancellationToken.None);
}

public async Task<PageData> ReadAsync(HttpContext context)
{
if (context.Request.Query.ContainsKey("refresh")) await RefreshAsync(includeReleases: context.Request.Path == "/setup");
var installationTask = Installation.GetAsync();
var toolsTask = Tools.GetAsync();
var catalogTask = catalog.ReadAsync(context.RequestAborted);
await Task.WhenAll(installationTask, toolsTask, catalogTask);
return new(installationTask.Result, toolsTask.Result, catalogTask.Result);
}

/// <summary>Reads the machine again and waits for it, so the page after a change shows the result.</summary>
public async Task RefreshAsync(bool includeReleases = false)
{
// The release list comes from the network and only the Settings page shows it, so nothing else waits for it.
var reads = new List<Task> { Installation.RefreshAsync(), Tools.RefreshAsync(), ActiveSdk.RefreshAsync() };
if (includeReleases) reads.Add(Setup.RefreshAsync());
await Task.WhenAll(reads);
}

/// <summary>Changes when what a page shows would change. Pages compare it to notice outside changes.</summary>
public string Revision(bool includeToolMetadata)
{
var installation = Installation.Current?.Signature ?? "";
var tools = Tools.Current is { } snapshot
? string.Join('|', snapshot.Global.Tools.Concat(snapshot.Local.Tools).Select(tool => $"{tool.PackageId}:{tool.Version}").Order(StringComparer.Ordinal))
: "";
var state = includeToolMetadata ? $"{installation}#{tools}#{ToolMetadataCatalog.Generation}" : $"{installation}#{tools}";
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(state)))[..16];
}

public async Task<string> ReadFreshRevisionAsync(bool includeToolMetadata)
{
var age = TimeSpan.FromSeconds(1.5);
await Task.WhenAll(Installation.GetFreshAsync(age), Tools.GetFreshAsync(age));
return Revision(includeToolMetadata);
}

private async Task<InstallationSnapshot> ReadInstallationAsync(DotnetInstallationService service)
{
try { return await service.ReadAsync(CancellationToken.None); }
catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException or OperationCanceledException)
{
logger.LogWarning(exception, "Could not query the .NET installation.");
return InstallationSnapshot.Create("error", null, error: "The .NET installation could not be read. Check your PATH and try again.");
}
}

private async Task<ToolsSnapshot> ReadToolsAsync(DotnetToolsService service)
{
ToolsSnapshot snapshot;
try { snapshot = await service.ReadAsync(CancellationToken.None); }
catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException or OperationCanceledException)
{
logger.LogWarning(exception, "Could not query .NET tools.");
return ToolsSnapshot.Empty("error", Environment.CurrentDirectory, "The tools could not be read. Check the .NET installation and try again.");
}

if (snapshot.Status == "ready" && Interlocked.CompareExchange(ref metadataRefreshRunning, 1, 0) == 0)
_ = RefreshToolMetadataAsync(snapshot);
return snapshot;
}

private async Task RefreshToolMetadataAsync(ToolsSnapshot snapshot)
{
try { await ToolMetadataCatalog.WarmCacheAsync(snapshot, httpClient, CancellationToken.None); }
catch (Exception exception) when (exception is HttpRequestException or IOException or InvalidOperationException or OperationCanceledException)
{
logger.LogDebug(exception, "Could not refresh the tool metadata.");
}
finally { Interlocked.Exchange(ref metadataRefreshRunning, 0); }
}
}
2 changes: 1 addition & 1 deletion src/DotnetCockpit/OverviewPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ public static string Render(InstallationSnapshot snapshot, ToolsSnapshot tools,
</div>
{{RenderTimeline(snapshot, sdkSnapshot, currentDate)}}
</section>
""", "/overview", ".NET Cockpit · Overview", issues, snapshot.InstallationCount, toolsAvailable ? tools.Global.Tools.Length + tools.Local.Tools.Length : null, inventorySignature: snapshot.Signature);
""", "/overview", ".NET Cockpit · Overview", issues, snapshot.InstallationCount, toolsAvailable ? tools.Global.Tools.Length + tools.Local.Tools.Length : null);
}

private static string RenderTimeline(InstallationSnapshot snapshot, SdkDownloadSnapshot sdkSnapshot, DateOnly today)
Expand Down
24 changes: 16 additions & 8 deletions src/DotnetCockpit/PageLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ internal static class PageLayout
/// <summary>Set at startup from the settings file. Tests and tools that render pages directly get the defaults.</summary>
public static Func<AppSettings> CurrentSettings { get; set; } = () => new();

public static string AssetVersion { get; set; } = "dev";

/// <summary>When the installation was last read, which can be earlier than now because pages answer from memory.</summary>
public static Func<DateTimeOffset> CheckedAt { get; set; } = () => DateTimeOffset.Now;

/// <summary>What this page was built from. The script compares it to notice changes made outside Cockpit.</summary>
public static Func<string, string?> CurrentRevision { get; set; } = _ => null;

/// <summary>Set at startup. Rendered as an attribute only where the system has an accent color.</summary>
public static Func<string?> CurrentAccent { get; set; } = () => null;

Expand All @@ -15,7 +23,7 @@ internal static class PageLayout
private static string WorkspaceName =>
Path.GetFileName(Environment.CurrentDirectory.TrimEnd(Path.DirectorySeparatorChar)) is { Length: > 0 } name ? name : Environment.CurrentDirectory;

public static string Render(string content, string currentPath, string title = ".NET Cockpit", int issueCount = 0, int? versionsCount = null, int? toolsCount = null, string? refreshHref = null, string? inventorySignature = null) => $$"""
public static string Render(string content, string currentPath, string title = ".NET Cockpit", int issueCount = 0, int? versionsCount = null, int? toolsCount = null, string? refreshHref = null) => $$"""
<!doctype html>
<html lang="en" data-theme="{{CurrentSettings().Theme}}" data-palette="{{CurrentSettings().Palette}}"{{AccentAttribute}}>
<head>
Expand All @@ -24,16 +32,16 @@ public static string Render(string content, string currentPath, string title = "
<meta name="color-scheme" content="light dark">
<meta name="description" content="Your local .NET environment, in one place.">
<title>{{Encode(title)}}</title>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/logo-32.png">
<link rel="stylesheet" href="/assets/app.css">
<script type="module" src="/assets/app.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/logo-32.png?v={{AssetVersion}}">
<link rel="stylesheet" href="/assets/app.css?v={{AssetVersion}}">
<script type="module" src="/assets/app.js?v={{AssetVersion}}"></script>
</head>
<body class="app-body"{{(inventorySignature is null ? "" : $" data-inventory=\"{Encode(inventorySignature)}\"")}}>
<body class="app-body"{{(CurrentRevision(currentPath) is { } revision ? $" data-revision=\"{Encode(revision)}\"" : "")}}>
<a class="skip-link" href="#main">Skip to content</a>
<div class="app">
<aside class="rail" aria-label="Workspace navigation">
<div class="brand">
<img src="/assets/logo.png" alt=".NET Cockpit logo">
<img src="/assets/logo.png?v={{AssetVersion}}" alt=".NET Cockpit logo">
<div>
<b>.NET Cockpit</b>
<small>Runs on this machine</small>
Expand Down Expand Up @@ -67,9 +75,9 @@ public static string Render(string content, string currentPath, string title = "
<span>Search versions, tools and actions</span>
<kbd>⌘K</kbd>
</button>
<a class="checked" href="{{Encode(refreshHref ?? (currentPath == "/confirm" ? "/overview" : currentPath))}}" title="Check again" aria-label="Refresh. Checked {{DateTime.Now.ToString("HH:mm")}}">
<a class="checked" href="{{Encode(refreshHref ?? $"{(currentPath == "/confirm" ? "/overview" : currentPath)}?refresh=1")}}" title="Check again" aria-label="Refresh. Checked {{CheckedAt().ToLocalTime().ToString("HH:mm")}}">
{{Icon("refresh", "xs")}}
<span>Checked <time datetime="{{DateTimeOffset.Now:O}}">{{DateTime.Now.ToString("HH:mm")}}</time></span>
<span>Checked <time datetime="{{CheckedAt():O}}">{{CheckedAt().ToLocalTime().ToString("HH:mm")}}</time></span>
</a>
{{ThemeSwitch()}}
</header>
Expand Down
8 changes: 4 additions & 4 deletions src/DotnetCockpit/PreflightPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ public static string Render(InstallationSnapshot installation, ToolsSnapshot too
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Pre-flight Check | .NET Cockpit</title>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/logo-32.png">
<link rel="stylesheet" href="/assets/app.css">
<script type="module" src="/assets/app.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/logo-32.png?v={{PageLayout.AssetVersion}}">
<link rel="stylesheet" href="/assets/app.css?v={{PageLayout.AssetVersion}}">
<script type="module" src="/assets/app.js?v={{PageLayout.AssetVersion}}"></script>
</head>
<body class="app-body preflight-body">
{{PageLayout.ThemeSwitch(extraClass: "pf-theme-btn")}}

<div class="preflight">
<div class="pf">
<div class="pf-brand">
<img src="/assets/logo.png" alt=".NET Cockpit logo">
<img src="/assets/logo.png?v={{PageLayout.AssetVersion}}" alt=".NET Cockpit logo">
<div>
<div class="muted pf-kicker">.NET Cockpit</div>
<h1 class="pf-title">Pre-flight <span class="grad-text">check</span></h1>
Expand Down
Loading
Loading