XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

增强安装服务健壮性,完善错误处理与升级流程

- 新增详细错误信息收集与展示,便于定位异常 - 安装包应用流程支持重试,提升容错能力 - 自动排除与当前 Installer.exe 路径一致的升级文件,防止自我覆盖 - 安装前仅终止路径完全匹配的主程序进程,避免误杀 - 替换文件前移除特殊属性,失败时恢复原属性 - 回滚时恢复原文件及属性,异常信息更详细 - Zip 解压流支持 leaveOpen,避免提前关闭外部流 - 安装进度页错误信息更详细 - README 增加升级包发布流程说明 - csproj 支持通过 EmbedInstallationPackage 控制内嵌 Source.zip - 版本号升级至 1.1.3

fbb07d5
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

5 个文件 +174 -19
Modified XFEToolBox.Client.Installer/README.md +9 -0
@@ -26,3 +26,12 @@
26 26 - Installer 会将压缩包下载为安装目录下的 `InstallPackage.zip`,解压覆盖完成后删除临时压缩包,并启动 `XFEToolBox.exe`
27 27 - 发布升级包时不要增加二级目录;压缩包根目录应直接包含 `XFEToolBox.exe` 及其依赖文件
28 28 - 在线升级包不要覆盖正在运行的 `Installer.exe`;Installer 自身需要更新时,应在后续安装包发布流程中单独替换
29
30 ## 推荐发布流程
31
32 1. 先以 `-p:EmbedInstallationPackage=false` 发布一个不内嵌 `Source.zip` 的升级用 Installer。
33 2. 发布 XFEToolBox 客户端,并把上述 Installer 放入客户端发布目录。
34 3. 将客户端发布目录根内容压缩为 `Source.zip`,不要增加二级目录。
35 4. 恢复默认配置发布最终 Installer;默认会把新的 `Source.zip` 内嵌到单文件安装器中。
36
37 安装时若目标目录中的 `XFEToolBox.exe` 仍在运行,Installer 会先尝试正常关闭,超时后仅终止路径完全匹配的目标进程;不会按进程名结束其他目录中的同名程序。升级包中若包含正在运行的 Installer 本身,该文件会被跳过,其余应用文件继续安装。
Modified XFEToolBox.Client.Installer/Utilities/InstallationService.cs +159 -15
@@ -1,3 +1,4 @@
1 using System.ComponentModel;
1 2 using System.Diagnostics;
2 3 using System.IO;
3 4
@@ -8,6 +9,8 @@ namespace XFEToolBox.Client.Installer.Utilities;
8 9 /// </summary>
9 10 public static class InstallationService
10 11 {
12 private const int FileOperationRetryCount = 8;
13
11 14 public static void InstallPackage(Stream packageStream, string installPath, string executableName)
12 15 {
13 16 ArgumentNullException.ThrowIfNull(packageStream);
@@ -32,7 +35,8 @@ public static class InstallationService
32 35 if (!File.Exists(stagedExecutable))
33 36 throw new InvalidDataException($"安装包根目录中缺少 {executableName}。请确认压缩包没有额外的二级目录。");
34 37
35 EnsureCurrentInstallerWillNotBeOverwritten(stagingRoot, targetRoot);
38 ExcludeRunningInstallerFromStaging(stagingRoot, targetRoot);
39 StopRunningTargetApplication(Path.Combine(targetRoot, executableName));
36 40 ApplyStagedFiles(stagingRoot, targetRoot);
37 41
38 42 var installedExecutable = Path.Combine(targetRoot, executableName);
@@ -45,26 +49,97 @@ public static class InstallationService
45 49 }
46 50 }
47 51
48 private static void EnsureCurrentInstallerWillNotBeOverwritten(string stagingRoot, string targetRoot)
52 public static string GetDetailedErrorMessage(Exception exception)
53 {
54 ArgumentNullException.ThrowIfNull(exception);
55 var messages = new List<string>();
56 CollectExceptionMessages(exception, messages);
57 return string.Join(Environment.NewLine, messages.Distinct(StringComparer.CurrentCulture));
58 }
59
60 private static void CollectExceptionMessages(Exception exception, ICollection<string> messages)
61 {
62 if (!string.IsNullOrWhiteSpace(exception.Message))
63 messages.Add(exception.Message.Trim());
64 if (exception is AggregateException aggregateException)
65 {
66 foreach (var innerException in aggregateException.InnerExceptions)
67 CollectExceptionMessages(innerException, messages);
68 return;
69 }
70 if (exception.InnerException is not null)
71 CollectExceptionMessages(exception.InnerException, messages);
72 }
73
74 private static void ExcludeRunningInstallerFromStaging(string stagingRoot, string targetRoot)
49 75 {
50 76 var currentProcessPath = Environment.ProcessPath;
51 77 if (string.IsNullOrWhiteSpace(currentProcessPath))
52 78 return;
53 79
54 80 var currentPath = Path.GetFullPath(currentProcessPath);
55 foreach (var stagedFile in Directory.EnumerateFiles(stagingRoot, "*", SearchOption.AllDirectories))
81 foreach (var stagedFile in Directory.EnumerateFiles(stagingRoot, "*", SearchOption.AllDirectories).ToArray())
56 82 {
57 83 var relativePath = Path.GetRelativePath(stagingRoot, stagedFile);
58 84 var targetPath = Path.GetFullPath(Path.Combine(targetRoot, relativePath));
59 85 if (targetPath.Equals(currentPath, StringComparison.OrdinalIgnoreCase))
60 throw new IOException("升级包包含正在运行的 Installer.exe,无法安全覆盖。请从升级包中移除安装器后重试。");
86 {
87 File.Delete(stagedFile);
88 Debug.WriteLine($"[Installer] 已跳过正在运行的安装器文件:{relativePath}");
89 }
90 }
91 }
92
93 private static void StopRunningTargetApplication(string executablePath)
94 {
95 var expectedPath = Path.GetFullPath(executablePath);
96 var processName = Path.GetFileNameWithoutExtension(expectedPath);
97 foreach (var process in Process.GetProcessesByName(processName))
98 {
99 using (process)
100 {
101 if (process.Id == Environment.ProcessId)
102 continue;
103
104 string? processPath;
105 try
106 {
107 processPath = process.MainModule?.FileName;
108 }
109 catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException)
110 {
111 continue;
112 }
113
114 if (string.IsNullOrWhiteSpace(processPath)
115 || !Path.GetFullPath(processPath).Equals(expectedPath, StringComparison.OrdinalIgnoreCase))
116 continue;
117
118 try
119 {
120 if (process.HasExited)
121 continue;
122 if (process.CloseMainWindow() && process.WaitForExit(5000))
123 continue;
124 if (!process.HasExited)
125 process.Kill(entireProcessTree: true);
126 if (!process.WaitForExit(5000))
127 throw new TimeoutException("进程未在 5 秒内退出。");
128 }
129 catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException or TimeoutException)
130 {
131 throw new IOException(
132 $"无法关闭正在运行的 {Path.GetFileName(expectedPath)} (PID {process.Id})。请手动退出应用后重试。",
133 exception);
134 }
135 }
61 136 }
62 137 }
63 138
64 139 private static void ApplyStagedFiles(string stagingRoot, string targetRoot)
65 140 {
66 141 var backupRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer.Backup", Guid.NewGuid().ToString("N"));
67 var appliedFiles = new List<(string TargetPath, string? BackupPath)>();
142 var appliedFiles = new List<AppliedFile>();
68 143 var transientFiles = new List<string>();
69 144 Directory.CreateDirectory(targetRoot);
70 145 Directory.CreateDirectory(backupRoot);
@@ -83,32 +158,61 @@ public static class InstallationService
83 158 Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
84 159
85 160 string? backupFile = null;
161 FileAttributes? originalAttributes = null;
86 162 if (File.Exists(targetFile))
87 163 {
164 originalAttributes = File.GetAttributes(targetFile);
88 165 backupFile = Path.Combine(backupRoot, relativePath);
89 166 Directory.CreateDirectory(Path.GetDirectoryName(backupFile)!);
90 File.Copy(targetFile, backupFile, overwrite: true);
167 ExecuteFileOperationWithRetry(
168 () => File.Copy(targetFile, backupFile, overwrite: true),
169 targetFile,
170 "备份");
91 171 }
92 172
93 173 var transientFile = targetFile + $".xfe-install-{Guid.NewGuid():N}.tmp";
94 174 transientFiles.Add(transientFile);
95 File.Copy(sourceFile, transientFile, overwrite: true);
96 File.Move(transientFile, targetFile, overwrite: true);
175 ExecuteFileOperationWithRetry(
176 () => File.Copy(sourceFile, transientFile, overwrite: true),
177 targetFile,
178 "准备");
179 try
180 {
181 MakeFileReplaceable(targetFile);
182 ExecuteFileOperationWithRetry(
183 () => File.Move(transientFile, targetFile, overwrite: true),
184 targetFile,
185 "替换");
186 }
187 catch
188 {
189 if (originalAttributes is { } attributes && File.Exists(targetFile))
190 {
191 try { File.SetAttributes(targetFile, attributes); }
192 catch { /* 主异常会包含具体失败文件,属性恢复失败交由后续重试处理。 */ }
193 }
194 throw;
195 }
97 196 transientFiles.Remove(transientFile);
98 appliedFiles.Add((targetFile, backupFile));
197 appliedFiles.Add(new AppliedFile(targetFile, backupFile, originalAttributes));
99 198 }
100 199 }
101 200 catch (Exception installException)
102 201 {
103 202 Exception? rollbackException = null;
104 foreach (var (targetPath, backupPath) in appliedFiles.AsEnumerable().Reverse())
203 foreach (var appliedFile in appliedFiles.AsEnumerable().Reverse())
105 204 {
106 205 try
107 206 {
108 if (backupPath is not null && File.Exists(backupPath))
109 File.Copy(backupPath, targetPath, overwrite: true);
110 else if (File.Exists(targetPath))
111 File.Delete(targetPath);
207 MakeFileReplaceable(appliedFile.TargetPath);
208 if (appliedFile.BackupPath is not null && File.Exists(appliedFile.BackupPath))
209 {
210 File.Copy(appliedFile.BackupPath, appliedFile.TargetPath, overwrite: true);
211 if (appliedFile.OriginalAttributes is { } originalAttributes)
212 File.SetAttributes(appliedFile.TargetPath, originalAttributes);
213 }
214 else if (File.Exists(appliedFile.TargetPath))
215 File.Delete(appliedFile.TargetPath);
112 216 }
113 217 catch (Exception exception)
114 218 {
@@ -119,7 +223,9 @@ public static class InstallationService
119 223 if (rollbackException is not null)
120 224 throw new AggregateException("写入安装文件失败,且部分旧文件未能自动恢复。", installException, rollbackException);
121 225
122 throw new IOException("写入安装文件失败,原有文件已恢复。", installException);
226 throw new IOException(
227 $"写入安装文件失败,原有文件已恢复。{Environment.NewLine}{GetDetailedErrorMessage(installException)}",
228 installException);
123 229 }
124 230 finally
125 231 {
@@ -139,6 +245,39 @@ public static class InstallationService
139 245 }
140 246 }
141 247
248 private static void ExecuteFileOperationWithRetry(Action operation, string targetPath, string operationName)
249 {
250 Exception? lastException = null;
251 for (var attempt = 1; attempt <= FileOperationRetryCount; attempt++)
252 {
253 try
254 {
255 operation();
256 return;
257 }
258 catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
259 {
260 lastException = exception;
261 if (attempt < FileOperationRetryCount)
262 Thread.Sleep(100 * attempt);
263 }
264 }
265
266 throw new IOException(
267 $"无法{operationName}文件“{targetPath}”。请确认文件未被其他程序占用且当前用户具有写入权限。",
268 lastException);
269 }
270
271 private static void MakeFileReplaceable(string path)
272 {
273 if (!File.Exists(path))
274 return;
275 var attributes = File.GetAttributes(path);
276 var replaceableAttributes = attributes & ~(FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System);
277 if (replaceableAttributes != attributes)
278 File.SetAttributes(path, replaceableAttributes);
279 }
280
142 281 private static void TryDeleteDirectory(string path)
143 282 {
144 283 try
@@ -151,4 +290,9 @@ public static class InstallationService
151 290 Debug.WriteLine($"[Installer] 无法清理临时目录 {path}:{exception}");
152 291 }
153 292 }
293
294 private sealed record AppliedFile(
295 string TargetPath,
296 string? BackupPath,
297 FileAttributes? OriginalAttributes);
154 298 }
Modified XFEToolBox.Client.Installer/Utilities/ZipHelper.cs +1 -1
@@ -13,7 +13,7 @@ namespace XFEToolBox.Client.Installer.Utilities
13 13
14 14 public static void ExtraZipStream(Stream stream, string targetPath)
15 15 {
16 using var zipArchive = new ZipArchive(stream);
16 using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
17 17 ExtraZip(zipArchive, targetPath);
18 18 }
19 19
Modified XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs +1 -1
@@ -54,7 +54,7 @@ public partial class InstallProgressPage : Page
54 54 progress.IsError = true;
55 55 progress.SetBusy();
56 56 progress.SetError();
57 errorMessageText.Text = $"{exception.Message}\n\n请检查安装目录权限和安装包完整性后重试。";
57 errorMessageText.Text = $"{InstallationService.GetDetailedErrorMessage(exception)}\n\n请根据上面的具体文件和原因处理后重试。";
58 58 installGrid.Visibility = Visibility.Collapsed;
59 59 successGrid.Visibility = Visibility.Collapsed;
60 60 errorGrid.Visibility = Visibility.Visible;
Modified XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj +4 -2
@@ -9,7 +9,8 @@
9 9 <ApplicationManifest>app.manifest</ApplicationManifest>
10 10 <ApplicationIcon>Resources\Icon\Icon.ico</ApplicationIcon>
11 11 <AssemblyName>Installer</AssemblyName>
12 <Version>1.1.2</Version>
12 <Version>1.1.3</Version>
13 <EmbedInstallationPackage Condition="'$(EmbedInstallationPackage)' == ''">true</EmbedInstallationPackage>
13 14 </PropertyGroup>
14 15
15 16 <ItemGroup>
@@ -33,7 +34,8 @@
33 34 <ItemGroup>
34 35 <EmbeddedResource Include="Resources\Resource\EULA.txt" />
35 36 <EmbeddedResource Include="Resources\Resource\PrivateService.txt" />
36 <EmbeddedResource Include="Resources\Resource\Source.zip" />
37 <EmbeddedResource Include="Resources\Resource\Source.zip"
38 Condition="'$(EmbedInstallationPackage)' == 'true'" />
37 39 </ItemGroup>
38 40
39 41 <ItemGroup>