返回提交历史
Modified
XFEToolBox.Client.Installer/README.md
+9
-0
Modified
XFEToolBox.Client.Installer/Utilities/InstallationService.cs
+159
-15
Modified
XFEToolBox.Client.Installer/Utilities/ZipHelper.cs
+1
-1
Modified
XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs
+1
-1
Modified
XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj
+4
-2
XFEstudio/XFEToolBox
增强安装服务健壮性,完善错误处理与升级流程
- 新增详细错误信息收集与展示,便于定位异常 - 安装包应用流程支持重试,提升容错能力 - 自动排除与当前 Installer.exe 路径一致的升级文件,防止自我覆盖 - 安装前仅终止路径完全匹配的主程序进程,避免误杀 - 替换文件前移除特殊属性,失败时恢复原属性 - 回滚时恢复原文件及属性,异常信息更详细 - Zip 解压流支持 leaveOpen,避免提前关闭外部流 - 安装进度页错误信息更详细 - README 增加升级包发布流程说明 - csproj 支持通过 EmbedInstallationPackage 控制内嵌 Source.zip - 版本号升级至 1.1.3
fbb07d5
代码差异
5 个文件
+174
-19
@@ -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 本身,该文件会被跳过,其余应用文件继续安装。
@@ -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
}
@@ -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
@@ -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;
@@ -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>