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

XFEToolBox

【WPF】XFE工具箱

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

XFEstudio/XFEToolBox

增强安装器文件操作与下载流程,支持重试与暂停恢复

- 新增 InstallerFileOperations 工具类,统一实现文件操作自动重试,提升健壮性 - 所有关键文件操作替换为 ExecuteWithRetry,处理文件锁/冲突/拒绝访问 - 优化安装包应用流程,跳过当前运行的安装器文件,避免误删和异常 - 重构下载流程,支持暂停/恢复,确保文件流安全释放,避免并发冲突 - 下载完成后确保所有文件写入结束再进入安装流程,保证包完整性 - 安装包清理失败仅提示下次清理,不影响升级 - 增加多项单元测试,覆盖暂停恢复、文件锁冲突、解压重试等场景 - 项目文件调整,测试项目直接引用 Installer 工程 - README 补充在线升级、暂停恢复、文件占用重试说明及回归测试命令

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

代码差异

10 个文件 +481 -95
Modified XFEToolBox.Client.Installer/README.md +10 -0
@@ -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 ```
Modified XFEToolBox.Client.Installer/Utilities/InstallationService.cs +40 -58
@@ -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))
Added XFEToolBox.Client.Installer/Utilities/InstallerFileOperations.cs +59 -0
@@ -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 }
Modified XFEToolBox.Client.Installer/Utilities/ZipHelper.cs +5 -3
@@ -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 }
Modified XFEToolBox.Client.Installer/ViewModel/Pages/DownloadProgressPageViewModel.cs +56 -20
@@ -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 }
Modified XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs +4 -4
@@ -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()
Added XFEToolBox.Client.Wpf.Test/InstallerDownloadTests.cs +192 -0
@@ -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 }
Modified XFEToolBox.Client.Wpf.Test/InstallerTests.cs +112 -4
@@ -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();
Modified XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj +2 -5
@@ -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>
Modified XFEToolBox/XFEToolBox.Client.csproj +1 -1
@@ -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>