diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd72a74..16876b5 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,41 +165,48 @@ 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 }}" + $publishDir = "publish/windows-$arch" + + 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: Publish AirAppHost @@ -250,8 +255,7 @@ jobs: 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 +278,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 +297,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 +331,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.Appearance/AppearanceCornerRadiusTokenFactory.cs b/LanMountainDesktop.Appearance/AppearanceCornerRadiusTokenFactory.cs index 0203e17..be4f25c 100644 --- a/LanMountainDesktop.Appearance/AppearanceCornerRadiusTokenFactory.cs +++ b/LanMountainDesktop.Appearance/AppearanceCornerRadiusTokenFactory.cs @@ -38,6 +38,15 @@ public static class AppearanceCornerRadiusTokenFactory Xl: new CornerRadius(40), Island: new CornerRadius(44), Component: new CornerRadius(32)), + GlobalAppearanceSettings.CornerRadiusStyleFluent => new AppearanceCornerRadiusTokens( + Micro: new CornerRadius(2), + Xs: new CornerRadius(4), + Sm: new CornerRadius(4), + Md: new CornerRadius(8), + Lg: new CornerRadius(8), + Xl: new CornerRadius(12), + Island: new CornerRadius(16), + Component: new CornerRadius(8)), // Balanced (default) _ => new AppearanceCornerRadiusTokens( Micro: new CornerRadius(6), diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs index 6e00ea2..3f85ef1 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,54 @@ 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..04c9a41 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DotNetRuntimeProbe.cs @@ -0,0 +1,401 @@ +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 string? LocalAppDataPath { 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 const string WindowsDesktopSharedFrameworkName = "Microsoft.WindowsDesktop.App"; + + private static readonly string[] RequiredSharedFrameworkNames = + [ + RequiredSharedFrameworkName, + WindowsDesktopSharedFrameworkName + ]; + + public static DotNetRuntimeProbeResult Probe(DotNetRuntimeProbeOptions? options = null) + { + options ??= new DotNetRuntimeProbeOptions(); + + var searchedPaths = new List(); + var detected = new List(); + var requiredMajor = options.RequiredMajorVersion; + + var localAppDataRoot = GetLocalAppDataPath(options); + var perUserDotnetRoot = !string.IsNullOrWhiteSpace(localAppDataRoot) + ? Path.Combine(localAppDataRoot, "dotnet") + : null; + + foreach (var frameworkName in RequiredSharedFrameworkNames) + { + foreach (var basePath in EnumerateDotNetInstallRoots(options)) + { + var sharedFrameworkDirectory = Path.Combine(basePath, "shared", frameworkName); + searchedPaths.Add(sharedFrameworkDirectory); + var isPerUser = perUserDotnetRoot is not null && + string.Equals(basePath, perUserDotnetRoot, StringComparison.OrdinalIgnoreCase); + AddDirectoryRuntimes(sharedFrameworkDirectory, frameworkName, + isPerUser ? "shared-framework-directory-per-user" : "shared-framework-directory", + detected); + } + } + + 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) + { + foreach (var frameworkName in RequiredSharedFrameworkNames) + { + AddRegistryRuntimes(options.Architecture, frameworkName, detected); + } + } + + if (options.IncludeDotNetCli) + { + AddDotNetCliRuntimes(dotNetHostPath, 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 IEnumerable EnumerateDotNetInstallRoots(DotNetRuntimeProbeOptions options) + { + var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86 + ? GetProgramFilesX86Path(options) + : GetProgramFilesPath(options); + + yield return Path.Combine(programFilesRoot, "dotnet"); + + var localAppData = GetLocalAppDataPath(options); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + var perUserDotnet = Path.Combine(localAppData, "dotnet"); + if (!string.Equals(perUserDotnet, Path.Combine(programFilesRoot, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + yield return perUserDotnet; + } + } + } + + 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 programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86 + ? GetProgramFilesX86Path(options) + : GetProgramFilesPath(options); + + yield return Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + + var localAppData = GetLocalAppDataPath(options); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + var perUserHost = Path.Combine(localAppData, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + if (!string.Equals(perUserHost, Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + yield return perUserHost; + } + } + } + + 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 string GetLocalAppDataPath(DotNetRuntimeProbeOptions options) + { + if (!string.IsNullOrWhiteSpace(options.LocalAppDataPath)) + { + return Path.GetFullPath(options.LocalAppDataPath); + } + + return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + + 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, + 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 && + RequiredSharedFrameworkNames.Contains(parsed.Value.Name, StringComparer.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.Settings.Core/GlobalAppearanceSettings.cs b/LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs index 65a089b..28999ea 100644 --- a/LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs +++ b/LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs @@ -6,6 +6,7 @@ public static class GlobalAppearanceSettings public const string CornerRadiusStyleBalanced = "Balanced"; public const string CornerRadiusStyleRounded = "Rounded"; public const string CornerRadiusStyleOpen = "Open"; + public const string CornerRadiusStyleFluent = "Fluent"; public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced; /// @@ -43,6 +44,11 @@ public static class GlobalAppearanceSettings return CornerRadiusStyleOpen; } + if (string.Equals(trimmed, CornerRadiusStyleFluent, StringComparison.OrdinalIgnoreCase)) + { + return CornerRadiusStyleFluent; + } + return DefaultCornerRadiusStyle; } @@ -51,7 +57,8 @@ public static class GlobalAppearanceSettings CornerRadiusStyleSharp, CornerRadiusStyleBalanced, CornerRadiusStyleRounded, - CornerRadiusStyleOpen + CornerRadiusStyleOpen, + CornerRadiusStyleFluent ]; /// 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/CornerRadiusStyleTests.cs b/LanMountainDesktop.Tests/CornerRadiusStyleTests.cs index dafa4d0..ce750d2 100644 --- a/LanMountainDesktop.Tests/CornerRadiusStyleTests.cs +++ b/LanMountainDesktop.Tests/CornerRadiusStyleTests.cs @@ -13,6 +13,7 @@ public sealed class CornerRadiusStyleTests [InlineData("Balanced", "Balanced")] [InlineData("Rounded", "Rounded")] [InlineData("Open", "Open")] + [InlineData("Fluent", "Fluent")] [InlineData("Unknown", "Balanced")] [InlineData(null, "Balanced")] public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected) @@ -20,6 +21,23 @@ public sealed class CornerRadiusStyleTests Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input)); } + [Fact] + public void FluentStyle_ReturnsFluentDesignSystemValues() + { + var tokens = LanMountainDesktop.Appearance.AppearanceCornerRadiusTokenFactory.Create( + GlobalAppearanceSettings.CornerRadiusStyleFluent); + + // Microsoft Fluent Design System: ControlCornerRadius = 4px, OverlayCornerRadius = 8px + Assert.Equal(new CornerRadius(2), tokens.Micro); + Assert.Equal(new CornerRadius(4), tokens.Xs); + Assert.Equal(new CornerRadius(4), tokens.Sm); + Assert.Equal(new CornerRadius(8), tokens.Md); + Assert.Equal(new CornerRadius(8), tokens.Lg); + Assert.Equal(new CornerRadius(12), tokens.Xl); + Assert.Equal(new CornerRadius(16), tokens.Island); + Assert.Equal(new CornerRadius(8), tokens.Component); + } + [Fact] public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues() { diff --git a/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs new file mode 100644 index 0000000..6f2f64d --- /dev/null +++ b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs @@ -0,0 +1,238 @@ +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; + private readonly string _localAppData; + + public DotNetRuntimeProbeTests() + { + _root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DotNetRuntimeProbeTests", Guid.NewGuid().ToString("N")); + _programFiles = Path.Combine(_root, "ProgramFiles"); + _programFilesX86 = Path.Combine(_root, "ProgramFilesX86"); + _localAppData = Path.Combine(_root, "LocalAppData"); + Directory.CreateDirectory(_programFiles); + Directory.CreateDirectory(_programFilesX86); + Directory.CreateDirectory(_localAppData); + } + + [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 Probe_DetectsPerUserRuntime() + { + CreateRuntime(_localAppData, "10.0.5", DotNetRuntimeProbe.RequiredSharedFrameworkName); + + var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64)); + + Assert.True(result.IsAvailable); + Assert.Contains(result.DetectedRuntimes, runtime => + runtime.Version == "10.0.5" && + runtime.Source == "shared-framework-directory-per-user"); + } + + [Fact] + public void Probe_DetectsWindowsDesktopRuntime() + { + CreateRuntime(_programFiles, "10.0.5", DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName); + + var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64)); + + Assert.False(result.IsAvailable); + Assert.Contains(result.DetectedRuntimes, runtime => + runtime.Name == DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName && + runtime.Version == "10.0.5"); + } + + [Fact] + public void Probe_DetectsPerUserWindowsDesktopRuntime() + { + CreateRuntime(_localAppData, "10.0.5", DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName); + + var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64)); + + Assert.Contains(result.DetectedRuntimes, runtime => + runtime.Name == DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName && + runtime.Version == "10.0.5" && + runtime.Source == "shared-framework-directory-per-user"); + } + + [Fact] + public void Probe_FindsDotNetHost_InPerUserPath() + { + var dotnetDir = Path.Combine(_localAppData, "dotnet"); + Directory.CreateDirectory(dotnetDir); + File.WriteAllText(Path.Combine(dotnetDir, "dotnet.exe"), string.Empty); + + var result = DotNetRuntimeProbe.Probe(new DotNetRuntimeProbeOptions + { + Architecture = DotNetRuntimeArchitecture.X64, + ProgramFilesPath = _programFiles, + ProgramFilesX86Path = _programFilesX86, + LocalAppDataPath = _localAppData, + IncludeRegistry = false, + IncludeDotNetCli = false + }); + + Assert.NotNull(result.DotNetHostPath); + Assert.Contains("LocalAppData", result.DotNetHostPath); + } + + [Fact] + public void Probe_PrefersProgramFilesHost_OverPerUserHost() + { + var systemDotnetDir = Path.Combine(_programFiles, "dotnet"); + Directory.CreateDirectory(systemDotnetDir); + File.WriteAllText(Path.Combine(systemDotnetDir, "dotnet.exe"), string.Empty); + + var perUserDotnetDir = Path.Combine(_localAppData, "dotnet"); + Directory.CreateDirectory(perUserDotnetDir); + File.WriteAllText(Path.Combine(perUserDotnetDir, "dotnet.exe"), string.Empty); + + var result = DotNetRuntimeProbe.Probe(new DotNetRuntimeProbeOptions + { + Architecture = DotNetRuntimeArchitecture.X64, + ProgramFilesPath = _programFiles, + ProgramFilesX86Path = _programFilesX86, + LocalAppDataPath = _localAppData, + IncludeRegistry = false, + IncludeDotNetCli = false + }); + + Assert.NotNull(result.DotNetHostPath); + Assert.Contains("ProgramFiles", result.DotNetHostPath); + } + + [Fact] + public void Probe_CombinesSystemAndPerUserRuntimes() + { + CreateRuntime(_programFiles, "10.0.5"); + CreateRuntime(_localAppData, "10.0.3"); + + var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64)); + + Assert.True(result.IsAvailable); + Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.5"); + Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.3"); + } + + [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, + LocalAppDataPath = _localAppData, + DotNetHostCandidates = [], + IncludeRegistry = false, + IncludeDotNetCli = false + }; + } + + private static void CreateRuntime(string root, string version, string? frameworkName = null) + { + frameworkName ??= DotNetRuntimeProbe.RequiredSharedFrameworkName; + Directory.CreateDirectory(Path.Combine( + root, + "dotnet", + "shared", + frameworkName, + 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/Services/AppSettingsService.cs b/LanMountainDesktop/Services/AppSettingsService.cs index d1b48b9..43d1c3e 100644 --- a/LanMountainDesktop/Services/AppSettingsService.cs +++ b/LanMountainDesktop/Services/AppSettingsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Text.Json; using LanMountainDesktop.Models; @@ -82,7 +82,9 @@ public sealed class AppSettingsService } var json = JsonSerializer.Serialize(snapshotToPersist, SerializerOptions); - File.WriteAllText(_settingsPath, json); + var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, json); + File.Move(tempPath, _settingsPath, overwrite: true); var writeTimeUtc = File.Exists(_settingsPath) ? File.GetLastWriteTimeUtc(_settingsPath) diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs index 74822c4..fa5f89f 100644 --- a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs +++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs @@ -57,7 +57,9 @@ public sealed class ClockAirAppSettingsStore Directory.CreateDirectory(directory); } - File.WriteAllText(_settingsPath, JsonSerializer.Serialize(normalized, SerializerOptions)); + var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(normalized, SerializerOptions)); + File.Move(tempPath, _settingsPath, overwrite: true); } catch (Exception ex) { diff --git a/LanMountainDesktop/Services/FusedDesktopLayoutService.cs b/LanMountainDesktop/Services/FusedDesktopLayoutService.cs index 2f1cd8a..9b34248 100644 --- a/LanMountainDesktop/Services/FusedDesktopLayoutService.cs +++ b/LanMountainDesktop/Services/FusedDesktopLayoutService.cs @@ -100,8 +100,9 @@ internal sealed class FusedDesktopLayoutService : IFusedDesktopLayoutService Directory.CreateDirectory(directory); } - var json = JsonSerializer.Serialize(snapshot, JsonOptions); - File.WriteAllText(ConfigFilePath, json); + var tempPath = $"{ConfigFilePath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(snapshot, JsonOptions)); + File.Move(tempPath, ConfigFilePath, overwrite: true); } catch (Exception ex) { diff --git a/LanMountainDesktop/Services/LauncherSettingsService.cs b/LanMountainDesktop/Services/LauncherSettingsService.cs index 796576f..c7b827e 100644 --- a/LanMountainDesktop/Services/LauncherSettingsService.cs +++ b/LanMountainDesktop/Services/LauncherSettingsService.cs @@ -197,7 +197,9 @@ public sealed class LauncherSettingsService } var json = JsonSerializer.Serialize(snapshot, SerializerOptions); - File.WriteAllText(_settingsPath, json); + var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, json); + File.Move(tempPath, _settingsPath, overwrite: true); return File.Exists(_settingsPath) ? File.GetLastWriteTimeUtc(_settingsPath) diff --git a/LanMountainDesktop/Services/Settings/SettingsService.cs b/LanMountainDesktop/Services/Settings/SettingsService.cs index df41063..a2c0387 100644 --- a/LanMountainDesktop/Services/Settings/SettingsService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsService.cs @@ -358,7 +358,9 @@ internal sealed class SettingsService : ISettingsService Directory.CreateDirectory(directory); } - File.WriteAllText(_pluginSettingsPath, JsonSerializer.Serialize(document, SerializerOptions)); + var tempPath = $"{_pluginSettingsPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(document, SerializerOptions)); + File.Move(tempPath, _pluginSettingsPath, overwrite: true); } catch (Exception ex) { diff --git a/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs b/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs index aa0baa2..7a1aaab 100644 --- a/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs +++ b/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs @@ -440,7 +440,9 @@ public sealed class ZhiJiaoHubCacheService : IDisposable } Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!); - File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions)); + var tempPath = $"{_manifestPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(manifest, JsonOptions)); + File.Move(tempPath, _manifestPath, overwrite: true); } } @@ -469,7 +471,9 @@ public sealed class ZhiJiaoHubCacheService : IDisposable manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow); Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!); - File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions)); + var tempPath = $"{_manifestPath}.{Guid.NewGuid():N}.tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(manifest, JsonOptions)); + File.Move(tempPath, _manifestPath, overwrite: true); } } diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml index f6dd17b..d4327d2 100644 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml @@ -15,7 +15,7 @@ - + diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs index e7526fa..b341cfc 100644 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs @@ -89,7 +89,7 @@ public partial class ExtendedWeatherWidget : WeatherWidgetBase var inner = (StackPanel)panel.Child!; inner.Children.Add(new TextBlock { Text = FormatTime(item.Time), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }); inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) }); - inner.Children.Add(new TextBlock { Text = FormatTemperature(item.TemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 12 }); + inner.Children.Add(new TextBlock { Text = FormatTemperature(item.TemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 12, ClipToBounds = false }); HourlyGrid.Children.Add(panel); } } @@ -111,7 +111,7 @@ public partial class ExtendedWeatherWidget : WeatherWidgetBase var inner = (StackPanel)panel.Child!; inner.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }); inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText) }); - inner.Children.Add(new TextBlock { Text = $"{FormatTemperature(item.HighTemperatureC)} / {FormatTemperature(item.LowTemperatureC)}", Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11 }); + inner.Children.Add(new TextBlock { Text = $"{FormatTemperature(item.HighTemperatureC)} / {FormatTemperature(item.LowTemperatureC)}", Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, ClipToBounds = false }); DailyGrid.Children.Add(panel); } } diff --git a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml index 7c7f23f..cbce7ce 100644 --- a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml @@ -10,7 +10,7 @@ - + diff --git a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs index e20f11b..adcbe7f 100644 --- a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs @@ -73,7 +73,7 @@ public partial class HourlyWeatherWidget : WeatherWidgetBase var inner = (StackPanel)panel.Child!; inner.Children.Add(new TextBlock { Text = item.Label, FontSize = 10, Foreground = Brush(CurrentPalette.TextSecondary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, TextAlignment = Avalonia.Media.TextAlignment.Center }); inner.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) }); - inner.Children.Add(new TextBlock { Text = item.Value, FontWeight = Avalonia.Media.FontWeight.SemiBold, Foreground = Brush(CurrentPalette.TextPrimary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, TextAlignment = Avalonia.Media.TextAlignment.Center }); + inner.Children.Add(new TextBlock { Text = item.Value, FontWeight = Avalonia.Media.FontWeight.SemiBold, Foreground = Brush(CurrentPalette.TextPrimary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, TextAlignment = Avalonia.Media.TextAlignment.Center, ClipToBounds = false }); return panel; } diff --git a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml index 0339bc5..e7dd794 100644 --- a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml @@ -11,7 +11,7 @@ - + diff --git a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs index 0a430cf..679a186 100644 --- a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs @@ -66,9 +66,9 @@ public partial class MultiDayWeatherWidget : WeatherWidgetBase row.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText), VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center }); row.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); Grid.SetColumn(row.Children[^1], 1); - row.Children.Add(new TextBlock { Text = FormatTemperature(item.HighTemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); + row.Children.Add(new TextBlock { Text = FormatTemperature(item.HighTemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12, ClipToBounds = false }); Grid.SetColumn(row.Children[^1], 2); - row.Children.Add(new TextBlock { Text = FormatTemperature(item.LowTemperatureC), Foreground = Brush(CurrentPalette.TextSecondary), FontWeight = Avalonia.Media.FontWeight.Medium, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); + row.Children.Add(new TextBlock { Text = FormatTemperature(item.LowTemperatureC), Foreground = Brush(CurrentPalette.TextSecondary), FontWeight = Avalonia.Media.FontWeight.Medium, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12, ClipToBounds = false }); Grid.SetColumn(row.Children[^1], 3); rowPanel.Children.Add(row); diff --git a/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml b/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml index 54b2be3..0a9c96d 100644 --- a/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml +++ b/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml @@ -15,7 +15,7 @@ - + diff --git a/LanMountainDesktop/Views/Components/WeatherWidget.axaml b/LanMountainDesktop/Views/Components/WeatherWidget.axaml index 6255dcf..1835ae5 100644 --- a/LanMountainDesktop/Views/Components/WeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/WeatherWidget.axaml @@ -11,7 +11,7 @@ - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 916cc36..d8cb3cd 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -10,9 +10,11 @@ using Avalonia.Threading; using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Windowing; +using LanMountainDesktop.Appearance; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Settings.Core; using LanMountainDesktop.ViewModels; using Symbol = FluentIcons.Common.Symbol; @@ -69,6 +71,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext InitializeComponent(); SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon()); ApplyChromeMode(useSystemChrome); + ApplyFluentCornerRadius(); if (RootNavigationView is not null) { @@ -798,6 +801,30 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId); } + /// + /// Override global corner radius tokens on the settings window root grid + /// so all child controls use Microsoft Fluent Design System values, + /// independent of the user's global corner radius preference. + /// + private void ApplyFluentCornerRadius() + { + if (RootGrid is null) + { + return; + } + + var tokens = AppearanceCornerRadiusTokenFactory.Create( + GlobalAppearanceSettings.CornerRadiusStyleFluent); + RootGrid.Resources["DesignCornerRadiusMicro"] = tokens.Micro; + RootGrid.Resources["DesignCornerRadiusXs"] = tokens.Xs; + RootGrid.Resources["DesignCornerRadiusSm"] = tokens.Sm; + RootGrid.Resources["DesignCornerRadiusMd"] = tokens.Md; + RootGrid.Resources["DesignCornerRadiusLg"] = tokens.Lg; + RootGrid.Resources["DesignCornerRadiusXl"] = tokens.Xl; + RootGrid.Resources["DesignCornerRadiusIsland"] = tokens.Island; + RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component; + } + private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) { _ = sender; diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index 88d61a9..796b230 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,118 @@ 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 GetPerUserDotNetDesktopRuntimePath(): String; +begin + Result := ExpandConstant('{localappdata}\dotnet\shared\Microsoft.WindowsDesktop.App'); +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()) or + IsDotNet10RuntimePresent(GetPerUserDotNetDesktopRuntimePath()); +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 +690,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..a71b977 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,13 @@ 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..5b3bc77 100644 --- a/LanMountainDesktop/scripts/package.ps1 +++ b/LanMountainDesktop/scripts/package.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$Project = "LanMountainDesktop.csproj", [string]$Configuration = "Release", @@ -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,71 @@ 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 +340,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/CORNER_RADIUS_SPEC.md b/docs/CORNER_RADIUS_SPEC.md index f9f027f..70f26c1 100644 --- a/docs/CORNER_RADIUS_SPEC.md +++ b/docs/CORNER_RADIUS_SPEC.md @@ -4,11 +4,13 @@ 为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**,全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言。 +此外,阑山桌面引入了 **Fluent** 预设,遵循 Microsoft Fluent Design System 规范。设置窗口始终使用 Fluent 圆角,独立于用户选择的全局圆角风格。 + 所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。 ## 预设风格 (Preset Styles) -用户可以在设置中选择以下四种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。 +用户可以在设置中选择以下五种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。 | 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 | | :--- | :--- | :--- | :--- | @@ -16,21 +18,33 @@ | **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 | | **Rounded** | 圆润 | 28px | 保守、柔和、亲切 | | **Open** | 开放 | 32px | 现代、沉浸、夸张 | +| **Fluent** | Fluent | 8px | Microsoft Fluent Design System。标准、规范、一致 | ## Token 阶梯映射 (Token Step Mapping) 每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**。 -| Token | Sharp | Balanced | Rounded | Open | 典型场景 | -| :--- | :--- | :--- | :--- | :--- | :--- | -| **Micro** | 4px | 6px | 8px | 10px | 小图标容器、角标 (Badge) | -| **Xs** | 8px | 12px | 14px | 16px | 小标签 (Tag)、输入框 | -| **Sm** | 10px | 14px | 16px | 20px | 普通按钮、搜索栏、复选框 | -| **Md** | 14px | 20px | 24px | 28px | 悬浮菜单、小提示框、子卡片 | -| **Lg** | 20px | 28px | 32px | 36px | 普通面板、对话框内容区 | -| **Xl** | 24px | 32px | 36px | 40px | 大尺寸容器、设置中心页面 | -| **Island** | 28px | 36px | 40px | 44px | 任务栏、全局大悬浮容器 | -| **Component** | **20px** | **24px** | **28px** | **32px** | **所有桌面组件 (Widget) 的主边框** | +| Token | Sharp | Balanced | Rounded | Open | Fluent | 典型场景 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **Micro** | 4px | 6px | 8px | 10px | 2px | 小图标容器、角标 (Badge) | +| **Xs** | 8px | 12px | 14px | 16px | 4px | 小标签 (Tag)、输入框 | +| **Sm** | 10px | 14px | 16px | 20px | 4px | 普通按钮、搜索栏、复选框 | +| **Md** | 14px | 20px | 24px | 28px | 8px | 悬浮菜单、小提示框、子卡片 | +| **Lg** | 20px | 28px | 32px | 36px | 8px | 普通面板、对话框内容区 | +| **Xl** | 24px | 32px | 36px | 40px | 12px | 大尺寸容器、设置中心页面 | +| **Island** | 28px | 36px | 40px | 44px | 16px | 任务栏、全局大悬浮容器 | +| **Component** | **20px** | **24px** | **28px** | **32px** | **8px** | **所有桌面组件 (Widget) 的主边框** | + +## Fluent Design System 参考 (Fluent Reference) + +Fluent 预设的核心值来源于 Microsoft 官方规范: + +- **ControlCornerRadius = 4px**:用于标准持久 UI 元素(按钮、复选框、输入框等) +- **OverlayCornerRadius = 8px**:用于临时覆盖 UI 元素(对话框、浮出菜单等) + +> [!IMPORTANT] +> **设置窗口强制约束**: +> 设置窗口 (`SettingsWindow`) 始终使用 Fluent 圆角 Token,不受用户全局圆角设置影响。这确保设置 UI 作为标准 Windows 应用窗口与 Fluent Design 一致。 ## 开发准则 (Implementation Rules) 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" +``` diff --git a/docs/auto_commit_md/20260519_7a70476.md b/docs/auto_commit_md/20260519_7a70476.md new file mode 100644 index 0000000..c336fc0 --- /dev/null +++ b/docs/auto_commit_md/20260519_7a70476.md @@ -0,0 +1,269 @@ +# Git 提交分析报告 + +## 📋 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **完整哈希** | `7a70476ce8093ea6000f25fba7ba404d4f3e8f3c` | +| **短哈希** | `7a70476` | +| **作者** | lincube | +| **提交日期** | 2026-05-19 | +| **提交时间** | 07:55:21 | +| **时区** | +0800 | +| **提交类型** | 🟡 合并提交 (Merge Commit) | +| **关联 PR** | #11 | + +## 📝 提交信息摘要 + +``` +合并对设置系统的更新 (#11) +``` + +**详细提交说明**: + +本次合并包含了对设置系统的全面更新和改进,主要涉及以下子提交: + +1. **Add Windows system chrome patchers (Harmony)** - 使用 Harmony 修补器添加 Windows 系统 chrome 切换支持 +2. **Refactor settings window UI and theming** - 重构设置窗口 UI 和主题 +3. **Add localization and localize settings pages** - 添加本地化和多语言支持 +4. **Redesign settings window with fluent shell & search** - 使用 Fluent Shell 重新设计设置窗口并添加搜索功能 +5. **Add OOBE startup presentation and settings merge** - 添加 OOBE 启动演示和设置合并功能 +6. **Move whiteboard persistence to file storage** - 将白板持久化迁移到文件存储 +7. **Introduce render gate and chart caching** - 引入渲染门控和图表缓存机制 +8. **Use MaterialColorSnapshot in appearance flow** - 在外观流程中使用 MaterialColorSnapshot +9. **Add material color services, plugin DTOs, and tests** - 添加材质颜色服务、插件 DTO 和测试 +10. **Add CODE_WIKI and update localization** - 添加 CODE_WIKI 文档和更新本地化 +11. **Add Data settings page and storage scanner** - 添加数据设置页面和存储扫描器 +12. **Add IPC backoff/retries and safer disposal** - 添加 IPC 退避/重试和更安全的资源释放 +13. **Add preview controls and settings UI tweaks** - 添加预览控件和设置 UI 调整 +14. **Add install checkpoint/resume and DDSS workflows** - 添加安装检查点/恢复和 DDSS 工作流 + +## 📊 变更统计 + +| 统计项 | 数值 | +|--------|------| +| **变更文件总数** | 904 个文件 | +| **新增代码行数** | +78,048 行 | +| **删除代码行数** | -18,362 行 | +| **净增代码行数** | +59,686 行 | + +## 📂 详细变更分析(按文件类型分组) + +### 1. 核心代码文件 (.cs) + +#### LanMountainDesktop 核心项目 +| 文件路径 | 类型 | 变更说明 | +|----------|------|----------| +| `LanMountainDesktop/Program.cs` | 修改 | 添加 Windows chrome 修补器加载逻辑 | +| `LanMountainDesktop/ViewModels/SettingsViewModels.cs` | 修改 | 重构设置视图模型,添加新属性和本地化支持 | +| `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs` | 修改 | 添加材质颜色和壁纸设置 | +| `LanMountainDesktop/ViewModels/UpdateSettingsPageViewModel.cs` | 修改 | 更新设置页面视图模型 | +| `LanMountainDesktop/Views/SettingsWindow.axaml` | 修改 | Fluent Shell 设置窗口重构,添加自定义标题栏 | +| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改 | 设置窗口代码重构,添加搜索功能 | +| `LanMountainDesktop/Views/TransparentOverlayWindow.axaml` | 修改 | 透明覆盖窗口大幅重构 | +| `LanMountainDesktop/Services/LocalizationService.cs` | 修改 | 本地化服务更新 | +| `LanMountainDesktop/Services/SettingsSearchService.cs` | 新增 | 设置搜索服务(搜索索引、导航、结果高亮) | +| `LanMountainDesktop/Services/MaterialSurfaceService.cs` | 修改 | 添加特殊材质参数和窗口材质处理 | +| `LanMountainDesktop/Services/GlassEffectService.cs` | 修改 | 添加自适应设置窗口调色笔刷 | +| `LanMountainDesktop/Services/SettingsWindowService.cs` | 修改 | 重构主题应用逻辑 | +| `LanMountainDesktop/Services/AppearanceThemeService.cs` | 修改 | 依赖 MaterialColorService,更新外观主题处理 | +| `LanMountainDesktop/Services/WindowMaterialService.cs` | 修改 | 窗口材质服务和自动材质模式支持 | +| `LanMountainDesktop/Services/DataStorageService.cs` | 新增 | 数据存储服务(扫描、磁盘信息、清理操作) | +| `LanMountainDesktop/Services/WallpaperColorPipeline.cs` | 新增 | 壁纸颜色管道服务 | +| `LanMountainDesktop/Services/Launch/LauncherWindowsStartupService.cs` | 新增 | 启动器 Windows 启动服务 | +| `LanMountainDesktop/Services/HostAppSettingsOobeMerger.cs` | 新增 | Host 应用设置 OOBE 合并服务 | +| `LanMountainDesktop/Services/UpdateEngineService.cs` | 修改 | 添加检查点加载/保存/恢复逻辑 | +| `LanMountainDesktop/Services/IPC/...` | 多文件 | 添加重试逻辑、退避策略、更安全的资源释放 | + +#### 视图模型 (ViewModels) +| 文件路径 | 变更说明 | +|----------|----------| +| `NotificationSettingsPageViewModel.cs` | 添加本地化支持 | +| `DevSettingsPageViewModel.cs` | 添加本地化支持 | +| `AboutSettingsPageViewModel.cs` | 添加本地化支持 | +| `StatusBarSettingsPageViewModel.cs` | 添加本地化支持 | +| `MaterialColorSettingsPageViewModel.cs` | 新增材质颜色设置视图模型 | +| `DataSettingsPageViewModel.cs` | 新增数据设置视图模型 | +| `GeneralSettingsPageViewModel.cs` | 更新通用设置视图模型 | +| `AppearanceSettingsPageViewModel.cs` | 更新外观设置视图模型 | +| `ComponentsSettingsPageViewModel.cs` | 添加预览控件和实时预览支持 | + +#### 设置页面视图 (Views) +| 文件路径 | 变更说明 | +|----------|----------| +| `NotificationSettingsPage.axaml` | 更新通知设置页面 | +| `UpdateSettingsPage.axaml` | 大幅重构更新设置页面(530 行变更) | +| `WeatherSettingsPage.axaml` | 更新天气设置页面 | +| `GeneralSettingsPage.axaml` | 更新通用设置页面 | +| `LauncherSettingsPage.axaml` | 更新启动器设置页面 | +| `MaterialColorSettingsPage.axaml` | 新增材质颜色设置页面 | +| `MaterialColorSettingsPage.axaml.cs` | 新增材质颜色设置代码 | +| `DataSettingsPage.axaml` | 新增数据设置页面 | +| `DataSettingsPage.axaml.cs` | 新增数据设置代码 | +| `StatusBarSettingsPage.axaml` | 更新状态栏设置页面 | +| `WallpaperSettingsPage.axaml` | 更新壁纸设置页面 | + +### 2. 第三方库集成 + +#### DotNetCampus.InkCanvas 墨迹画布库(重大新增) + +本次合并添加了完整的 **DotNetCampus.InkCanvas** 库,这是一个功能完整的墨迹/手写画布解决方案: + +| 子项目 | 文件数 | 主要功能 | +|--------|--------|----------| +| **DotNetCampus.AvaloniaInkCanvas** | 多个 | Avalonia 平台的墨迹画布实现 | +| **DotNetCampus.InkCanvas.InkCore** | 30+ | 核心墨迹处理算法和接口 | +| **DotNetCampus.InkCanvas.SkiaInk** | 多个 | Skia 渲染引擎的墨迹实现 | + +**主要功能**: +- 墨迹绘制和渲染(Stroke rendering) +- 橡皮擦功能(Eraser modes) +- 点抽稀算法(Drop point algorithm) +- 墨迹序列化格式(Ink Serialized Format) +- 多平台支持(Skia, WPF, Avalonia) + +**涉及的核心文件**: +``` +ThirdParty/DotNetCampus.InkCanvas/src/ +├── DotNetCampus.AvaloniaInkCanvas/ +│ ├── API/InkCanvas.cs +│ ├── Caching/InkBitmapCache.cs +│ ├── Core/AvaloniaSkiaInkCanvas.cs +│ └── Erasing/PointPathEraserManager.cs +├── DotNetCampus.InkCanvas.InkCore/ +│ ├── Inking/Interactives/InkingModeInputDispatcher.cs +│ ├── InkSerializedFormat/InkSerializer.cs +│ └── System/Windows/Ink/Stroke.cs +└── DotNetCampus.InkCanvas.SkiaInk/ + ├── Settings/SkInkCanvasSettings.cs + └── Utils/SkiaExtension.cs +``` + +### 3. 脚本和工具文件 + +#### 分析脚本(新增) +| 文件 | 用途 | +|------|------| +| `parse_git_log.py` | 解析 Git HEAD 日志文件 | +| `scripts/Analyze-GitCommits.ps1` | PowerShell 提交分析脚本 | +| `scripts/GitCommitAnalyzer.cs` | C# 提交分析器 | +| `scripts/analyze_commits.ps1` | 提交分析 PowerShell 脚本 | +| `scripts/analyze_commits.py` | Python 提交分析脚本 | +| `scripts/analyze_git_commits.py` | Git 提交分析 Python 脚本 | +| `scripts/generate_commit_docs.py` | 生成提交 Markdown 文档 | +| `scripts/generate_commit_reports.py` | 生成提交报告 | + +#### 构建和发布脚本 +| 文件 | 变更说明 | +|------|----------| +| `LanMountainDesktop/scripts/package.ps1` | 包脚本更新 | +| `LanMountainDesktop/scripts/Optimize-PublishPayload.ps1` | 新增优化发布脚本(203 行) | + +### 4. 文档文件 + +| 文件 | 变更 | 说明 | +|------|------|------| +| `docs/ARCHITECTURE.md` | +34 行 | 架构文档更新 | +| `docs/LAUNCHER.md` | +2 行 | 启动器文档更新 | +| `docs/LAUNCHER_COORDINATOR.md` | +11 行 | 启动器协调器文档 | +| `docs/PLUGIN_SDK_V5_MIGRATION.md` | +14 行 | 插件 SDK v5 迁移文档 | +| `docs/VISUAL_SPEC.md` | +8 行 | 视觉规范文档 | +| `docs/ai/CODEBASE_MAP.md` | +1 行 | 代码库地图更新 | +| `docs/ai/SETTINGS_WINDOW_DESIGN.md` | +48 行 | 设置窗口设计文档(新增) | +| `docs/auto_commit_md/20260518_93758fc0.md` | +321 行 | 自动提交分析文档 | +| `SECURITY_AUDIT_REPORT.md` | +196 行 | 安全审计报告(新增) | +| `CODE_WIKI.md` | 新增 | 综合代码维基文档 | +| `design.md` | +2 行 | 设计文档更新 | + +### 5. 配置文件 + +| 文件 | 变更说明 | +|------|----------| +| `NuGet.Config` | +7 行,新增本地 NuGet 包文件夹配置 | +| `LanMountainDesktop/HostApp.csproj` | 添加 Lib.Harmony.Thin 包引用 | +| `LanMountainDesktop/LanMountainDesktop.csproj` | 添加 PostHog 包更新到 2.6.0 | +| `LanMountainDesktop/plugins/PluginLoader.cs` | +61 行,插件加载器更新 | +| `LanMountainDesktop/plugins/PluginRuntimeService.cs` | +38 行,新增插件运行时服务 | + +### 6. Mockup 和原型文件 + +| 文件 | 说明 | +|------|------| +| `mocks/class-schedule-mock.html` | +459 行,课程表 Mockup | +| `mocks/weather-widget-mock.html` | +209 行,天气组件 Mockup | +| `mockup-noise-level.html` | +898 行,噪音级别 Mockup | + +## 🔍 代码审查要点 + +### ✅ 优点 + +1. **功能完整性**:此次合并涵盖了设置系统的多个重要方面,包括 UI 改进、本地化、搜索功能、数据管理等,是一个全面的更新。 + +2. **代码质量**: + - 添加了大量的单元测试 + - 引入了渲染门控机制,避免不必要的重绘,提升性能 + - 使用 MaterialColorSnapshot 作为统一的数据源,简化了主题管理 + +3. **第三方库集成**: + - 引入 DotNetCampus.InkCanvas 库,提供了完整的墨迹画布功能 + - 使用 Harmony 进行系统级修补,提供了更灵活的系统集成方式 + +4. **安全性**: + - 添加了安全审计报告 + - 改进了 IPC 通信的健壮性(退避、重试、资源释放) + +5. **用户体验**: + - Fluent Shell 设计语言的应用,使设置窗口更加现代化 + - 添加了设置搜索功能,提升了可访问性 + - 数据存储管理功能让用户可以更好地管理应用空间 + +### ⚠️ 需要注意的点 + +1. **合并提交风险**: + - 这是一个大型合并提交(904 个文件),增加了代码审查的难度 + - 建议:未来考虑拆分为更小的、功能明确的合并请求 + +2. **二进制文件**: + - `diff.txt` 是二进制文件(303KB),可能是补丁或差异文件 + - 建议:检查 .gitattributes 确保二进制文件处理正确 + +3. **大量文件变更**: + - 78,048 行新增代码是一次性引入的,虽然功能完整,但风险集中 + - 建议:确保有足够的测试覆盖,特别是对新集成的 DotNetCampus.InkCanvas 库 + +4. **本地化工作量**: + - 添加了多个语言的本地化字符串 + - 建议:验证所有新增字符串的翻译准确性和一致性 + +5. **性能考虑**: + - 透明覆盖窗口有 1,258 行代码变更,需要特别关注渲染性能 + - 建议:进行性能测试,特别是在不同硬件配置下 + +6. **依赖管理**: + - 添加了新的第三方库依赖 + - 建议:评估库的维护状态和长期支持情况 + +### 📌 建议后续行动 + +1. **测试覆盖**:确保对新功能有充分的单元测试和集成测试 +2. **文档更新**:更新用户文档以反映新的设置选项和功能 +3. **性能监控**:部署后监控应用性能,特别是启动时间和内存使用 +4. **用户体验反馈**:收集用户对新设置界面和搜索功能的反馈 +5. **版本发布说明**:准备详细的发布说明,记录所有新增功能和重大变更 + +## 📈 影响范围评估 + +| 影响领域 | 评级 | 说明 | +|----------|------|------| +| **用户体验** | 🟢 高正面 | Fluent Shell 设计、搜索功能、本地化 | +| **系统性能** | 🟢 正面 | 渲染门控、图表缓存、IPC 优化 | +| **代码架构** | 🟢 正面 | MaterialColorSnapshot 统一数据源 | +| **功能完整性** | 🟢 正面 | InkCanvas 集成、数据管理 | +| **安全性** | 🟢 正面 | IPC 健壮性改进、安全审计 | +| **维护成本** | 🟡 中性 | 新增第三方库依赖需要维护 | + +--- + +*此报告由自动提交分析工具生成* +*生成时间: 2026-05-19 10:30:00* +*工具版本: Git Commit Analyzer v1.0* diff --git a/docs/auto_commit_md/20260523_ac8ee8d.md b/docs/auto_commit_md/20260523_ac8ee8d.md new file mode 100644 index 0000000..f5ffab4 --- /dev/null +++ b/docs/auto_commit_md/20260523_ac8ee8d.md @@ -0,0 +1,259 @@ +# Git 提交分析报告 + +## 📋 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **完整哈希** | `ac8ee8dc5467d51cc09ad614aac2c783a6c5dad5` | +| **短哈希** | `ac8ee8d` | +| **作者** | lincube | +| **提交日期** | 2026-05-23 | +| **提交时间** | 02:49:01 | +| **时区** | +0800 | +| **提交类型** | 🟢 常规提交 | + +## 📝 提交信息摘要 + +``` +changed.优化了天气组件 +``` + +**详细分析**: + +本次提交主要针对天气组件进行了 UI 优化,重点解决了文本显示被截断的问题。通过调整 `ClipToBounds` 属性和添加适当的 `Padding`,改善了天气组件在各种分辨率下的文本可见性。 + +## 📊 变更统计 + +| 统计项 | 数值 | +|--------|------| +| **变更文件总数** | 15 个文件 | +| **新增代码行数** | +301 行 | +| **删除代码行数** | -19 行 | +| **净增代码行数** | +282 行 | + +### 变更文件类型分布 + +| 文件类型 | 文件数量 | 说明 | +|----------|----------|------| +| **C# 服务文件** | 6 个 | 后端服务逻辑 | +| **XAML 视图文件** | 6 个 | UI 组件定义 | +| **XAML.CS 代码文件** | 4 个 | 视图代码逻辑 | +| **文档文件** | 1 个 | 自动生成的分析文档 | + +## 📂 详细变更分析 + +### 1. 服务层文件变更 (Services) + +#### LanMountainDesktop/Services/AppSettingsService.cs +- **变更类型**: 修改 +- **变更行数**: ±6 行 +- **变更说明**: 更新应用设置服务,可能涉及天气相关配置的调整 + +#### LanMountainDesktop/Services/FusedDesktopLayoutService.cs +- **变更类型**: 修改 +- **变更行数**: ±5 行 +- **变更说明**: 融合桌面布局服务更新 + +#### LanMountainDesktop/Services/LauncherSettingsService.cs +- **变更类型**: 修改 +- **变更行数**: ±4 行 +- **变更说明**: 启动器设置服务更新 + +#### LanMountainDesktop/Services/Settings/SettingsService.cs +- **变更类型**: 修改 +- **变更行数**: ±4 行 +- **变更说明**: 通用设置服务更新 + +#### LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs +- **变更类型**: 修改 +- **变更行数**: ±8 行 +- **变更说明**: 智慧教育缓存服务更新(变更较大) + +### 2. ClockAirApp 相关文件 + +#### LanMountainDesktop/ClockAirApp/ClockAirAppSettingsStore.cs +- **变更类型**: 修改 +- **变更行数**: ±4 行 +- **变更说明**: 时钟应用设置存储更新 + +### 3. 天气组件核心变更 (Weather Widgets) 🔥 + +这是本次提交的核心变更区域,涉及多个天气组件的 UI 优化: + +#### ExtendedWeatherWidget 扩展天气组件 +| 文件 | 变更类型 | 变更说明 | +|------|----------|----------| +| `Views/Components/ExtendedWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False"` | +| `Views/Components/ExtendedWeatherWidget.axaml.cs` | 修改 | 动态生成 TextBlock 时添加 `ClipToBounds = false` | + +**关键代码变更**: +```csharp +// ExtendedWeatherWidget.axaml.cs +// 变更前 +inner.Children.Add(new TextBlock { ... }); + +// 变更后 +inner.Children.Add(new TextBlock { + Text = item.Value, + ClipToBounds = false // 允许文本溢出显示 +}); +``` + +#### HourlyWeatherWidget 小时天气组件 +| 文件 | 变更类型 | 变更说明 | +|------|----------|----------| +| `Views/Components/HourlyWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False"` | +| `Views/Components/HourlyWeatherWidget.axaml.cs` | 修改 | 添加 `ClipToBounds = false` | + +#### MultiDayWeatherWidget 多日天气组件 +| 文件 | 变更类型 | 变更说明 | +|------|----------|----------| +| `Views/Components/MultiDayWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False" Padding="0,1,0,0"` | +| `Views/Components/MultiDayWeatherWidget.axaml.cs` | 修改 | 多处 TextBlock 添加 `ClipToBounds = false` | + +**关键 UI 调整**: +```xml + + + + + + +``` + +**代码层变更**: +```csharp +// 多处高低温 TextBlock 添加了 ClipToBounds = false +new TextBlock { + Text = FormatTemperature(item.HighTemperatureC), + ClipToBounds = false // 允许温度值完整显示 +} +``` + +#### WeatherClockWidget 天气时钟组件 +| 文件 | 变更类型 | 变更说明 | +|------|----------|----------| +| `Views/Components/WeatherClockWidget.axaml` | 修改 | 温度文本块添加 `ClipToBounds="False"` | + +#### WeatherWidget 主天气组件 +| 文件 | 变更类型 | 变更说明 | +|------|----------|----------| +| `Views/Components/WeatherWidget.axaml` | 修改 | 温度文本块添加 `ClipToBounds="False" Padding="0,2,0,0"` | + +**WeatherWidget 详细变更**: +```xml + + + + + +``` +- 添加了 `ClipToBounds="False"` 以允许文本溢出显示 +- 添加了 `Padding="0,2,0,0"` 调整文本垂直位置,避免上下被截断 + +### 4. 文档变更 + +#### docs/auto_commit_md/20260519_7a70476.md +- **变更类型**: 新增 +- **变更行数**: +269 行 +- **变更说明**: 自动生成的历史提交分析文档 + +## 🔍 代码审查要点 + +### ✅ 优点 + +1. **UI 修复明确**: + - 清楚地识别了文本截断问题 + - 使用 `ClipToBounds="False"` 是解决文本溢出显示的标准做法 + - 添加适当的 `Padding` 调整了文本位置,避免被边框裁切 + +2. **一致性处理**: + - 在所有相关天气组件中都应用了相同的修复策略 + - 保持了 UI 调整的一致性,包括 XAML 和 C# 代码 + +3. **性能考虑**: + - `ClipToBounds="False"` 的使用是局部的、针对性的 + - 不会对整体渲染性能产生显著影响 + +4. **向后兼容**: + - 修改仅影响文本显示方式,不影响数据逻辑 + - 用户不会感知到底层数据的变化 + +### ⚠️ 需要注意的点 + +1. **字体渲染差异**: + - `Padding` 值(0,1,0,0 或 0,2,0,0)可能需要根据不同字体进行微调 + - 建议在不同字体、不同 DPI 设置下测试显示效果 + +2. **文本溢出风险**: + - 虽然允许文本溢出,但需要确保有足够的容器空间 + - 极端情况下文本可能仍然会被父容器裁切 + +3. **多语言支持**: + - 不同的语言文本长度不同,需要确保各种语言的文本都能正确显示 + - 建议测试中文、英文、日文等多种语言的天气描述文本 + +4. **动态内容**: + - 温度值在不同单位(°C/°F)下长度可能不同 + - 需要测试各种温度值的显示效果 + +### 📌 建议后续行动 + +1. **UI 测试**:在多种分辨率和 DPI 设置下测试天气组件 +2. **多语言测试**:确保各种语言环境下文本显示正常 +3. **边界测试**:测试温度值在极端情况下的显示(如 -40°C 或 50°C) +4. **性能监控**:监控修改后的渲染性能,确保没有性能退化 + +## 📈 技术分析 + +### 变更的技术背景 + +在 Avalonia UI 框架中,`ClipToBounds` 属性默认值为 `true`,这会导致子元素在超出容器边界时被裁切。对于天气组件中的温度文本、天气描述等动态内容,这种裁切会导致文本显示不完整。 + +### 解决方案的有效性 + +| 解决方案 | 效果 | 风险 | +|----------|------|------| +| `ClipToBounds="False"` | ✅ 允许文本完整显示 | ⚠️ 可能溢出到其他元素 | +| `Padding="0,2,0,0"` | ✅ 调整文本位置 | ⚠️ 需要精确调整数值 | + +### 相关设计模式 + +本次修改涉及以下 UI 设计考虑: +- **溢出处理**:在固定尺寸容器中显示动态内容 +- **对齐策略**:通过 Padding 微调元素位置 +- **层级管理**:避免文本溢出影响其他 UI 元素 + +## 📊 影响范围评估 + +| 影响领域 | 评级 | 说明 | +|----------|------|------| +| **用户体验** | 🟢 正面 | 修复了文本截断问题,提升可读性 | +| **系统性能** | 🟢 无影响 | UI 属性调整,无性能影响 | +| **代码维护性** | 🟢 正面 | 统一了天气组件的文本显示处理方式 | +| **兼容性** | 🟢 正面 | 向后兼容,无破坏性变更 | +| **测试覆盖率** | 🟡 需补充 | 建议增加 UI 显示测试用例 | + +--- + +## 📋 总结 + +本次提交 `ac8ee8d` 主要解决了天气组件的文本显示问题,通过在所有相关组件中添加 `ClipToBounds="False"` 和适当的 `Padding`,确保了温度、天气描述等文本能够完整显示。 + +**关键成果**: +- ✅ 修复了 5 个天气组件的文本截断问题 +- ✅ 保持了一致的 UI 处理方式 +- ✅ 代码变更精确、风险低 + +**建议关注**: +- 多语言文本显示效果 +- 不同 DPI 下的字体渲染 +- 极端温度值的显示 + +--- + +*此报告由自动提交分析工具生成* +*生成时间: 2026-05-23* +*工具版本: Git Commit Analyzer v1.0*