using System.ComponentModel;
using System.Diagnostics;
using System.IO;
namespace XFEToolBox.Client.Installer.Utilities;
///
/// 在临时目录中验证安装包,再将已验证的文件应用到目标目录。
///
public static class InstallationService
{
private const int FileOperationRetryCount = 8;
public static void InstallPackage(Stream packageStream, string installPath, string executableName)
{
ArgumentNullException.ThrowIfNull(packageStream);
if (!packageStream.CanRead)
throw new InvalidDataException("安装包数据流不可读。");
if (string.IsNullOrWhiteSpace(installPath))
throw new ArgumentException("安装目录不能为空。", nameof(installPath));
if (string.IsNullOrWhiteSpace(executableName) || Path.GetFileName(executableName) != executableName)
throw new ArgumentException("应用程序文件名无效。", nameof(executableName));
var targetRoot = Path.GetFullPath(installPath);
if (Path.GetPathRoot(targetRoot)?.Equals(targetRoot, StringComparison.OrdinalIgnoreCase) == true)
throw new InvalidOperationException("不能直接安装到磁盘根目录,请选择一个应用文件夹。");
var stagingRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer", Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(stagingRoot);
ZipHelper.ExtraZipStream(packageStream, stagingRoot);
var stagedExecutable = Path.Combine(stagingRoot, executableName);
if (!File.Exists(stagedExecutable))
throw new InvalidDataException($"安装包根目录中缺少 {executableName}。请确认压缩包没有额外的二级目录。");
ExcludeRunningInstallerFromStaging(stagingRoot, targetRoot);
StopRunningTargetApplication(Path.Combine(targetRoot, executableName));
ApplyStagedFiles(stagingRoot, targetRoot);
var installedExecutable = Path.Combine(targetRoot, executableName);
if (!File.Exists(installedExecutable))
throw new IOException($"安装完成后仍未找到 {executableName}。");
}
finally
{
TryDeleteDirectory(stagingRoot);
}
}
public static string GetDetailedErrorMessage(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
var messages = new List();
CollectExceptionMessages(exception, messages);
return string.Join(Environment.NewLine, messages.Distinct(StringComparer.CurrentCulture));
}
private static void CollectExceptionMessages(Exception exception, ICollection messages)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
messages.Add(exception.Message.Trim());
if (exception is AggregateException aggregateException)
{
foreach (var innerException in aggregateException.InnerExceptions)
CollectExceptionMessages(innerException, messages);
return;
}
if (exception.InnerException is not null)
CollectExceptionMessages(exception.InnerException, messages);
}
private static void ExcludeRunningInstallerFromStaging(string stagingRoot, string targetRoot)
{
var currentProcessPath = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(currentProcessPath))
return;
var currentPath = Path.GetFullPath(currentProcessPath);
foreach (var stagedFile in Directory.EnumerateFiles(stagingRoot, "*", SearchOption.AllDirectories).ToArray())
{
var relativePath = Path.GetRelativePath(stagingRoot, stagedFile);
var targetPath = Path.GetFullPath(Path.Combine(targetRoot, relativePath));
if (targetPath.Equals(currentPath, StringComparison.OrdinalIgnoreCase))
{
File.Delete(stagedFile);
Debug.WriteLine($"[Installer] 已跳过正在运行的安装器文件:{relativePath}");
}
}
}
private static void StopRunningTargetApplication(string executablePath)
{
var expectedPath = Path.GetFullPath(executablePath);
var processName = Path.GetFileNameWithoutExtension(expectedPath);
foreach (var process in Process.GetProcessesByName(processName))
{
using (process)
{
if (process.Id == Environment.ProcessId)
continue;
string? processPath;
try
{
processPath = process.MainModule?.FileName;
}
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException)
{
continue;
}
if (string.IsNullOrWhiteSpace(processPath)
|| !Path.GetFullPath(processPath).Equals(expectedPath, StringComparison.OrdinalIgnoreCase))
continue;
try
{
if (process.HasExited)
continue;
if (process.CloseMainWindow() && process.WaitForExit(5000))
continue;
if (!process.HasExited)
process.Kill(entireProcessTree: true);
if (!process.WaitForExit(5000))
throw new TimeoutException("进程未在 5 秒内退出。");
}
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException or TimeoutException)
{
throw new IOException(
$"无法关闭正在运行的 {Path.GetFileName(expectedPath)} (PID {process.Id})。请手动退出应用后重试。",
exception);
}
}
}
}
private static void ApplyStagedFiles(string stagingRoot, string targetRoot)
{
var backupRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer.Backup", Guid.NewGuid().ToString("N"));
var appliedFiles = new List();
var transientFiles = new List();
Directory.CreateDirectory(targetRoot);
Directory.CreateDirectory(backupRoot);
try
{
foreach (var directory in Directory.EnumerateDirectories(stagingRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(stagingRoot, directory);
Directory.CreateDirectory(Path.Combine(targetRoot, relativePath));
}
foreach (var sourceFile in Directory.EnumerateFiles(stagingRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(stagingRoot, sourceFile);
var targetFile = Path.Combine(targetRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
string? backupFile = null;
FileAttributes? originalAttributes = null;
if (File.Exists(targetFile))
{
originalAttributes = File.GetAttributes(targetFile);
backupFile = Path.Combine(backupRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(backupFile)!);
ExecuteFileOperationWithRetry(
() => File.Copy(targetFile, backupFile, overwrite: true),
targetFile,
"备份");
}
var transientFile = targetFile + $".xfe-install-{Guid.NewGuid():N}.tmp";
transientFiles.Add(transientFile);
ExecuteFileOperationWithRetry(
() => File.Copy(sourceFile, transientFile, overwrite: true),
targetFile,
"准备");
try
{
MakeFileReplaceable(targetFile);
ExecuteFileOperationWithRetry(
() => File.Move(transientFile, targetFile, overwrite: true),
targetFile,
"替换");
}
catch
{
if (originalAttributes is { } attributes && File.Exists(targetFile))
{
try { File.SetAttributes(targetFile, attributes); }
catch { /* 主异常会包含具体失败文件,属性恢复失败交由后续重试处理。 */ }
}
throw;
}
transientFiles.Remove(transientFile);
appliedFiles.Add(new AppliedFile(targetFile, backupFile, originalAttributes));
}
}
catch (Exception installException)
{
Exception? rollbackException = null;
foreach (var appliedFile in appliedFiles.AsEnumerable().Reverse())
{
try
{
MakeFileReplaceable(appliedFile.TargetPath);
if (appliedFile.BackupPath is not null && File.Exists(appliedFile.BackupPath))
{
File.Copy(appliedFile.BackupPath, appliedFile.TargetPath, overwrite: true);
if (appliedFile.OriginalAttributes is { } originalAttributes)
File.SetAttributes(appliedFile.TargetPath, originalAttributes);
}
else if (File.Exists(appliedFile.TargetPath))
File.Delete(appliedFile.TargetPath);
}
catch (Exception exception)
{
rollbackException ??= exception;
}
}
if (rollbackException is not null)
throw new AggregateException("写入安装文件失败,且部分旧文件未能自动恢复。", installException, rollbackException);
throw new IOException(
$"写入安装文件失败,原有文件已恢复。{Environment.NewLine}{GetDetailedErrorMessage(installException)}",
installException);
}
finally
{
foreach (var transientFile in transientFiles)
{
try
{
if (File.Exists(transientFile))
File.Delete(transientFile);
}
catch (Exception exception)
{
Debug.WriteLine($"[Installer] 无法清理临时写入文件 {transientFile}:{exception}");
}
}
TryDeleteDirectory(backupRoot);
}
}
private static void ExecuteFileOperationWithRetry(Action operation, string targetPath, string operationName)
{
Exception? lastException = null;
for (var attempt = 1; attempt <= FileOperationRetryCount; attempt++)
{
try
{
operation();
return;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
lastException = exception;
if (attempt < FileOperationRetryCount)
Thread.Sleep(100 * attempt);
}
}
throw new IOException(
$"无法{operationName}文件“{targetPath}”。请确认文件未被其他程序占用且当前用户具有写入权限。",
lastException);
}
private static void MakeFileReplaceable(string path)
{
if (!File.Exists(path))
return;
var attributes = File.GetAttributes(path);
var replaceableAttributes = attributes & ~(FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System);
if (replaceableAttributes != attributes)
File.SetAttributes(path, replaceableAttributes);
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch (Exception exception)
{
Debug.WriteLine($"[Installer] 无法清理临时目录 {path}:{exception}");
}
}
private sealed record AppliedFile(
string TargetPath,
string? BackupPath,
FileAttributes? OriginalAttributes);
}