using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace Deploymentcenter.UpdateAgent
{
///
/// Prueft, ob die Laufzeit vorhanden ist, die die installierte Anwendung
/// braucht.
///
/// Ohne diese Pruefung endet eine gelungene Installation mit einer
/// Anwendung, die sich nicht starten laesst - und die Fehlersuche beginnt
/// beim Deploymentcenter, weil das der letzte Schritt war, den jemand
/// bewusst getan hat. Die Meldung des Systems ("You must install .NET to
/// run this application") sieht dann jeder, der von Hand startet, aber
/// niemand, der einen Dienst einrichtet.
///
/// Gefragt wird nicht die Anwendung, sondern ihre runtimeconfig.json: dort
/// steht, was sie tatsaechlich erwartet. Das kostet die ausliefernde Seite
/// nichts - die Datei entsteht bei jedem dotnet publish von selbst - und
/// deckt auch Pakete ab, die lange vor dieser Pruefung gebaut wurden.
///
/// Ein eigenstaendig veroeffentlichtes Paket (self-contained) bringt seine
/// Laufzeit mit; dort gibt es nichts zu pruefen und entsprechend nichts zu
/// melden.
///
internal static class RuntimeCheck
{
/// Ein Framework, das die Anwendung erwartet.
internal sealed class Requirement
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
///
/// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von
/// .NET ist "Minor": eine hoehere Nebenversion derselben
/// Hauptversion wird genommen, eine hoehere Hauptversion nicht.
///
public string RollForward { get; set; } = "Minor";
}
/// Was auf dieser Maschine installiert ist.
internal sealed class InstalledRuntime
{
public string Name { get; set; } = string.Empty;
public Version Version { get; set; } = new Version(0, 0);
}
internal sealed class Result
{
/// Liess sich ueberhaupt etwas feststellen?
public bool Inspected { get; set; }
/// Das Paket bringt seine Laufzeit selbst mit.
public bool SelfContained { get; set; }
/// Ist ueberhaupt ein "dotnet" auffindbar?
public bool DotnetFound { get; set; }
public List Required { get; } = new List();
public List Installed { get; } = new List();
/// Anforderungen, die nichts Installiertes erfuellt.
public List Missing { get; } = new List();
/// Nichts zu beanstanden - oder nichts zu pruefen.
public bool Ok => SelfContained || !Inspected || Missing.Count == 0;
}
///
/// Untersucht das Zielverzeichnis und die Maschine.
///
public static Result Inspect(string targetDir)
{
var result = new Result();
var requirements = ReadRequirements(targetDir, result);
if (!result.Inspected || result.SelfContained)
{
return result;
}
result.Required.AddRange(requirements);
foreach (var runtime in ListInstalledRuntimes())
{
result.Installed.Add(runtime);
}
result.DotnetFound = result.Installed.Count > 0;
foreach (var requirement in requirements)
{
if (!IsSatisfied(requirement, result.Installed))
{
result.Missing.Add(requirement);
}
}
return result;
}
///
/// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas -
/// die Anwendung wird so nicht starten.
///
public static bool Report(Result result)
{
if (result.SelfContained)
{
AnsiConsole.MarkupLine("[grey]Laufzeit: das Paket bringt sie selbst mit.[/]");
return true;
}
if (!result.Inspected)
{
// Kein Grund zur Beunruhigung: nicht jedes Paket ist eine
// .NET-Anwendung, und ohne runtimeconfig.json gibt es hier
// schlicht nichts zu sagen.
return true;
}
if (result.Missing.Count == 0)
{
foreach (var requirement in result.Required)
{
var best = BestMatch(requirement, result.Installed);
AnsiConsole.MarkupLine($"[green] Laufzeit vorhanden:[/] "
+ $"{Markup.Escape(requirement.Name)} {Markup.Escape(best?.Version.ToString() ?? "?")} "
+ $"[grey](verlangt {Markup.Escape(requirement.Version)})[/]");
}
return true;
}
AnsiConsole.WriteLine();
if (!result.DotnetFound)
{
AnsiConsole.MarkupLine("[bold red]Auf dieser Maschine ist keine .NET-Laufzeit installiert.[/]");
}
else
{
AnsiConsole.MarkupLine("[bold red]Die benoetigte .NET-Laufzeit fehlt.[/]");
string installed = string.Join(", ", result.Installed
.GroupBy(r => r.Name)
.Select(g => $"{g.Key} {string.Join("/", g.Select(r => r.Version.ToString()).Distinct())}"));
AnsiConsole.MarkupLine($"[grey]Vorhanden: {Markup.Escape(installed)}[/]");
}
foreach (var missing in result.Missing)
{
AnsiConsole.MarkupLine($"[red]Verlangt: {Markup.Escape(missing.Name)} {Markup.Escape(missing.Version)}[/]");
}
AnsiConsole.MarkupLine("[yellow]Die Anwendung ist vollstaendig installiert, wird sich so aber nicht "
+ "starten lassen.[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Nachzuholen mit:[/]");
foreach (string hint in InstallHints(result.Missing))
{
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(hint)}[/]");
}
// Die Verwechslung ist haeufig genug, um sie hier auszuraeumen:
// zum Ausfuehren genuegt die Laufzeit. Das SDK ist zum Bauen da
// und bringt ein Vielfaches an Umfang mit.
AnsiConsole.MarkupLine("[grey]Zum Ausfuehren genuegt die Laufzeit - das SDK wird nur zum Bauen "
+ "gebraucht.[/]");
return false;
}
// ------------------------------------------------------------------
///
/// Liest die Anforderungen aus der runtimeconfig.json im
/// Zielverzeichnis.
///
private static List ReadRequirements(string targetDir, Result result)
{
var requirements = new List();
string[] candidates;
try
{
candidates = Directory.GetFiles(targetDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
}
catch
{
return requirements;
}
foreach (string path in candidates)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("runtimeOptions", out var options))
continue;
result.Inspected = true;
// Eigenstaendig veroeffentlicht: die Laufzeit liegt daneben
// im Verzeichnis, es gibt nichts zu installieren.
if (options.TryGetProperty("includedFrameworks", out var included)
&& included.ValueKind == JsonValueKind.Array
&& included.GetArrayLength() > 0)
{
result.SelfContained = true;
return requirements;
}
string rollForward = options.TryGetProperty("rollForward", out var roll)
&& roll.ValueKind == JsonValueKind.String
? (roll.GetString() ?? "Minor")
: "Minor";
// Einzelnes Framework oder eine Liste - beide Schreibweisen
// kommen vor, je nach Projektart und SDK-Fassung.
if (options.TryGetProperty("framework", out var single))
{
AddRequirement(requirements, single, rollForward);
}
if (options.TryGetProperty("frameworks", out var many)
&& many.ValueKind == JsonValueKind.Array)
{
foreach (var entry in many.EnumerateArray())
{
AddRequirement(requirements, entry, rollForward);
}
}
}
catch
{
// Eine unlesbare runtimeconfig.json ist kein Grund, die
// Installation zu bemaengeln - nur einer, hier nichts zu
// behaupten.
}
}
return requirements;
}
private static void AddRequirement(List into, JsonElement element, string rollForward)
{
if (element.ValueKind != JsonValueKind.Object)
return;
string name = element.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
? (n.GetString() ?? string.Empty)
: string.Empty;
string version = element.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String
? (v.GetString() ?? string.Empty)
: string.Empty;
if (name.Length == 0 || version.Length == 0)
return;
if (into.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)))
return;
into.Add(new Requirement { Name = name, Version = version, RollForward = rollForward });
}
///
/// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die
/// Antwort leer - genau das ist die Auskunft, um die es geht.
///
private static List ListInstalledRuntimes()
{
var found = new List();
string output;
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "--list-runtimes",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (process == null)
return found;
output = process.StandardOutput.ReadToEnd();
// Ein haengendes dotnet darf die Installation nicht aufhalten.
if (!process.WaitForExit(15000))
{
try { process.Kill(entireProcessTree: true); } catch { }
return found;
}
}
catch
{
// Kein dotnet im Pfad.
return found;
}
// Zeilenform: "Microsoft.AspNetCore.App 8.0.14 [/usr/share/dotnet/shared/...]"
var pattern = new Regex(@"^(\S+)\s+(\d+\.\d+\.\d+\S*)\s+\[", RegexOptions.Compiled);
foreach (string line in output.Split('\n'))
{
var match = pattern.Match(line.Trim());
if (!match.Success)
continue;
if (TryParseVersion(match.Groups[2].Value, out var version))
{
found.Add(new InstalledRuntime { Name = match.Groups[1].Value, Version = version });
}
}
return found;
}
///
/// Wird die Anforderung von irgendetwas Installiertem erfuellt?
///
private static bool IsSatisfied(Requirement requirement, List installed)
{
return BestMatch(requirement, installed) != null;
}
private static InstalledRuntime? BestMatch(Requirement requirement, List installed)
{
if (!TryParseVersion(requirement.Version, out var wanted))
{
// Ohne verwertbare Angabe wird nichts bemaengelt: die Namen
// muessen dann genuegen.
return installed.FirstOrDefault(r =>
string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase));
}
string mode = (requirement.RollForward ?? "Minor").ToLowerInvariant();
var candidates = installed
.Where(r => string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase))
.Where(r => Matches(mode, wanted, r.Version))
.OrderByDescending(r => r.Version)
.ToList();
return candidates.FirstOrDefault();
}
private static bool Matches(string mode, Version wanted, Version have)
{
switch (mode)
{
// Ausdruecklich abgeschaltet: es muss genau diese Fassung sein.
case "disable":
return have == wanted;
// Eine hoehere Hauptversion ist erlaubt.
case "major":
case "latestmajor":
return have >= wanted;
// Vorgabe und alle uebrigen Werte (Minor, LatestMinor,
// LatestPatch): dieselbe Hauptversion, mindestens die
// verlangte Fassung.
default:
return have.Major == wanted.Major && have >= wanted;
}
}
private static bool TryParseVersion(string raw, out Version version)
{
// Vorabversionen ("8.0.0-preview.3") lassen sich nicht als Version
// lesen; der Teil vor dem Bindestrich genuegt hier.
string cleaned = raw.Split('-')[0].Trim();
return Version.TryParse(cleaned, out version!);
}
///
/// Was der Mensch davor jetzt tun muss - als Befehl, nicht als
/// Verweis auf eine Downloadseite.
///
private static List InstallHints(List missing)
{
var hints = new List();
bool windows = OperatingSystem.IsWindows();
foreach (var requirement in missing)
{
if (!TryParseVersion(requirement.Version, out var version))
continue;
string majorMinor = $"{version.Major}.{version.Minor}";
if (windows)
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"Microsoft.DotNet.AspNetCore.{version.Major}",
"Microsoft.WindowsDesktop.App" => $"Microsoft.DotNet.DesktopRuntime.{version.Major}",
_ => $"Microsoft.DotNet.Runtime.{version.Major}",
};
hints.Add($"winget install --id {package} --source winget");
}
else
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"aspnetcore-runtime-{majorMinor}",
_ => $"dotnet-runtime-{majorMinor}",
};
// Debian und Ubuntu fuehren die Pakete seit 22.04 in den
// eigenen Quellen; wo nicht, hilft der Verweis darunter.
hints.Add($"sudo apt-get install -y {package}");
}
}
if (hints.Count == 0)
{
hints.Add("https://dotnet.microsoft.com/download");
}
else
{
hints.Add("Falls das Paket dort nicht gefuehrt wird: https://dotnet.microsoft.com/download");
}
return hints;
}
}
}