返回提交历史
Modified
XFEToolBox.Client.Installer/README.md
+17
-12
Modified
XFEToolBox.Client.Installer/Utilities/InstallationService.cs
+35
-6
Modified
XFEToolBox.Client.Installer/Views/Pages/InstallProgressPage.xaml.cs
+8
-4
Modified
XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj
+4
-2
Modified
XFEToolBox.Client.Wpf.Test/InstallerTests.cs
+117
-5
XFEstudio/XFEToolBox
增强安装器自复制与升级流程,补充测试用例
- 安装器自动复制为 Installer.exe,支持在线升级,兼容旧包 - 统一处理 Installer.exe 的复制、备份、回滚,失败不视为成功 - 避免升级时覆盖运行中的 Installer.exe,需后续包替换 - 发布流程与 README 更新,Source.zip 仅含应用文件 - 支持 MSBuild 参数指定安装包路径 - 大幅补充单元测试,覆盖多种升级与回滚场景 - 优化代码结构,方法增加 installerSourcePath 参数
ba14bc6
代码差异
5 个文件
+181
-29
@@ -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
@@ -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}");
@@ -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 已升级完成,安装包验证通过并已清理临时文件。"
@@ -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
@@ -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
{