diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd72a74..bf6fab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,10 +98,8 @@ jobs: matrix: include: - arch: x64 - self_contained: true suffix: '' - arch: x86 - self_contained: true suffix: '' name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }} @@ -167,91 +165,55 @@ jobs: - name: Publish Main App run: | - $selfContained = "${{ matrix.self_contained }}" -eq "true" - $publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" } + $publishDir = "publish/windows-${{ matrix.arch }}" - 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 ` - -c Release ` - -o ./$publishDir ` - --self-contained:false ` - -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 }} - } + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj ` + -c Release ` + -o ./$publishDir ` + --self-contained:false ` + -r win-${{ matrix.arch }} ` + -p:SelfContained=false ` + -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 }} shell: pwsh - name: Publish AirAppHost run: | $arch = "${{ matrix.arch }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" - $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $publishDir = "publish/windows-$arch" - if ($selfContained) { - dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj ` - -c Release ` - -o ./$publishDir ` - --self-contained:false ` - -r win-$arch ` - -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 }} - } 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 }} - } + dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj ` + -c Release ` + -o ./$publishDir ` + --self-contained:false ` + -r win-$arch ` + -p:SelfContained=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 - name: Restructure for Launcher run: | $version = "${{ needs.prepare.outputs.version }}" $arch = "${{ matrix.arch }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" - $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $publishDir = "publish/windows-$arch" $launcherPublishDir = "publish/launcher-win-$arch" $appDir = "app-$version" $newStructure = "publish-launcher/windows-$arch" @@ -274,8 +236,7 @@ jobs: - name: Optimize and Guard Windows Payload run: | $arch = "${{ matrix.arch }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" - $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $publishDir = "publish/windows-$arch" ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 ` -PublishDir $publishDir ` @@ -294,8 +255,7 @@ jobs: $version = "${{ needs.prepare.outputs.version }}" $arch = "${{ matrix.arch }}" $suffix = "${{ matrix.suffix }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" - $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $publishDir = "publish/windows-$arch" $outputDir = "build-installer" $installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss" @@ -329,7 +289,6 @@ jobs: "/DMyOutputDir=$outputDir", "/DMyAppArch=$arch", "/DMyAppSuffix=$suffix", - "/DIsSelfContained=$selfContained", $installerScript ) diff --git a/.trae/specs/runtime-packaging-fix/checklist.md b/.trae/specs/runtime-packaging-fix/checklist.md new file mode 100644 index 0000000..8e85689 --- /dev/null +++ b/.trae/specs/runtime-packaging-fix/checklist.md @@ -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. diff --git a/.trae/specs/runtime-packaging-fix/spec.md b/.trae/specs/runtime-packaging-fix/spec.md new file mode 100644 index 0000000..e014b1c --- /dev/null +++ b/.trae/specs/runtime-packaging-fix/spec.md @@ -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. diff --git a/.trae/specs/runtime-packaging-fix/tasks.md b/.trae/specs/runtime-packaging-fix/tasks.md new file mode 100644 index 0000000..6523b05 --- /dev/null +++ b/.trae/specs/runtime-packaging-fix/tasks.md @@ -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. diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs index 6e00ea2..a4c5ddc 100644 --- a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs +++ b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services.AirApp; @@ -13,17 +14,20 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter private readonly Func _packageRootProvider; private readonly Func _hostPathProvider; private readonly Func _dataRootProvider; + private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions; public AirAppProcessStarter( AirAppHostLocator locator, Func packageRootProvider, Func hostPathProvider, - Func dataRootProvider) + Func dataRootProvider, + DotNetRuntimeProbeOptions? runtimeProbeOptions = null) { _locator = locator; _packageRootProvider = packageRootProvider; _hostPathProvider = hostPathProvider; _dataRootProvider = dataRootProvider; + _runtimeProbeOptions = runtimeProbeOptions; } public Process? Start( @@ -34,22 +38,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter string? sourcePlacementId) { var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider()); - var startInfo = new ProcessStartInfo - { - 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); - } + var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions); AddArgument(startInfo, "--app-id", appId); AddArgument(startInfo, "--session-id", sessionId); @@ -94,6 +83,53 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter 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) { startInfo.ArgumentList.Add(name); diff --git a/LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs b/LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs new file mode 100644 index 0000000..87fbf55 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs @@ -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? 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 SearchedPaths, + IReadOnlyList DetectedRuntimes, + string Message) +{ + public Dictionary ToDetails(string prefix = "dotnetRuntime") + { + return new Dictionary(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(); + var detected = new List(); + 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 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 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 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 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; + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 265db07..1bbe086 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -930,6 +930,44 @@ internal sealed class LauncherFlowCoordinator 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 LaunchHostWithResolvedPathAsync( HostResolutionResult resolution, bool forceDirectMode, @@ -937,6 +975,12 @@ internal sealed class LauncherFlowCoordinator { var dataRoot = _dataLocationResolver.ResolveDataRoot(); 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; if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { diff --git a/LanMountainDesktop.Tests/AirAppProcessStarterRuntimeTests.cs b/LanMountainDesktop.Tests/AirAppProcessStarterRuntimeTests.cs new file mode 100644 index 0000000..0d36850 --- /dev/null +++ b/LanMountainDesktop.Tests/AirAppProcessStarterRuntimeTests.cs @@ -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); + } + } +} diff --git a/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs new file mode 100644 index 0000000..6472c12 --- /dev/null +++ b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs @@ -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(), + 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); + } + } +} diff --git a/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs b/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs new file mode 100644 index 0000000..79b9cec --- /dev/null +++ b/LanMountainDesktop.Tests/PackagingRuntimePolicyTests.cs @@ -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])); + } +} diff --git a/LanMountainDesktop/PACKAGING.md b/LanMountainDesktop/PACKAGING.md index 1185de3..8d5c5bd 100644 --- a/LanMountainDesktop/PACKAGING.md +++ b/LanMountainDesktop/PACKAGING.md @@ -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. +### 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-/`. +- 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 - use `scripts/package.ps1` with the target runtime identifier diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index 88d61a9..6580abd 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -24,10 +24,6 @@ #define MyAppSuffix "" #endif -#ifndef IsSelfContained - #define IsSelfContained "true" -#endif - [Setup] AppId={#MyAppId} AppName={#MyAppName} @@ -112,6 +108,14 @@ english.DotNetRuntimeMissingMessage=This application requires .NET 10.0 Desktop 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. 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. chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。 english.DotNetRuntimeOpenFailedAction=Please open this URL manually: @@ -157,7 +161,8 @@ const UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1'; WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; 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; UpgradeChoiceRelocate = 1; @@ -547,78 +552,112 @@ begin end; end; -// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime -// 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; +function GetTargetDotNetDesktopRuntimePath(): String; begin - Result := False; - - // Check 64-bit Program Files - BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App'); - if IsDotNet10RuntimePresent(BasePath) then + if '{#MyAppArch}' = 'x64' then + 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; +end; + +function EnsureDotNetDesktopRuntimeInstalled(var NeedsRestart: Boolean): String; +var + DownloadPage: TDownloadWizardPage; + InstallerPath: String; + ExitCode: Integer; +begin + Result := ''; + + if IsDotNetDesktopRuntimeInstalled() then begin - Result := True; exit; end; - BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App'); - if IsDotNet10RuntimePresent(BasePath) then + DownloadPage := CreateDownloadPage( + 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 - Result := True; + Result := CustomMessage('DotNetRuntimeInstallFailed'); exit; end; - // Check 32-bit Program Files - BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App'); - if IsDotNet10RuntimePresent(BasePath) then + if (ExitCode <> 0) and (ExitCode <> 3010) then begin - Result := True; + Result := CustomMessage('DotNetRuntimeInstallFailed') + ' Exit code: ' + IntToStr(ExitCode); exit; end; - BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App'); - if IsDotNet10RuntimePresent(BasePath) then + if ExitCode = 3010 then begin - Result := True; - exit; + NeedsRestart := True; + end; + + if not IsDotNetDesktopRuntimeInstalled() then + begin + Result := CustomMessage('DotNetRuntimeStillMissing') + #13#10 + GetTargetDotNetDesktopRuntimePath(); end; end; function InitializeSetup(): Boolean; var ErrorCode: Integer; - IsSelfContainedBuild: Boolean; 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 begin @@ -645,6 +684,11 @@ begin Result := False; end; +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := EnsureDotNetDesktopRuntimeInstalled(NeedsRestart); +end; + procedure InitializeWizard; var DetailsText: String; diff --git a/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 index b6d0fa2..50f9802 100644 --- a/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 +++ b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 @@ -164,6 +164,12 @@ function Assert-WindowsPayloadClean { $violations = [System.Collections.Generic.List[string]]::new() $forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a") + $forbiddenBundledRuntimeFiles = @( + "coreclr.dll", + "hostfxr.dll", + "hostpolicy.dll", + "System.Private.CoreLib.dll" + ) Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } | @@ -171,6 +177,12 @@ function Assert-WindowsPayloadClean { $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 | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue | diff --git a/LanMountainDesktop/scripts/package.ps1 b/LanMountainDesktop/scripts/package.ps1 index e56c6ad..b40826e 100644 --- a/LanMountainDesktop/scripts/package.ps1 +++ b/LanMountainDesktop/scripts/package.ps1 @@ -236,6 +236,7 @@ function Publish-AirAppHostPayload { "-c", $Configuration, "-r", $Rid, "--self-contained", "false", + "-p:SelfContained=false", "-p:PublishSingleFile=false", "-p:PublishTrimmed=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 $repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..") @@ -274,33 +339,46 @@ if (-not [System.IO.Path]::IsPathRooted($PublishDir)) { } Clear-DirectoryContents -TargetDirectory $PublishDir -Write-Host "Publishing project..." -$publishArgs = @( - "publish", - $projectPath, - "-c", $Configuration, - "-r", $RuntimeIdentifier, - "--self-contained", "true", - "-p:PublishSingleFile=false", - "-p:PublishTrimmed=false", - "-p:DebugType=None", - "-p:DebugSymbols=false", - "-p:SkipAirAppHostBuild=true", - "-p:Version=$Version", - "-o", $PublishDir -) +if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) { + $appPublishDir = Join-Path $PublishDir "app-$Version" + [System.IO.Directory]::CreateDirectory($appPublishDir) | Out-Null -& dotnet @publishArgs -if ($LASTEXITCODE -ne 0) { - throw "dotnet publish failed with exit code $LASTEXITCODE." -} + 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 -Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version -Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir + Remove-LibVlcForOtherArch -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier + Remove-LegacyOutputArtifacts -TargetDirectory $appPublishDir +} else { + Write-Host "Publishing project..." + $publishArgs = @( + "publish", + $projectPath, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "--self-contained", "true", + "-p:PublishSingleFile=false", + "-p:PublishTrimmed=false", + "-p:DebugType=None", + "-p:DebugSymbols=false", + "-p:SkipAirAppHostBuild=true", + "-p:Version=$Version", + "-o", $PublishDir + ) -if ($RuntimeIdentifier -like "linux-*") { - Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot + & dotnet @publishArgs + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE." + } + + Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version + Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier + Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir + + if ($RuntimeIdentifier -like "linux-*") { + Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot + } } Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier diff --git a/SECURITY_AUDIT_REPORT_2026-05-24.md b/SECURITY_AUDIT_REPORT_2026-05-24.md new file mode 100644 index 0000000..97bcd35 --- /dev/null +++ b/SECURITY_AUDIT_REPORT_2026-05-24.md @@ -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. **代码审计**: 建议进行定期安全审计 + +--- + +*报告生成工具: 自动安全审计系统* +*审计方法: 静态代码分析 + 架构审查 + 攻击面映射* diff --git a/docs/RUNTIME_PACKAGING.md b/docs/RUNTIME_PACKAGING.md new file mode 100644 index 0000000..eff7b7a --- /dev/null +++ b/docs/RUNTIME_PACKAGING.md @@ -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-/`. +- 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" +```