diff --git a/AGENTS.md b/AGENTS.md index be3075e..b30fc93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `, 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. diff --git a/docs/Privacy-and-Safety.md b/docs/Privacy-and-Safety.md index 381ee96..a11e54b 100644 --- a/docs/Privacy-and-Safety.md +++ b/docs/Privacy-and-Safety.md @@ -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. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 032c9e0..5f083b6 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -70,7 +70,7 @@ async function runWithoutRepositoryFiles(environment, verify, expectedStatus = 2 assert.equal(page.status, expectedStatus); const html = await page.text(); assert.match(html, /\.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)) { diff --git a/src/DotnetCockpit/AppJsonContext.cs b/src/DotnetCockpit/AppJsonContext.cs index 5ccf406..a4e02ea 100644 --- a/src/DotnetCockpit/AppJsonContext.cs +++ b/src/DotnetCockpit/AppJsonContext.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using DotnetCockpit.Installation; +using DotnetCockpit.SdkDownloads; using DotnetCockpit.Tools; namespace DotnetCockpit; @@ -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); diff --git a/src/DotnetCockpit/InstallationPage.cs b/src/DotnetCockpit/InstallationPage.cs index abd9c01..784bc4a 100644 --- a/src/DotnetCockpit/InstallationPage.cs +++ b/src/DotnetCockpit/InstallationPage.cs @@ -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) diff --git a/src/DotnetCockpit/LiveData.cs b/src/DotnetCockpit/LiveData.cs new file mode 100644 index 0000000..6b433e3 --- /dev/null +++ b/src/DotnetCockpit/LiveData.cs @@ -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); } + } +} diff --git a/src/DotnetCockpit/OverviewPage.cs b/src/DotnetCockpit/OverviewPage.cs index c400a7b..64aa554 100644 --- a/src/DotnetCockpit/OverviewPage.cs +++ b/src/DotnetCockpit/OverviewPage.cs @@ -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) diff --git a/src/DotnetCockpit/PageLayout.cs b/src/DotnetCockpit/PageLayout.cs index 5a220fe..08993ee 100644 --- a/src/DotnetCockpit/PageLayout.cs +++ b/src/DotnetCockpit/PageLayout.cs @@ -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; @@ -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> @@ -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)}} - - - + + + - +