2026-05-24 23:12:48 +08:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
using Microsoft.Win32;
|
|
|
|
|
|
2026-05-28 10:43:30 +08:00
|
|
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
2026-05-24 23:12:48 +08:00
|
|
|
|
|
|
|
|
internal enum DotNetRuntimeArchitecture
|
|
|
|
|
{
|
|
|
|
|
X64,
|
|
|
|
|
X86
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed record DotNetRuntimeInfo(
|
|
|
|
|
string Name,
|
|
|
|
|
string Version,
|
|
|
|
|
string Source,
|
|
|
|
|
string? Location);
|
|
|
|
|
|
|
|
|
|
internal sealed record DotNetRuntimeProbeOptions
|
|
|
|
|
{
|
|
|
|
|
public int RequiredMajorVersion { get; init; } = 10;
|
|
|
|
|
|
|
|
|
|
public DotNetRuntimeArchitecture Architecture { get; init; } = DotNetRuntimeProbe.GetCurrentArchitecture();
|
|
|
|
|
|
|
|
|
|
public string? ProgramFilesPath { get; init; }
|
|
|
|
|
|
|
|
|
|
public string? ProgramFilesX86Path { get; init; }
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
public string? LocalAppDataPath { get; init; }
|
|
|
|
|
|
2026-05-24 23:12:48 +08:00
|
|
|
public IReadOnlyList<string>? DotNetHostCandidates { get; init; }
|
|
|
|
|
|
|
|
|
|
public bool IncludeRegistry { get; init; } = true;
|
|
|
|
|
|
|
|
|
|
public bool IncludeDotNetCli { get; init; } = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed record DotNetRuntimeProbeResult(
|
|
|
|
|
bool IsAvailable,
|
|
|
|
|
int RequiredMajorVersion,
|
|
|
|
|
DotNetRuntimeArchitecture Architecture,
|
|
|
|
|
string? DotNetHostPath,
|
|
|
|
|
IReadOnlyList<string> SearchedPaths,
|
|
|
|
|
IReadOnlyList<DotNetRuntimeInfo> DetectedRuntimes,
|
|
|
|
|
string Message)
|
|
|
|
|
{
|
|
|
|
|
public Dictionary<string, string> ToDetails(string prefix = "dotnetRuntime")
|
|
|
|
|
{
|
|
|
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
{
|
|
|
|
|
[$"{prefix}Available"] = IsAvailable.ToString(),
|
|
|
|
|
[$"{prefix}RequiredMajorVersion"] = RequiredMajorVersion.ToString(),
|
|
|
|
|
[$"{prefix}Architecture"] = Architecture.ToString(),
|
|
|
|
|
[$"{prefix}DotNetHostPath"] = DotNetHostPath ?? string.Empty,
|
|
|
|
|
[$"{prefix}SearchedPaths"] = string.Join(" | ", SearchedPaths),
|
|
|
|
|
[$"{prefix}DetectedRuntimes"] = string.Join(
|
|
|
|
|
" | ",
|
|
|
|
|
DetectedRuntimes.Select(runtime =>
|
|
|
|
|
$"{runtime.Name} {runtime.Version} [{runtime.Source}{(string.IsNullOrWhiteSpace(runtime.Location) ? string.Empty : $": {runtime.Location}")}]")),
|
|
|
|
|
[$"{prefix}Message"] = Message
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal static class DotNetRuntimeProbe
|
|
|
|
|
{
|
|
|
|
|
public const string RequiredSharedFrameworkName = "Microsoft.NETCore.App";
|
2026-05-25 01:24:18 +08:00
|
|
|
public const string WindowsDesktopSharedFrameworkName = "Microsoft.WindowsDesktop.App";
|
|
|
|
|
|
|
|
|
|
private static readonly string[] RequiredSharedFrameworkNames =
|
|
|
|
|
[
|
|
|
|
|
RequiredSharedFrameworkName,
|
|
|
|
|
WindowsDesktopSharedFrameworkName
|
|
|
|
|
];
|
2026-05-24 23:12:48 +08:00
|
|
|
|
|
|
|
|
public static DotNetRuntimeProbeResult Probe(DotNetRuntimeProbeOptions? options = null)
|
|
|
|
|
{
|
|
|
|
|
options ??= new DotNetRuntimeProbeOptions();
|
|
|
|
|
|
|
|
|
|
var searchedPaths = new List<string>();
|
|
|
|
|
var detected = new List<DotNetRuntimeInfo>();
|
|
|
|
|
var requiredMajor = options.RequiredMajorVersion;
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
var localAppDataRoot = GetLocalAppDataPath(options);
|
|
|
|
|
var perUserDotnetRoot = !string.IsNullOrWhiteSpace(localAppDataRoot)
|
|
|
|
|
? Path.Combine(localAppDataRoot, "dotnet")
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
foreach (var frameworkName in RequiredSharedFrameworkNames)
|
|
|
|
|
{
|
|
|
|
|
foreach (var basePath in EnumerateDotNetInstallRoots(options))
|
|
|
|
|
{
|
|
|
|
|
var sharedFrameworkDirectory = Path.Combine(basePath, "shared", frameworkName);
|
|
|
|
|
searchedPaths.Add(sharedFrameworkDirectory);
|
|
|
|
|
var isPerUser = perUserDotnetRoot is not null &&
|
|
|
|
|
string.Equals(basePath, perUserDotnetRoot, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
AddDirectoryRuntimes(sharedFrameworkDirectory, frameworkName,
|
|
|
|
|
isPerUser ? "shared-framework-directory-per-user" : "shared-framework-directory",
|
|
|
|
|
detected);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-24 23:12:48 +08:00
|
|
|
|
|
|
|
|
string? dotNetHostPath = null;
|
|
|
|
|
foreach (var candidate in EnumerateDotNetHostCandidates(options))
|
|
|
|
|
{
|
|
|
|
|
searchedPaths.Add(candidate);
|
|
|
|
|
if (dotNetHostPath is null && File.Exists(candidate))
|
|
|
|
|
{
|
|
|
|
|
dotNetHostPath = candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (OperatingSystem.IsWindows() && options.IncludeRegistry)
|
|
|
|
|
{
|
2026-05-25 01:24:18 +08:00
|
|
|
foreach (var frameworkName in RequiredSharedFrameworkNames)
|
|
|
|
|
{
|
|
|
|
|
AddRegistryRuntimes(options.Architecture, frameworkName, detected);
|
|
|
|
|
}
|
2026-05-24 23:12:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (options.IncludeDotNetCli)
|
|
|
|
|
{
|
2026-05-25 01:24:18 +08:00
|
|
|
AddDotNetCliRuntimes(dotNetHostPath, detected);
|
2026-05-24 23:12:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var isAvailable = detected.Any(runtime =>
|
|
|
|
|
string.Equals(runtime.Name, RequiredSharedFrameworkName, StringComparison.OrdinalIgnoreCase) &&
|
|
|
|
|
IsRequiredMajor(runtime.Version, requiredMajor));
|
|
|
|
|
|
|
|
|
|
var message = isAvailable
|
|
|
|
|
? $".NET {requiredMajor} runtime found for {options.Architecture}."
|
|
|
|
|
: $".NET {requiredMajor} runtime was not found for {options.Architecture}.";
|
|
|
|
|
|
|
|
|
|
return new DotNetRuntimeProbeResult(
|
|
|
|
|
isAvailable,
|
|
|
|
|
requiredMajor,
|
|
|
|
|
options.Architecture,
|
|
|
|
|
dotNetHostPath,
|
|
|
|
|
searchedPaths
|
|
|
|
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
|
|
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToList(),
|
|
|
|
|
detected
|
|
|
|
|
.DistinctBy(runtime => $"{runtime.Name}|{runtime.Version}|{runtime.Source}|{runtime.Location}", StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.OrderBy(runtime => runtime.Name, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ThenBy(runtime => runtime.Version, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToList(),
|
|
|
|
|
message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static DotNetRuntimeArchitecture GetCurrentArchitecture()
|
|
|
|
|
{
|
|
|
|
|
return RuntimeInformation.ProcessArchitecture switch
|
|
|
|
|
{
|
|
|
|
|
Architecture.X86 => DotNetRuntimeArchitecture.X86,
|
|
|
|
|
_ => DotNetRuntimeArchitecture.X64
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string? FindDotNetHostPath(DotNetRuntimeProbeOptions? options = null)
|
|
|
|
|
{
|
|
|
|
|
options ??= new DotNetRuntimeProbeOptions();
|
|
|
|
|
return EnumerateDotNetHostCandidates(options).FirstOrDefault(File.Exists);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static bool IsFrameworkDependentWindowsApp(string executablePath)
|
|
|
|
|
{
|
|
|
|
|
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(executablePath))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(executablePath));
|
|
|
|
|
if (string.IsNullOrWhiteSpace(directory))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var appName = Path.GetFileNameWithoutExtension(executablePath);
|
|
|
|
|
var runtimeConfigPath = Path.Combine(directory, $"{appName}.runtimeconfig.json");
|
|
|
|
|
if (!File.Exists(runtimeConfigPath))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return !File.Exists(Path.Combine(directory, "coreclr.dll")) &&
|
|
|
|
|
!File.Exists(Path.Combine(directory, "hostfxr.dll")) &&
|
|
|
|
|
!File.Exists(Path.Combine(directory, "hostpolicy.dll")) &&
|
|
|
|
|
!File.Exists(Path.Combine(directory, "System.Private.CoreLib.dll"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
private static IEnumerable<string> EnumerateDotNetInstallRoots(DotNetRuntimeProbeOptions options)
|
2026-05-24 23:12:48 +08:00
|
|
|
{
|
2026-05-25 01:24:18 +08:00
|
|
|
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
|
2026-05-24 23:12:48 +08:00
|
|
|
? GetProgramFilesX86Path(options)
|
|
|
|
|
: GetProgramFilesPath(options);
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
yield return Path.Combine(programFilesRoot, "dotnet");
|
|
|
|
|
|
|
|
|
|
var localAppData = GetLocalAppDataPath(options);
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(localAppData))
|
|
|
|
|
{
|
|
|
|
|
var perUserDotnet = Path.Combine(localAppData, "dotnet");
|
|
|
|
|
if (!string.Equals(perUserDotnet, Path.Combine(programFilesRoot, "dotnet"), StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
yield return perUserDotnet;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-24 23:12:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IEnumerable<string> EnumerateDotNetHostCandidates(DotNetRuntimeProbeOptions options)
|
|
|
|
|
{
|
|
|
|
|
if (options.DotNetHostCandidates is not null)
|
|
|
|
|
{
|
|
|
|
|
foreach (var candidate in options.DotNetHostCandidates)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(candidate))
|
|
|
|
|
{
|
|
|
|
|
yield return Path.GetFullPath(candidate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
yield break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
|
2026-05-24 23:12:48 +08:00
|
|
|
? GetProgramFilesX86Path(options)
|
|
|
|
|
: GetProgramFilesPath(options);
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
yield return Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
|
|
|
|
|
|
|
|
|
|
var localAppData = GetLocalAppDataPath(options);
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(localAppData))
|
|
|
|
|
{
|
|
|
|
|
var perUserHost = Path.Combine(localAppData, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
|
|
|
|
|
if (!string.Equals(perUserHost, Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"), StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
yield return perUserHost;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-24 23:12:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetProgramFilesPath(DotNetRuntimeProbeOptions options)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.ProgramFilesPath))
|
|
|
|
|
{
|
|
|
|
|
return Path.GetFullPath(options.ProgramFilesPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Environment.GetEnvironmentVariable("ProgramW6432") ??
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetProgramFilesX86Path(DotNetRuntimeProbeOptions options)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.ProgramFilesX86Path))
|
|
|
|
|
{
|
|
|
|
|
return Path.GetFullPath(options.ProgramFilesX86Path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Environment.GetEnvironmentVariable("ProgramFiles(x86)") ??
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 01:24:18 +08:00
|
|
|
private static string GetLocalAppDataPath(DotNetRuntimeProbeOptions options)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.LocalAppDataPath))
|
|
|
|
|
{
|
|
|
|
|
return Path.GetFullPath(options.LocalAppDataPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 23:12:48 +08:00
|
|
|
private static void AddDirectoryRuntimes(
|
|
|
|
|
string sharedFrameworkDirectory,
|
|
|
|
|
string sharedFrameworkName,
|
|
|
|
|
string source,
|
|
|
|
|
List<DotNetRuntimeInfo> detected)
|
|
|
|
|
{
|
|
|
|
|
if (!Directory.Exists(sharedFrameworkDirectory))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var directory in Directory.GetDirectories(sharedFrameworkDirectory))
|
|
|
|
|
{
|
|
|
|
|
var version = Path.GetFileName(directory);
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(version))
|
|
|
|
|
{
|
|
|
|
|
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, version, source, directory));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AddRegistryRuntimes(
|
|
|
|
|
DotNetRuntimeArchitecture architecture,
|
|
|
|
|
string sharedFrameworkName,
|
|
|
|
|
List<DotNetRuntimeInfo> detected)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var registryView = architecture == DotNetRuntimeArchitecture.X86
|
|
|
|
|
? RegistryView.Registry32
|
|
|
|
|
: RegistryView.Registry64;
|
|
|
|
|
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, registryView);
|
|
|
|
|
using var key = baseKey.OpenSubKey(
|
|
|
|
|
$@"SOFTWARE\dotnet\Setup\InstalledVersions\{(architecture == DotNetRuntimeArchitecture.X86 ? "x86" : "x64")}\sharedfx\{sharedFrameworkName}");
|
|
|
|
|
|
|
|
|
|
if (key is null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var valueName in key.GetValueNames())
|
|
|
|
|
{
|
|
|
|
|
if (key.GetValue(valueName) is not null)
|
|
|
|
|
{
|
|
|
|
|
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, valueName, "registry", key.Name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Warn($"Failed to inspect .NET runtime registry keys: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AddDotNetCliRuntimes(
|
|
|
|
|
string? dotNetHostPath,
|
|
|
|
|
List<DotNetRuntimeInfo> detected)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(dotNetHostPath) || !File.Exists(dotNetHostPath))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var process = new Process();
|
|
|
|
|
process.StartInfo = new ProcessStartInfo
|
|
|
|
|
{
|
|
|
|
|
FileName = dotNetHostPath,
|
|
|
|
|
Arguments = "--list-runtimes",
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
RedirectStandardOutput = true,
|
|
|
|
|
RedirectStandardError = true,
|
|
|
|
|
CreateNoWindow = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
process.Start();
|
|
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
|
|
|
process.WaitForExit(3000);
|
|
|
|
|
|
|
|
|
|
foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
{
|
|
|
|
|
var parsed = ParseListRuntimeLine(line);
|
|
|
|
|
if (parsed is not null &&
|
2026-05-25 01:24:18 +08:00
|
|
|
RequiredSharedFrameworkNames.Contains(parsed.Value.Name, StringComparer.OrdinalIgnoreCase))
|
2026-05-24 23:12:48 +08:00
|
|
|
{
|
|
|
|
|
detected.Add(new DotNetRuntimeInfo(
|
|
|
|
|
parsed.Value.Name,
|
|
|
|
|
parsed.Value.Version,
|
|
|
|
|
"dotnet-cli",
|
|
|
|
|
parsed.Value.Location));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Warn($"Failed to inspect .NET runtimes via dotnet CLI: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static (string Name, string Version, string? Location)? ParseListRuntimeLine(string line)
|
|
|
|
|
{
|
|
|
|
|
var firstSpace = line.IndexOf(' ');
|
|
|
|
|
if (firstSpace <= 0 || firstSpace + 1 >= line.Length)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var secondSpace = line.IndexOf(' ', firstSpace + 1);
|
|
|
|
|
if (secondSpace <= firstSpace)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var name = line[..firstSpace].Trim();
|
|
|
|
|
var version = line[(firstSpace + 1)..secondSpace].Trim();
|
|
|
|
|
var location = line[(secondSpace + 1)..].Trim().Trim('[', ']');
|
|
|
|
|
return (name, version, string.IsNullOrWhiteSpace(location) ? null : location);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsRequiredMajor(string version, int requiredMajor)
|
|
|
|
|
{
|
|
|
|
|
var dotIndex = version.IndexOf('.');
|
|
|
|
|
var majorText = dotIndex < 0 ? version : version[..dotIndex];
|
|
|
|
|
return int.TryParse(majorText, out var major) && major == requiredMajor;
|
|
|
|
|
}
|
|
|
|
|
}
|