返回提交历史
Modified
XFEToolBox.Client.Installer/README.md
+10
-0
Modified
XFEToolBox.Client.Installer/Utilities/InstallationService.cs
+40
-58
Added
XFEToolBox.Client.Installer/Utilities/InstallerFileOperations.cs
+59
-0
Modified
XFEToolBox.Client.Installer/Utilities/ZipHelper.cs
+5
-3
Modified
XFEToolBox.Client.Installer/ViewModel/Pages/DownloadProgressPageViewModel.cs
+56
-20
Modified
XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs
+4
-4
Added
XFEToolBox.Client.Wpf.Test/InstallerDownloadTests.cs
+192
-0
Modified
XFEToolBox.Client.Wpf.Test/InstallerTests.cs
+112
-4
Modified
XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj
+2
-5
Modified
XFEToolBox/XFEToolBox.Client.csproj
+1
-1
XFEstudio/XFEToolBox
增强安装器文件操作与下载流程,支持重试与暂停恢复
- 新增 InstallerFileOperations 工具类,统一实现文件操作自动重试,提升健壮性 - 所有关键文件操作替换为 ExecuteWithRetry,处理文件锁/冲突/拒绝访问 - 优化安装包应用流程,跳过当前运行的安装器文件,避免误删和异常 - 重构下载流程,支持暂停/恢复,确保文件流安全释放,避免并发冲突 - 下载完成后确保所有文件写入结束再进入安装流程,保证包完整性 - 安装包清理失败仅提示下次清理,不影响升级 - 增加多项单元测试,覆盖暂停恢复、文件锁冲突、解压重试等场景 - 项目文件调整,测试项目直接引用 Installer 工程 - README 补充在线升级、暂停恢复、文件占用重试说明及回归测试命令
1cce4a2
代码差异
10 个文件
+481
-95
@@ -35,3 +35,13 @@
35
35
4. 恢复默认配置发布最终 Installer;默认会把新的 `Source.zip` 内嵌到单文件安装器中。
36
36
37
37
安装时若目标目录中的 `XFEToolBox.exe` 仍在运行,Installer 会先尝试正常关闭,超时后仅终止路径完全匹配的目标进程;不会按进程名结束其他目录中的同名程序。升级包中若包含正在运行的 Installer 本身,该文件会被跳过,其余应用文件继续安装。
38
39
在线升级会等待下载任务结束并关闭所有安装包文件流后,再进入安装页面;下载进度达到 100% 本身不会触发安装。暂停后恢复也会等待上一轮下载释放文件,避免多个下载任务同时写入安装包。
40
41
安装包读取、解压、文件属性修改、备份、替换及回滚遇到 Windows 临时共享冲突、锁冲突或拒绝访问时,会自动等待并重试,每个操作最多等待 10 秒。持续占用仍会报告具体失败文件并尝试回滚;无效压缩包、磁盘空间不足等错误不会按文件占用重复尝试。升级成功后,临时安装包清理失败不会把已经完成的升级误报为安装失败。
42
43
安装器回归测试(包含真实文件锁以及下载完成、暂停恢复和页面卸载的时序测试):
44
45
```powershell
46
dotnet run --project XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj -c Release -p:EmbedInstallationPackage=false -- --tests --filter Installer --no-parallel --report none
47
```
@@ -9,7 +9,14 @@ namespace XFEToolBox.Client.Installer.Utilities;
9
9
/// </summary>
10
10
public static class InstallationService
11
11
{
12
private const int FileOperationRetryCount = 8;
12
public static void InstallPackageFile(string packagePath, string installPath, string executableName)
13
{
14
using var packageStream = InstallerFileOperations.ExecuteWithRetry(
15
() => new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.Read),
16
packagePath,
17
"读取安装包");
18
InstallPackage(packageStream, installPath, executableName);
19
}
13
20
14
21
public static void InstallPackage(Stream packageStream, string installPath, string executableName)
15
22
{
@@ -35,7 +42,6 @@ public static class InstallationService
35
42
if (!File.Exists(stagedExecutable))
36
43
throw new InvalidDataException($"安装包根目录中缺少 {executableName}。请确认压缩包没有额外的二级目录。");
37
44
38
ExcludeRunningInstallerFromStaging(stagingRoot, targetRoot);
39
45
StopRunningTargetApplication(Path.Combine(targetRoot, executableName));
40
46
ApplyStagedFiles(stagingRoot, targetRoot);
41
47
@@ -71,25 +77,6 @@ public static class InstallationService
71
77
CollectExceptionMessages(exception.InnerException, messages);
72
78
}
73
79
74
private static void ExcludeRunningInstallerFromStaging(string stagingRoot, string targetRoot)
75
{
76
var currentProcessPath = Environment.ProcessPath;
77
if (string.IsNullOrWhiteSpace(currentProcessPath))
78
return;
79
80
var currentPath = Path.GetFullPath(currentProcessPath);
81
foreach (var stagedFile in Directory.EnumerateFiles(stagingRoot, "*", SearchOption.AllDirectories).ToArray())
82
{
83
var relativePath = Path.GetRelativePath(stagingRoot, stagedFile);
84
var targetPath = Path.GetFullPath(Path.Combine(targetRoot, relativePath));
85
if (targetPath.Equals(currentPath, StringComparison.OrdinalIgnoreCase))
86
{
87
File.Delete(stagedFile);
88
Debug.WriteLine($"[Installer] 已跳过正在运行的安装器文件:{relativePath}");
89
}
90
}
91
}
92
93
80
private static void StopRunningTargetApplication(string executablePath)
94
81
{
95
82
var expectedPath = Path.GetFullPath(executablePath);
@@ -126,6 +113,11 @@ public static class InstallationService
126
113
if (!process.WaitForExit(5000))
127
114
throw new TimeoutException("进程未在 5 秒内退出。");
128
115
}
116
// 进程可能恰好在 HasExited 检查与关闭/终止调用之间自行退出。
117
catch (Exception exception) when ((exception is Win32Exception or InvalidOperationException) && process.HasExited)
118
{
119
continue;
120
}
129
121
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException or TimeoutException)
130
122
{
131
123
throw new IOException(
@@ -155,16 +147,23 @@ public static class InstallationService
155
147
{
156
148
var relativePath = Path.GetRelativePath(stagingRoot, sourceFile);
157
149
var targetFile = Path.Combine(targetRoot, relativePath);
150
if (Path.GetFullPath(targetFile).Equals(Environment.ProcessPath, StringComparison.OrdinalIgnoreCase))
151
{
152
// 直接跳过,不必删除可能正被扫描器占用的暂存安装器。
153
Debug.WriteLine($"[Installer] 已跳过正在运行的安装器文件:{relativePath}");
154
continue;
155
}
158
156
Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
159
157
160
158
string? backupFile = null;
161
159
FileAttributes? originalAttributes = null;
162
160
if (File.Exists(targetFile))
163
161
{
164
originalAttributes = File.GetAttributes(targetFile);
162
originalAttributes = InstallerFileOperations.ExecuteWithRetry(
163
() => File.GetAttributes(targetFile), targetFile, "读取属性");
165
164
backupFile = Path.Combine(backupRoot, relativePath);
166
165
Directory.CreateDirectory(Path.GetDirectoryName(backupFile)!);
167
ExecuteFileOperationWithRetry(
166
InstallerFileOperations.ExecuteWithRetry(
168
167
() => File.Copy(targetFile, backupFile, overwrite: true),
169
168
targetFile,
170
169
"备份");
@@ -172,15 +171,18 @@ public static class InstallationService
172
171
173
172
var transientFile = targetFile + $".xfe-install-{Guid.NewGuid():N}.tmp";
174
173
transientFiles.Add(transientFile);
175
ExecuteFileOperationWithRetry(
174
InstallerFileOperations.ExecuteWithRetry(
176
175
() => File.Copy(sourceFile, transientFile, overwrite: true),
177
176
targetFile,
178
177
"准备");
179
178
try
180
179
{
181
MakeFileReplaceable(targetFile);
182
ExecuteFileOperationWithRetry(
183
() => File.Move(transientFile, targetFile, overwrite: true),
180
InstallerFileOperations.ExecuteWithRetry(
181
() =>
182
{
183
MakeFileReplaceable(targetFile);
184
File.Move(transientFile, targetFile, overwrite: true);
185
},
184
186
targetFile,
185
187
"替换");
186
188
}
@@ -204,15 +206,18 @@ public static class InstallationService
204
206
{
205
207
try
206
208
{
207
MakeFileReplaceable(appliedFile.TargetPath);
208
if (appliedFile.BackupPath is not null && File.Exists(appliedFile.BackupPath))
209
InstallerFileOperations.ExecuteWithRetry(() =>
209
210
{
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);
211
MakeFileReplaceable(appliedFile.TargetPath);
212
if (appliedFile.BackupPath is not null && File.Exists(appliedFile.BackupPath))
213
{
214
File.Copy(appliedFile.BackupPath, appliedFile.TargetPath, overwrite: true);
215
if (appliedFile.OriginalAttributes is { } originalAttributes)
216
File.SetAttributes(appliedFile.TargetPath, originalAttributes);
217
}
218
else if (File.Exists(appliedFile.TargetPath))
219
File.Delete(appliedFile.TargetPath);
220
}, appliedFile.TargetPath, "恢复");
216
221
}
217
222
catch (Exception exception)
218
223
{
@@ -245,29 +250,6 @@ public static class InstallationService
245
250
}
246
251
}
247
252
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
253
private static void MakeFileReplaceable(string path)
272
254
{
273
255
if (!File.Exists(path))
@@ -0,0 +1,59 @@
1
using System.Diagnostics;
2
using System.IO;
3
4
namespace XFEToolBox.Client.Installer.Utilities;
5
6
internal static class InstallerFileOperations
7
{
8
private static readonly TimeSpan RetryTimeout = TimeSpan.FromSeconds(10);
9
10
public static void ExecuteWithRetry(Action operation, string path, string operationName)
11
=> ExecuteWithRetry(() =>
12
{
13
operation();
14
return true;
15
}, path, operationName);
16
17
public static T ExecuteWithRetry<T>(Func<T> operation, string path, string operationName)
18
{
19
var stopwatch = Stopwatch.StartNew();
20
var delayMilliseconds = 100;
21
while (true)
22
{
23
try
24
{
25
return operation();
26
}
27
catch (Exception exception) when (IsTransientFileError(exception))
28
{
29
var remaining = RetryTimeout - stopwatch.Elapsed;
30
if (remaining <= TimeSpan.Zero)
31
throw new IOException(
32
$"无法{operationName}文件“{path}”。等待文件释放超时,请确认文件未被其他程序占用且当前用户具有写入权限。",
33
exception);
34
35
Thread.Sleep(TimeSpan.FromMilliseconds(Math.Min(delayMilliseconds, remaining.TotalMilliseconds)));
36
delayMilliseconds = Math.Min(delayMilliseconds * 2, 1000);
37
}
38
}
39
}
40
41
public static bool TryDeleteFile(string path)
42
{
43
try
44
{
45
ExecuteWithRetry(() => File.Delete(path), path, "清理");
46
return true;
47
}
48
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
49
{
50
Debug.WriteLine($"[Installer] 无法清理临时文件 {path}:{exception}");
51
return false;
52
}
53
}
54
55
private static bool IsTransientFileError(Exception exception)
56
=> (exception is IOException or UnauthorizedAccessException)
57
// Windows 的共享/锁冲突,以及扫描器或尚未退出的进程引起的暂时拒绝访问。
58
&& (exception.HResult & 0xffff) is 5 or 32 or 33;
59
}
@@ -1,4 +1,4 @@
1
using System.IO;
1
using System.IO;
2
2
using System.IO.Compression;
3
3
4
4
namespace XFEToolBox.Client.Installer.Utilities
@@ -7,7 +7,8 @@ namespace XFEToolBox.Client.Installer.Utilities
7
7
{
8
8
public static void ExtraZipFile(string zipPath, string targetPath)
9
9
{
10
using var zipArchive = ZipFile.OpenRead(zipPath);
10
using var zipArchive = InstallerFileOperations.ExecuteWithRetry(
11
() => ZipFile.OpenRead(zipPath), zipPath, "读取安装包");
11
12
ExtraZip(zipArchive, targetPath);
12
13
}
13
14
@@ -43,7 +44,8 @@ namespace XFEToolBox.Client.Installer.Utilities
43
44
}
44
45
45
46
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
46
entry.ExtractToFile(filePath, true);
47
InstallerFileOperations.ExecuteWithRetry(
48
() => entry.ExtractToFile(filePath, true), filePath, "解压");
47
49
}
48
50
}
49
51
}
@@ -4,6 +4,7 @@ using System.IO;
4
4
using XFEExtension.NetCore.FileExtension;
5
5
using XFEExtension.NetCore.WebExtension;
6
6
using XFEToolBox.Client.Installer.Profiles;
7
using XFEToolBox.Client.Installer.Utilities;
7
8
using XFEToolBox.Client.Installer.Views.Pages;
8
9
using XFEToolBox.Client.Installer.Views.Windows;
9
10
@@ -43,6 +44,7 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
43
44
private string errorMessage = string.Empty;
44
45
45
46
private XFEDownloader? downloader;
47
private TaskCompletionSource? resumeRequested;
46
48
private int transitionStarted;
47
49
private bool isDisposed;
48
50
@@ -77,7 +79,11 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
77
79
Directory.CreateDirectory(SystemProfile.InstallPath);
78
80
var packagePath = Path.Combine(SystemProfile.InstallPath, "InstallPackage.zip");
79
81
if (File.Exists(packagePath))
80
File.Delete(packagePath);
82
await Task.Run(() => InstallerFileOperations.ExecuteWithRetry(
83
() => File.Delete(packagePath), packagePath, "清理旧安装包"));
84
85
if (isDisposed)
86
return;
81
87
82
88
ReplaceDownloader(new XFEDownloader
83
89
{
@@ -88,14 +94,46 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
88
94
IsBusy = false;
89
95
PauseSwitchEnable = true;
90
96
RefreshProgressVisual();
91
await downloader!.Download(false);
97
var continueFromLastDownload = false;
98
while (true)
99
{
100
// Downloaded 事件在文件流释放之前触发;必须等待整个下载任务退出。
101
await downloader!.Download(continueFromLastDownload);
102
if (isDisposed)
103
return;
104
if (downloader.Downloaded || !downloader.IsPaused)
105
break;
106
107
// 暂停会结束当前下载任务,恢复前先等它释放文件,避免两个任务同时写入。
108
await resumeRequested!.Task;
109
if (isDisposed)
110
return;
111
downloader.IsPaused = false;
112
IsPause = false;
113
PauseText = "暂停";
114
PauseSwitchEnable = true;
115
RefreshProgressVisual();
116
continueFromLastDownload = true;
117
}
118
119
if (Interlocked.Exchange(ref transitionStarted, 1) != 0)
120
return;
121
PauseSwitchEnable = false;
122
IsDownloading = false;
123
ReplaceDownloader(null);
124
if (MainWindow.Current is not null)
125
MainWindow.Current.contentFrame.Content = new InstallProgressPage();
92
126
}
93
catch (Exception exception) when (!isDisposed)
127
catch (Exception exception)
94
128
{
95
SetDownloadError(exception);
129
if (!isDisposed)
130
SetDownloadError(exception);
96
131
}
97
132
finally
98
133
{
134
// Dispose 内部会释放 Task,不能在任务仍运行时从进度事件或页面卸载中调用。
135
ReplaceDownloader(null);
136
resumeRequested = null;
99
137
if (!isDisposed)
100
138
{
101
139
IsDownloading = false;
@@ -109,7 +147,7 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
109
147
{
110
148
ViewPage.Dispatcher.BeginInvoke(() =>
111
149
{
112
if (isDisposed)
150
if (isDisposed || !ReferenceEquals(sender, downloader) || Volatile.Read(ref transitionStarted) != 0)
113
151
return;
114
152
115
153
DownloadText = $"{e.DownloadedBufferSize.FileSize()}/{(e.TotalBufferSize is not null ? e.TotalBufferSize.Value.FileSize() : "未知大小")}";
@@ -117,15 +155,6 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
117
155
if (e.TotalBufferSize is not null && e.TotalBufferSize.Value > 0)
118
156
MaxValue = e.TotalBufferSize.Value;
119
157
RefreshProgressVisual();
120
121
if (!e.Downloaded || Interlocked.Exchange(ref transitionStarted, 1) != 0)
122
return;
123
124
PauseSwitchEnable = false;
125
IsDownloading = false;
126
ReplaceDownloader(null);
127
if (MainWindow.Current is not null)
128
MainWindow.Current.contentFrame.Content = new InstallProgressPage();
129
158
});
130
159
}
131
160
@@ -135,14 +164,14 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
135
164
if (downloader is null || !PauseSwitchEnable)
136
165
return;
137
166
138
if (downloader.IsPaused)
167
if (IsPause)
139
168
{
140
downloader.Continue();
141
IsPause = false;
142
PauseText = "暂停";
169
PauseSwitchEnable = false;
170
resumeRequested?.TrySetResult();
143
171
}
144
172
else
145
173
{
174
resumeRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
146
175
downloader.Pause();
147
176
IsPause = true;
148
177
PauseText = "继续";
@@ -161,7 +190,6 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
161
190
ErrorMessage = $"下载失败:{exception.Message}";
162
191
DownloadText = "未能获取更新包";
163
192
RefreshProgressVisual();
164
ReplaceDownloader(null);
165
193
}
166
194
167
195
if (ViewPage.Dispatcher.CheckAccess())
@@ -199,7 +227,15 @@ public partial class DownloadProgressPageViewModel(DownloadProgressPage viewPage
199
227
if (isDisposed)
200
228
return;
201
229
isDisposed = true;
202
ReplaceDownloader(null);
230
resumeRequested?.TrySetResult();
231
if (downloader is not null)
232
{
233
downloader.BufferDownloaded -= Downloader_BufferDownloaded;
234
if (IsDownloading)
235
downloader.Pause();
236
else
237
ReplaceDownloader(null);
238
}
203
239
GC.SuppressFinalize(this);
204
240
}
205
241
}
@@ -94,11 +94,11 @@ public partial class InstallProgressPage : Page
94
94
if (!File.Exists(packagePath))
95
95
throw new FileNotFoundException("未找到已下载的升级包,请返回 XFEToolBox 重新检查更新。", packagePath);
96
96
97
using (var packageStream = new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.Read))
98
InstallationService.InstallPackage(packageStream, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName);
97
InstallationService.InstallPackageFile(packagePath, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName);
99
98
100
File.Delete(packagePath);
101
return "XFEToolBox 已升级完成,安装包验证通过并已清理临时文件。";
99
return InstallerFileOperations.TryDeleteFile(packagePath)
100
? "XFEToolBox 已升级完成,安装包验证通过并已清理临时文件。"
101
: "XFEToolBox 已升级完成,临时安装包将在下次更新时清理。";
102
102
}
103
103
104
104
private void ShowInstalling()
@@ -0,0 +1,192 @@
1
using System.IO;
2
using System.Net;
3
using System.Net.Sockets;
4
using System.Reflection;
5
using System.Text;
6
using System.Windows.Threading;
7
using XFEExtension.NetCore.WebExtension;
8
using XFEToolBox.Client.Installer.Profiles;
9
using XFEToolBox.Client.Installer.ViewModel.Pages;
10
using XFEToolBox.Client.Installer.Views.Pages;
11
12
namespace XFEToolBox.Client.Wpf.Test;
13
14
[NonParallel]
15
public static class InstallerDownloadTests
16
{
17
[Test]
18
public static void InstallerWaitsForDownloadStreamsBeforeFinishing()
19
=> RunOnDispatcher(() => VerifyDownloadLifecycleAsync());
20
21
[Test]
22
public static void InstallerPauseAndResumeDoNotOverlapWriters()
23
=> RunOnDispatcher(() => VerifyDownloadLifecycleAsync(pauseAndResume: true));
24
25
[Test]
26
public static void InstallerCanLeaveThePageWhileDownloadIsFinishing()
27
=> RunOnDispatcher(() => VerifyDownloadLifecycleAsync(leavePage: true));
28
29
private static async Task VerifyDownloadLifecycleAsync(bool pauseAndResume = false, bool leavePage = false)
30
{
31
var targetRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer.Tests", Guid.NewGuid().ToString("N"));
32
Directory.CreateDirectory(targetRoot);
33
var originalPath = SystemProfile.InstallPath;
34
var originalUrl = SystemProfile.DownloadUrl;
35
var payload = Enumerable.Range(0, 64 * 1024).Select(index => (byte)(index % 251 + 1)).ToArray();
36
await using var server = new PackageServer(payload);
37
using var releaseWriter = new ManualResetEventSlim();
38
var writerBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
39
DownloadProgressPageViewModel? viewModel = null;
40
Task? downloadTask = null;
41
try
42
{
43
SystemProfile.InstallPath = targetRoot;
44
SystemProfile.DownloadUrl = server.Url;
45
var page = new DownloadProgressPage();
46
viewModel = page.ViewModel;
47
downloadTask = viewModel.RetryCommand.ExecuteAsync(null);
48
49
// 阻塞真实下载器的事件回调,稳定重现“已报告完成,但文件流仍打开”的窗口。
50
var downloaderField = typeof(DownloadProgressPageViewModel).GetField("downloader", BindingFlags.Instance | BindingFlags.NonPublic)!;
51
var downloader = (XFEDownloader)downloaderField.GetValue(viewModel)!;
52
downloader.BufferDownloaded += (_, args) =>
53
{
54
if ((pauseAndResume ? !args.Downloaded : args.Downloaded) && writerBlocked.TrySetResult())
55
Ensure(releaseWriter.Wait(TimeSpan.FromSeconds(10)), "测试未能及时释放下载线程。");
56
};
57
server.AllowResponses.TrySetResult();
58
59
await writerBlocked.Task.WaitAsync(TimeSpan.FromSeconds(10));
60
await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
61
Ensure(viewModel.IsDownloading && !downloadTask.IsCompleted && !viewModel.IsError,
62
"下载流仍打开时,安装器已经结束下载或尝试切换安装页面。");
63
64
if (pauseAndResume)
65
{
66
viewModel.PauseSwitchCommand.Execute(null);
67
Ensure(viewModel.IsPause, "暂停命令没有暂停下载。");
68
viewModel.PauseSwitchCommand.Execute(null);
69
await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
70
Ensure(!viewModel.PauseSwitchEnable && viewModel.IsDownloading,
71
"旧下载任务未退出就开始了恢复下载。");
72
Ensure(server.RangeRequests == 1, "暂停恢复启动了并发下载请求。");
73
}
74
if (leavePage)
75
viewModel.Dispose();
76
77
releaseWriter.Set();
78
await downloadTask.WaitAsync(TimeSpan.FromSeconds(10));
79
Ensure(!viewModel.IsError, $"下载生命周期操作失败:{viewModel.ErrorMessage}");
80
if (!leavePage)
81
Ensure(!viewModel.IsDownloading && !viewModel.RetryCommand.CanExecute(null), "下载完成后没有结束下载状态。");
82
83
var packagePath = Path.Combine(targetRoot, "InstallPackage.zip");
84
using var completedPackage = File.Open(packagePath, FileMode.Open, FileAccess.Read, FileShare.None);
85
using var actual = new MemoryStream();
86
await completedPackage.CopyToAsync(actual);
87
Ensure(actual.ToArray().SequenceEqual(payload), "下载完成或暂停恢复后,安装包内容不完整。");
88
}
89
finally
90
{
91
releaseWriter.Set();
92
server.AllowResponses.TrySetResult();
93
viewModel?.Dispose();
94
if (downloadTask is not null)
95
await downloadTask.WaitAsync(TimeSpan.FromSeconds(10));
96
SystemProfile.InstallPath = originalPath;
97
SystemProfile.DownloadUrl = originalUrl;
98
Directory.Delete(targetRoot, recursive: true);
99
}
100
}
101
102
private static void RunOnDispatcher(Func<Task> action)
103
{
104
Exception? failure = null;
105
var thread = new Thread(() =>
106
{
107
var dispatcher = Dispatcher.CurrentDispatcher;
108
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher));
109
dispatcher.UnhandledException += (_, args) =>
110
{
111
failure ??= args.Exception;
112
args.Handled = true;
113
};
114
dispatcher.BeginInvoke(new Action(async () =>
115
{
116
try { await action(); }
117
catch (Exception exception) { failure ??= exception; }
118
finally { dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); }
119
}));
120
Dispatcher.Run();
121
}) { IsBackground = true };
122
thread.SetApartmentState(ApartmentState.STA);
123
thread.Start();
124
Ensure(thread.Join(TimeSpan.FromSeconds(40)), "安装器下载测试超时。");
125
if (failure is not null)
126
throw new InvalidOperationException("安装器下载生命周期验证失败。", failure);
127
}
128
129
private static void Ensure(bool condition, string message)
130
{
131
if (!condition)
132
throw new InvalidOperationException(message);
133
}
134
135
private sealed class PackageServer : IAsyncDisposable
136
{
137
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
138
private readonly CancellationTokenSource stopping = new();
139
private readonly Task serving;
140
private int rangeRequests;
141
142
public string Url { get; }
143
public int RangeRequests => Volatile.Read(ref rangeRequests);
144
public TaskCompletionSource AllowResponses { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
145
146
public PackageServer(byte[] payload)
147
{
148
listener.Start();
149
Url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/package.zip";
150
serving = ServeAsync(payload);
151
}
152
153
private async Task ServeAsync(byte[] payload)
154
{
155
try
156
{
157
while (!stopping.IsCancellationRequested)
158
{
159
using var client = await listener.AcceptTcpClientAsync(stopping.Token);
160
using var stream = client.GetStream();
161
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
162
var offset = 0;
163
var partial = false;
164
while (await reader.ReadLineAsync(stopping.Token) is { Length: > 0 } line)
165
{
166
if (!line.StartsWith("Range: bytes=", StringComparison.OrdinalIgnoreCase))
167
continue;
168
partial = true;
169
offset = int.Parse(line[13..].Split('-')[0]);
170
Interlocked.Increment(ref rangeRequests);
171
}
172
173
await AllowResponses.Task.WaitAsync(stopping.Token);
174
var status = partial ? "206 Partial Content" : "200 OK";
175
var range = partial ? $"Content-Range: bytes {offset}-{payload.Length - 1}/{payload.Length}\r\n" : string.Empty;
176
var header = $"HTTP/1.1 {status}\r\nContent-Length: {payload.Length - offset}\r\n{range}Connection: close\r\n\r\n";
177
await stream.WriteAsync(Encoding.ASCII.GetBytes(header), stopping.Token);
178
await stream.WriteAsync(payload.AsMemory(offset), stopping.Token);
179
}
180
}
181
catch (OperationCanceledException) when (stopping.IsCancellationRequested) { }
182
}
183
184
public async ValueTask DisposeAsync()
185
{
186
await stopping.CancelAsync();
187
listener.Stop();
188
await serving;
189
stopping.Dispose();
190
}
191
}
192
}
@@ -55,6 +55,79 @@ public static class InstallerTests
55
55
}
56
56
}
57
57
58
[Test]
59
public static void InstallerWaitsForTheDownloadedPackageToBeReleased()
60
{
61
var targetRoot = CreateTemporaryDirectory();
62
try
63
{
64
var packagePath = Path.Combine(targetRoot, "InstallPackage.zip");
65
using var package = CreatePackage(("XFEToolBox.exe", "application"));
66
File.WriteAllBytes(packagePath, package.ToArray());
67
68
WithTemporaryFileLock(packagePath, FileShare.ReadWrite, 500, () =>
69
InstallationService.InstallPackageFile(packagePath, targetRoot, "XFEToolBox.exe"));
70
71
Ensure(File.ReadAllText(Path.Combine(targetRoot, "XFEToolBox.exe")) == "application",
72
"下载器释放安装包后没有自动完成安装。");
73
using var releasedPackage = File.Open(packagePath, FileMode.Open, FileAccess.Read, FileShare.None);
74
}
75
finally
76
{
77
DeleteTemporaryDirectory(targetRoot);
78
}
79
}
80
81
[Test]
82
public static void InstallerWaitsForALockedTargetBeyondTheOldRetryWindow()
83
{
84
var targetRoot = CreateTemporaryDirectory();
85
try
86
{
87
var executablePath = Path.Combine(targetRoot, "XFEToolBox.exe");
88
File.WriteAllText(executablePath, "old-application");
89
File.SetAttributes(executablePath, FileAttributes.ReadOnly);
90
using var package = CreatePackage(("XFEToolBox.exe", "new-application"));
91
92
// 允许备份读取,但保持禁止替换,超过原先总共 2.8 秒的重试窗口。
93
WithTemporaryFileLock(executablePath, FileShare.Read, 4000, () =>
94
InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe"), FileAccess.Read);
95
96
Ensure(File.ReadAllText(executablePath) == "new-application",
97
"目标文件解除占用后仍需要用户手动重试。");
98
Ensure(!Directory.EnumerateFiles(targetRoot, "*.xfe-install-*.tmp", SearchOption.AllDirectories).Any(),
99
"自动重试成功后留下了临时写入文件。");
100
}
101
finally
102
{
103
var executablePath = Path.Combine(targetRoot, "XFEToolBox.exe");
104
if (File.Exists(executablePath))
105
File.SetAttributes(executablePath, FileAttributes.Normal);
106
DeleteTemporaryDirectory(targetRoot);
107
}
108
}
109
110
[Test]
111
public static void InstallerRetriesExtractionAfterATemporaryFileLock()
112
{
113
var targetRoot = CreateTemporaryDirectory();
114
try
115
{
116
var filePath = Path.Combine(targetRoot, "library.dll");
117
File.WriteAllText(filePath, "old-library");
118
using var package = CreatePackage(("library.dll", "new-library"));
119
120
WithTemporaryFileLock(filePath, FileShare.None, 500, () =>
121
ZipHelper.ExtraZipStream(package, targetRoot));
122
123
Ensure(File.ReadAllText(filePath) == "new-library", "解压没有在文件释放后重新读取完整条目。");
124
}
125
finally
126
{
127
DeleteTemporaryDirectory(targetRoot);
128
}
129
}
130
58
131
[Test]
59
132
public static void InstallerRestoresExistingFilesWhenAnOverwriteFails()
60
133
{
@@ -63,7 +136,9 @@ public static class InstallerTests
63
136
{
64
137
var executablePath = Path.Combine(targetRoot, "XFEToolBox.exe");
65
138
var settingsPath = Path.Combine(targetRoot, "settings.json");
66
var blockedPath = Path.Combine(targetRoot, "blocked.dat");
139
var addedPath = Path.Combine(targetRoot, "added.dat");
140
var blockedPath = Path.Combine(targetRoot, "locked", "blocked.dat");
141
Directory.CreateDirectory(Path.GetDirectoryName(blockedPath)!);
67
142
File.WriteAllText(executablePath, "old-application");
68
143
File.WriteAllText(settingsPath, "old-settings");
69
144
File.WriteAllText(blockedPath, "locked");
@@ -71,15 +146,29 @@ public static class InstallerTests
71
146
using var package = CreatePackage(
72
147
("XFEToolBox.exe", "new-application"),
73
148
("settings.json", "new-settings"),
74
("blocked.dat", "new-blocked"));
149
("added.dat", "new-file"),
150
("locked/blocked.dat", "new-blocked"));
75
151
using (File.Open(blockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
76
EnsureThrows<IOException>(() =>
77
InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe"));
152
{
153
var installTask = Task.Run(() => InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe"));
154
var sawAppliedFiles = SpinWait.SpinUntil(() =>
155
{
156
try
157
{
158
return File.Exists(addedPath) && File.ReadAllText(executablePath) == "new-application"
159
&& File.ReadAllText(settingsPath) == "new-settings";
160
}
161
catch (IOException) { return false; }
162
}, TimeSpan.FromSeconds(5));
163
EnsureThrows<IOException>(() => installTask.GetAwaiter().GetResult());
164
Ensure(sawAppliedFiles, "回滚测试没有实际经过旧文件被替换的阶段。");
165
}
78
166
79
167
Ensure(File.ReadAllText(executablePath) == "old-application",
80
168
"覆盖失败后没有恢复旧主程序。");
81
169
Ensure(File.ReadAllText(settingsPath) == "old-settings",
82
170
"覆盖失败后没有恢复旧配置。");
171
Ensure(!File.Exists(addedPath), "覆盖失败后没有移除本次新增的文件。");
83
172
}
84
173
finally
85
174
{
@@ -87,6 +176,25 @@ public static class InstallerTests
87
176
}
88
177
}
89
178
179
private static void WithTemporaryFileLock(
180
string path, FileShare share, int releaseAfterMilliseconds, Action action, FileAccess access = FileAccess.ReadWrite)
181
{
182
using var fileLock = File.Open(path, FileMode.Open, access, share);
183
var releaseTask = Task.Run(async () =>
184
{
185
await Task.Delay(releaseAfterMilliseconds);
186
fileLock.Dispose();
187
});
188
try
189
{
190
action();
191
}
192
finally
193
{
194
releaseTask.GetAwaiter().GetResult();
195
}
196
}
197
90
198
private static MemoryStream CreatePackage(params (string Name, string Content)[] entries)
91
199
{
92
200
var stream = new MemoryStream();
@@ -18,11 +18,8 @@
18
18
<ProjectReference Include="..\XFEToolBox\XFEToolBox.Client.csproj" />
19
19
<ProjectReference Include="..\XFEToolBox.Client.Core\XFEToolBox.Client.Core.csproj" />
20
20
<ProjectReference Include="..\XFEToolBox.WpfCore\XFEToolBox.WpfCore.csproj" />
21
</ItemGroup>
22
23
<ItemGroup>
24
<Compile Include="..\XFEToolBox.Client.Installer\Utilities\InstallationService.cs" Link="Installer\InstallationService.cs" />
25
<Compile Include="..\XFEToolBox.Client.Installer\Utilities\ZipHelper.cs" Link="Installer\ZipHelper.cs" />
21
<ProjectReference Include="..\XFEToolBox.Client.Installer\XFEToolBox.Client.Installer.csproj"
22
AdditionalProperties="EmbedInstallationPackage=false" />
26
23
</ItemGroup>
27
24
28
25
<ItemGroup>
@@ -8,7 +8,7 @@
8
8
<UseWPF>true</UseWPF>
9
9
<ApplicationManifest>app.manifest</ApplicationManifest>
10
10
<ApplicationIcon>Resources\Icon\XFEToolBoxIcon.ico</ApplicationIcon>
11
<Version>1.2.0</Version>
11
<Version>1.2.1</Version>
12
12
<AssemblyTitle>XFEToolBox</AssemblyTitle>
13
13
<AssemblyName>XFEToolBox</AssemblyName>
14
14
</PropertyGroup>