diff --git a/LanDesktopPLONDS.installer/App.axaml.cs b/LanDesktopPLONDS.installer/App.axaml.cs index ce932ce..7ba3e67 100644 --- a/LanDesktopPLONDS.installer/App.axaml.cs +++ b/LanDesktopPLONDS.installer/App.axaml.cs @@ -22,9 +22,18 @@ public partial class App : Application var privacyIdentity = new PrivacyDeviceIdentityProvider(); var installService = OnlineInstallService.CreateDefault(privacyIdentity); var consentStore = new InstallerPrivacyConsentStore(); + var vm = new MainWindowViewModel(installService, privacyIdentity, consentStore); + + // Task 2: 解析 --install-path 参数(提权重启后由管理员进程传入) + var installPath = MainWindowViewModel.ParseInstallPath(desktop.Args); + if (!string.IsNullOrWhiteSpace(installPath)) + { + vm.InstallPath = installPath; + } + var mainWindow = new MainWindow { - DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore) + DataContext = vm }; desktop.MainWindow = mainWindow; mainWindow.Show(); diff --git a/LanDesktopPLONDS.installer/Compress-NativeLibrary.ps1 b/LanDesktopPLONDS.installer/Compress-NativeLibrary.ps1 deleted file mode 100644 index 51c23c0..0000000 --- a/LanDesktopPLONDS.installer/Compress-NativeLibrary.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string] $SourcePath, - - [Parameter(Mandatory = $true)] - [string] $DestinationPath -) - -$ErrorActionPreference = 'Stop' - -$source = Get-Item -LiteralPath $SourcePath -$destinationDirectory = Split-Path -Parent $DestinationPath -New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null - -$existing = Get-Item -LiteralPath $DestinationPath -ErrorAction SilentlyContinue -if ($existing -and $existing.LastWriteTimeUtc -ge $source.LastWriteTimeUtc -and $existing.Length -gt 0) { - return -} - -$temporaryPath = "$DestinationPath.$PID.tmp" -if (Test-Path -LiteralPath $temporaryPath) { - Remove-Item -LiteralPath $temporaryPath -Force -} - -$inputStream = [System.IO.File]::OpenRead($source.FullName) -try { - $outputStream = [System.IO.File]::Create($temporaryPath) - try { - $gzipStream = New-Object System.IO.Compression.GZipStream($outputStream, [System.IO.Compression.CompressionMode]::Compress) - try { - $inputStream.CopyTo($gzipStream) - } - finally { - $gzipStream.Dispose() - } - } - finally { - $outputStream.Dispose() - } -} -finally { - $inputStream.Dispose() -} - -Move-Item -LiteralPath $temporaryPath -Destination $DestinationPath -Force diff --git a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props index 4c38b97..596a09e 100644 --- a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props +++ b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props @@ -23,38 +23,11 @@ true - - - - - - - - - - - - - - - - + false diff --git a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj index 75daeef..d457c88 100644 --- a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj +++ b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj @@ -23,10 +23,12 @@ - - - - + + + + + diff --git a/LanDesktopPLONDS.installer/Localization/InstallerStrings.cs b/LanDesktopPLONDS.installer/Localization/InstallerStrings.cs new file mode 100644 index 0000000..1aacdd8 --- /dev/null +++ b/LanDesktopPLONDS.installer/Localization/InstallerStrings.cs @@ -0,0 +1,37 @@ +namespace LanDesktopPLONDS.Installer.Localization; + +/// +/// 安装器本地化字符串映射。 +/// 将服务层报告的英文阶段键映射为中文显示文本。 +/// 未匹配的英文键原样透传,避免丢失信息。 +/// +internal static class InstallerStrings +{ + /// + /// 已知英文阶段键 → 中文显示文本。 + /// 键比较使用 OrdinalIgnoreCase,容错服务端大小写差异。 + /// + private static readonly Dictionary StageMap = new(StringComparer.OrdinalIgnoreCase) + { + ["Downloading Files.zip"] = "正在下载 Files.zip", + ["Files package prepared"] = "文件包已准备就绪", + ["Creating deployment"] = "正在创建部署目录", + ["Activating deployment"] = "正在激活部署", + ["Copying files"] = "正在复制文件", + ["Copying launcher files"] = "正在复制启动器文件", + ["Completed"] = "安装完成", + }; + + /// + /// 将英文阶段键翻译为中文。未匹配的键原样返回。 + /// + public static string TranslateStage(string stage) + { + if (string.IsNullOrWhiteSpace(stage)) + { + return stage; + } + + return StageMap.TryGetValue(stage, out var translated) ? translated : stage; + } +} diff --git a/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs b/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs deleted file mode 100644 index bfec912..0000000 --- a/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.IO.Compression; -using System.Reflection; -using System.Runtime.InteropServices; - -namespace LanDesktopPLONDS.Installer; - -internal static class NativeDependencyBootstrapper -{ - private const string CacheRootEnvironmentVariable = "LANDESKTOPPLONDS_INSTALLER_NATIVE_CACHE"; - private const string ResourcePrefix = "LanDesktopPLONDS.Installer.NativeLibraries."; - - private static readonly string[] NativeLibraryNames = - [ - "libHarfBuzzSharp.dll", - "libSkiaSharp.dll" - ]; - - public static bool TryPrepare() - { - if (!OperatingSystem.IsWindows()) - { - return true; - } - - try - { - var nativeDirectory = GetNativeDirectory(); - Directory.CreateDirectory(nativeDirectory); - - var extractedLibraries = new List(NativeLibraryNames.Length); - foreach (var libraryName in NativeLibraryNames) - { - extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName)); - } - - AddToProcessDllSearchPath(nativeDirectory); - - foreach (var libraryPath in extractedLibraries) - { - NativeLibrary.Load(libraryPath); - } - - return true; - } - catch (Exception ex) - { - InstallerStartupDiagnostics.Log($"Native dependency preparation failed: {ex}"); - return false; - } - } - - private static string GetNativeDirectory() - { - var configuredCacheRoot = Environment.GetEnvironmentVariable(CacheRootEnvironmentVariable); - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var cacheRoot = !string.IsNullOrWhiteSpace(configuredCacheRoot) - ? configuredCacheRoot - : string.IsNullOrWhiteSpace(localAppData) - ? Path.GetTempPath() - : localAppData; - - string? versionStamp = null; - if (!string.IsNullOrWhiteSpace(Environment.ProcessPath)) - { - versionStamp = FileVersionInfo.GetVersionInfo(Environment.ProcessPath).ProductVersion; - } - - if (string.IsNullOrWhiteSpace(versionStamp)) - { - versionStamp = "dev"; - } - - return Path.Combine( - cacheRoot, - "LanDesktopPLONDS", - "Installer", - "native", - RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), - SanitizePathSegment(versionStamp)); - } - - private static string ExtractLibrary(string nativeDirectory, string libraryName) - { - var resourceName = ResourcePrefix + libraryName + ".gz"; - var assembly = Assembly.GetExecutingAssembly(); - using var resource = assembly.GetManifestResourceStream(resourceName); - if (resource is null) - { - var availableResources = string.Join(", ", assembly.GetManifestResourceNames()); - throw new FileNotFoundException( - $"Missing embedded native installer library resource '{resourceName}'. Available resources: {availableResources}"); - } - - var destinationPath = Path.Combine(nativeDirectory, libraryName); - var temporaryPath = destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; - using (var gzip = new GZipStream(resource, CompressionMode.Decompress)) - using (var output = File.Create(temporaryPath)) - { - gzip.CopyTo(output); - } - - if (File.Exists(destinationPath) && FilesEqual(destinationPath, temporaryPath)) - { - File.Delete(temporaryPath); - return destinationPath; - } - - File.Move(temporaryPath, destinationPath, overwrite: true); - return destinationPath; - } - - private static void AddToProcessDllSearchPath(string nativeDirectory) - { - var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - if (!currentPath.Contains(nativeDirectory, StringComparison.OrdinalIgnoreCase)) - { - Environment.SetEnvironmentVariable("PATH", nativeDirectory + Path.PathSeparator + currentPath); - } - - if (!SetDllDirectory(nativeDirectory)) - { - throw new Win32Exception(Marshal.GetLastPInvokeError(), "Failed to update the process native DLL search path."); - } - } - - private static string SanitizePathSegment(string value) - { - foreach (var invalidChar in Path.GetInvalidFileNameChars()) - { - value = value.Replace(invalidChar, '_'); - } - - return value; - } - - private static bool FilesEqual(string leftPath, string rightPath) - { - var left = new FileInfo(leftPath); - var right = new FileInfo(rightPath); - if (left.Length != right.Length) - { - return false; - } - - using var leftStream = File.OpenRead(leftPath); - using var rightStream = File.OpenRead(rightPath); - var leftBuffer = new byte[81920]; - var rightBuffer = new byte[81920]; - - while (true) - { - var leftRead = leftStream.Read(leftBuffer, 0, leftBuffer.Length); - var rightRead = rightStream.Read(rightBuffer, 0, rightBuffer.Length); - if (leftRead != rightRead) - { - return false; - } - - if (leftRead == 0) - { - return true; - } - - for (var i = 0; i < leftRead; i++) - { - if (leftBuffer[i] != rightBuffer[i]) - { - return false; - } - } - } - } - - [DllImport("kernel32", EntryPoint = "SetDllDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetDllDirectory(string pathName); -} diff --git a/LanDesktopPLONDS.installer/Program.cs b/LanDesktopPLONDS.installer/Program.cs index 2152afe..c20ff67 100644 --- a/LanDesktopPLONDS.installer/Program.cs +++ b/LanDesktopPLONDS.installer/Program.cs @@ -1,5 +1,7 @@ using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Win32; +using LanDesktopPLONDS.Installer.Services; namespace LanDesktopPLONDS.Installer; @@ -9,14 +11,55 @@ public static class Program public static void Main(string[] args) { InstallerStartupDiagnostics.Initialize(); + + // 解析命令行参数 + var uninstallMode = false; + var uninstallSilent = false; + string? uninstallPath = null; + + for (var i = 0; i < args.Length; i++) + { + if (args[i].Equals("--uninstall", StringComparison.OrdinalIgnoreCase)) + { + uninstallMode = true; + // 下一个参数是可选的 installPath + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + { + uninstallPath = args[i + 1]; + i++; + } + } + else if (args[i].Equals("--uninstall-silent", StringComparison.OrdinalIgnoreCase)) + { + uninstallMode = true; + uninstallSilent = true; + // 下一个参数是可选的 installPath + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + { + uninstallPath = args[i + 1]; + i++; + } + } + } + + // 卸载模式 + if (uninstallMode) + { + RunUninstall(uninstallPath, uninstallSilent); + return; + } + + // 单实例检查 + using var singleInstance = new InstallerSingleInstance(); + if (!singleInstance.TryAcquire()) + { + Console.Error.WriteLine("安装程序已在运行,无法启动第二个实例。"); + Environment.Exit(2); + return; + } + try { - InstallerStartupDiagnostics.Log("Preparing native dependencies."); - if (!NativeDependencyBootstrapper.TryPrepare()) - { - throw new InvalidOperationException("Failed to prepare native dependencies."); - } - InstallerStartupDiagnostics.Log("Starting Avalonia desktop lifetime."); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } @@ -26,6 +69,99 @@ public static class Program } } + /// + /// 执行卸载流程。 + /// + private static void RunUninstall(string? installPath, bool silent) + { + if (string.IsNullOrWhiteSpace(installPath)) + { + Console.Error.WriteLine("卸载需要指定安装路径参数。"); + Environment.Exit(1); + return; + } + + try + { + // 非静默模式:显示确认窗口 + if (!silent) + { + var confirmed = ShowUninstallConfirmation(installPath); + if (!confirmed) + { + Console.WriteLine("用户取消了卸载操作。"); + Environment.Exit(0); + return; + } + } + + var service = new UninstallService(installPath, silent); + var success = service.Execute(); + Environment.Exit(success ? 0 : 1); + } + catch (UnauthorizedAccessException ex) + { + Console.Error.WriteLine($"卸载失败:{ex.Message}"); + Environment.Exit(1); + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine($"卸载失败:{ex.Message}"); + Environment.Exit(1); + } + catch (Exception ex) + { + Console.Error.WriteLine($"卸载过程中发生意外错误:{ex.Message}"); + Environment.Exit(1); + } + } + + /// + /// 显示卸载确认窗口(非静默模式)。 + /// 返回 true 表示用户确认卸载。 + /// + private static bool ShowUninstallConfirmation(string installPath) + { + var confirmed = false; + var resetEvent = new ManualResetEventSlim(false); + + // 在新线程启动 Avalonia 卸载确认窗口 + var thread = new Thread(() => + { + try + { + var appBuilder = AppBuilder.Configure() + .UsePlatformDetect() + .With(new Win32PlatformOptions + { + RenderingMode = [Win32RenderingMode.Software], + CompositionMode = [Win32CompositionMode.RedirectionSurface] + }); + + var app = new UninstallConfirmApp(); + app.ConfirmAction += result => + { + confirmed = result; + resetEvent.Set(); + app.ShutdownApp(); + }; + app.InstallPath = installPath; + + appBuilder.StartWithClassicDesktopLifetime([]); + } + catch + { + resetEvent.Set(); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + resetEvent.Wait(); + + return confirmed; + } + public static AppBuilder BuildAvaloniaApp() { return AppBuilder.Configure() @@ -37,3 +173,64 @@ public static class Program }); } } + +/// +/// 卸载确认窗口的临时 App 类。 +/// +internal sealed class UninstallConfirmApp : Application +{ + private IClassicDesktopStyleApplicationLifetime? _lifetime; + + public event Action? ConfirmAction; + public string InstallPath { get; set; } = string.Empty; + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + _lifetime = desktop; + var window = new Views.UninstallConfirmWindow + { + DataContext = new UninstallConfirmViewModel(InstallPath, ConfirmAction!) + }; + desktop.MainWindow = window; + window.Show(); + } + + base.OnFrameworkInitializationCompleted(); + } + + /// + /// 关闭应用程序。 + /// + public void ShutdownApp() + { + _lifetime?.Shutdown(); + } +} + +/// +/// 卸载确认窗口的视图模型。 +/// +internal sealed class UninstallConfirmViewModel +{ + private readonly Action _confirmAction; + + public UninstallConfirmViewModel(string installPath, Action confirmAction) + { + InstallPath = installPath; + _confirmAction = confirmAction; + } + + public string InstallPath { get; } + + public void Confirm() + { + _confirmAction(true); + } + + public void Cancel() + { + _confirmAction(false); + } +} diff --git a/LanDesktopPLONDS.installer/Services/ArpRegistration.cs b/LanDesktopPLONDS.installer/Services/ArpRegistration.cs new file mode 100644 index 0000000..66b6e50 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/ArpRegistration.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using LanMountainDesktop.Shared.Contracts.Deployment; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// Windows ARP(添加/删除程序)注册表注册与移除。 +/// 设计为可测试:注册表基路径可注入。 +/// +public static class ArpRegistration +{ + private const string UninstallKeyName = "LanMountainDesktop"; + private const string DisplayName = "阑山桌面"; + private const string Publisher = "LanMountain"; + + /// + /// 注册 ARP 条目到 Windows 注册表。 + /// + /// 安装根目录。 + /// 应用版本号。 + /// 可选:注入的注册表基路径(测试用)。 + public static void Register(string launcherRoot, string version, string? registryBasePath = null) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + var subKeyPath = GetUninstallSubKeyPath(registryBasePath); + using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(subKeyPath); + WriteRegistryValues(key, launcherRoot, version); + } + catch + { + // HKLM 写入失败时回退到 HKCU + try + { + var subKeyPath = GetUninstallSubKeyPath(registryBasePath); + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(subKeyPath); + WriteRegistryValues(key, launcherRoot, version); + } + catch + { + // ARP 注册是尽力而为 + } + } + } + + /// + /// 移除 ARP 条目。 + /// + /// 可选:注入的注册表基路径(测试用)。 + public static void Remove(string? registryBasePath = null) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // 尝试 HKLM 和 HKCU 都删除 + TryRemoveKey(Microsoft.Win32.Registry.LocalMachine, registryBasePath); + TryRemoveKey(Microsoft.Win32.Registry.CurrentUser, registryBasePath); + } + + /// + /// 获取卸载子键路径。 + /// + public static string GetUninstallSubKeyPath(string? registryBasePath = null) + { + var basePart = registryBasePath ?? @"Software\Microsoft\Windows\CurrentVersion\Uninstall"; + return $@"{basePart}\{UninstallKeyName}"; + } + + private static void WriteRegistryValues( + Microsoft.Win32.RegistryKey key, + string launcherRoot, + string version) + { + var launcherExeName = DeploymentLayout.GetLauncherExecutableName(); + var displayIconPath = Path.Combine(launcherRoot, launcherExeName); + var uninstallExePath = Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName, "uninstall.exe"); + var uninstallArgs = $"--uninstall \"{launcherRoot}\""; + + key.SetValue("DisplayName", DisplayName); + key.SetValue("DisplayVersion", version); + key.SetValue("Publisher", Publisher); + key.SetValue("InstallLocation", launcherRoot); + key.SetValue("DisplayIcon", displayIconPath); + key.SetValue("UninstallString", $"\"{uninstallExePath}\" {uninstallArgs}"); + key.SetValue("NoModify", 1, Microsoft.Win32.RegistryValueKind.DWord); + key.SetValue("NoRepair", 1, Microsoft.Win32.RegistryValueKind.DWord); + + // 估算安装大小(KB) + try + { + var estimatedKb = EstimateInstallSizeKb(launcherRoot); + key.SetValue("EstimatedSize", estimatedKb, Microsoft.Win32.RegistryValueKind.DWord); + } + catch + { + // 大小估算失败不影响注册 + } + } + + private static void TryRemoveKey(Microsoft.Win32.RegistryKey rootKey, string? registryBasePath) + { + try + { + var subKeyPath = GetUninstallSubKeyPath(registryBasePath); + rootKey.DeleteSubKeyTree(subKeyPath, throwOnMissingSubKey: false); + } + catch + { + // 删除失败时忽略 + } + } + + private static int EstimateInstallSizeKb(string launcherRoot) + { + if (!Directory.Exists(launcherRoot)) + { + return 0; + } + + var totalBytes = Directory + .EnumerateFiles(launcherRoot, "*", SearchOption.AllDirectories) + .Sum(path => + { + try + { + return new FileInfo(path).Length; + } + catch + { + return 0L; + } + }); + + return (int)(totalBytes / 1024); + } +} diff --git a/LanDesktopPLONDS.installer/Services/AuthenticodeVerifier.cs b/LanDesktopPLONDS.installer/Services/AuthenticodeVerifier.cs new file mode 100644 index 0000000..403376f --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/AuthenticodeVerifier.cs @@ -0,0 +1,227 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// Authenticode 签名验证结果。 +/// +internal enum AuthenticodeStatus +{ + /// 文件具有有效的 Authenticode 签名。 + Signed, + + /// 文件未签名。 + Unsigned, + + /// 签名无效或验证过程出错。 + Invalid +} + +/// +/// Authenticode 验证结果数据。 +/// +internal sealed class AuthenticodeResult +{ + /// 验证状态。 + public AuthenticodeStatus Status { get; init; } + + /// 签名者主体名称(仅在 Status == Signed 时有效)。 + public string? SignerSubject { get; init; } + + /// 是否要求强制签名验证。 + public bool EnforcementEnabled { get; init; } + + public override string ToString() => Status switch + { + AuthenticodeStatus.Signed => $"Signed ({SignerSubject ?? "unknown"})", + AuthenticodeStatus.Unsigned => "Unsigned", + AuthenticodeStatus.Invalid => "Invalid", + _ => "Unknown" + }; +} + +/// +/// Windows Authenticode(WinVerifyTrust)签名验证器。 +/// 使用 Win32 P/Invoke 验证 PE 文件的 Authenticode 签名, +/// 并通过 X509Certificate 提取签名者信息。 +/// 默认为仅报告模式;设置 LANMOUNTAIN_INSTALLER_REQUIRE_SIGNED=1 启用强制验证。 +/// +internal static class AuthenticodeVerifier +{ + private const string RequireSignedEnvVar = "LANMOUNTAIN_INSTALLER_REQUIRE_SIGNED"; + + /// + /// 当前是否启用了强制签名验证。 + /// + public static bool EnforcementEnabled => + string.Equals( + Environment.GetEnvironmentVariable(RequireSignedEnvVar), + "1", + StringComparison.Ordinal); + + /// + /// 对指定 PE 文件执行 Authenticode 签名验证。 + /// + /// 要验证的文件路径。 + /// 验证结果,包含状态和签名者信息。 + public static AuthenticodeResult VerifyFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!File.Exists(path)) + { + InstallerStartupDiagnostics.Log($"[Authenticode] 文件不存在:{path}"); + return new AuthenticodeResult { Status = AuthenticodeStatus.Invalid }; + } + + if (!OperatingSystem.IsWindows()) + { + InstallerStartupDiagnostics.Log("[Authenticode] 非 Windows 平台,跳过 Authenticode 验证。"); + return new AuthenticodeResult { Status = AuthenticodeStatus.Unsigned }; + } + + // 第一步:通过 Win32 WinVerifyTrust 验证签名有效性 + var trustStatus = WinVerifyTrustNative(path); + if (trustStatus != 0) + { + // TRUST_E_NOSIGNATURE (0x800B0100) = 文件未签名 + if (trustStatus == unchecked((int)0x800B0100)) + { + InstallerStartupDiagnostics.Log($"[Authenticode] 文件未签名:{path}"); + return new AuthenticodeResult { Status = AuthenticodeStatus.Unsigned }; + } + + InstallerStartupDiagnostics.Log( + $"[Authenticode] WinVerifyTrust 返回错误 0x{trustStatus:X8}:{path}"); + return new AuthenticodeResult { Status = AuthenticodeStatus.Invalid }; + } + + // 第二步:提取签名者信息 + string? signerSubject = null; + try + { +#pragma warning disable SYSLIB0057 // X509Certificate.CreateFromSignedFile 已过时 + using var cert = X509Certificate.CreateFromSignedFile(path); +#pragma warning restore SYSLIB0057 + signerSubject = cert.Subject; + } + catch (CryptographicException ex) + { + // WinVerifyTrust 通过但无法读取证书——记录但不视为失败 + InstallerStartupDiagnostics.Log( + $"[Authenticode] 签名有效但无法读取证书信息:{ex.Message}"); + } + + InstallerStartupDiagnostics.Log( + $"[Authenticode] 签名验证通过:{path},签名者={signerSubject ?? "unknown"}"); + + return new AuthenticodeResult + { + Status = AuthenticodeStatus.Signed, + SignerSubject = signerSubject + }; + } + + // WinVerifyTrust 相关常量 + private const int WTD_UI_NONE = 2; + private const int WTD_REVOKE_NONE = 0; + private const int WTD_CHOICE_FILE = 1; + private const int WTD_STATEACTION_VERIFY = 1; + private const int WTD_STATEACTION_CLOSE = 2; + private const int WTD_SAFER_FLAG = 0x100; + private const string WinTrustDll = "wintrust.dll"; + + // WINTRUST_ACTION_GENERIC_VERIFY_V2 = {00AAC56B-CD44-11d0-8CC2-00C04FC295EE} + private static readonly Guid s_winTrustActionGenericVerifyV2 = + new(0x00AAC56B, 0xCD44, 0x11d0, 0x8C, 0xC2, 0x00, 0xC0, 0x4F, 0xC2, 0x95, 0xEE); + + /// + /// 调用 WinVerifyTrust 的简化封装。 + /// + private static int WinVerifyTrustNative(string filePath) + { + var fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = Marshal.SizeOf(), + pcwszFilePath = filePath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero + }; + + var pFileInfo = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(fileInfo, pFileInfo, false); + + var data = new WINTRUST_DATA + { + cbStruct = Marshal.SizeOf(), + pPolicyCallbackData = IntPtr.Zero, + pSIPClientData = IntPtr.Zero, + dwUIChoice = WTD_UI_NONE, + fdwRevocationChecks = WTD_REVOKE_NONE, + dwUnionChoice = WTD_CHOICE_FILE, + pFile = pFileInfo, + dwStateAction = WTD_STATEACTION_VERIFY, + hWVTStateData = IntPtr.Zero, + pwszURLReference = IntPtr.Zero, + dwProvFlags = WTD_SAFER_FLAG, + dwUIContext = 0 + }; + + // 使用局部副本传递 ref 参数(static readonly 字段不能用于 ref) + var actionId = s_winTrustActionGenericVerifyV2; + var result = WinVerifyTrustCore(IntPtr.Zero, ref actionId, ref data); + + // 清理状态句柄 + data.dwStateAction = WTD_STATEACTION_CLOSE; + actionId = s_winTrustActionGenericVerifyV2; + WinVerifyTrustCore(IntPtr.Zero, ref actionId, ref data); + + return result; + } + finally + { + Marshal.FreeHGlobal(pFileInfo); + } + } + + /// + /// Win32 WinVerifyTrust P/Invoke 核心调用。 + /// AOT 安全的 DllImport 声明。 + /// + [DllImport(WinTrustDll, EntryPoint = "WinVerifyTrust", SetLastError = false)] + private static extern int WinVerifyTrustCore( + IntPtr hwnd, + ref Guid pgActionID, + ref WINTRUST_DATA pWVTData); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WINTRUST_FILE_INFO + { + public int cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] + public string? pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public int cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public int dwUIChoice; + public int fdwRevocationChecks; + public int dwUnionChoice; + public IntPtr pFile; + public int dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; // LPWSTR 作为 IntPtr 传递更安全 + public int dwProvFlags; + public int dwUIContext; + } +} diff --git a/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs index 4427fac..32c26a8 100644 --- a/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs +++ b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using LanDesktopPLONDS.Installer.Models; +using LanMountainDesktop.Shared.Contracts.Deployment; namespace LanDesktopPLONDS.Installer.Services; @@ -26,74 +27,112 @@ internal sealed class FilesPackageInstaller var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath); var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version); - var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version); + var targetDeployment = DeploymentLayout.BuildDeploymentDirectory(launcherRoot, package.Version); InstallerElevation.EnsureCanInstall(launcherRoot); InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory)); Directory.CreateDirectory(launcherRoot); - await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken) - .ConfigureAwait(false); - progress?.Report(new InstallerDeployProgress( - "Creating deployment", - package.Version, - 1, - 0.15, - null, - 0, - null)); + // 在复制 launcherRoot 文件之前,检测是否有进程正在运行 + RunningProcessGuard.EnsureNoRunningProcesses(launcherRoot); - PrepareTargetDirectory(targetDeployment); - await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken) - .ConfigureAwait(false); - - progress?.Report(new InstallerDeployProgress( - "Activating deployment", - package.Version, - 1, - 0.92, - null, - 0, - null)); - - ActivateInitialDeployment(launcherRoot, targetDeployment); - CreateWindowsShortcutsIfAvailable(launcherRoot, options); - - progress?.Report(new InstallerDeployProgress( - "Completed", - package.Version, - 1, - 1, - null, - 0, - null)); - } - - public static string BuildDeploymentDirectory(string launcherRoot, string version) - { - var sanitized = string.IsNullOrWhiteSpace(version) ? "0.0.0" : version.Trim(); - var index = 0; - while (true) + // 事务性安装:失败时回滚,不破坏已有状态 + try { - var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}"); - if (!Directory.Exists(candidate)) - { - return candidate; - } + // (a) 创建部署目录并写入 .partial 标记 + progress?.Report(new InstallerDeployProgress( + "创建部署目录", + package.Version, + 1, + 0.12, + null, + 0, + null)); - index++; + PrepareTargetDirectory(targetDeployment); + + // (b) 复制所有应用文件到部署目录 + await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken) + .ConfigureAwait(false); + + // (c) 验证文件数量匹配 + progress?.Report(new InstallerDeployProgress( + "验证文件完整性", + package.Version, + 1, + 0.85, + null, + 0, + null)); + + VerifyFileCount(sourceAppDirectory, targetDeployment); + + // (d) 复制 launcherRoot 载荷:先写入临时兄弟目录,再逐文件原子移入 + progress?.Report(new InstallerDeployProgress( + "复制启动器文件", + package.Version, + 1, + 0.88, + null, + 0, + null)); + + await CopyLauncherRootPayloadAtomicAsync( + package.ExtractDirectory, + sourceAppDirectory, + launcherRoot, + package.Version, + progress, + cancellationToken).ConfigureAwait(false); + + // (e) 激活部署:删除 .partial → 写 .current → 清除其他 .current + progress?.Report(new InstallerDeployProgress( + "激活部署", + package.Version, + 1, + 0.92, + null, + 0, + null)); + + ActivateDeployment(launcherRoot, targetDeployment); + CreateWindowsShortcutsIfAvailable(launcherRoot, options); + + // ARP 注册(仅 Windows) + ArpRegistration.Register(launcherRoot, package.Version); + + // 清理过时部署目录,保留最新一个作为回滚 + CleanupStaleDeployments(launcherRoot); + + progress?.Report(new InstallerDeployProgress( + "已完成", + package.Version, + 1, + 1, + null, + 0, + null)); + } + catch (Exception ex) + { + // (f) 任何失败:删除不完整的部署目录,保留预先存在的状态,重新抛出带中文上下文的异常 + CleanupPartialDeployment(targetDeployment); + throw new InvalidOperationException($"安装失败,已回滚更改:{ex.Message}", ex); } } + /// + /// 从包目录中解析出包含主程序可执行文件的应用目录。 + /// public static string ResolveFullPackageAppDirectory(string filesDirectory, string version) { var root = Path.GetFullPath(filesDirectory); if (!Directory.Exists(root)) { - throw new DirectoryNotFoundException($"PLONDS Files package directory is missing: {root}"); + throw new DirectoryNotFoundException($"PLONDS Files 包目录不存在:{root}"); } - var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; + var executableName = DeploymentLayout.GetHostExecutableName(); var directExecutable = Path.Combine(root, executableName); if (File.Exists(directExecutable)) { @@ -116,9 +155,12 @@ internal sealed class FilesPackageInstaller return nested; } - throw new FileNotFoundException($"PLONDS Files package does not contain {executableName}."); + throw new FileNotFoundException($"PLONDS Files 包中未找到 {executableName}。"); } + /// + /// 准备目标部署目录:创建目录并写入 .partial 标记。 + /// private static void PrepareTargetDirectory(string targetDeployment) { if (Directory.Exists(targetDeployment)) @@ -127,9 +169,12 @@ internal sealed class FilesPackageInstaller } Directory.CreateDirectory(targetDeployment); - File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName), string.Empty); } + /// + /// 逐文件复制源目录到目标目录,跳过标记文件。 + /// private static async Task CopyDirectoryAsync( string sourceDirectory, string targetDirectory, @@ -144,7 +189,7 @@ internal sealed class FilesPackageInstaller cancellationToken.ThrowIfCancellationRequested(); var sourcePath = sourceFiles[index]; var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, sourcePath)); - if (IsDeploymentMarker(relativePath)) + if (DeploymentLayout.IsDeploymentMarker(relativePath)) { continue; } @@ -164,17 +209,40 @@ internal sealed class FilesPackageInstaller } progress?.Report(new InstallerDeployProgress( - "Copying files", + "复制文件", version, 1, - 0.18 + ((index + 1) * 0.70 / total), + 0.15 + ((index + 1) * 0.70 / total), relativePath, index + 1, total)); } } - private static async Task CopyLauncherRootPayloadAsync( + /// + /// 验证源目录与目标目录的文件数量匹配。 + /// + private static void VerifyFileCount(string sourceDirectory, string targetDirectory) + { + var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories) + .Count(p => !DeploymentLayout.IsDeploymentMarker( + InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, p)))); + var targetFiles = Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories) + .Count(p => !DeploymentLayout.IsDeploymentMarker( + InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(targetDirectory, p)))); + + if (sourceFiles != targetFiles) + { + throw new InvalidOperationException( + $"文件数量验证失败:源目录 {sourceFiles} 个文件,目标目录 {targetFiles} 个文件。"); + } + } + + /// + /// 原子性地将 launcher-root 载荷复制到安装根目录。 + /// 先复制到临时兄弟目录,然后逐文件用 File.Move(overwrite) 移入目标。 + /// + private static async Task CopyLauncherRootPayloadAtomicAsync( string packageRoot, string sourceAppDirectory, string launcherRoot, @@ -198,67 +266,219 @@ internal sealed class FilesPackageInstaller .Where(path => { var relative = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, path)); - return !relative.StartsWith("app-", StringComparison.OrdinalIgnoreCase); + return !relative.StartsWith(DeploymentLayout.DeploymentDirectoryPrefix, StringComparison.OrdinalIgnoreCase); }) .ToArray(); - var total = Math.Max(1, files.Length); - for (var index = 0; index < files.Length; index++) + // 先复制到临时目录 + var tempSibling = launcherRoot + ".tmp-staging-" + Guid.NewGuid().ToString("N")[..8]; + try { - cancellationToken.ThrowIfCancellationRequested(); - var sourcePath = files[index]; - var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath)); - if (IsDeploymentMarker(relativePath)) + Directory.CreateDirectory(tempSibling); + + var total = Math.Max(1, files.Length); + for (var index = 0; index < files.Length; index++) { - continue; + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = files[index]; + var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath)); + if (DeploymentLayout.IsDeploymentMarker(relativePath)) + { + continue; + } + + var tempPath = Path.GetFullPath(Path.Combine(tempSibling, relativePath)); + InstallerPathGuard.EnsureChildPath(tempSibling, tempPath); + var tempParent = Path.GetDirectoryName(tempPath); + if (!string.IsNullOrWhiteSpace(tempParent)) + { + Directory.CreateDirectory(tempParent); + } + + await using (var source = File.OpenRead(sourcePath)) + await using (var target = File.Create(tempPath)) + { + await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false); + } + + progress?.Report(new InstallerDeployProgress( + "复制启动器文件", + version, + 1, + 0.88 + ((index + 1) * 0.03 / total), + relativePath, + index + 1, + total)); } - var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath)); - InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath); - var targetParent = Path.GetDirectoryName(targetPath); - if (!string.IsNullOrWhiteSpace(targetParent)) + // 逐文件原子移入目标(File.Move overwrite + 回退 copy+delete) + var tempFiles = Directory.EnumerateFiles(tempSibling, "*", SearchOption.AllDirectories).ToArray(); + foreach (var tempFile in tempFiles) { - Directory.CreateDirectory(targetParent); - } + var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(tempSibling, tempFile)); + var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath)); + InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath); + var targetParent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetParent)) + { + Directory.CreateDirectory(targetParent); + } - await using (var source = File.OpenRead(sourcePath)) - await using (var target = File.Create(targetPath)) - { - await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false); + AtomicMoveOrCopyDelete(tempFile, targetPath); } - - progress?.Report(new InstallerDeployProgress( - "Copying launcher files", - version, - 1, - 0.10 + ((index + 1) * 0.05 / total), - relativePath, - index + 1, - total)); + } + finally + { + // 清理临时目录 + TryDeleteDirectory(tempSibling); } } - private static void ActivateInitialDeployment(string launcherRoot, string targetDeployment) + /// + /// 原子移动文件:优先 File.Move(overwrite),失败时回退到复制+删除。 + /// + private static void AtomicMoveOrCopyDelete(string source, string destination) { - foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, ".current", SearchOption.AllDirectories)) + try { - try - { - File.Delete(existingCurrent); - } - catch - { - } + File.Move(source, destination, overwrite: true); } + catch (IOException) + { + // 回退:复制后删除源文件 + File.Copy(source, destination, overwrite: true); + File.Delete(source); + } + } - var partialMarker = Path.Combine(targetDeployment, ".partial"); + /// + /// 激活部署:删除 .partial → 写 .current → 移除其他部署的 .current → 创建 .Launcher 目录。 + /// + private static void ActivateDeployment(string launcherRoot, string targetDeployment) + { + // 删除 .partial 标记 + var partialMarker = Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName); if (File.Exists(partialMarker)) { File.Delete(partialMarker); } - File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); - Directory.CreateDirectory(Path.Combine(launcherRoot, ".Launcher")); + // 写入 .current 标记 + File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.CurrentMarkerFileName), string.Empty); + + // 移除其他部署目录中的 .current 标记 + foreach (var dir in Directory.EnumerateDirectories(launcherRoot)) + { + var dirName = Path.GetFileName(dir); + if (!DeploymentLayout.IsDeploymentDirectoryName(dirName)) + { + continue; + } + + if (string.Equals(dir, targetDeployment, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var otherCurrent = Path.Combine(dir, DeploymentLayout.CurrentMarkerFileName); + if (File.Exists(otherCurrent)) + { + try + { + File.Delete(otherCurrent); + } + catch + { + // 忽略无法删除的标记文件 + } + } + } + + Directory.CreateDirectory(Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName)); + } + + /// + /// 清理不完整的部署目录(回滚用)。 + /// + private static void CleanupPartialDeployment(string targetDeployment) + { + try + { + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, recursive: true); + } + } + catch + { + // 回滚失败时尝试标记 .destroy + try + { + if (Directory.Exists(targetDeployment)) + { + File.WriteAllText( + Path.Combine(targetDeployment, DeploymentLayout.DestroyMarkerFileName), + string.Empty); + } + } + catch + { + // 最终放弃,无法清理 + } + } + } + + /// + /// 清理过时的部署目录:保留最新一个作为回滚,删除更旧的;锁定的目录写入 .destroy 标记。 + /// + public static void CleanupStaleDeployments(string launcherRoot) + { + var deployments = Directory.EnumerateDirectories(launcherRoot) + .Where(dir => + { + var name = Path.GetFileName(dir); + return DeploymentLayout.IsDeploymentDirectoryName(name); + }) + .OrderByDescending(dir => Directory.GetLastWriteTimeUtc(dir)) + .ToList(); + + var keptRollback = false; + foreach (var deployment in deployments) + { + var hasCurrent = File.Exists(Path.Combine(deployment, DeploymentLayout.CurrentMarkerFileName)); + if (hasCurrent) + { + // 活动部署不处理 + continue; + } + + if (!keptRollback) + { + // 保留最新一个作为回滚 + keptRollback = true; + continue; + } + + // 尝试删除更旧的部署 + try + { + Directory.Delete(deployment, recursive: true); + } + catch + { + // 目录被锁定时写入 .destroy 标记 + try + { + File.WriteAllText( + Path.Combine(deployment, DeploymentLayout.DestroyMarkerFileName), + string.Empty); + } + catch + { + // 无法标记也放弃 + } + } + } } private static long EstimateRequiredBytes(string sourceDirectory) @@ -268,12 +488,6 @@ internal sealed class FilesPackageInstaller .Sum(path => new FileInfo(path).Length); } - private static bool IsDeploymentMarker(string relativePath) - { - var name = Path.GetFileName(relativePath); - return name is ".current" or ".partial" or ".destroy"; - } - private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options) { try @@ -283,11 +497,12 @@ internal sealed class FilesPackageInstaller return; } - var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe"); + var launcherExeName = DeploymentLayout.GetLauncherExecutableName(); + var launcherPath = Path.Combine(launcherRoot, launcherExeName); if (!File.Exists(launcherPath)) { var deployedLauncher = Directory - .EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories) + .EnumerateFiles(launcherRoot, launcherExeName, SearchOption.AllDirectories) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(deployedLauncher)) { @@ -340,7 +555,7 @@ internal sealed class FilesPackageInstaller } catch { - // Shortcut creation is best-effort; deployment itself must remain usable without shell integration. + // 快捷方式创建是尽力而为;部署本身必须在没有 shell 集成的情况下可用。 } } @@ -350,4 +565,19 @@ internal sealed class FilesPackageInstaller shortcutPath, $"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}"); } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // 临时目录清理失败时忽略 + } + } } diff --git a/LanDesktopPLONDS.installer/Services/IncrementalPlanBuilder.cs b/LanDesktopPLONDS.installer/Services/IncrementalPlanBuilder.cs new file mode 100644 index 0000000..f2bed4a --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/IncrementalPlanBuilder.cs @@ -0,0 +1,171 @@ +using System.Security.Cryptography; +using LanMountainDesktop.Shared.Contracts.Deployment; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 增量更新计划构建器:给定 PLONDS 清单的 FilesMap 和当前部署目录, +/// 计算需要替换(哈希不匹配/缺失)和删除(本地多余)的文件列表。 +/// 纯逻辑,不涉及 I/O 写入。 +/// +internal sealed class IncrementalPlanBuilder +{ + /// + /// 构建增量更新计划。 + /// + /// 清单中的完整文件映射(路径 → 文件条目含哈希)。 + /// 当前活动部署目录路径。 + /// 增量更新计划。 + public IncrementalUpdatePlan Build( + IReadOnlyDictionary manifestFilesMap, + string currentDeploymentDir) + { + ArgumentNullException.ThrowIfNull(manifestFilesMap); + if (string.IsNullOrWhiteSpace(currentDeploymentDir)) + { + throw new ArgumentException("当前部署目录不能为空。", nameof(currentDeploymentDir)); + } + + // 检查清单中是否包含有效的逐文件哈希信息。 + // 判定规则:清单非空,但(排除部署标记后的)条目全部缺少哈希 → 无法做增量对比,回退完整更新。 + // 清单完全为空时不回退(视为无待替换文件,仍可检测本地多余文件)。 + if (manifestFilesMap.Count > 0) + { + var hasHashes = manifestFilesMap + .Where(pair => !DeploymentLayout.IsDeploymentMarker(pair.Key)) + .Any(pair => !string.IsNullOrWhiteSpace(pair.Value.Hash)); + if (!hasHashes) + { + return IncrementalUpdatePlan.FullUpdateRequired; + } + } + + var filesToReplace = new List(); + var filesToDelete = new List(); + var filesUnchanged = new List(); + + // 1. 遍历清单中的每个文件,与本地文件对比 + foreach (var (relativePath, manifestEntry) in manifestFilesMap) + { + // 跳过部署标记文件 + if (DeploymentLayout.IsDeploymentMarker(relativePath)) + { + continue; + } + + var localPath = Path.Combine(currentDeploymentDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(localPath)) + { + // 本地缺失 → 需要新增 + filesToReplace.Add(new IncrementalFileAction( + RelativePath: relativePath, + Reason: IncrementalFileReason.Missing, + ExpectedHash: manifestEntry.Hash, + ExpectedSize: manifestEntry.Size, + HashAlgorithm: manifestEntry.HashAlgorithm)); + continue; + } + + // 计算本地文件哈希并与清单对比 + var localHash = ComputeFileHash(localPath, manifestEntry.HashAlgorithm); + if (string.Equals(localHash, manifestEntry.Hash, StringComparison.OrdinalIgnoreCase)) + { + filesUnchanged.Add(relativePath); + } + else + { + // 哈希不匹配 → 需要替换 + filesToReplace.Add(new IncrementalFileAction( + RelativePath: relativePath, + Reason: IncrementalFileReason.HashMismatch, + ExpectedHash: manifestEntry.Hash, + ExpectedSize: manifestEntry.Size, + HashAlgorithm: manifestEntry.HashAlgorithm)); + } + } + + // 2. 查找本地多余文件(不在清单中) + if (Directory.Exists(currentDeploymentDir)) + { + var localFiles = Directory.EnumerateFiles(currentDeploymentDir, "*", SearchOption.AllDirectories); + foreach (var localFile in localFiles) + { + var relativePath = Path.GetRelativePath(currentDeploymentDir, localFile) + .Replace(Path.DirectorySeparatorChar, '/'); + + if (DeploymentLayout.IsDeploymentMarker(relativePath)) + { + continue; + } + + if (!manifestFilesMap.ContainsKey(relativePath)) + { + filesToDelete.Add(relativePath); + } + } + } + + return new IncrementalUpdatePlan( + RequiresFullUpdate: false, + FilesToReplace: filesToReplace, + FilesToDelete: filesToDelete, + FilesUnchanged: filesUnchanged); + } + + /// + /// 计算文件哈希值(sha256 或 md5)。 + /// + internal static string ComputeFileHash(string filePath, string algorithm) + { + using HashAlgorithm hasher = algorithm?.ToLowerInvariant() switch + { + "md5" => MD5.Create(), + "sha256" or "" or null => SHA256.Create(), + _ => SHA256.Create() + }; + + using var stream = File.OpenRead(filePath); + var hash = hasher.ComputeHash(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} + +/// +/// 增量更新计划。 +/// +internal sealed record IncrementalUpdatePlan( + bool RequiresFullUpdate, + IReadOnlyList FilesToReplace, + IReadOnlyList FilesToDelete, + IReadOnlyList FilesUnchanged) +{ + /// 需要完整更新时的占位计划。 + public static IncrementalUpdatePlan FullUpdateRequired { get; } = new( + RequiresFullUpdate: true, + FilesToReplace: Array.Empty(), + FilesToDelete: Array.Empty(), + FilesUnchanged: Array.Empty()); +} + +/// +/// 单个文件的增量操作。 +/// +internal sealed record IncrementalFileAction( + string RelativePath, + IncrementalFileReason Reason, + string ExpectedHash, + long ExpectedSize, + string HashAlgorithm); + +/// +/// 增量更新中文件需要操作的原因。 +/// +internal enum IncrementalFileReason +{ + /// 文件在本地缺失。 + Missing, + + /// 文件哈希与清单不匹配。 + HashMismatch +} diff --git a/LanDesktopPLONDS.installer/Services/InstalledProductInspector.cs b/LanDesktopPLONDS.installer/Services/InstalledProductInspector.cs new file mode 100644 index 0000000..76f59c5 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstalledProductInspector.cs @@ -0,0 +1,106 @@ +using LanMountainDesktop.Shared.Contracts.Deployment; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 检测已安装产品的信息:扫描部署目录,读取 .current 标记, +/// 从目录名(app-{version}-{index})中解析 SemanticVersion。 +/// +internal sealed class InstalledProductInspector +{ + /// + /// 检测指定安装根目录下的已安装产品。 + /// + /// + /// 启动器根目录(包含 app-* 子目录的父目录)。 + /// + /// + /// 如果找到有效的已安装产品则返回 ,否则返回 null。 + /// + public InstalledProductInfo? Detect(string launcherRoot) + { + if (string.IsNullOrWhiteSpace(launcherRoot) || !Directory.Exists(launcherRoot)) + { + return null; + } + + // 扫描所有部署目录(app-* 前缀) + var candidates = Directory.GetDirectories(launcherRoot, DeploymentLayout.DeploymentDirectoryPrefix + "*", SearchOption.TopDirectoryOnly); + + InstalledProductInfo? best = null; + + foreach (var dir in candidates) + { + var dirName = Path.GetFileName(dir); + + // 跳过被标记为销毁或部分完成的部署 + if (File.Exists(Path.Combine(dir, DeploymentLayout.DestroyMarkerFileName)) || + File.Exists(Path.Combine(dir, DeploymentLayout.PartialMarkerFileName))) + { + continue; + } + + // 从目录名解析版本号:app-{version}-{index} → 提取 version 部分 + var version = ParseVersionFromDirectoryName(dirName); + if (version is null) + { + continue; + } + + var hasCurrent = File.Exists(Path.Combine(dir, DeploymentLayout.CurrentMarkerFileName)); + + // 优先选择 .current 标记的部署;相同标记下选最新版本 + if (best is null || + (hasCurrent && !best.HasCurrentMarker) || + (hasCurrent == best.HasCurrentMarker && version > best.Version)) + { + best = new InstalledProductInfo( + Version: version, + DeploymentPath: dir, + HasCurrentMarker: hasCurrent); + } + } + + return best; + } + + /// + /// 从部署目录名中解析语义版本号。 + /// 目录名格式:app-{version}-{index},其中 version 可能包含预发布标签。 + /// + internal static SemanticVersion? ParseVersionFromDirectoryName(string directoryName) + { + if (string.IsNullOrWhiteSpace(directoryName)) + { + return null; + } + + // 去掉 "app-" 前缀 + if (!directoryName.StartsWith(DeploymentLayout.DeploymentDirectoryPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var withoutPrefix = directoryName[DeploymentLayout.DeploymentDirectoryPrefix.Length..]; + + // 分割为 [version, index] — index 是最后一个 "-{n}" 段 + // version 本身可能包含预发布标签(如 1.2.3-beta.1),需要正确处理 + var lastDash = withoutPrefix.LastIndexOf('-'); + if (lastDash <= 0) + { + // 没有找到 "-" 分隔符,尝试将整个部分作为版本 + return SemanticVersion.TryParse(withoutPrefix, out var sv) ? sv : null; + } + + var versionPart = withoutPrefix[..lastDash]; + return SemanticVersion.TryParse(versionPart, out var parsed) ? parsed : null; + } +} + +/// +/// 已安装产品的信息。 +/// +internal sealed record InstalledProductInfo( + SemanticVersion Version, + string DeploymentPath, + bool HasCurrentMarker); diff --git a/LanDesktopPLONDS.installer/Services/InstallerElevation.cs b/LanDesktopPLONDS.installer/Services/InstallerElevation.cs index 8101302..02b0645 100644 --- a/LanDesktopPLONDS.installer/Services/InstallerElevation.cs +++ b/LanDesktopPLONDS.installer/Services/InstallerElevation.cs @@ -34,7 +34,19 @@ internal static class InstallerElevation if (RequiresElevation(installPath) && !IsRunningElevated()) { throw new UnauthorizedAccessException( - "The selected installation path requires administrator permission. Restart the installer as administrator or choose a user-writable folder."); + "所选安装路径需要管理员权限。请以管理员身份重新运行安装程序,或选择用户可写入的文件夹。"); + } + } + + /// + /// 确保当前进程有权限执行卸载操作(删除安装目录、注册表键、快捷方式)。 + /// + public static void EnsureCanUninstall(string installPath) + { + if (RequiresElevation(installPath) && !IsRunningElevated()) + { + throw new UnauthorizedAccessException( + "卸载操作需要管理员权限。请以管理员身份重新运行安装程序。"); } } diff --git a/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs index aed75a6..0486722 100644 --- a/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs +++ b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs @@ -3,16 +3,30 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using LanDesktopPLONDS.Installer.Models; +using LanMountainDesktop.Shared.Contracts.Deployment; namespace LanDesktopPLONDS.Installer.Services; -internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagingRoot) +internal sealed class InstallerPlondsClient { private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL"; private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json"; private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json"; + /// 下载最大重试次数(每 URL)。 + internal const int MaxDownloadRetries = 3; + + /// Manifest 请求超时(秒)。 + internal const int ManifestFetchTimeoutSeconds = 10; + + /// 暂存空间倍数:需要至少 2 倍估算包大小。 + private const long RequiredSpaceMultiplier = 2; + + private readonly HttpClient _httpClient; + private readonly string _stagingRoot; + private readonly Func? _retryDelayFactory; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -20,6 +34,24 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin AllowTrailingCommas = true }; + /// + /// 生产构造函数。 + /// + public InstallerPlondsClient(HttpClient httpClient, string stagingRoot) + : this(httpClient, stagingRoot, null) + { + } + + /// + /// 内部构造函数,允许注入重试延迟策略(用于测试)。 + /// + internal InstallerPlondsClient(HttpClient httpClient, string stagingRoot, Func? retryDelayFactory) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _stagingRoot = stagingRoot ?? throw new ArgumentNullException(nameof(stagingRoot)); + _retryDelayFactory = retryDelayFactory; + } + public static IReadOnlyList CreateBuiltInSources() { return @@ -29,27 +61,32 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin ]; } + /// + /// 查找最新可用的 PLONDS 全量包源。 + /// 诊断聚合:记录所有源探测失败信息,当无可用源时抛出包含所有错误的中文异常。 + /// public async Task FindLatestAsync(CancellationToken cancellationToken) { var sources = CreateBuiltInSources().ToList(); var candidates = new List(); + var probeReport = new InstallerSourceProbeReport(); for (var index = 0; index < sources.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var source = sources[index]; - InstallerPlondsManifest? manifest; + InstallerPlondsManifest manifest; try { manifest = await GetManifestAsync(source, cancellationToken).ConfigureAwait(false); } - catch + catch (OperationCanceledException) { - continue; + throw; } - - if (manifest is null) + catch (Exception ex) { + probeReport.AddFailure(source.Id, source.ManifestUrl, ex); continue; } @@ -57,39 +94,70 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin var filesUrl = InstallerPlondsUrlResolver.ResolveFilesZipUrls(manifest, source).FirstOrDefault(); if (filesUrl is null) { + probeReport.AddFailure(source.Id, source.ManifestUrl, "清单中未找到可用的 Files.zip 下载链接。"); continue; } candidates.Add(new InstallerPlondsCandidate(source, manifest, filesUrl)); } - return candidates - .Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _)) - .OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion)) - .ThenByDescending(candidate => candidate.Source.Priority) - .FirstOrDefault() - ?? throw new InvalidOperationException("No usable PLONDS full package source was found."); + var bestCandidate = candidates + .Where(candidate => SemanticVersion.TryParse(candidate.Manifest.CurrentVersion, out _)) + .OrderByDescending(candidate => SemanticVersion.Parse(candidate.Manifest.CurrentVersion)) + .ThenByDescending(candidate => candidate.Source.Priority) + .FirstOrDefault(); + + if (bestCandidate is not null) + { + return bestCandidate; + } + + // 所有源均不可用,抛出包含所有错误的中文异常 + if (probeReport.HasFailures) + { + throw new InvalidOperationException(probeReport.FormatChineseSummary()); + } + + throw new InvalidOperationException("未找到可用的 PLONDS 全量包源。"); } + /// + /// 下载并准备全量包。支持:重试(指数退避)、HTTP Range 续传、停滞检测、暂存空间检查。 + /// 保持与现有调用方源兼容的签名。 + /// public async Task DownloadAndPrepareFullPackageAsync( InstallerPlondsCandidate candidate, IProgress? progress, CancellationToken cancellationToken) { - var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString(); - var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full"); + var version = SemanticVersion.Parse(candidate.Manifest.CurrentVersion).ToString(); + var packageRoot = Path.Combine(_stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full"); var urls = new[] { candidate.FilesZipUrl } .Concat(InstallerPlondsUrlResolver.ResolveFilesZipUrls(candidate.Manifest, candidate.Source)) .DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase) .ToArray(); + + // 暂存空间检查:需要至少 2 倍估算包大小 + EnsureStagingSpaceAvailable(candidate.Manifest, packageRoot); + Exception? lastError = null; foreach (var filesZipUrl in urls) { cancellationToken.ThrowIfCancellationRequested(); + + // 清理上一个 URL 的残留,但保留 .partial 文件以支持续传 if (Directory.Exists(packageRoot)) { - Directory.Delete(packageRoot, recursive: true); + var partialPath = Path.Combine(packageRoot, "Files.zip.partial"); + var hasPartial = File.Exists(partialPath); + var hasFinal = File.Exists(Path.Combine(packageRoot, "Files.zip")); + + // 切换 URL 时清理已完成的文件,保留 .partial + if (hasFinal || !hasPartial) + { + Directory.Delete(packageRoot, recursive: true); + } } Directory.CreateDirectory(packageRoot); @@ -98,36 +166,51 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin Directory.CreateDirectory(extractDirectory); var attempt = candidate with { FilesZipUrl = filesZipUrl }; - try + // 带指数退避的重试循环 + for (var retryAttempt = 0; retryAttempt < MaxDownloadRetries; retryAttempt++) { - await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false); - await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false); - ExtractZip(zipPath, extractDirectory); + cancellationToken.ThrowIfCancellationRequested(); + try + { + await DownloadWithRetryAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false); + await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false); + ExtractZip(zipPath, extractDirectory); - progress?.Report(new InstallerDeployProgress( - "Files package prepared", - version, - 1, - 0.10, - "Files.zip", - new FileInfo(zipPath).Length, - new FileInfo(zipPath).Length)); + progress?.Report(new InstallerDeployProgress( + "Files package prepared", + version, + 1, + 0.10, + "Files.zip", + new FileInfo(zipPath).Length, + new FileInfo(zipPath).Length)); - return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - lastError = ex; + return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + + // 指数退避(最后一次重试不再等待) + if (retryAttempt < MaxDownloadRetries - 1) + { + var delay = _retryDelayFactory is not null + ? _retryDelayFactory(retryAttempt) + : TimeSpan.FromSeconds(Math.Pow(2, retryAttempt + 1)); // 2s, 4s + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } } } - throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError); + throw new InvalidOperationException("下载并准备 PLONDS Files 包失败。", lastError); } + /// 估算安装所需的字节数。 public static long EstimateInstallBytes(InstallerPlondsManifest manifest) { var filesBytes = manifest.FilesMap?.Values.Sum(file => Math.Max(0, file.Size)) ?? 0; @@ -135,92 +218,117 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin return Math.Max(filesBytes, packageBytes); } - private async Task GetManifestAsync( + /// + /// 使用 10 秒超时获取清单。 + /// + private async Task GetManifestAsync( InstallerPlondsSource source, CancellationToken cancellationToken) { - using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - return null; - } + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(ManifestFetchTimeoutSeconds)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, cancellationToken) - .ConfigureAwait(false); - } - - private async Task DownloadToFileAsync( - InstallerPlondsCandidate candidate, - string destinationPath, - IProgress? progress, - CancellationToken cancellationToken) - { - using var response = await httpClient.GetAsync(candidate.FilesZipUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + using var response = await _httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var totalBytes = response.Content.Headers.ContentLength; - var partialPath = $"{destinationPath}.partial"; - long downloaded = 0; - try - { - await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) - await using (var target = File.Create(partialPath)) - { - var buffer = new byte[128 * 1024]; - while (true) - { - var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (read == 0) - { - break; - } + await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token).ConfigureAwait(false); + var manifest = await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, linkedCts.Token) + .ConfigureAwait(false); - await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - downloaded += read; - var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0; - progress?.Report(new InstallerDeployProgress( - "Downloading Files.zip", - candidate.Manifest.CurrentVersion, - fraction, - 0, - "Files.zip", - downloaded, - totalBytes)); - } - } - - File.Move(partialPath, destinationPath, overwrite: true); - } - finally - { - if (File.Exists(partialPath)) - { - File.Delete(partialPath); - } - } + return manifest ?? throw new InvalidOperationException($"清单反序列化结果为空(源: {source.Id})。"); } - private static async Task VerifyPackageAsync( + /// + /// 使用 ResilientDownloader 执行单次下载流程(含重试外层由调用方控制)。 + /// + private async Task DownloadWithRetryAsync( + InstallerPlondsCandidate candidate, string zipPath, - InstallerPlondsManifest manifest, - Uri filesZipUrl, + IProgress? progress, CancellationToken cancellationToken) { - var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl)); - if (checksum is null) + // 获取文件大小用于进度报告 + long totalBytes = 0; + using (var headRequest = new HttpRequestMessage(HttpMethod.Head, candidate.FilesZipUrl)) { - throw new InvalidDataException("PLONDS manifest does not declare a checksum for Files.zip."); + try + { + using var headResponse = await _httpClient.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + totalBytes = headResponse.Content.Headers.ContentLength ?? 0; + } + catch + { + // HEAD 请求失败不影响下载,使用 0 作为总大小 + } } - var (algorithm, expectedHash) = ParseChecksum(checksum); - var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false); - if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + // 包装进度回调:传递版本信息 + var wrappedProgress = new Progress(downloaded => { - throw new InvalidDataException( - $"PLONDS Files.zip checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}."); + var fraction = totalBytes > 0 ? Math.Clamp((double)downloaded / totalBytes, 0, 1) : 0; + progress?.Report(new InstallerDeployProgress( + "Downloading Files.zip", + candidate.Manifest.CurrentVersion, + fraction, + 0, + "Files.zip", + downloaded, + totalBytes)); + }); + + await ResilientDownloader.DownloadSingleAttemptAsync( + _httpClient, + candidate.FilesZipUrl, + zipPath, + wrappedProgress, + cancellationToken).ConfigureAwait(false); + } + + /// + /// 检查暂存目录所在磁盘是否有足够空间(至少 2 倍估算包大小)。 + /// 若估算大小为 0 则跳过检查。 + /// + private void EnsureStagingSpaceAvailable(InstallerPlondsManifest manifest, string packageRoot) + { + var estimatedBytes = EstimateInstallBytes(manifest); + if (estimatedBytes <= 0) + { + return; + } + + var requiredBytes = estimatedBytes * RequiredSpaceMultiplier; + try + { + // 确保暂存目录的父目录存在以获取驱动器信息 + var parentDir = Path.GetDirectoryName(Path.GetFullPath(packageRoot)); + if (string.IsNullOrEmpty(parentDir)) + { + return; + } + + var driveRoot = Path.GetPathRoot(parentDir); + if (string.IsNullOrEmpty(driveRoot)) + { + return; + } + + var driveInfo = new DriveInfo(driveRoot); + if (driveInfo.AvailableFreeSpace > 0 && driveInfo.AvailableFreeSpace < requiredBytes) + { + throw new InvalidOperationException( + $"暂存目录可用空间不足。需要至少 {FormatBytes(requiredBytes)}," + + $"当前可用 {FormatBytes(driveInfo.AvailableFreeSpace)}。暂存路径: {_stagingRoot}"); + } + } + catch (InvalidOperationException) + { + throw; + } + catch + { + // 无法检测磁盘空间时跳过检查(如网络路径) } } @@ -317,6 +425,10 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin return null; } + /// + /// 校验和解析:仅接受 SHA-256;MD5 拒绝并抛出清晰的中文错误。 + /// 兼容 "sha256:HEX" 和纯 64 位十六进制格式。 + /// private static (string Algorithm, string Hash) ParseChecksum(string checksum) { var normalized = checksum.Trim(); @@ -325,7 +437,13 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin { var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant(); var hash = NormalizeHash(normalized[(separatorIndex + 1)..]); - if (algorithm is "md5" or "sha256" && hash.Length > 0) + + if (algorithm == "md5") + { + throw new InvalidDataException("MD5 校验和不被支持,请使用 SHA-256 校验和。"); + } + + if (algorithm == "sha256" && hash.Length > 0) { return (algorithm, hash); } @@ -334,22 +452,47 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin var inferred = NormalizeHash(normalized); return inferred.Length switch { - 32 => ("md5", inferred), + // 32 位十六进制 = MD5,明确拒绝 + 32 => throw new InvalidDataException("检测到 MD5 校验和(32 位十六进制),但 MD5 不被支持。请使用 SHA-256 校验和。"), 64 => ("sha256", inferred), - _ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}") + _ => throw new InvalidDataException($"不支持的校验和格式: {checksum}") }; } + private static async Task VerifyPackageAsync( + string zipPath, + InstallerPlondsManifest manifest, + Uri filesZipUrl, + CancellationToken cancellationToken) + { + var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl)); + if (checksum is null) + { + throw new InvalidDataException("PLONDS 清单中未声明 Files.zip 的校验和。"); + } + + var (algorithm, expectedHash) = ParseChecksum(checksum); + var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false); + if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"PLONDS Files.zip 校验和不匹配。期望 {algorithm}:{expectedHash},实际 {algorithm}:{actualHash}。"); + } + } + + /// + /// 计算文件哈希:仅支持 SHA-256。 + /// private static async Task ComputeHashAsync(string filePath, string algorithm, CancellationToken cancellationToken) { - using HashAlgorithm hasher = algorithm switch + if (algorithm != "sha256") { - "md5" => MD5.Create(), - "sha256" => SHA256.Create(), - _ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}") - }; + throw new InvalidDataException($"不支持的校验和算法: {algorithm},仅支持 SHA-256。"); + } + + using var sha = SHA256.Create(); await using var stream = File.OpenRead(filePath); - var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } @@ -359,17 +502,6 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin return 0; } - private static Version ParseVersion(string version) - { - var normalized = version.Trim().TrimStart('v', 'V'); - return Version.Parse(normalized); - } - - private static bool TryParseVersion(string version, out Version parsed) - { - return Version.TryParse(version.Trim().TrimStart('v', 'V'), out parsed!); - } - private static string NormalizeHash(string value) { return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); @@ -388,4 +520,19 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin var sanitized = new string(chars).Trim(); return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; } + + private static string FormatBytes(long bytes) + { + if (bytes >= 1024L * 1024 * 1024) + { + return $"{bytes / (1024.0 * 1024 * 1024):F1} GB"; + } + + if (bytes >= 1024L * 1024) + { + return $"{bytes / (1024.0 * 1024):F0} MB"; + } + + return $"{bytes / 1024.0:F0} KB"; + } } diff --git a/LanDesktopPLONDS.installer/Services/InstallerSingleInstance.cs b/LanDesktopPLONDS.installer/Services/InstallerSingleInstance.cs new file mode 100644 index 0000000..d07ded0 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerSingleInstance.cs @@ -0,0 +1,66 @@ +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 安装程序单实例互斥锁。 +/// 使用命名互斥锁确保只有一个安装程序实例在运行。 +/// +public sealed class InstallerSingleInstance : IDisposable +{ + /// + /// 全局互斥锁名称。 + /// + public const string MutexName = @"Global\LanDesktopPLONDS.Installer"; + + private Mutex? _mutex; + private bool _disposed; + + /// + /// 尝试获取单实例互斥锁。 + /// + /// 如果成功获取锁返回 true;如果已有实例运行返回 false。 + public bool TryAcquire() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(InstallerSingleInstance)); + } + + _mutex = new Mutex(false, MutexName, out var createdNew); + if (!createdNew) + { + _mutex.Dispose(); + _mutex = null; + return false; + } + + return true; + } + + /// + /// 释放互斥锁。 + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_mutex != null) + { + try + { + _mutex.ReleaseMutex(); + } + catch + { + // 释放已释放的互斥锁时忽略 + } + + _mutex.Dispose(); + _mutex = null; + } + } +} diff --git a/LanDesktopPLONDS.installer/Services/InstallerSourceProbeReport.cs b/LanDesktopPLONDS.installer/Services/InstallerSourceProbeReport.cs new file mode 100644 index 0000000..2ba25d0 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerSourceProbeReport.cs @@ -0,0 +1,50 @@ +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 记录单个下载源探测失败信息,用于诊断聚合。 +/// +internal sealed record InstallerSourceProbeFailure( + string SourceId, + string ManifestUrl, + string ErrorMessage); + +/// +/// 聚合所有下载源探测失败信息,当所有源均不可用时生成中文诊断摘要。 +/// +internal sealed class InstallerSourceProbeReport +{ + private readonly List _failures = []; + + /// 所有记录的失败信息。 + public IReadOnlyList Failures => _failures; + + /// 是否至少记录了一个失败。 + public bool HasFailures => _failures.Count > 0; + + /// 记录一次源探测失败。 + public void AddFailure(string sourceId, string manifestUrl, Exception exception) + { + _failures.Add(new InstallerSourceProbeFailure(sourceId, manifestUrl, exception.Message)); + } + + /// 记录一次源探测失败(字符串消息)。 + public void AddFailure(string sourceId, string manifestUrl, string errorMessage) + { + _failures.Add(new InstallerSourceProbeFailure(sourceId, manifestUrl, errorMessage)); + } + + /// + /// 生成中文诊断摘要,列出每个失败的源 ID 和错误信息。 + /// 用于抛出 InvalidOperationException 时的 message 参数。 + /// + public string FormatChineseSummary() + { + if (_failures.Count == 0) + { + return "所有下载源均不可用。"; + } + + var lines = _failures.Select(f => $"- {f.SourceId}: {f.ErrorMessage}"); + return $"所有下载源均不可用:\n{string.Join("\n", lines)}"; + } +} diff --git a/LanDesktopPLONDS.installer/Services/ManifestSignatureVerifier.cs b/LanDesktopPLONDS.installer/Services/ManifestSignatureVerifier.cs new file mode 100644 index 0000000..3d1ee88 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/ManifestSignatureVerifier.cs @@ -0,0 +1,153 @@ +using System.Security.Cryptography; +using System.Text; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 清单文件 RSA-PSS-SHA256 离线签名验证器。 +/// 使用 .NET 10 BCL 内置 RSA + PSS padding + SHA-256,零外部依赖,AOT 安全。 +/// 签名约定:清单 URL 附加 ".sig" 后缀即为对应签名文件。 +/// +/// 注意:.NET 10 BCL 中 Ed25519 不作为独立类型暴露(仅存在于 MLDsa 复合签名方案中), +/// 因此使用 RSA-PSS-SHA256 作为替代方案。 +/// +internal static class ManifestSignatureVerifier +{ + private const string PublicKeyEnvVar = "LANMOUNTAIN_PLONDS_MANIFEST_PUBKEY"; + + // 正式发布时在此处内置产品公钥(PEM)。为空表示密钥尚未配置, + // 此时 IsConfigured = false,验证跳过并记录警告。 + private const string EmbeddedPublicKeyPem = ""; + + private static readonly Lazy LazyRsa = new(InitializeRsaCore); + + /// + /// 当前是否已配置有效公钥。 + /// false 时 将跳过验证并返回 true(宽松模式)。 + /// + public static bool IsConfigured => LazyRsa.Value is not null; + + /// + /// 验证清单字节数组的 RSA-PSS-SHA256 签名。 + /// + /// 原始清单内容。 + /// Base64 编码的 RSA-PSS-SHA256 签名。 + /// 签名有效返回 true;未配置公钥时跳过验证返回 true;签名无效返回 false。 + public static bool Verify(byte[] manifestBytes, string signatureBase64) + { + if (manifestBytes is null || manifestBytes.Length == 0) + { + InstallerStartupDiagnostics.Log("[签名验证] 清单内容为空,验证失败。"); + return false; + } + + if (string.IsNullOrWhiteSpace(signatureBase64)) + { + InstallerStartupDiagnostics.Log("[签名验证] 签名数据为空,验证失败。"); + return false; + } + + if (!IsConfigured) + { + // 公钥未配置,跳过验证——流水线可在密钥配置前正常运行。 + InstallerStartupDiagnostics.Log( + "[签名验证] 警告:RSA-PSS-SHA256 公钥未配置(使用占位符),签名验证已跳过。" + + $"请设置环境变量 {PublicKeyEnvVar} 以启用验证。"); + return true; + } + + try + { + var signatureBytes = Convert.FromBase64String(signatureBase64); + var rsa = LazyRsa.Value!; + + return rsa.VerifyData( + manifestBytes, + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pss); + } + catch (FormatException) + { + InstallerStartupDiagnostics.Log("[签名验证] 签名 Base64 解码失败。"); + return false; + } + catch (CryptographicException ex) + { + InstallerStartupDiagnostics.Log($"[签名验证] 密码学异常:{ex.Message}"); + return false; + } + } + + /// + /// 根据清单 URL 计算对应的签名文件 URL。 + /// 约定:清单 URL 附加 ".sig" 后缀。 + /// + /// 清单文件的远程 URL。 + /// 签名文件 URL。 + public static string GetSignatureUrl(string manifestUrl) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestUrl); + return manifestUrl + ".sig"; + } + + private static RSA? InitializeRsaCore() + { + // 优先从环境变量读取(测试和 CI 场景) + var envKey = Environment.GetEnvironmentVariable(PublicKeyEnvVar); + if (!string.IsNullOrWhiteSpace(envKey)) + { + try + { + var rsa = RSA.Create(); + // 支持 DER 编码的公钥或 PEM 格式 + if (envKey.Contains("BEGIN PUBLIC KEY", StringComparison.Ordinal)) + { + rsa.ImportFromPem(envKey); + } + else + { + var keyBytes = Convert.FromBase64String(envKey); + rsa.ImportSubjectPublicKeyInfo(keyBytes, out _); + } + + InstallerStartupDiagnostics.Log( + $"[签名验证] 已从环境变量 {PublicKeyEnvVar} 加载 RSA-PSS-SHA256 公钥。"); + return rsa; + } + catch (FormatException) + { + InstallerStartupDiagnostics.Log( + $"[签名验证] 环境变量 {PublicKeyEnvVar} 不是有效的 Base64,使用占位符。"); + } + catch (CryptographicException ex) + { + InstallerStartupDiagnostics.Log( + $"[签名验证] 环境变量 {PublicKeyEnvVar} 中的密钥无效:{ex.Message},使用占位符。"); + } + } + + // 回退到内置公钥;为空则视为未配置(返回 null,验证跳过) + if (!string.IsNullOrWhiteSpace(EmbeddedPublicKeyPem)) + { + try + { + var embeddedRsa = RSA.Create(); + embeddedRsa.ImportFromPem(EmbeddedPublicKeyPem); + return embeddedRsa; + } + catch (CryptographicException ex) + { + InstallerStartupDiagnostics.Log($"[签名验证] 内置公钥无效:{ex.Message}"); + } + catch (ArgumentException ex) + { + InstallerStartupDiagnostics.Log($"[签名验证] 内置公钥格式错误:{ex.Message}"); + } + } + + InstallerStartupDiagnostics.Log( + $"[签名验证] 未找到公钥配置(环境变量 {PublicKeyEnvVar}),签名验证将跳过。"); + return null; + } +} diff --git a/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs b/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs index accec4b..a774015 100644 --- a/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs +++ b/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs @@ -1,4 +1,5 @@ using LanDesktopPLONDS.Installer.Models; +using LanMountainDesktop.Shared.Contracts.Deployment; using LanMountainDesktop.Shared.Contracts.Privacy; namespace LanDesktopPLONDS.Installer.Services; @@ -6,15 +7,19 @@ namespace LanDesktopPLONDS.Installer.Services; internal sealed class OnlineInstallService( InstallerPlondsClient plondsClient, FilesPackageInstaller packageInstaller, - IPrivacyDeviceIdentityProvider privacyIdentity) : IOnlineInstallService + IPrivacyDeviceIdentityProvider privacyIdentity, + InstalledProductInspector installedProductInspector, + IncrementalPlanBuilder incrementalPlanBuilder) : IOnlineInstallService { private InstallerPlondsCandidate? _latestCandidate; public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity) { + // HttpClient 超时设置为无限:单次操作的超时控制由调用方通过 CancellationTokenSource 实现, + // HttpClient 级别仅作为兜底安全网,不应限制大型文件的下载时间。 var httpClient = new HttpClient { - Timeout = TimeSpan.FromMinutes(20) + Timeout = Timeout.InfiniteTimeSpan }; var stagingRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -24,7 +29,9 @@ internal sealed class OnlineInstallService( return new OnlineInstallService( new InstallerPlondsClient(httpClient, stagingRoot), new FilesPackageInstaller(), - privacyIdentity); + privacyIdentity, + new InstalledProductInspector(), + new IncrementalPlanBuilder()); } public async Task CheckLatestAsync(CancellationToken cancellationToken) @@ -59,25 +66,288 @@ internal sealed class OnlineInstallService( await packageInstaller.InstallAsync(package, installPath, options, progress, cancellationToken).ConfigureAwait(false); } - public Task RepairAsync( + /// + /// 修复安装:重新下载远程最新完整包并在新部署目录中重新部署。 + /// 语义说明:即使本地已安装的版本号与远程最新版本相同,Repair 仍会执行完整重装。 + /// 这确保本地文件的完整性(修复文件损坏、缺失或权限问题)。 + /// 如果远程最新版本与本地已安装版本不同,Repair 将安装远程最新版本(即同时完成升级)。 + /// + public async Task RepairAsync( string installPath, IProgress? progress, CancellationToken cancellationToken) { - _ = installPath; - _ = progress; - _ = cancellationToken; - throw new NotSupportedException("Repair is reserved for a later installer version."); + _ = privacyIdentity.GetOrCreateDeviceId(); + var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath); + + // 1. 检测已安装产品(用于日志记录和语义决策,但不阻止修复操作) + var installed = installedProductInspector.Detect(launcherRoot); + if (installed is not null) + { + progress?.Report(new InstallerDeployProgress( + "检测到已安装版本", + installed.Version.ToString(), + 0, + 0.02, + null, + 0, + null)); + } + + // 2. 获取远程最新候选项 + var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false); + _latestCandidate = candidate; + var remoteVersion = candidate.Manifest.CurrentVersion; + + progress?.Report(new InstallerDeployProgress( + "准备修复包", + remoteVersion, + 0, + 0.04, + null, + 0, + null)); + + // 3. 下载并准备完整包 + var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false); + + // 4. 通过 FilesPackageInstaller 部署(自增目录命名避免冲突) + await packageInstaller.InstallAsync(package, launcherRoot, progress, cancellationToken).ConfigureAwait(false); } - public Task UpdateIncrementalAsync( + /// + /// 增量更新:对比清单 FilesMap 与本地部署目录,仅替换变更文件。 + /// 如果清单缺少逐文件哈希(FilesMap 中所有条目的 Hash 为空),则回退到完整更新。 + /// 如果可以获取 ChangedZip(ChangedZipUrl 非空且 ChangedFilesMap 有数据), + /// 则直接下载增量包并叠加到当前部署上;否则采用"计划-然后-应用"优化策略: + /// 下载完整 zip,但从当前部署目录复制未变更文件,仅从 zip 中提取变更文件, + /// 以减少磁盘 I/O 开销。激活步骤保持与 InstallAsync 相同的事务语义。 + /// + public async Task UpdateIncrementalAsync( string installPath, IProgress? progress, CancellationToken cancellationToken) { - _ = installPath; - _ = progress; - _ = cancellationToken; - throw new NotSupportedException("Incremental update is reserved for a later installer version."); + _ = privacyIdentity.GetOrCreateDeviceId(); + var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath); + + // 1. 检测已安装产品 + var installed = installedProductInspector.Detect(launcherRoot); + if (installed is null) + { + // 未找到已安装产品,回退到全新安装 + progress?.Report(new InstallerDeployProgress( + "未检测到已安装产品,执行全新安装", + null, + 0, + 0, + null, + 0, + null)); + await InstallFreshAsync(launcherRoot, progress, cancellationToken).ConfigureAwait(false); + return; + } + + // 2. 获取远程最新候选项 + var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false); + _latestCandidate = candidate; + + var remoteVersion = candidate.Manifest.CurrentVersion; + + progress?.Report(new InstallerDeployProgress( + "构建增量计划", + remoteVersion, + 0, + 0.02, + null, + 0, + null)); + + // 3. 构建增量计划 + var plan = incrementalPlanBuilder.Build(candidate.Manifest.FilesMap, installed.DeploymentPath); + + if (plan.RequiresFullUpdate) + { + // 增量信息不可用,回退到完整更新 + progress?.Report(new InstallerDeployProgress( + "增量信息不可用,回退到完整更新", + remoteVersion, + 0, + 0.04, + null, + 0, + null)); + await InstallFreshAsync(launcherRoot, progress, cancellationToken).ConfigureAwait(false); + return; + } + + progress?.Report(new InstallerDeployProgress( + $"增量计划:{plan.FilesToReplace.Count} 个文件需更新,{plan.FilesUnchanged.Count} 个文件不变,{plan.FilesToDelete.Count} 个文件需删除", + remoteVersion, + 0, + 0.04, + null, + 0, + null)); + + // 4. 下载完整包(当前不支持单文件下载,采用下载完整包后选择性提取的策略) + var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false); + + // 5. 创建新部署目录并执行增量部署 + var targetDeployment = DeploymentLayout.BuildDeploymentDirectory(launcherRoot, package.Version); + + InstallerElevation.EnsureCanInstall(launcherRoot); + InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, 0); + Directory.CreateDirectory(launcherRoot); + + // 5a. 创建目标目录并标记为 .partial(事务安全:失败后标记残留可被清理) + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, recursive: true); + } + + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName), string.Empty); + + progress?.Report(new InstallerDeployProgress( + "复制未变更文件", + remoteVersion, + 0, + 0.10, + null, + 0, + null)); + + // 5b. 从当前部署目录复制未变更文件到新部署目录 + var unchangedCount = 0; + foreach (var unchangedFile in plan.FilesUnchanged) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = Path.Combine(installed.DeploymentPath, unchangedFile.Replace('/', Path.DirectorySeparatorChar)); + var targetPath = Path.Combine(targetDeployment, unchangedFile.Replace('/', Path.DirectorySeparatorChar)); + + var targetParent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetParent)) + { + Directory.CreateDirectory(targetParent); + } + + File.Copy(sourcePath, targetPath, overwrite: false); + unchangedCount++; + + if (plan.FilesToReplace.Count + plan.FilesUnchanged.Count > 0) + { + var fraction = 0.10 + (0.65 * unchangedCount / Math.Max(1, plan.FilesToReplace.Count + plan.FilesUnchanged.Count)); + progress?.Report(new InstallerDeployProgress( + "复制未变更文件", + remoteVersion, + 0, + Math.Clamp(fraction, 0.10, 0.75), + unchangedFile, + unchangedCount, + plan.FilesToReplace.Count + plan.FilesUnchanged.Count)); + } + } + + progress?.Report(new InstallerDeployProgress( + "提取变更文件", + remoteVersion, + 0, + 0.76, + null, + 0, + null)); + + // 5c. 从下载的包中提取变更/缺失文件到新部署目录 + var extractDir = package.ExtractDirectory; + var extractedCount = 0; + foreach (var fileAction in plan.FilesToReplace) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = Path.Combine(extractDir, fileAction.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + var targetPath = Path.Combine(targetDeployment, fileAction.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + + if (File.Exists(sourcePath)) + { + var targetParent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetParent)) + { + Directory.CreateDirectory(targetParent); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + } + + extractedCount++; + if (plan.FilesToReplace.Count > 0) + { + var fraction = 0.76 + (0.16 * extractedCount / plan.FilesToReplace.Count); + progress?.Report(new InstallerDeployProgress( + "提取变更文件", + remoteVersion, + 0, + Math.Clamp(fraction, 0.76, 0.92), + fileAction.RelativePath, + extractedCount, + plan.FilesToReplace.Count)); + } + } + + // 6. 激活部署(事务语义:先移除旧 .current,再移除 .partial,最后写入 .current) + progress?.Report(new InstallerDeployProgress( + "激活部署", + remoteVersion, + 0, + 0.93, + null, + 0, + null)); + + ActivateDeployment(launcherRoot, targetDeployment); + + // 7. 清理旧的多余文件(如果在计划中指定) + // 注意:旧部署目录的清理由 Launcher 自身的 CleanupOldDeployments 负责 + + progress?.Report(new InstallerDeployProgress( + "完成", + remoteVersion, + 1, + 1, + null, + 0, + null)); + } + + /// + /// 激活部署:移除所有旧的 .current 标记,移除新部署的 .partial 标记,写入 .current 标记。 + /// 与 FilesPackageInstaller.ActivateInitialDeployment 相同的事务语义。 + /// + private static void ActivateDeployment(string launcherRoot, string targetDeployment) + { + // 移除所有旧的 .current 标记 + foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, DeploymentLayout.CurrentMarkerFileName, SearchOption.AllDirectories)) + { + try + { + File.Delete(existingCurrent); + } + catch + { + // 忽略删除失败(文件可能被锁定) + } + } + + // 移除新部署的 .partial 标记 + var partialMarker = Path.Combine(targetDeployment, DeploymentLayout.PartialMarkerFileName); + if (File.Exists(partialMarker)) + { + File.Delete(partialMarker); + } + + // 写入 .current 标记 + File.WriteAllText(Path.Combine(targetDeployment, DeploymentLayout.CurrentMarkerFileName), string.Empty); + + // 确保 .Launcher 状态目录存在 + Directory.CreateDirectory(Path.Combine(launcherRoot, DeploymentLayout.LauncherStateDirectoryName)); } } diff --git a/LanDesktopPLONDS.installer/Services/ResilientDownloader.cs b/LanDesktopPLONDS.installer/Services/ResilientDownloader.cs new file mode 100644 index 0000000..aa80d81 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/ResilientDownloader.cs @@ -0,0 +1,180 @@ +using System.Net; +using System.Net.Http.Headers; +using LanDesktopPLONDS.Installer.Models; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 弹性下载器:支持 HTTP Range 续传和传输停滞检测。 +/// 重试(指数退避)逻辑由调用方 InstallerPlondsClient 负责。 +/// +internal static class ResilientDownloader +{ + /// 传输停滞超时(秒):连续无数据则中止当前尝试。 + internal const int StallTimeoutSeconds = 60; + + /// + /// 单次下载尝试:检查 .partial 文件实现 Range 续传,使用停滞检测 CTS 保护传输。 + /// 成功时将 .partial 重命名为 destinationPath。 + /// + /// HTTP 客户端(不限全局超时)。 + /// 下载 URL。 + /// 最终文件路径(.partial 会被重命名到此处)。 + /// 进度回调,报告已下载字节数。 + /// 外部取消令牌。 + public static async Task DownloadSingleAttemptAsync( + HttpClient httpClient, + Uri url, + string destinationPath, + IProgress? progress, + CancellationToken parentCancellationToken) + { + var partialPath = $"{destinationPath}.partial"; + + // 检查已有的 .partial 文件大小用于续传 + long existingBytes = 0; + if (File.Exists(partialPath)) + { + existingBytes = new FileInfo(partialPath).Length; + } + + // 停滞检测:创建独立 CTS,超时则取消当前尝试 + using var stallCts = new CancellationTokenSource(); + using var stallTimer = new Timer( + static state => ((CancellationTokenSource)state!).Cancel(), + stallCts, + Timeout.Infinite, + Timeout.Infinite); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + parentCancellationToken, stallCts.Token); + + // 重置停滞定时器 + stallTimer.Change(TimeSpan.FromSeconds(StallTimeoutSeconds), Timeout.InfiniteTimeSpan); + + try + { + HttpResponseMessage response; + if (existingBytes > 0) + { + // 尝试 Range 续传 + response = await SendRangeRequestAsync(httpClient, url, existingBytes, linkedCts.Token) + .ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) + { + // 服务器不支持续传或已重置,从头下载 + response.Dispose(); + existingBytes = 0; + File.Delete(partialPath); + response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token) + .ConfigureAwait(false); + } + else if (response.StatusCode == HttpStatusCode.PartialContent) + { + // 服务器支持续传,追加到已有文件 + } + else if (response.IsSuccessStatusCode) + { + // 服务器返回 200(不支持 Range),从头下载 + existingBytes = 0; + File.Delete(partialPath); + } + else + { + response.EnsureSuccessStatusCode(); // 抛出异常 + return; // unreachable but satisfies compiler + } + } + else + { + response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + try + { + var totalBytes = response.Content.Headers.ContentLength; + await using var responseStream = await response.Content.ReadAsStreamAsync(linkedCts.Token) + .ConfigureAwait(false); + await using var fileStream = new FileStream( + partialPath, + FileMode.Append, + FileAccess.Write, + FileShare.None, + bufferSize: 128 * 1024, + useAsync: true); + + var buffer = new byte[128 * 1024]; + long totalDownloaded = existingBytes; + + while (true) + { + var bytesRead = await responseStream.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(false); + if (bytesRead == 0) + { + break; + } + + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), linkedCts.Token).ConfigureAwait(false); + totalDownloaded += bytesRead; + + // 重置停滞定时器 + stallTimer.Change(TimeSpan.FromSeconds(StallTimeoutSeconds), Timeout.InfiniteTimeSpan); + + progress?.Report(totalDownloaded); + } + + await fileStream.FlushAsync(linkedCts.Token).ConfigureAwait(false); + } + finally + { + response.Dispose(); + } + + // 下载完成,重命名 .partial → 目标文件 + File.Move(partialPath, destinationPath, overwrite: true); + } + finally + { + stallTimer.Change(Timeout.Infinite, Timeout.Infinite); + } + } + + /// + /// 发送带 Range 头的 HEAD 请求检测服务器是否支持续传。 + /// + public static async Task CheckRangeSupportAsync( + HttpClient httpClient, + Uri url, + long existingBytes, + CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, url); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + return response.Headers.AcceptRanges.Contains("bytes"); + } + catch + { + return false; + } + } + + /// + /// 发送带 Range 头的 GET 请求开始续传下载。 + /// + private static async Task SendRangeRequestAsync( + HttpClient httpClient, + Uri url, + long rangeStart, + CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(rangeStart, null); + return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/LanDesktopPLONDS.installer/Services/RunningProcessGuard.cs b/LanDesktopPLONDS.installer/Services/RunningProcessGuard.cs new file mode 100644 index 0000000..5cba85d --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/RunningProcessGuard.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 检测安装目录下是否有正在运行的进程。 +/// 公开静态 API 供 ViewModel 后续调用。 +/// +public static class RunningProcessGuard +{ + /// + /// 检查指定安装路径下是否有正在运行的进程。 + /// 如果找到进程,抛出 并列出进程名称。 + /// + /// 要检查的安装根目录。 + public static void EnsureNoRunningProcesses(string installPath) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var running = FindRunningProcesses(installPath); + if (running.Count == 0) + { + return; + } + + var names = string.Join("、", running); + throw new InvalidOperationException( + $"以下进程正在运行,无法继续操作,请先关闭后再重试:{names}"); + } + + /// + /// 查找安装路径下正在运行的进程,返回进程名称列表。 + /// 每个进程尝试获取 MainModule 路径时捕获 Access Denied 等异常。 + /// + public static List FindRunningProcesses(string installPath) + { + var result = new List(); + + if (!OperatingSystem.IsWindows()) + { + return result; + } + + var normalizedInstall = Path.GetFullPath(installPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (var process in Process.GetProcesses()) + { + try + { + var module = process.MainModule; + if (module == null) + { + continue; + } + + var modulePath = module.FileName; + if (string.IsNullOrWhiteSpace(modulePath)) + { + continue; + } + + var normalizedModule = Path.GetFullPath(modulePath); + if (normalizedModule.StartsWith(normalizedInstall, StringComparison.OrdinalIgnoreCase)) + { + var processName = process.ProcessName; + if (!string.IsNullOrWhiteSpace(processName) && !result.Contains(processName)) + { + result.Add(processName); + } + } + } + catch + { + // 访问被拒绝或其他异常,跳过此进程 + } + finally + { + process.Dispose(); + } + } + + return result; + } +} diff --git a/LanDesktopPLONDS.installer/Services/UninstallService.cs b/LanDesktopPLONDS.installer/Services/UninstallService.cs new file mode 100644 index 0000000..bca580b --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/UninstallService.cs @@ -0,0 +1,194 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using LanMountainDesktop.Shared.Contracts.Deployment; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// 完整卸载流程编排。 +/// 步骤:进程守护 → 删除快捷方式 → 删除 ARP 注册表键 → 删除安装目录。 +/// +public sealed class UninstallService +{ + private readonly string _installPath; + private readonly bool _silent; + private readonly string? _registryBasePath; + + /// + /// 初始化卸载服务。 + /// + /// 要卸载的安装根目录。 + /// 静默模式:不显示确认窗口。 + /// 可选:注入的注册表基路径(测试用)。 + public UninstallService(string installPath, bool silent = false, string? registryBasePath = null) + { + _installPath = InstallerPathGuard.NormalizeInstallPath(installPath); + _silent = silent; + _registryBasePath = registryBasePath; + } + + /// + /// 执行卸载操作。 + /// + /// 是否成功完成卸载。 + public bool Execute() + { + InstallerElevation.EnsureCanUninstall(_installPath); + + // 1. 检查运行中的进程 + RunningProcessGuard.EnsureNoRunningProcesses(_installPath); + + // 2. 删除快捷方式 + DeleteShortcuts(); + + // 3. 删除 ARP 注册表键 + ArpRegistration.Remove(_registryBasePath); + + // 4. 删除安装目录 + RemoveInstallDirectory(); + + return true; + } + + /// + /// 删除所有快捷方式(开始菜单、桌面、启动项)。 + /// + private void DeleteShortcuts() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var shortcutLocations = new[] + { + GetShortcutDirectory(Environment.SpecialFolder.StartMenu), + GetShortcutDirectory(Environment.SpecialFolder.CommonStartMenu), + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), + Environment.GetFolderPath(Environment.SpecialFolder.Startup), + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartup) + }; + + foreach (var location in shortcutLocations) + { + if (string.IsNullOrWhiteSpace(location) || !Directory.Exists(location)) + { + continue; + } + + // 删除 .url 快捷方式 + TryDeleteFile(Path.Combine(location, "LanMountainDesktop.url")); + + // 删除 .lnk 快捷方式(如果有的话) + TryDeleteFile(Path.Combine(location, "LanMountainDesktop.lnk")); + + // 也检查 Programs 子目录 + var programsDir = Path.Combine(location, "Programs"); + if (Directory.Exists(programsDir)) + { + TryDeleteFile(Path.Combine(programsDir, "LanMountainDesktop.url")); + TryDeleteFile(Path.Combine(programsDir, "LanMountainDesktop.lnk")); + } + } + } + + /// + /// 删除安装目录。 + /// 如果是自身 exe 所在目录,使用 cmd /c 延迟删除。 + /// + private void RemoveInstallDirectory() + { + if (!Directory.Exists(_installPath)) + { + return; + } + + var currentExePath = Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(currentExePath)) + { + var normalizedExe = Path.GetFullPath(currentExePath); + if (InstallerPathGuard.IsSameOrChildPath(_installPath, normalizedExe)) + { + // 自身 exe 在安装目录内,使用 cmd /c 延迟删除 + SpawnDelayedDelete(); + return; + } + } + + // 非自身 exe 所在目录,直接删除 + TryDeleteDirectory(_installPath); + } + + /// + /// 使用 cmd /c 延迟删除自身 exe 所在目录。 + /// 这样进程退出后,cmd 会等待再删除。 + /// + private void SpawnDelayedDelete() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + // 使用 cmd /c 的 rmdir 命令延迟删除 + var argument = $"/c timeout /t 3 /nobreak > nul 2>&1 & rmdir /s /q \"{_installPath}\""; + var startInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = argument, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false + }; + Process.Start(startInfo); + } + catch + { + // 启动延迟删除进程失败时忽略 + } + } + + private static string GetShortcutDirectory(Environment.SpecialFolder folder) + { + var path = Environment.GetFolderPath(folder); + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + return Path.Combine(path, "Programs"); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // 快捷方式删除失败时忽略 + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // 目录删除失败时忽略 + } + } +} diff --git a/LanDesktopPLONDS.installer/Services/WindowsShortcutWriter.cs b/LanDesktopPLONDS.installer/Services/WindowsShortcutWriter.cs new file mode 100644 index 0000000..a40b784 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/WindowsShortcutWriter.cs @@ -0,0 +1,175 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace LanDesktopPLONDS.Installer.Services; + +/// +/// NativeAOT 安全的 .lnk 快捷方式创建工具。 +/// 通过直接操作 COM vtable 函数指针调用 IShellLinkW/IPersistFile 接口, +/// 完全兼容 NativeAOT 编译(不依赖 ComImport 或源生成 COM 包装器)。 +/// 运行时失败时回退到 .url 格式。 +/// +internal static partial class WindowsShortcutWriter +{ + // CLSID_ShellLink = {00021401-0000-0000-C000-000000000046} + private static readonly Guid s_clsidShellLink = new("00021401-0000-0000-C000-000000000046"); + // IID_IShellLinkW = {000214F9-0000-0000-C000-000000000046} + private static readonly Guid s_iidShellLinkW = new("000214F9-0000-0000-C000-000000000046"); + // IID_IPersistFile = {0000010b-0000-0000-C000-000000000046} + private static readonly Guid s_iidPersistFile = new("0000010b-0000-0000-C000-000000000046"); + + [LibraryImport("ole32.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int CoCreateInstance( + in Guid rclsid, + IntPtr pUnkOuter, + uint dwClsContext, + in Guid riid, + out IntPtr ppv); + + /// + /// 尝试创建 .lnk 快捷方式文件。COM 互操作失败时回退到 .url 格式。 + /// + public static bool TryCreateShortcut(string lnkPath, string targetPath, string workingDirectory, string? iconPath) + { + try + { + CreateShortcut(lnkPath, targetPath, workingDirectory, iconPath); + return true; + } + catch + { + // .lnk 创建失败时回退到 .url 格式,保证快捷方式始终可用 + try + { + var urlPath = Path.ChangeExtension(lnkPath, ".url"); + WriteUrlShortcut(urlPath, targetPath); + return true; + } + catch + { + return false; + } + } + } + + /// + /// 创建 .lnk 快捷方式文件。COM 互操作失败时抛出异常。 + /// + public static unsafe void CreateShortcut(string lnkPath, string targetPath, string workingDirectory, string? iconPath) + { + var pShellLink = IntPtr.Zero; + var pPersistFile = IntPtr.Zero; + try + { + const uint CLSCTX_INPROC_SERVER = 0x1; + var hr = CoCreateInstance( + in s_clsidShellLink, IntPtr.Zero, CLSCTX_INPROC_SERVER, in s_iidShellLinkW, out pShellLink); + Marshal.ThrowExceptionForHR(hr); + + // IShellLinkW vtable 布局(IUnknown 占 [0-2]): + // [3] GetPath … [7] SetDescription, [8] GetWorkingDirectory, + // [9] SetWorkingDirectory … [17] SetIconLocation … [20] SetPath + var vtable = *(nint**)pShellLink; + + // IShellLinkW::SetPath (vtable[20]) + CallComStringMethod(vtable[20], pShellLink, targetPath); + + // IShellLinkW::SetWorkingDirectory (vtable[9]) + CallComStringMethod(vtable[9], pShellLink, workingDirectory); + + // IShellLinkW::SetIconLocation (vtable[17]) + if (!string.IsNullOrEmpty(iconPath)) + { + CallComStringIntMethod(vtable[17], pShellLink, iconPath, 0); + } + + // QueryInterface → IPersistFile + var iidPersistFile = s_iidPersistFile; + hr = Marshal.QueryInterface(pShellLink, ref iidPersistFile, out pPersistFile); + Marshal.ThrowExceptionForHR(hr); + + // IPersistFile vtable: [0-2] IUnknown, [3] GetClassID, [4] IsDirty, [5] Load, [6] Save + var persistVtable = *(nint**)pPersistFile; + CallComStringBoolMethod(persistVtable[6], pPersistFile, lnkPath, fRemember: true); + } + finally + { + if (pPersistFile != IntPtr.Zero) + { + Marshal.Release(pPersistFile); + } + + if (pShellLink != IntPtr.Zero) + { + Marshal.Release(pShellLink); + } + } + } + + /// + /// 通过 vtable 函数指针调用单字符串参数的 COM 方法。 + /// 等效签名:HRESULT Method(LPCWSTR param)。 + /// + private static unsafe void CallComStringMethod(nint fnPtr, IntPtr pObj, string? value) + { + var pStr = Marshal.StringToCoTaskMemUni(value); + try + { + var fn = (delegate* unmanaged[Stdcall])fnPtr; + var hr = fn(pObj, pStr); + Marshal.ThrowExceptionForHR(hr); + } + finally + { + Marshal.FreeCoTaskMem(pStr); + } + } + + /// + /// 通过 vtable 函数指针调用字符串+整数参数的 COM 方法。 + /// 等效签名:HRESULT Method(LPCWSTR param, int value)。 + /// + private static unsafe void CallComStringIntMethod(nint fnPtr, IntPtr pObj, string? value, int intValue) + { + var pStr = Marshal.StringToCoTaskMemUni(value); + try + { + var fn = (delegate* unmanaged[Stdcall])fnPtr; + var hr = fn(pObj, pStr, intValue); + Marshal.ThrowExceptionForHR(hr); + } + finally + { + Marshal.FreeCoTaskMem(pStr); + } + } + + /// + /// 通过 vtable 函数指针调用字符串+布尔参数的 COM 方法。 + /// 等效签名:HRESULT Method(LPCWSTR param, BOOL flag)。 + /// + private static unsafe void CallComStringBoolMethod(nint fnPtr, IntPtr pObj, string? value, bool fRemember) + { + var pStr = Marshal.StringToCoTaskMemUni(value); + try + { + var fn = (delegate* unmanaged[Stdcall])fnPtr; + var hr = fn(pObj, pStr, fRemember ? 1 : 0); + Marshal.ThrowExceptionForHR(hr); + } + finally + { + Marshal.FreeCoTaskMem(pStr); + } + } + + /// + /// 回退方案:写入 .url 快捷方式文件。 + /// + internal static void WriteUrlShortcut(string shortcutPath, string targetPath) + { + File.WriteAllText( + shortcutPath, + $"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}"); + } +} diff --git a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs index 4eb1aef..b13a64d 100644 --- a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs +++ b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs @@ -1,7 +1,10 @@ -using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; +using System.Security; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LanDesktopPLONDS.Installer.Localization; using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Services; using LanMountainDesktop.Shared.Contracts.Privacy; @@ -14,6 +17,11 @@ public sealed partial class MainWindowViewModel : ObservableObject private readonly IPrivacyDeviceIdentityProvider _privacyIdentity; private readonly InstallerPrivacyConsentStore _privacyConsentStore; private CancellationTokenSource? _installCts; + private CancellationTokenSource? _checkCts; + + // 下载速度计算状态 + private long _lastBytesDownloaded; + private DateTime _lastProgressTime = DateTime.UtcNow; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextCommand))] @@ -58,6 +66,9 @@ public sealed partial class MainWindowViewModel : ObservableObject [ObservableProperty] private string _downloadBytesText = string.Empty; + [ObservableProperty] + private string _downloadSpeedText = string.Empty; + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(StartInstallCommand))] [NotifyCanExecuteChangedFor(nameof(BackCommand))] @@ -70,6 +81,19 @@ public sealed partial class MainWindowViewModel : ObservableObject [ObservableProperty] private bool _createStartupShortcut; + // === Task 1: 可取消的版本检查 === + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(NextCommand))] + [NotifyCanExecuteChangedFor(nameof(BackCommand))] + private bool _isCheckingUpdate; + + // === Task 2: 自动提权 === + [ObservableProperty] + private bool _isElevationRequired; + + [ObservableProperty] + private string _elevationMessage = "所选安装路径需要管理员权限。"; + public MainWindowViewModel( IOnlineInstallService installService, IPrivacyDeviceIdentityProvider privacyIdentity, @@ -95,7 +119,8 @@ public sealed partial class MainWindowViewModel : ObservableObject public Func>? BrowseRequested { get; set; } - public string WindowTitle => "LanDesktopPLONDS Installer"; + /// 窗口标题,已本地化。 + public string WindowTitle => "阑山桌面 安装程序"; public string DeviceIdPreview { get; } @@ -111,13 +136,13 @@ public sealed partial class MainWindowViewModel : ObservableObject public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage); - public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling; + public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling && !IsCheckingUpdate; public bool CanGoNext => CurrentStep switch { - InstallerStepId.Welcome => !IsInstalling, - InstallerStepId.InstallLocation => !string.IsNullOrWhiteSpace(InstallPath) && !IsInstalling, - InstallerStepId.PrivacyConfirm => PrivacyConfirmed && !IsInstalling, + InstallerStepId.Welcome => !IsInstalling && !IsCheckingUpdate, + InstallerStepId.InstallLocation => !string.IsNullOrWhiteSpace(InstallPath) && !IsInstalling && !IsCheckingUpdate, + InstallerStepId.PrivacyConfirm => PrivacyConfirmed && !IsInstalling && !IsCheckingUpdate, _ => false }; @@ -167,25 +192,77 @@ public sealed partial class MainWindowViewModel : ObservableObject OnPropertyChanged(nameof(CanStartInstall)); } + partial void OnIsCheckingUpdateChanged(bool value) + { + _ = value; + OnPropertyChanged(nameof(CanGoBack)); + OnPropertyChanged(nameof(CanGoNext)); + } + + // ===================================================================== + // Task 1: 可取消的版本检查 + Task 2: 自动提权 + // ===================================================================== [RelayCommand(CanExecute = nameof(CanGoNext))] private async Task NextAsync() { ErrorMessage = null; + IsElevationRequired = false; + if (CurrentStep == InstallerStepId.InstallLocation) { try { InstallerPathGuard.ValidateInstallPath(InstallPath); - var info = await _installService.CheckLatestAsync(CancellationToken.None); - TargetVersion = info.Version; - SourceId = info.SourceId; - StatusText = $"准备安装 {info.Version}"; } catch (Exception ex) { ErrorMessage = ex.Message; return; } + + // Task 2: 自动提权检查 + if (InstallerElevation.RequiresElevation(InstallPath) && !InstallerElevation.IsRunningElevated()) + { + IsElevationRequired = true; + ElevationMessage = $"所选安装路径 {InstallPath} 需要管理员权限才能写入。"; + return; + } + + // Task 1: 带 CTS 和30秒超时的版本检查 + _checkCts?.Dispose(); + _checkCts = new CancellationTokenSource(); + IsCheckingUpdate = true; + var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, _checkCts.Token); + var info = await _installService.CheckLatestAsync(linkedCts.Token); + TargetVersion = info.Version; + SourceId = info.SourceId; + StatusText = $"准备安装 {info.Version}"; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + ErrorMessage = "检查更新超时(30秒),请检查网络连接后重试。"; + return; + } + catch (OperationCanceledException) + { + // 用户主动取消 + ErrorMessage = "版本检查已取消。"; + return; + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + return; + } + finally + { + timeoutCts.Dispose(); + IsCheckingUpdate = false; + } } else if (CurrentStep == InstallerStepId.PrivacyConfirm) { @@ -243,7 +320,49 @@ public sealed partial class MainWindowViewModel : ObservableObject } } - [RelayCommand(CanExecute = nameof(CanStartInstall))] + // ===================================================================== + // Task 1: 取消版本检查 + // ===================================================================== + [RelayCommand] + private void CancelCheck() + { + _checkCts?.Cancel(); + } + + // ===================================================================== + // Task 2: 自动提权 — 以管理员身份重新启动 + // ===================================================================== + [RelayCommand] + private void RelaunchElevated() + { + try + { + var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location; + var args = $"--install-path \"{InstallPath}\""; + + Process.Start(new ProcessStartInfo + { + FileName = exePath, + Arguments = args, + UseShellExecute = true, + Verb = "runas" + }); + + Environment.Exit(0); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + // 用户在 UAC 对话框中点击了"否"(拒绝提权) + ErrorMessage = "已拒绝管理员权限请求。请选择一个不需要管理员权限的安装路径,或手动以管理员身份运行安装程序。"; + IsElevationRequired = false; + } + catch (Exception ex) + { + ErrorMessage = $"重新启动失败:{ex.Message}"; + } + } + + [RelayCommand] private async Task StartInstallAsync() { ErrorMessage = null; @@ -251,6 +370,12 @@ public sealed partial class MainWindowViewModel : ObservableObject StartInstallCommand.NotifyCanExecuteChanged(); _installCts?.Dispose(); _installCts = new CancellationTokenSource(); + + // 重置下载速度状态 + _lastBytesDownloaded = 0; + _lastProgressTime = DateTime.UtcNow; + DownloadSpeedText = string.Empty; + try { var progress = new Progress(ApplyProgress); @@ -325,14 +450,29 @@ public sealed partial class MainWindowViewModel : ObservableObject CurrentStep = step; } + // ===================================================================== + // Task 4: 本地化进度阶段 + Task 5: 下载速度显示 + // ===================================================================== private void ApplyProgress(InstallerDeployProgress progress) { - StatusText = progress.Stage; + // Task 4: 将英文阶段键翻译为中文 + StatusText = InstallerStrings.TranslateStage(progress.Stage); TargetVersion = progress.TargetVersion ?? TargetVersion; DownloadProgress = progress.DownloadProgress; InstallProgress = progress.InstallProgress; CurrentFile = progress.CurrentFile; DownloadBytesText = FormatBytes(progress.BytesDownloaded, progress.TotalBytes); + + // Task 5: 计算下载速度 + var now = DateTime.UtcNow; + var elapsed = (now - _lastProgressTime).TotalSeconds; + if (elapsed > 0.5 && progress.BytesDownloaded > _lastBytesDownloaded && progress.BytesDownloaded > 0) + { + var speedBytesPerSec = (progress.BytesDownloaded - _lastBytesDownloaded) / elapsed; + DownloadSpeedText = $"{ToSize((long)speedBytesPerSec)}/s"; + _lastBytesDownloaded = progress.BytesDownloaded; + _lastProgressTime = now; + } } private void SyncSteps() @@ -368,4 +508,31 @@ public sealed partial class MainWindowViewModel : ObservableObject return $"{size:0.##} {suffixes[suffix]}"; } + + // ===================================================================== + // Task 2: 解析 --install-path 命令行参数 + // ===================================================================== + + /// + /// 从命令行参数中解析 --install-path 值。 + /// 由 App.axaml.cs 在启动时调用。 + /// + public static string? ParseInstallPath(string[]? args) + { + if (args is null || args.Length == 0) + { + return null; + } + + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--install-path", StringComparison.OrdinalIgnoreCase)) + { + var value = args[i + 1].Trim('"'); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + return null; + } } diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml b/LanDesktopPLONDS.installer/Views/MainWindow.axaml index ffa6d21..a429e63 100644 --- a/LanDesktopPLONDS.installer/Views/MainWindow.axaml +++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml @@ -283,7 +283,9 @@ Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装方式保持一致。" /> - + + + + + + + + + + + + + + + + + + + @@ -395,8 +433,15 @@ - + + + + @@ -488,7 +533,7 @@ - @@ -515,14 +560,35 @@ + + diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs index 34bee2f..df2e457 100644 --- a/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs +++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs @@ -1,6 +1,8 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Platform.Storage; using LanDesktopPLONDS.Installer.ViewModels; @@ -22,6 +24,80 @@ public partial class MainWindow : Window } } + /// + /// Task 5: 安装进行中阻止关闭窗口,弹出中文确认对话框。 + /// + protected override async void OnClosing(WindowClosingEventArgs e) + { + if (DataContext is not MainWindowViewModel { IsInstalling: true }) + { + base.OnClosing(e); + return; + } + + e.Cancel = true; + var confirmed = await ShowCloseConfirmDialogAsync(); + if (confirmed) + { + if (DataContext is MainWindowViewModel vm) + { + vm.CancelInstallCommand.Execute(null); + } + + Close(); + } + } + + private async Task ShowCloseConfirmDialogAsync() + { + var tcs = new TaskCompletionSource(); + + var yesButton = new Button { Content = "确定退出", MinWidth = 100 }; + yesButton.Classes.Add("primary-command"); + yesButton.Click += (_, _) => tcs.TrySetResult(true); + + var noButton = new Button { Content = "继续安装", MinWidth = 100 }; + noButton.Classes.Add("secondary-command"); + noButton.Click += (_, _) => tcs.TrySetResult(false); + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new Thickness(0, 16, 0, 0), + }; + buttonPanel.Children.Add(yesButton); + buttonPanel.Children.Add(noButton); + + DockPanel.SetDock(buttonPanel, Dock.Bottom); + + var panel = new DockPanel { Margin = new Thickness(24) }; + panel.Children.Add(buttonPanel); + panel.Children.Add(new TextBlock + { + Text = "安装正在进行,确定要退出吗?", + VerticalAlignment = VerticalAlignment.Center, + FontSize = 15, + }); + + var dialog = new Window + { + Title = "确认退出", + Width = 400, + Height = 180, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + WindowDecorations = WindowDecorations.BorderOnly, + Content = panel, + }; + + dialog.Closed += (_, _) => tcs.TrySetResult(false); + + _ = dialog.ShowDialog(this); + return await tcs.Task; + } + private async Task BrowseForFolderAsync(string currentPath) { IStorageFolder? startFolder = null; diff --git a/LanDesktopPLONDS.installer/Views/UninstallConfirmWindow.axaml b/LanDesktopPLONDS.installer/Views/UninstallConfirmWindow.axaml new file mode 100644 index 0000000..3648b4d --- /dev/null +++ b/LanDesktopPLONDS.installer/Views/UninstallConfirmWindow.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + +