mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fix.正在修复 .NET运行时问题
This commit is contained in:
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -98,10 +98,8 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: x64
|
- arch: x64
|
||||||
self_contained: true
|
|
||||||
suffix: ''
|
suffix: ''
|
||||||
- arch: x86
|
- arch: x86
|
||||||
self_contained: true
|
|
||||||
suffix: ''
|
suffix: ''
|
||||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||||
|
|
||||||
@@ -167,30 +165,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-${{ matrix.arch }}"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
|
||||||
|
|
||||||
if ($selfContained) {
|
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
|
||||||
-c Release `
|
|
||||||
-o ./$publishDir `
|
|
||||||
--self-contained `
|
|
||||||
-r win-${{ matrix.arch }} `
|
|
||||||
-p:PublishSingleFile=false `
|
|
||||||
-p:DebugType=none `
|
|
||||||
-p:DebugSymbols=false `
|
|
||||||
-p:SkipAirAppHostBuild=true `
|
|
||||||
-p:PublishTrimmed=false `
|
|
||||||
-p:PublishReadyToRun=false `
|
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
|
||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
|
||||||
} else {
|
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$publishDir `
|
-o ./$publishDir `
|
||||||
--self-contained:false `
|
--self-contained:false `
|
||||||
|
-r win-${{ matrix.arch }} `
|
||||||
|
-p:SelfContained=false `
|
||||||
-p:PublishSingleFile=false `
|
-p:PublishSingleFile=false `
|
||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false `
|
-p:DebugSymbols=false `
|
||||||
@@ -201,21 +183,19 @@ jobs:
|
|||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
}
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish AirAppHost
|
- name: Publish AirAppHost
|
||||||
run: |
|
run: |
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
|
|
||||||
if ($selfContained) {
|
|
||||||
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
|
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$publishDir `
|
-o ./$publishDir `
|
||||||
--self-contained:false `
|
--self-contained:false `
|
||||||
-r win-$arch `
|
-r win-$arch `
|
||||||
|
-p:SelfContained=false `
|
||||||
-p:PublishSingleFile=false `
|
-p:PublishSingleFile=false `
|
||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false `
|
-p:DebugSymbols=false `
|
||||||
@@ -227,31 +207,13 @@ jobs:
|
|||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
} else {
|
|
||||||
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
|
|
||||||
-c Release `
|
|
||||||
-o ./$publishDir `
|
|
||||||
--self-contained:false `
|
|
||||||
-p:PublishSingleFile=false `
|
|
||||||
-p:DebugType=none `
|
|
||||||
-p:DebugSymbols=false `
|
|
||||||
-p:PublishTrimmed=false `
|
|
||||||
-p:PublishReadyToRun=false `
|
|
||||||
-p:BuildingAirAppHost=true `
|
|
||||||
-p:SkipAirAppHostBuild=true `
|
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
|
||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
|
||||||
}
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
@@ -274,8 +236,7 @@ jobs:
|
|||||||
- name: Optimize and Guard Windows Payload
|
- name: Optimize and Guard Windows Payload
|
||||||
run: |
|
run: |
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
|
|
||||||
./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 `
|
./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 `
|
||||||
-PublishDir $publishDir `
|
-PublishDir $publishDir `
|
||||||
@@ -294,8 +255,7 @@ jobs:
|
|||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
$outputDir = "build-installer"
|
$outputDir = "build-installer"
|
||||||
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||||
|
|
||||||
@@ -329,7 +289,6 @@ jobs:
|
|||||||
"/DMyOutputDir=$outputDir",
|
"/DMyOutputDir=$outputDir",
|
||||||
"/DMyAppArch=$arch",
|
"/DMyAppArch=$arch",
|
||||||
"/DMyAppSuffix=$suffix",
|
"/DMyAppSuffix=$suffix",
|
||||||
"/DIsSelfContained=$selfContained",
|
|
||||||
$installerScript
|
$installerScript
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
5
.trae/specs/runtime-packaging-fix/checklist.md
Normal file
5
.trae/specs/runtime-packaging-fix/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Runtime Packaging Fix Checklist
|
||||||
|
|
||||||
|
- [x] `dotnet build LanMountainDesktop.slnx -c Debug -v minimal` succeeds.
|
||||||
|
- [x] Runtime probe, AirAppHost startup, and packaging policy tests pass.
|
||||||
|
- [ ] Full `win-x64` package dry run completes without timeout.
|
||||||
12
.trae/specs/runtime-packaging-fix/spec.md
Normal file
12
.trae/specs/runtime-packaging-fix/spec.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Runtime Packaging Fix
|
||||||
|
|
||||||
|
Windows releases use the launcher as the only self-contained bootstrapper. The
|
||||||
|
desktop host and AirAppHost are framework-dependent and rely on an
|
||||||
|
architecture-matched .NET 10 Desktop Runtime installed by the Inno setup flow.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- Windows installer payload does not bundle .NET shared runtime files.
|
||||||
|
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime.
|
||||||
|
- Launcher blocks framework-dependent host startup with `dotnet_runtime_missing` when the runtime is unavailable.
|
||||||
|
- AirAppHost startup uses packaged executables or an explicit architecture-matched dotnet host for DLL fallback.
|
||||||
7
.trae/specs/runtime-packaging-fix/tasks.md
Normal file
7
.trae/specs/runtime-packaging-fix/tasks.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Runtime Packaging Fix Tasks
|
||||||
|
|
||||||
|
- [x] Add launcher-side .NET runtime probe and host startup guard.
|
||||||
|
- [x] Update AirAppHost process start behavior for packaged exe and DLL fallback.
|
||||||
|
- [x] Update Windows packaging scripts and CI release workflow.
|
||||||
|
- [x] Update Inno Setup prerequisite download/install flow.
|
||||||
|
- [x] Add regression tests and runtime packaging documentation.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
||||||
|
|
||||||
@@ -13,17 +14,20 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
private readonly Func<string?> _packageRootProvider;
|
private readonly Func<string?> _packageRootProvider;
|
||||||
private readonly Func<string?> _hostPathProvider;
|
private readonly Func<string?> _hostPathProvider;
|
||||||
private readonly Func<string?> _dataRootProvider;
|
private readonly Func<string?> _dataRootProvider;
|
||||||
|
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
||||||
|
|
||||||
public AirAppProcessStarter(
|
public AirAppProcessStarter(
|
||||||
AirAppHostLocator locator,
|
AirAppHostLocator locator,
|
||||||
Func<string?> packageRootProvider,
|
Func<string?> packageRootProvider,
|
||||||
Func<string?> hostPathProvider,
|
Func<string?> hostPathProvider,
|
||||||
Func<string?> dataRootProvider)
|
Func<string?> dataRootProvider,
|
||||||
|
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||||
{
|
{
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_packageRootProvider = packageRootProvider;
|
_packageRootProvider = packageRootProvider;
|
||||||
_hostPathProvider = hostPathProvider;
|
_hostPathProvider = hostPathProvider;
|
||||||
_dataRootProvider = dataRootProvider;
|
_dataRootProvider = dataRootProvider;
|
||||||
|
_runtimeProbeOptions = runtimeProbeOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Process? Start(
|
public Process? Start(
|
||||||
@@ -34,22 +38,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
string? sourcePlacementId)
|
string? sourcePlacementId)
|
||||||
{
|
{
|
||||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
|
||||||
};
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows() &&
|
|
||||||
string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
startInfo.FileName = hostPath;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
startInfo.FileName = "dotnet";
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
AddArgument(startInfo, "--app-id", appId);
|
AddArgument(startInfo, "--app-id", appId);
|
||||||
AddArgument(startInfo, "--session-id", sessionId);
|
AddArgument(startInfo, "--session-id", sessionId);
|
||||||
@@ -94,6 +83,53 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
return process;
|
return process;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static ProcessStartInfo CreateStartInfo(
|
||||||
|
string hostPath,
|
||||||
|
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
||||||
|
};
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
||||||
|
{
|
||||||
|
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||||
|
if (!executableRuntime.IsAvailable)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||||
|
executableRuntime.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.FileName = hostPath;
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||||
|
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||||
|
runtime.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.FileName = runtime.DotNetHostPath;
|
||||||
|
startInfo.ArgumentList.Add(hostPath);
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.FileName = "dotnet";
|
||||||
|
startInfo.ArgumentList.Add(hostPath);
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||||
{
|
{
|
||||||
startInfo.ArgumentList.Add(name);
|
startInfo.ArgumentList.Add(name);
|
||||||
|
|||||||
345
LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs
Normal file
345
LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
public static DotNetRuntimeProbeResult Probe(DotNetRuntimeProbeOptions? options = null)
|
||||||
|
{
|
||||||
|
options ??= new DotNetRuntimeProbeOptions();
|
||||||
|
|
||||||
|
var searchedPaths = new List<string>();
|
||||||
|
var detected = new List<DotNetRuntimeInfo>();
|
||||||
|
var requiredMajor = options.RequiredMajorVersion;
|
||||||
|
var sharedFrameworkDirectory = GetSharedFrameworkDirectory(options, RequiredSharedFrameworkName);
|
||||||
|
searchedPaths.Add(sharedFrameworkDirectory);
|
||||||
|
|
||||||
|
AddDirectoryRuntimes(sharedFrameworkDirectory, RequiredSharedFrameworkName, "shared-framework-directory", detected);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
AddRegistryRuntimes(options.Architecture, RequiredSharedFrameworkName, detected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.IncludeDotNetCli)
|
||||||
|
{
|
||||||
|
AddDotNetCliRuntimes(dotNetHostPath, RequiredSharedFrameworkName, detected);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSharedFrameworkDirectory(DotNetRuntimeProbeOptions options, string sharedFrameworkName)
|
||||||
|
{
|
||||||
|
var root = options.Architecture == DotNetRuntimeArchitecture.X86
|
||||||
|
? GetProgramFilesX86Path(options)
|
||||||
|
: GetProgramFilesPath(options);
|
||||||
|
|
||||||
|
return Path.Combine(root, "dotnet", "shared", sharedFrameworkName);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = options.Architecture == DotNetRuntimeArchitecture.X86
|
||||||
|
? GetProgramFilesX86Path(options)
|
||||||
|
: GetProgramFilesPath(options);
|
||||||
|
|
||||||
|
yield return Path.Combine(root, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
string sharedFrameworkName,
|
||||||
|
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 &&
|
||||||
|
string.Equals(parsed.Value.Name, sharedFrameworkName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -930,6 +930,44 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
|
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static LauncherResult? ValidateDotNetRuntimePrerequisite(
|
||||||
|
HostLaunchPlan plan,
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
DotNetRuntimeProbeOptions? probeOptions = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plan);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolution);
|
||||||
|
|
||||||
|
if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime = DotNetRuntimeProbe.Probe(probeOptions);
|
||||||
|
Logger.Info(
|
||||||
|
$"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " +
|
||||||
|
$"Architecture={runtime.Architecture}; Message='{runtime.Message}'.");
|
||||||
|
|
||||||
|
if (runtime.IsAvailable)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var details = BuildResolutionDetails(resolution, null, null, "runtime");
|
||||||
|
foreach (var pair in runtime.ToDetails())
|
||||||
|
{
|
||||||
|
details[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launchHost",
|
||||||
|
code: "dotnet_runtime_missing",
|
||||||
|
message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.",
|
||||||
|
details: details,
|
||||||
|
errorMessage: runtime.Message);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
||||||
HostResolutionResult resolution,
|
HostResolutionResult resolution,
|
||||||
bool forceDirectMode,
|
bool forceDirectMode,
|
||||||
@@ -937,6 +975,12 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
||||||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
||||||
|
var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution);
|
||||||
|
if (prerequisiteFailure is not null)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(prerequisiteFailure);
|
||||||
|
}
|
||||||
|
|
||||||
var hostPath = plan.HostPath;
|
var hostPath = plan.HostPath;
|
||||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
|
|||||||
74
LanMountainDesktop.Tests/AirAppProcessStarterRuntimeTests.cs
Normal file
74
LanMountainDesktop.Tests/AirAppProcessStarterRuntimeTests.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Launcher.Services.AirApp;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class AirAppProcessStarterRuntimeTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public AirAppProcessStarterRuntimeTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.AirAppProcessStarterRuntimeTests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateStartInfo_UsesPackagedExecutable_WhenExeExists()
|
||||||
|
{
|
||||||
|
var hostPath = Path.Combine(_root, OperatingSystem.IsWindows()
|
||||||
|
? "LanMountainDesktop.AirAppHost.exe"
|
||||||
|
: "LanMountainDesktop.AirAppHost");
|
||||||
|
File.WriteAllText(hostPath, string.Empty);
|
||||||
|
|
||||||
|
var startInfo = AirAppProcessStarter.CreateStartInfo(hostPath);
|
||||||
|
|
||||||
|
Assert.Equal(hostPath, startInfo.FileName);
|
||||||
|
Assert.Empty(startInfo.ArgumentList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var programFiles = Path.Combine(_root, "ProgramFiles");
|
||||||
|
var dotnetRoot = Path.Combine(programFiles, "dotnet");
|
||||||
|
Directory.CreateDirectory(dotnetRoot);
|
||||||
|
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
|
||||||
|
File.WriteAllText(dotnetHost, string.Empty);
|
||||||
|
Directory.CreateDirectory(Path.Combine(
|
||||||
|
dotnetRoot,
|
||||||
|
"shared",
|
||||||
|
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
||||||
|
"10.0.5"));
|
||||||
|
|
||||||
|
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
||||||
|
File.WriteAllText(hostDll, string.Empty);
|
||||||
|
var options = new DotNetRuntimeProbeOptions
|
||||||
|
{
|
||||||
|
Architecture = DotNetRuntimeArchitecture.X64,
|
||||||
|
ProgramFilesPath = programFiles,
|
||||||
|
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
|
||||||
|
IncludeRegistry = false,
|
||||||
|
IncludeDotNetCli = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
|
||||||
|
|
||||||
|
Assert.Equal(dotnetHost, startInfo.FileName);
|
||||||
|
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs
Normal file
135
LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class DotNetRuntimeProbeTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly string _programFiles;
|
||||||
|
private readonly string _programFilesX86;
|
||||||
|
|
||||||
|
public DotNetRuntimeProbeTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DotNetRuntimeProbeTests", Guid.NewGuid().ToString("N"));
|
||||||
|
_programFiles = Path.Combine(_root, "ProgramFiles");
|
||||||
|
_programFilesX86 = Path.Combine(_root, "ProgramFilesX86");
|
||||||
|
Directory.CreateDirectory(_programFiles);
|
||||||
|
Directory.CreateDirectory(_programFilesX86);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_AcceptsTargetArchitectureRuntime_WhenDotnetHostIsMissing()
|
||||||
|
{
|
||||||
|
CreateRuntime(_programFiles, "10.0.5");
|
||||||
|
|
||||||
|
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
|
||||||
|
|
||||||
|
Assert.True(result.IsAvailable);
|
||||||
|
Assert.Null(result.DotNetHostPath);
|
||||||
|
Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_X64DoesNotAcceptX86OnlyRuntime()
|
||||||
|
{
|
||||||
|
CreateRuntime(_programFilesX86, "10.0.5");
|
||||||
|
|
||||||
|
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
|
||||||
|
|
||||||
|
Assert.False(result.IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_X86DoesNotAcceptX64OnlyRuntime()
|
||||||
|
{
|
||||||
|
CreateRuntime(_programFiles, "10.0.5");
|
||||||
|
|
||||||
|
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X86));
|
||||||
|
|
||||||
|
Assert.False(result.IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_RejectsOlderMajorVersions()
|
||||||
|
{
|
||||||
|
CreateRuntime(_programFiles, "8.0.25");
|
||||||
|
CreateRuntime(_programFiles, "9.0.14");
|
||||||
|
|
||||||
|
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
|
||||||
|
|
||||||
|
Assert.False(result.IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateDotNetRuntimePrerequisite_ReturnsStructuredFailure_WhenRuntimeIsMissing()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var appDir = Path.Combine(_root, "app-1.0.0");
|
||||||
|
Directory.CreateDirectory(appDir);
|
||||||
|
var hostPath = Path.Combine(appDir, "LanMountainDesktop.exe");
|
||||||
|
File.WriteAllText(hostPath, string.Empty);
|
||||||
|
File.WriteAllText(Path.Combine(appDir, "LanMountainDesktop.runtimeconfig.json"), "{}");
|
||||||
|
|
||||||
|
var plan = new HostLaunchPlan(
|
||||||
|
hostPath,
|
||||||
|
_root,
|
||||||
|
appDir,
|
||||||
|
[],
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
new() { Version = "1.0.0", Codename = "Test" });
|
||||||
|
var resolution = new HostResolutionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ResolvedHostPath = hostPath,
|
||||||
|
AppRoot = _root,
|
||||||
|
ResolutionSource = "test",
|
||||||
|
SearchedPaths = [hostPath]
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = LauncherFlowCoordinator.ValidateDotNetRuntimePrerequisite(
|
||||||
|
plan,
|
||||||
|
resolution,
|
||||||
|
CreateOptions(DotNetRuntimeArchitecture.X64));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("dotnet_runtime_missing", result.Code);
|
||||||
|
Assert.Equal("False", result.Details["dotnetRuntimeAvailable"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DotNetRuntimeProbeOptions CreateOptions(DotNetRuntimeArchitecture architecture)
|
||||||
|
{
|
||||||
|
return new DotNetRuntimeProbeOptions
|
||||||
|
{
|
||||||
|
Architecture = architecture,
|
||||||
|
ProgramFilesPath = _programFiles,
|
||||||
|
ProgramFilesX86Path = _programFilesX86,
|
||||||
|
DotNetHostCandidates = [],
|
||||||
|
IncludeRegistry = false,
|
||||||
|
IncludeDotNetCli = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateRuntime(string programFilesRoot, string version)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(
|
||||||
|
programFilesRoot,
|
||||||
|
"dotnet",
|
||||||
|
"shared",
|
||||||
|
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
||||||
|
version));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs
Normal file
57
LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class PackagingRuntimePolicyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WindowsPackageScript_PublishesLauncherRootAndFrameworkDependentAppDirectory()
|
||||||
|
{
|
||||||
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
||||||
|
|
||||||
|
Assert.Contains("Publish-LauncherPayload", script);
|
||||||
|
Assert.Contains("\"app-$Version\"", script);
|
||||||
|
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
||||||
|
Assert.Contains("\"--self-contained\", \"false\"", script);
|
||||||
|
Assert.Contains("\"-p:SelfContained=false\"", script);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowsPayloadGuard_BlocksBundledDotNetRuntimeFiles()
|
||||||
|
{
|
||||||
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
||||||
|
|
||||||
|
Assert.Contains("coreclr.dll", script);
|
||||||
|
Assert.Contains("hostfxr.dll", script);
|
||||||
|
Assert.Contains("hostpolicy.dll", script);
|
||||||
|
Assert.Contains("System.Private.CoreLib.dll", script);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
||||||
|
{
|
||||||
|
var installer = ReadRepositoryFile("LanMountainDesktop", "installer", "LanMountainDesktop.iss");
|
||||||
|
|
||||||
|
Assert.Contains("https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe", installer);
|
||||||
|
Assert.Contains("https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe", installer);
|
||||||
|
Assert.Contains("/install /quiet /norestart", installer);
|
||||||
|
Assert.Contains("ExitCode <> 3010", installer);
|
||||||
|
Assert.DoesNotContain("IsSelfContainedBuild", installer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRepositoryFile(params string[] pathParts)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||||
|
{
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directory is null)
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException("Unable to locate repository root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,13 @@ pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
|
|||||||
|
|
||||||
This guide covers local packaging and CI packaging for LanMountainDesktop.
|
This guide covers local packaging and CI packaging for LanMountainDesktop.
|
||||||
|
|
||||||
|
### Current Windows runtime policy
|
||||||
|
|
||||||
|
- Windows installers do not bundle the .NET shared runtime.
|
||||||
|
- `LanMountainDesktop.Launcher.exe` remains a Native AOT/self-contained bootstrapper at the package root.
|
||||||
|
- `LanMountainDesktop.exe` and `LanMountainDesktop.AirAppHost.exe` are published as framework-dependent, RID-specific apps under `app-<version>/`.
|
||||||
|
- The Inno installer downloads and silently installs the matching .NET 10 Desktop Runtime (`win-x64` or `win-x86`) before copying/launching the app.
|
||||||
|
|
||||||
### Key points
|
### Key points
|
||||||
|
|
||||||
- use `scripts/package.ps1` with the target runtime identifier
|
- use `scripts/package.ps1` with the target runtime identifier
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
#define MyAppSuffix ""
|
#define MyAppSuffix ""
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef IsSelfContained
|
|
||||||
#define IsSelfContained "true"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
AppId={#MyAppId}
|
AppId={#MyAppId}
|
||||||
AppName={#MyAppName}
|
AppName={#MyAppName}
|
||||||
@@ -112,6 +108,14 @@ english.DotNetRuntimeMissingMessage=This application requires .NET 10.0 Desktop
|
|||||||
chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。
|
chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。
|
||||||
english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
|
english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
|
||||||
chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。
|
chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。
|
||||||
|
english.DotNetRuntimeDownloadCaption=Installing .NET 10 Desktop Runtime
|
||||||
|
chinesesimplified.DotNetRuntimeDownloadCaption=Installing .NET 10 Desktop Runtime
|
||||||
|
english.DotNetRuntimeDownloadDescription=Setup is downloading the required Microsoft .NET runtime.
|
||||||
|
chinesesimplified.DotNetRuntimeDownloadDescription=Setup is downloading the required Microsoft .NET runtime.
|
||||||
|
english.DotNetRuntimeInstallFailed=Setup could not install the required .NET 10 Desktop Runtime.
|
||||||
|
chinesesimplified.DotNetRuntimeInstallFailed=Setup could not install the required .NET 10 Desktop Runtime.
|
||||||
|
english.DotNetRuntimeStillMissing=The .NET 10 Desktop Runtime is still not detected after installation.
|
||||||
|
chinesesimplified.DotNetRuntimeStillMissing=The .NET 10 Desktop Runtime is still not detected after installation.
|
||||||
english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically.
|
english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically.
|
||||||
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
|
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
|
||||||
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
|
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
|
||||||
@@ -157,7 +161,8 @@ const
|
|||||||
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
|
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
|
||||||
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
||||||
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
|
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
|
||||||
DotNetRuntimeDownloadUrl = 'https://dotnet.microsoft.com/download/dotnet/10.0';
|
DotNetRuntimeDownloadUrlX64 = 'https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe';
|
||||||
|
DotNetRuntimeDownloadUrlX86 = 'https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe';
|
||||||
UpgradeChoiceInPlace = 0;
|
UpgradeChoiceInPlace = 0;
|
||||||
UpgradeChoiceRelocate = 1;
|
UpgradeChoiceRelocate = 1;
|
||||||
|
|
||||||
@@ -547,78 +552,112 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime
|
function GetTargetDotNetDesktopRuntimePath(): String;
|
||||||
// which is sufficient for Avalonia apps) is found on the system.
|
|
||||||
// We check both Microsoft.WindowsDesktop.App and Microsoft.NETCore.App because
|
|
||||||
// the runtimeconfig.json may reference either framework depending on the
|
|
||||||
// publish mode and the app only needs the one it actually references.
|
|
||||||
function IsDotNetDesktopRuntimeInstalled(): Boolean;
|
|
||||||
var
|
|
||||||
BasePath: String;
|
|
||||||
begin
|
begin
|
||||||
Result := False;
|
if '{#MyAppArch}' = 'x64' then
|
||||||
|
|
||||||
// Check 64-bit Program Files
|
|
||||||
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
|
||||||
if IsDotNet10RuntimePresent(BasePath) then
|
|
||||||
begin
|
begin
|
||||||
|
Result := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||||
|
end;
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetDotNetRuntimeDownloadUrl(): String;
|
||||||
|
begin
|
||||||
|
if '{#MyAppArch}' = 'x64' then
|
||||||
|
begin
|
||||||
|
Result := DotNetRuntimeDownloadUrlX64;
|
||||||
|
end;
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
Result := DotNetRuntimeDownloadUrlX86;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetDotNetRuntimeInstallerFileName(): String;
|
||||||
|
begin
|
||||||
|
if '{#MyAppArch}' = 'x64' then
|
||||||
|
begin
|
||||||
|
Result := 'windowsdesktop-runtime-win-x64.exe';
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
Result := 'windowsdesktop-runtime-win-x86.exe';
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function IsDotNetDesktopRuntimeInstalled(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := IsDotNet10RuntimePresent(GetTargetDotNetDesktopRuntimePath());
|
||||||
|
end;
|
||||||
|
|
||||||
|
function DotNetDownloadProgress(
|
||||||
|
const Url, FileName: String;
|
||||||
|
const Progress, ProgressMax: Int64): Boolean;
|
||||||
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function EnsureDotNetDesktopRuntimeInstalled(var NeedsRestart: Boolean): String;
|
||||||
|
var
|
||||||
|
DownloadPage: TDownloadWizardPage;
|
||||||
|
InstallerPath: String;
|
||||||
|
ExitCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
|
||||||
|
if IsDotNetDesktopRuntimeInstalled() then
|
||||||
|
begin
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App');
|
DownloadPage := CreateDownloadPage(
|
||||||
if IsDotNet10RuntimePresent(BasePath) then
|
CustomMessage('DotNetRuntimeDownloadCaption'),
|
||||||
|
CustomMessage('DotNetRuntimeDownloadDescription'),
|
||||||
|
@DotNetDownloadProgress);
|
||||||
|
try
|
||||||
|
DownloadPage.Add(GetDotNetRuntimeDownloadUrl(), GetDotNetRuntimeInstallerFileName(), '');
|
||||||
|
DownloadPage.Show;
|
||||||
|
try
|
||||||
|
DownloadPage.Download;
|
||||||
|
except
|
||||||
|
Result := CustomMessage('DotNetRuntimeInstallFailed') + #13#10 + GetExceptionMessage;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DownloadPage.Hide;
|
||||||
|
end;
|
||||||
|
|
||||||
|
InstallerPath := ExpandConstant('{tmp}\' + GetDotNetRuntimeInstallerFileName());
|
||||||
|
if not Exec(InstallerPath, '/install /quiet /norestart', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := CustomMessage('DotNetRuntimeInstallFailed');
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Check 32-bit Program Files
|
if (ExitCode <> 0) and (ExitCode <> 3010) then
|
||||||
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
|
||||||
if IsDotNet10RuntimePresent(BasePath) then
|
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := CustomMessage('DotNetRuntimeInstallFailed') + ' Exit code: ' + IntToStr(ExitCode);
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App');
|
if ExitCode = 3010 then
|
||||||
if IsDotNet10RuntimePresent(BasePath) then
|
|
||||||
begin
|
begin
|
||||||
Result := True;
|
NeedsRestart := True;
|
||||||
exit;
|
end;
|
||||||
|
|
||||||
|
if not IsDotNetDesktopRuntimeInstalled() then
|
||||||
|
begin
|
||||||
|
Result := CustomMessage('DotNetRuntimeStillMissing') + #13#10 + GetTargetDotNetDesktopRuntimePath();
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function InitializeSetup(): Boolean;
|
function InitializeSetup(): Boolean;
|
||||||
var
|
var
|
||||||
ErrorCode: Integer;
|
ErrorCode: Integer;
|
||||||
IsSelfContainedBuild: Boolean;
|
|
||||||
begin
|
begin
|
||||||
IsSelfContainedBuild := ('{#IsSelfContained}' = 'true');
|
|
||||||
|
|
||||||
if not IsSelfContainedBuild then
|
|
||||||
begin
|
|
||||||
if not IsDotNetDesktopRuntimeInstalled() then
|
|
||||||
begin
|
|
||||||
if MsgBox(
|
|
||||||
CustomMessage('DotNetRuntimeMissingMessage') + #13#10#13#10 +
|
|
||||||
CustomMessage('DotNetRuntimeMissingAction'),
|
|
||||||
mbConfirmation,
|
|
||||||
MB_YESNO) = IDYES then
|
|
||||||
begin
|
|
||||||
if not ShellExec('open', DotNetRuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
|
||||||
begin
|
|
||||||
MsgBox(
|
|
||||||
CustomMessage('DotNetRuntimeOpenFailedMessage') + #13#10 +
|
|
||||||
CustomMessage('DotNetRuntimeOpenFailedAction') + #13#10 + DotNetRuntimeDownloadUrl,
|
|
||||||
mbError,
|
|
||||||
MB_OK);
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
Result := False;
|
|
||||||
exit;
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
if IsWebView2RuntimeInstalled() then
|
if IsWebView2RuntimeInstalled() then
|
||||||
begin
|
begin
|
||||||
@@ -645,6 +684,11 @@ begin
|
|||||||
Result := False;
|
Result := False;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
|
begin
|
||||||
|
Result := EnsureDotNetDesktopRuntimeInstalled(NeedsRestart);
|
||||||
|
end;
|
||||||
|
|
||||||
procedure InitializeWizard;
|
procedure InitializeWizard;
|
||||||
var
|
var
|
||||||
DetailsText: String;
|
DetailsText: String;
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ function Assert-WindowsPayloadClean {
|
|||||||
|
|
||||||
$violations = [System.Collections.Generic.List[string]]::new()
|
$violations = [System.Collections.Generic.List[string]]::new()
|
||||||
$forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a")
|
$forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a")
|
||||||
|
$forbiddenBundledRuntimeFiles = @(
|
||||||
|
"coreclr.dll",
|
||||||
|
"hostfxr.dll",
|
||||||
|
"hostpolicy.dll",
|
||||||
|
"System.Private.CoreLib.dll"
|
||||||
|
)
|
||||||
|
|
||||||
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
|
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } |
|
Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } |
|
||||||
@@ -171,6 +177,12 @@ function Assert-WindowsPayloadClean {
|
|||||||
$violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName))
|
$violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $forbiddenBundledRuntimeFiles -contains $_.Name } |
|
||||||
|
ForEach-Object {
|
||||||
|
$violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName))
|
||||||
|
}
|
||||||
|
|
||||||
Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue |
|
Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue |
|
||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue |
|
Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue |
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ function Publish-AirAppHostPayload {
|
|||||||
"-c", $Configuration,
|
"-c", $Configuration,
|
||||||
"-r", $Rid,
|
"-r", $Rid,
|
||||||
"--self-contained", "false",
|
"--self-contained", "false",
|
||||||
|
"-p:SelfContained=false",
|
||||||
"-p:PublishSingleFile=false",
|
"-p:PublishSingleFile=false",
|
||||||
"-p:PublishTrimmed=false",
|
"-p:PublishTrimmed=false",
|
||||||
"-p:PublishReadyToRun=false",
|
"-p:PublishReadyToRun=false",
|
||||||
@@ -253,6 +254,70 @@ function Publish-AirAppHostPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Publish-LauncherPayload {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Rid,
|
||||||
|
[Parameter(Mandatory = $true)][string]$VersionValue
|
||||||
|
)
|
||||||
|
|
||||||
|
$launcherProject = Join-Path $repoRoot "..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
|
||||||
|
$launcherProject = Resolve-ExistingPath -PathValue $launcherProject
|
||||||
|
Write-Host "Publishing Launcher AOT payload..."
|
||||||
|
$launcherPublishArgs = @(
|
||||||
|
"publish",
|
||||||
|
$launcherProject,
|
||||||
|
"-c", $Configuration,
|
||||||
|
"-r", $Rid,
|
||||||
|
"--self-contained",
|
||||||
|
"-p:PublishAot=true",
|
||||||
|
"-p:PublishSingleFile=true",
|
||||||
|
"-p:IncludeNativeLibrariesForSelfExtract=true",
|
||||||
|
"-p:EnableCompressionInSingleFile=true",
|
||||||
|
"-p:DebugType=None",
|
||||||
|
"-p:DebugSymbols=false",
|
||||||
|
"-p:Version=$VersionValue",
|
||||||
|
"-o", $PublishedDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
& dotnet @launcherPublishArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Launcher publish failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Publish-MainAppFrameworkDependentPayload {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$ProjectFile,
|
||||||
|
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Rid,
|
||||||
|
[Parameter(Mandatory = $true)][string]$VersionValue
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Publishing framework-dependent main app payload..."
|
||||||
|
$publishArgs = @(
|
||||||
|
"publish",
|
||||||
|
$ProjectFile,
|
||||||
|
"-c", $Configuration,
|
||||||
|
"-r", $Rid,
|
||||||
|
"--self-contained", "false",
|
||||||
|
"-p:SelfContained=false",
|
||||||
|
"-p:PublishSingleFile=false",
|
||||||
|
"-p:PublishTrimmed=false",
|
||||||
|
"-p:PublishReadyToRun=false",
|
||||||
|
"-p:DebugType=None",
|
||||||
|
"-p:DebugSymbols=false",
|
||||||
|
"-p:SkipAirAppHostBuild=true",
|
||||||
|
"-p:Version=$VersionValue",
|
||||||
|
"-o", $PublishedDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
& dotnet @publishArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
|
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
|
||||||
|
|
||||||
@@ -274,8 +339,20 @@ if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
|
|||||||
}
|
}
|
||||||
Clear-DirectoryContents -TargetDirectory $PublishDir
|
Clear-DirectoryContents -TargetDirectory $PublishDir
|
||||||
|
|
||||||
Write-Host "Publishing project..."
|
if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
|
||||||
$publishArgs = @(
|
$appPublishDir = Join-Path $PublishDir "app-$Version"
|
||||||
|
[System.IO.Directory]::CreateDirectory($appPublishDir) | Out-Null
|
||||||
|
|
||||||
|
Publish-LauncherPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||||
|
Publish-MainAppFrameworkDependentPayload -ProjectFile $projectPath -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||||
|
Publish-AirAppHostPayload -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPublishDir ".current") -Force | Out-Null
|
||||||
|
|
||||||
|
Remove-LibVlcForOtherArch -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier
|
||||||
|
Remove-LegacyOutputArtifacts -TargetDirectory $appPublishDir
|
||||||
|
} else {
|
||||||
|
Write-Host "Publishing project..."
|
||||||
|
$publishArgs = @(
|
||||||
"publish",
|
"publish",
|
||||||
$projectPath,
|
$projectPath,
|
||||||
"-c", $Configuration,
|
"-c", $Configuration,
|
||||||
@@ -288,19 +365,20 @@ $publishArgs = @(
|
|||||||
"-p:SkipAirAppHostBuild=true",
|
"-p:SkipAirAppHostBuild=true",
|
||||||
"-p:Version=$Version",
|
"-p:Version=$Version",
|
||||||
"-o", $PublishDir
|
"-o", $PublishDir
|
||||||
)
|
)
|
||||||
|
|
||||||
& dotnet @publishArgs
|
& dotnet @publishArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||||
}
|
}
|
||||||
|
|
||||||
Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||||
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
||||||
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
|
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
|
||||||
|
|
||||||
if ($RuntimeIdentifier -like "linux-*") {
|
if ($RuntimeIdentifier -like "linux-*") {
|
||||||
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
|
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
||||||
|
|||||||
253
SECURITY_AUDIT_REPORT_2026-05-24.md
Normal file
253
SECURITY_AUDIT_REPORT_2026-05-24.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# LanMountainDesktop 安全审计报告
|
||||||
|
|
||||||
|
**项目**: LanMountainDesktop
|
||||||
|
**审计日期**: 2026-05-24
|
||||||
|
**审计范围**: 代码库安全性系统性评估
|
||||||
|
**审计方法**: 静态代码分析 + 架构审查 + 攻击面映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
本次审计对 LanMountainDesktop 代码库进行了全面的安全评估,系统性地检查了认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。
|
||||||
|
|
||||||
|
**审计结论**: 发现 **5 个已确认的中等及以上严重度漏洞**,均具有可论证的利用路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已确认漏洞
|
||||||
|
|
||||||
|
### 漏洞 #1 - PostHog API Key 硬编码(高严重度)
|
||||||
|
|
||||||
|
| 属性 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **严重度** | 高 |
|
||||||
|
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
||||||
|
| **位置** | [PostHogUsageTelemetryService.cs:14](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L14) |
|
||||||
|
| **攻击者画像** | 源代码仓库的任何访问者(通过代码泄露、供应链攻击或Git历史) |
|
||||||
|
| **可控输入** | 无(静态硬编码密钥) |
|
||||||
|
|
||||||
|
**代码路径**:
|
||||||
|
```csharp
|
||||||
|
// PostHogUsageTelemetryService.cs:14
|
||||||
|
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- 攻击者可滥用此 API Key 向 PostHog 项目发送伪造遥测数据
|
||||||
|
- 可能导致遥测数据污染,干扰产品分析决策
|
||||||
|
- API Key 暴露在公开仓库中,任何人都能获取并滥用
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
```csharp
|
||||||
|
private static string GetPostHogApiKey()
|
||||||
|
{
|
||||||
|
var key = Environment.GetEnvironmentVariable("POSTHOG_API_KEY");
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new InvalidOperationException("PostHog API key not configured.");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 漏洞 #2 - Sentry DSN 硬编码(高严重度)
|
||||||
|
|
||||||
|
| 属性 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **严重度** | 高 |
|
||||||
|
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
||||||
|
| **位置** | [SentryCrashTelemetryService.cs:15](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L15) |
|
||||||
|
| **攻击者画像** | 源代码仓库的任何访问者 |
|
||||||
|
| **可控输入** | 无(静态硬编码密钥) |
|
||||||
|
|
||||||
|
**代码路径**:
|
||||||
|
```csharp
|
||||||
|
// SentryCrashTelemetryService.cs:15
|
||||||
|
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- Sentry DSN 等同于项目的访问凭证
|
||||||
|
- 攻击者可利用此 DSN 向项目发送伪造崩溃报告
|
||||||
|
- 可能导致崩溃数据污染,干扰错误追踪
|
||||||
|
- 如 DSN 配置不当,可导致敏感崩溃信息被发送至攻击者控制的端点
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
```csharp
|
||||||
|
private static string GetSentryDsn()
|
||||||
|
{
|
||||||
|
var dsn = Environment.GetEnvironmentVariable("SENTRY_DSN");
|
||||||
|
if (string.IsNullOrEmpty(dsn))
|
||||||
|
throw new InvalidOperationException("Sentry DSN not configured.");
|
||||||
|
return dsn;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度)
|
||||||
|
|
||||||
|
| 属性 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **严重度** | 高 |
|
||||||
|
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
||||||
|
| **位置** | [XiaomiWeatherService.cs:25](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L25) |
|
||||||
|
| **攻击者画像** | 源代码仓库的任何访问者 |
|
||||||
|
| **可控输入** | 无(静态硬编码密钥) |
|
||||||
|
|
||||||
|
**代码路径**:
|
||||||
|
```csharp
|
||||||
|
// XiaomiWeatherService.cs:25
|
||||||
|
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- API 签名凭证暴露在公开仓库
|
||||||
|
- 攻击者可能利用此凭证访问天气服务 API
|
||||||
|
- 可能导致 API 配额滥用或服务成本增加
|
||||||
|
- 如密钥具有更高权限,可能导致数据泄露
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
```csharp
|
||||||
|
public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 漏洞 #4 - Sentry PII 收集配置(中等严重度)
|
||||||
|
|
||||||
|
| 属性 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **严重度** | 中等 |
|
||||||
|
| **CWE** | CWE-359 - 个人身份信息(PII)意外暴露 |
|
||||||
|
| **位置** | [SentryCrashTelemetryService.cs:212](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L212) |
|
||||||
|
| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 |
|
||||||
|
| **可控输入** | 用户环境的机器名、用户名、IP地址等系统信息 |
|
||||||
|
|
||||||
|
**代码路径**:
|
||||||
|
```csharp
|
||||||
|
// SentryCrashTelemetryService.cs:212
|
||||||
|
options.SendDefaultPii = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址
|
||||||
|
- 可能违反隐私法规(如 GDPR、中国个人信息保护法)要求
|
||||||
|
- 在崩溃报告中可能暴露用户敏感信息
|
||||||
|
- 用户未明确同意即被收集 PII
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
```csharp
|
||||||
|
// 根据用户同意状态动态设置
|
||||||
|
options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 漏洞 #5 - SSL 证书验证被禁用(中等严重度)
|
||||||
|
|
||||||
|
| 属性 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **严重度** | 中等 |
|
||||||
|
| **CWE** | CWE-295 - 证书验证不正确 |
|
||||||
|
| **位置** | [RecommendationDataService.cs:105](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/RecommendationDataService.cs#L105) |
|
||||||
|
| **攻击者画像** | 网络中间人攻击者(在同一网络环境的攻击者) |
|
||||||
|
| **可控输入** | 用户网络流量 |
|
||||||
|
| **利用路径** | 用户发起API请求 → 攻击者拦截流量 → 伪造响应 |
|
||||||
|
|
||||||
|
**代码路径**:
|
||||||
|
```csharp
|
||||||
|
// RecommendationDataService.cs:100-106
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
||||||
|
System.Security.Authentication.SslProtocols.Tls13,
|
||||||
|
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- 禁用了服务器证书验证,使应用程序容易受到中间人(MITM)攻击
|
||||||
|
- 攻击者可以拦截和篡改 API 响应数据
|
||||||
|
- 可能导致注入恶意内容或数据操纵
|
||||||
|
- 即使使用 TLS 1.2/1.3,证书验证被禁用仍然不安全
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
```csharp
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
||||||
|
System.Security.Authentication.SslProtocols.Tls13,
|
||||||
|
// 删除 ServerCertificateCustomValidationCallback 或实现正确的验证
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未发现漏洞的区域
|
||||||
|
|
||||||
|
经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞:
|
||||||
|
|
||||||
|
### 认证与访问控制
|
||||||
|
- 单实例服务实现正确(使用互斥体)
|
||||||
|
- IPC 通信使用命名管道,无明显认证绕过风险
|
||||||
|
- 插件隔离使用独立进程边界
|
||||||
|
- 插件加载使用 AppDomain/AssemblyLoadContext 隔离
|
||||||
|
|
||||||
|
### 注入向量
|
||||||
|
- SQLite 使用参数化查询,无 SQL 注入风险 ([ComponentDomainStorage.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs))
|
||||||
|
- JSON 反序列化使用强类型上下文 (`JsonSerializerContext`),无反序列化漏洞
|
||||||
|
- 文件路径操作使用 `Path.Combine` 和 `Path.GetInvalidFileNameChars()` 过滤
|
||||||
|
- 未发现命令执行注入(Process.Start 使用固定参数)
|
||||||
|
|
||||||
|
### 外部交互
|
||||||
|
- HTTP 请求使用 `HttpClient` 和超时配置
|
||||||
|
- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码
|
||||||
|
- 下载服务验证目标路径,无路径遍历风险
|
||||||
|
- URL 参数正确使用编码函数
|
||||||
|
|
||||||
|
### 敏感数据处理
|
||||||
|
- 数据库本地存储,使用 WAL 模式
|
||||||
|
- 设置数据通过 JSON 序列化存储在用户目录
|
||||||
|
- 日志文件路径正确隔离在应用数据目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构安全评估
|
||||||
|
|
||||||
|
| 组件 | 安全评级 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 插件系统 | 良好 | 使用独立进程隔离 |
|
||||||
|
| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 |
|
||||||
|
| 更新系统 | 良好 | 支持签名验证 |
|
||||||
|
| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 |
|
||||||
|
| 数据存储 | 良好 | 使用标准加密实践 |
|
||||||
|
| 网络通信 | **需改进** | 存在证书验证绕过问题 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级
|
||||||
|
|
||||||
|
| 优先级 | 漏洞 | 严重度 | 预计工作量 |
|
||||||
|
|--------|------|--------|------------|
|
||||||
|
| P0 - 紧急 | #1 PostHog API Key | 高 | 低 |
|
||||||
|
| P0 - 紧急 | #2 Sentry DSN | 高 | 低 |
|
||||||
|
| P0 - 紧急 | #3 Xiaomi Weather Sign | 高 | 低 |
|
||||||
|
| P1 - 高 | #4 SendDefaultPii | 中 | 低 |
|
||||||
|
| P1 - 高 | #5 SSL 证书验证禁用 | 中 | 中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建议的安全改进
|
||||||
|
|
||||||
|
1. **实施密钥管理**: 使用环境变量或密钥管理服务存储所有 API 凭证
|
||||||
|
2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning(如 GitGuardian、trufflehog)
|
||||||
|
3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求
|
||||||
|
4. **证书验证修复**: 移除禁用的证书验证,确保 HTTPS 通信安全
|
||||||
|
5. **代码审计**: 建议进行定期安全审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告生成工具: 自动安全审计系统*
|
||||||
|
*审计方法: 静态代码分析 + 架构审查 + 攻击面映射*
|
||||||
19
docs/RUNTIME_PACKAGING.md
Normal file
19
docs/RUNTIME_PACKAGING.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Runtime Packaging
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
- Windows installers do not bundle the .NET shared runtime.
|
||||||
|
- `LanMountainDesktop.Launcher.exe` is the package-root bootstrapper and remains Native AOT/self-contained.
|
||||||
|
- `LanMountainDesktop.exe` and `LanMountainDesktop.AirAppHost.exe` are framework-dependent, RID-specific apps under `app-<version>/`.
|
||||||
|
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime before continuing:
|
||||||
|
- x64 installer: `https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe`
|
||||||
|
- x86 installer: `https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe`
|
||||||
|
- Launcher runtime probing validates the architecture-matched `Microsoft.NETCore.App 10.*` shared framework before starting framework-dependent processes.
|
||||||
|
|
||||||
|
If the launcher returns `dotnet_runtime_missing`, verify the runtime architecture:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet --list-runtimes
|
||||||
|
Test-Path "C:\Program Files\dotnet\shared\Microsoft.NETCore.App"
|
||||||
|
Test-Path "C:\Program Files (x86)\dotnet\shared\Microsoft.NETCore.App"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user