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

XFEToolBox

【WPF】XFE工具箱

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

XFEstudio/XFEToolBox

增强安装器自复制与升级流程,补充测试用例

- 安装器自动复制为 Installer.exe,支持在线升级,兼容旧包 - 统一处理 Installer.exe 的复制、备份、回滚,失败不视为成功 - 避免升级时覆盖运行中的 Installer.exe,需后续包替换 - 发布流程与 README 更新,Source.zip 仅含应用文件 - 支持 MSBuild 参数指定安装包路径 - 大幅补充单元测试,覆盖多种升级与回滚场景 - 优化代码结构,方法增加 installerSourcePath 参数

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

代码差异

5 个文件 +181 -29
Modified XFEToolBox.Client.Installer/README.md +17 -12
@@ -8,14 +8,11 @@
8 8 - 将安装包图标放置在`Resources\Icon`目录下,命名为`Icon.ico`
9 9 - 将软件本体图标放置在`Resources\Icon`目录下,命名为`Icon.png`
10 10
11 ## 将安装器兼更新器放入软件的exe可执行文件目录下
12
13 - 执行完前置操作后,点击发布软件,在选择好发布配置(windows平台,独立的)后点击发布
14 - 将发布后的所有文件全部复制到待您软件的exe可执行文件的目录下,与软件本体共处同一个文件夹
15
16 11 ## 将软件压缩包放入安装器
17 12
18 - 将完成上述操作后的软件打包为打包为`Source.zip`文件(请注意,压缩包内应为包含exe的直接目录,而非二级目录),并替换`Resources\Resource`目录下0KB的`Source.zip`文件
13 - 发布 XFEToolBox 客户端,将客户端的程序和依赖文件压缩为 `Source.zip`,放入 `Resources\Resource` 目录。压缩包根目录应直接包含 `XFEToolBox.exe`,不要增加二级目录。
14 - `Source.zip` 不需要包含 `Installer.exe`。安装器会自动将当前运行的单文件安装器复制到安装目录,统一命名为 `Installer.exe`,供客户端后续在线升级使用。下载后的安装器即使被重命名,也会使用这个固定名称。
15 - 安装器副本和软件文件共用备份、替换及回滚流程;复制失败不会被当作安装成功。兼容旧安装包:如果包内已经显式提供了 `Installer.exe`,则优先使用包内版本。
19 16
20 17 ## 在线升级协议
21 18
@@ -24,15 +21,23 @@
24 21 - 调用参数依次为:`Upgrade`、升级压缩包的 HTTP/HTTPS 地址、当前 XFEToolBox 安装目录
25 22 - 等价命令:`Installer.exe Upgrade <downloadUrl> <installDirectory>`
26 23 - Installer 会将压缩包下载为安装目录下的 `InstallPackage.zip`,解压覆盖完成后删除临时压缩包,并启动 `XFEToolBox.exe`
27 - 发布升级包时不要增加二级目录;压缩包根目录应直接包含 `XFEToolBox.exe` 及其依赖文件
24 - 发布升级包时不要增加二级目录;压缩包根目录应直接包含 `XFEToolBox.exe` 及其依赖文件,也无需包含 `Installer.exe`
28 25 - 在线升级包不要覆盖正在运行的 `Installer.exe`;Installer 自身需要更新时,应在后续安装包发布流程中单独替换
29 26
30 27 ## 推荐发布流程
31 28
32 1. 先以 `-p:EmbedInstallationPackage=false` 发布一个不内嵌 `Source.zip` 的升级用 Installer。
33 2. 发布 XFEToolBox 客户端,并把上述 Installer 放入客户端发布目录。
34 3. 将客户端发布目录根内容压缩为 `Source.zip`,不要增加二级目录。
35 4. 恢复默认配置发布最终 Installer;默认会把新的 `Source.zip` 内嵌到单文件安装器中。
29 1. 发布 XFEToolBox 客户端,仅将软件文件压缩为 `Source.zip`,打包时排除旧的 `Installer.exe` 和临时 `InstallPackage.zip`。
30 2. 将 `Source.zip` 放入 `XFEToolBox.Client.Installer/Resources/Resource`,然后发布安装器:
31
32 ```powershell
33 dotnet publish XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj -c Release -p:PublishProfile=FolderProfile
34 ```
35
36 3. 分发生成的单文件 `Installer.exe`。安装后,软件目录会自动得到一份同样的安装器,保留后续在线升级能力,无需预先再打包一份升级器。
37
38 `FolderProfile` 使用 Windows x64、自包含、单文件发布,以保证复制的 `Installer.exe` 可以独立运行。自动复制会保留安装器本身的内置安装包,但 `Source.zip` 不再嵌套第二份安装器。
39
40 可用 `-p:InstallationPackagePath=<Source.zip 的绝对路径>` 指定其他安装包,资源名称保持不变。只需要单独分发不含离线安装包的升级器时,仍可使用 `-p:EmbedInstallationPackage=false`。
36 41
37 42 安装时若目标目录中的 `XFEToolBox.exe` 仍在运行,Installer 会先尝试正常关闭,超时后仅终止路径完全匹配的目标进程;不会按进程名结束其他目录中的同名程序。升级包中若包含正在运行的 Installer 本身,该文件会被跳过,其余应用文件继续安装。
38 43
@@ -40,7 +45,7 @@
40 45
41 46 安装包读取、解压、文件属性修改、备份、替换及回滚遇到 Windows 临时共享冲突、锁冲突或拒绝访问时,会自动等待并重试,每个操作最多等待 10 秒。持续占用仍会报告具体失败文件并尝试回滚;无效压缩包、磁盘空间不足等错误不会按文件占用重复尝试。升级成功后,临时安装包清理失败不会把已经完成的升级误报为安装失败。
42 47
43 安装器回归测试(包含真实文件锁以及下载完成、暂停恢复和页面卸载的时序测试):
48 安装器回归测试(包含自动添加升级器、同目录升级、安装回滚、真实文件锁以及下载完成、暂停恢复和页面卸载的时序测试):
44 49
45 50 ```powershell
46 51 dotnet run --project XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj -c Release -p:EmbedInstallationPackage=false -- --tests --filter Installer --no-parallel --report none
Modified XFEToolBox.Client.Installer/Utilities/InstallationService.cs +35 -6
@@ -9,16 +9,21 @@ namespace XFEToolBox.Client.Installer.Utilities;
9 9 /// </summary>
10 10 public static class InstallationService
11 11 {
12 public static void InstallPackageFile(string packagePath, string installPath, string executableName)
12 public const string InstallerExecutableName = "Installer.exe";
13
14 public static void InstallPackageFile(
15 string packagePath, string installPath, string executableName, string? installerSourcePath = null)
13 16 {
14 17 using var packageStream = InstallerFileOperations.ExecuteWithRetry(
15 18 () => new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.Read),
16 19 packagePath,
17 20 "读取安装包");
18 InstallPackage(packageStream, installPath, executableName);
21 InstallPackage(packageStream, installPath, executableName, installerSourcePath);
19 22 }
20 23
21 public static void InstallPackage(Stream packageStream, string installPath, string executableName)
24 /// <param name="installerSourcePath">当前单文件安装器的路径;安装包未包含 Installer.exe 时自动添加其副本。</param>
25 public static void InstallPackage(
26 Stream packageStream, string installPath, string executableName, string? installerSourcePath = null)
22 27 {
23 28 ArgumentNullException.ThrowIfNull(packageStream);
24 29 if (!packageStream.CanRead)
@@ -31,6 +36,7 @@ public static class InstallationService
31 36 var targetRoot = Path.GetFullPath(installPath);
32 37 if (Path.GetPathRoot(targetRoot)?.Equals(targetRoot, StringComparison.OrdinalIgnoreCase) == true)
33 38 throw new InvalidOperationException("不能直接安装到磁盘根目录,请选择一个应用文件夹。");
39 var installerSource = installerSourcePath is null ? null : Path.GetFullPath(installerSourcePath);
34 40
35 41 var stagingRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer", Guid.NewGuid().ToString("N"));
36 42 try
@@ -42,8 +48,9 @@ public static class InstallationService
42 48 if (!File.Exists(stagedExecutable))
43 49 throw new InvalidDataException($"安装包根目录中缺少 {executableName}。请确认压缩包没有额外的二级目录。");
44 50
51 StageInstaller(stagingRoot, targetRoot, installerSource);
45 52 StopRunningTargetApplication(Path.Combine(targetRoot, executableName));
46 ApplyStagedFiles(stagingRoot, targetRoot);
53 ApplyStagedFiles(stagingRoot, targetRoot, installerSource);
47 54
48 55 var installedExecutable = Path.Combine(targetRoot, executableName);
49 56 if (!File.Exists(installedExecutable))
@@ -55,6 +62,27 @@ public static class InstallationService
55 62 }
56 63 }
57 64
65 private static void StageInstaller(string stagingRoot, string targetRoot, string? installerSourcePath)
66 {
67 if (installerSourcePath is null)
68 return;
69
70 var stagedInstaller = Path.Combine(stagingRoot, InstallerExecutableName);
71 var targetInstaller = Path.Combine(targetRoot, InstallerExecutableName);
72 // 兼容已经携带升级器的旧安装包;在安装目录内升级时直接保留正在运行的自身。
73 if (File.Exists(stagedInstaller) || targetInstaller.Equals(installerSourcePath, StringComparison.OrdinalIgnoreCase))
74 return;
75
76 if (!File.Exists(installerSourcePath))
77 throw new FileNotFoundException("找不到当前安装器,无法在安装目录中添加在线升级器。", installerSourcePath);
78
79 // 和应用文件一起暂存、备份、替换及回滚,避免升级器复制失败后留下半安装状态。
80 InstallerFileOperations.ExecuteWithRetry(
81 () => File.Copy(installerSourcePath, stagedInstaller, overwrite: true),
82 stagedInstaller,
83 "准备安装器副本");
84 }
85
58 86 public static string GetDetailedErrorMessage(Exception exception)
59 87 {
60 88 ArgumentNullException.ThrowIfNull(exception);
@@ -128,7 +156,7 @@ public static class InstallationService
128 156 }
129 157 }
130 158
131 private static void ApplyStagedFiles(string stagingRoot, string targetRoot)
159 private static void ApplyStagedFiles(string stagingRoot, string targetRoot, string? installerSourcePath)
132 160 {
133 161 var backupRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox.Installer.Backup", Guid.NewGuid().ToString("N"));
134 162 var appliedFiles = new List<AppliedFile>();
@@ -147,7 +175,8 @@ public static class InstallationService
147 175 {
148 176 var relativePath = Path.GetRelativePath(stagingRoot, sourceFile);
149 177 var targetFile = Path.Combine(targetRoot, relativePath);
150 if (Path.GetFullPath(targetFile).Equals(Environment.ProcessPath, StringComparison.OrdinalIgnoreCase))
178 if (Path.GetFullPath(targetFile).Equals(Environment.ProcessPath, StringComparison.OrdinalIgnoreCase)
179 || targetFile.Equals(installerSourcePath, StringComparison.OrdinalIgnoreCase))
151 180 {
152 181 // 直接跳过,不必删除可能正被扫描器占用的暂存安装器。
153 182 Debug.WriteLine($"[Installer] 已跳过正在运行的安装器文件:{relativePath}");
Modified XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs +8 -4
@@ -67,12 +67,15 @@ public partial class InstallProgressPage : Page
67 67
68 68 private static string InstallPackage()
69 69 {
70 var installerSourcePath = Environment.ProcessPath
71 ?? throw new InvalidOperationException("无法确定当前安装器路径,不能添加在线升级器。");
70 72 if (string.Equals(SystemProfile.StartMode, "Upgrade", StringComparison.OrdinalIgnoreCase))
71 return InstallUpgradePackage();
73 return InstallUpgradePackage(installerSourcePath);
72 74
73 75 using var packageStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(PackageResourceName)
74 76 ?? throw new InvalidDataException("安装程序中缺少内置安装包 Source.zip,请重新下载安装器。");
75 InstallationService.InstallPackage(packageStream, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName);
77 InstallationService.InstallPackage(
78 packageStream, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName, installerSourcePath);
76 79
77 80 var shortcutPath = Path.Combine(
78 81 Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
@@ -88,13 +91,14 @@ public partial class InstallProgressPage : Page
88 91 : "XFEToolBox 已安装完成;桌面快捷方式创建失败,但不影响正常使用。";
89 92 }
90 93
91 private static string InstallUpgradePackage()
94 private static string InstallUpgradePackage(string installerSourcePath)
92 95 {
93 96 var packagePath = Path.Combine(SystemProfile.InstallPath, "InstallPackage.zip");
94 97 if (!File.Exists(packagePath))
95 98 throw new FileNotFoundException("未找到已下载的升级包,请返回 XFEToolBox 重新检查更新。", packagePath);
96 99
97 InstallationService.InstallPackageFile(packagePath, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName);
100 InstallationService.InstallPackageFile(
101 packagePath, SystemProfile.InstallPath, SystemProfile.ApplicationExecutableName, installerSourcePath);
98 102
99 103 return InstallerFileOperations.TryDeleteFile(packagePath)
100 104 ? "XFEToolBox 已升级完成,安装包验证通过并已清理临时文件。"
Modified XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj +4 -2
@@ -1,4 +1,4 @@
1 <Project Sdk="Microsoft.NET.Sdk">
1 <Project Sdk="Microsoft.NET.Sdk">
2 2
3 3 <PropertyGroup>
4 4 <OutputType>WinExe</OutputType>
@@ -11,6 +11,7 @@
11 11 <AssemblyName>Installer</AssemblyName>
12 12 <Version>1.1.3</Version>
13 13 <EmbedInstallationPackage Condition="'$(EmbedInstallationPackage)' == ''">true</EmbedInstallationPackage>
14 <InstallationPackagePath Condition="'$(InstallationPackagePath)' == ''">Resources\Resource\Source.zip</InstallationPackagePath>
14 15 </PropertyGroup>
15 16
16 17 <ItemGroup>
@@ -34,7 +35,8 @@
34 35 <ItemGroup>
35 36 <EmbeddedResource Include="Resources\Resource\EULA.txt" />
36 37 <EmbeddedResource Include="Resources\Resource\PrivateService.txt" />
37 <EmbeddedResource Include="Resources\Resource\Source.zip"
38 <EmbeddedResource Include="$(InstallationPackagePath)"
39 LogicalName="XFEToolBox.Client.Installer.Resources.Resource.Source.zip"
38 40 Condition="'$(EmbedInstallationPackage)' == 'true'" />
39 41 </ItemGroup>
40 42
Modified XFEToolBox.Client.Wpf.Test/InstallerTests.cs +117 -5
@@ -30,6 +30,105 @@ public static class InstallerTests
30 30 }
31 31 }
32 32
33 [Test]
34 public static void InstallerAddsItselfWhenThePackageContainsOnlyTheApplication()
35 {
36 var testRoot = CreateTemporaryDirectory();
37 try
38 {
39 var targetRoot = Path.Combine(testRoot, "installed");
40 var installerSourcePath = Path.Combine(testRoot, "XFEToolBox Setup.exe");
41 // 使用真实 EXE 内容,同时覆盖下载后的安装器被重命名的情况。
42 File.Copy(Environment.ProcessPath!, installerSourcePath);
43 using var package = CreatePackage(("XFEToolBox.exe", "application"));
44
45 InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe", installerSourcePath);
46
47 var installedInstallerPath = Path.Combine(targetRoot, "Installer.exe");
48 Ensure(File.ReadAllBytes(installedInstallerPath).SequenceEqual(File.ReadAllBytes(installerSourcePath)),
49 "未将安装器完整复制为安装目录中的 Installer.exe。");
50 Ensure(!File.Exists(Path.Combine(targetRoot, "XFEToolBox Setup.exe")),
51 "安装器使用了下载文件名,导致客户端无法找到 Installer.exe。");
52 Ensure(File.ReadAllText(Path.Combine(targetRoot, "XFEToolBox.exe")) == "application",
53 "自动添加升级器后没有安装应用程序。");
54 }
55 finally
56 {
57 DeleteTemporaryDirectory(testRoot);
58 }
59 }
60
61 [Test]
62 public static void InstallerPreservesItsLockedExecutableDuringAnInPlaceUpgrade()
63 {
64 var targetRoot = CreateTemporaryDirectory();
65 try
66 {
67 var installerPath = Path.Combine(targetRoot, "Installer.exe");
68 File.WriteAllText(installerPath, "running-installer");
69 foreach (var includesInstaller in new[] { false, true })
70 {
71 using var package = includesInstaller
72 ? CreatePackage(("XFEToolBox.exe", "updated-app"), ("Installer.exe", "packaged-installer"))
73 : CreatePackage(("XFEToolBox.exe", "updated-app"));
74 using (File.Open(installerPath, FileMode.Open, FileAccess.Read, FileShare.Read))
75 InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe", installerPath.ToUpperInvariant());
76
77 Ensure(File.ReadAllText(installerPath) == "running-installer", "原地升级覆盖了正在运行的安装器。");
78 Ensure(File.ReadAllText(Path.Combine(targetRoot, "XFEToolBox.exe")) == "updated-app",
79 "安装器自身被占用时未能继续更新应用文件。");
80 }
81 }
82 finally
83 {
84 DeleteTemporaryDirectory(targetRoot);
85 }
86 }
87
88 [Test]
89 public static void InstallerKeepsAnInstallerExplicitlyIncludedInThePackage()
90 {
91 var testRoot = CreateTemporaryDirectory();
92 try
93 {
94 var installerSourcePath = Path.Combine(testRoot, "Setup.exe");
95 var targetRoot = Path.Combine(testRoot, "installed");
96 File.WriteAllText(installerSourcePath, "running-installer");
97 using var package = CreatePackage(("XFEToolBox.exe", "application"), ("Installer.exe", "packaged-installer"));
98
99 InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe", installerSourcePath);
100
101 Ensure(File.ReadAllText(Path.Combine(targetRoot, "Installer.exe")) == "packaged-installer",
102 "安装包中显式提供的升级器被当前安装器副本替换了。");
103 }
104 finally
105 {
106 DeleteTemporaryDirectory(testRoot);
107 }
108 }
109
110 [Test]
111 public static void InstallerLeavesExistingFilesUntouchedIfItsSourceIsMissing()
112 {
113 var targetRoot = CreateTemporaryDirectory();
114 try
115 {
116 var executablePath = Path.Combine(targetRoot, "XFEToolBox.exe");
117 File.WriteAllText(executablePath, "old-application");
118 using var package = CreatePackage(("XFEToolBox.exe", "new-application"));
119
120 EnsureThrows<FileNotFoundException>(() => InstallationService.InstallPackage(
121 package, targetRoot, "XFEToolBox.exe", Path.Combine(targetRoot, "missing-setup.exe")));
122
123 Ensure(File.ReadAllText(executablePath) == "old-application", "安装器副本准备失败后仍覆盖了旧应用。");
124 Ensure(!File.Exists(Path.Combine(targetRoot, "Installer.exe")), "安装器副本准备失败后留下了无效升级器。");
125 }
126 finally
127 {
128 DeleteTemporaryDirectory(targetRoot);
129 }
130 }
131
33 132 [Test]
34 133 public static void InstallerRejectsNestedAndTraversingPackages()
35 134 {
@@ -62,14 +161,18 @@ public static class InstallerTests
62 161 try
63 162 {
64 163 var packagePath = Path.Combine(targetRoot, "InstallPackage.zip");
164 var installerSourcePath = Path.Combine(targetRoot, "Setup.exe");
165 File.WriteAllText(installerSourcePath, "downloaded-installer");
65 166 using var package = CreatePackage(("XFEToolBox.exe", "application"));
66 167 File.WriteAllBytes(packagePath, package.ToArray());
67 168
68 169 WithTemporaryFileLock(packagePath, FileShare.ReadWrite, 500, () =>
69 InstallationService.InstallPackageFile(packagePath, targetRoot, "XFEToolBox.exe"));
170 InstallationService.InstallPackageFile(packagePath, targetRoot, "XFEToolBox.exe", installerSourcePath));
70 171
71 172 Ensure(File.ReadAllText(Path.Combine(targetRoot, "XFEToolBox.exe")) == "application",
72 173 "下载器释放安装包后没有自动完成安装。");
174 Ensure(File.ReadAllText(Path.Combine(targetRoot, "Installer.exe")) == "downloaded-installer",
175 "从文件安装升级包时未能自动添加升级器。");
73 176 using var releasedPackage = File.Open(packagePath, FileMode.Open, FileAccess.Read, FileShare.None);
74 177 }
75 178 finally
@@ -128,20 +231,26 @@ public static class InstallerTests
128 231 }
129 232 }
130 233
131 [Test]
132 public static void InstallerRestoresExistingFilesWhenAnOverwriteFails()
234 [TestCase(false)]
235 [TestCase(true)]
236 public static void InstallerRestoresExistingFilesWhenAnOverwriteFails(bool hasExistingInstaller)
133 237 {
134 238 var targetRoot = CreateTemporaryDirectory();
135 239 try
136 240 {
137 241 var executablePath = Path.Combine(targetRoot, "XFEToolBox.exe");
138 242 var settingsPath = Path.Combine(targetRoot, "settings.json");
243 var installerPath = Path.Combine(targetRoot, "Installer.exe");
244 var installerSourcePath = Path.Combine(targetRoot, "Setup.exe");
139 245 var addedPath = Path.Combine(targetRoot, "added.dat");
140 246 var blockedPath = Path.Combine(targetRoot, "locked", "blocked.dat");
141 247 Directory.CreateDirectory(Path.GetDirectoryName(blockedPath)!);
142 248 File.WriteAllText(executablePath, "old-application");
143 249 File.WriteAllText(settingsPath, "old-settings");
144 250 File.WriteAllText(blockedPath, "locked");
251 File.WriteAllText(installerSourcePath, "new-installer");
252 if (hasExistingInstaller)
253 File.WriteAllText(installerPath, "old-installer");
145 254
146 255 using var package = CreatePackage(
147 256 ("XFEToolBox.exe", "new-application"),
@@ -150,13 +259,14 @@ public static class InstallerTests
150 259 ("locked/blocked.dat", "new-blocked"));
151 260 using (File.Open(blockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
152 261 {
153 var installTask = Task.Run(() => InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe"));
262 var installTask = Task.Run(() => InstallationService.InstallPackage(package, targetRoot, "XFEToolBox.exe", installerSourcePath));
154 263 var sawAppliedFiles = SpinWait.SpinUntil(() =>
155 264 {
156 265 try
157 266 {
158 267 return File.Exists(addedPath) && File.ReadAllText(executablePath) == "new-application"
159 && File.ReadAllText(settingsPath) == "new-settings";
268 && File.ReadAllText(settingsPath) == "new-settings"
269 && File.ReadAllText(installerPath) == "new-installer";
160 270 }
161 271 catch (IOException) { return false; }
162 272 }, TimeSpan.FromSeconds(5));
@@ -169,6 +279,8 @@ public static class InstallerTests
169 279 Ensure(File.ReadAllText(settingsPath) == "old-settings",
170 280 "覆盖失败后没有恢复旧配置。");
171 281 Ensure(!File.Exists(addedPath), "覆盖失败后没有移除本次新增的文件。");
282 Ensure(hasExistingInstaller ? File.ReadAllText(installerPath) == "old-installer" : !File.Exists(installerPath),
283 "覆盖失败后未恢复旧升级器或移除本次新增的升级器。");
172 284 }
173 285 finally
174 286 {