Skip to content

Commit 4363491

Browse files
authored
Ensure MSIX reference package availability (#133)
1 parent 72403cd commit 4363491

File tree

15 files changed

+347
-112
lines changed

15 files changed

+347
-112
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,5 @@ dotnet-install.sh
367367

368368
# Test/runtime artifacts
369369
squashfs-root/
370+
src/DotnetPackaging.Msix.Tests/TestFiles/**/Actual.msix
371+
src/DotnetPackaging.Msix.Tests/TestFiles/**/Expected.msix

src/DotnetPackaging.Exe.Installer/Core/DefaultInstallerPayload.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ public Task<Result> CopyContents(string targetDirectory, IObserver<Progress>? pr
2222
=> EnsureLoaded(ct).Bind(p => Task.Run(() =>
2323
PayloadExtractor.CopyContentTo(p, targetDirectory, progressObserver), ct));
2424

25+
public Task<Result<Maybe<string>>> MaterializeUninstaller(string targetDirectory, CancellationToken ct = default)
26+
=> EnsureLoaded(ct).Bind(payload => Task.Run(
27+
() => PayloadExtractor.CopyUninstallerTo(payload, targetDirectory), ct));
28+
2529
private Task<Result<InstallerPayload>> EnsureLoaded(CancellationToken ct)
2630
=> Task.Run(() =>
2731
{

src/DotnetPackaging.Exe.Installer/Core/IInstallerPayload.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ Task<Result> CopyContents(
1616
string targetDirectory,
1717
IObserver<Progress>? progressObserver = null,
1818
CancellationToken ct = default);
19+
20+
Task<Result<Maybe<string>>> MaterializeUninstaller(
21+
string targetDirectory,
22+
CancellationToken ct = default);
1923
}

src/DotnetPackaging.Exe.Installer/Core/Installer.cs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,25 @@ namespace DotnetPackaging.Exe.Installer.Core;
88

99
internal static class Installer
1010
{
11-
public static Result<string> Install(string targetDir, InstallerMetadata meta, long payloadSizeBytes, Maybe<IByteSource> logo)
11+
public static Result<string> Install(
12+
string targetDir,
13+
InstallerMetadata meta,
14+
long payloadSizeBytes,
15+
Maybe<IByteSource> logo,
16+
Maybe<string> providedUninstaller)
1217
{
1318
return ResolveMainExe(targetDir, meta)
1419
.Tap(exePath => ShortcutService.TryCreateStartMenuShortcut(meta.ApplicationName, exePath))
15-
.Tap(exePath => RegisterUninstaller(targetDir, meta, exePath, payloadSizeBytes, logo));
20+
.Tap(exePath => RegisterUninstaller(targetDir, meta, exePath, payloadSizeBytes, logo, providedUninstaller));
1621
}
1722

18-
private static void RegisterUninstaller(string targetDir, InstallerMetadata meta, string mainExePath, long payloadSizeBytes, Maybe<IByteSource> logo)
23+
private static void RegisterUninstaller(
24+
string targetDir,
25+
InstallerMetadata meta,
26+
string mainExePath,
27+
long payloadSizeBytes,
28+
Maybe<IByteSource> logo,
29+
Maybe<string> providedUninstaller)
1930
{
2031
if (Environment.ProcessPath is null)
2132
{
@@ -36,22 +47,27 @@ private static void RegisterUninstaller(string targetDir, InstallerMetadata meta
3647
PersistMetadata(uninstallDir, meta);
3748
PersistLogo(uninstallDir, logo);
3849

39-
var uninstallerPath = Path.Combine(uninstallDir, "Uninstall.exe");
40-
var slimUninstallerResult = UninstallerBuilder.CreateSlimCopy(Environment.ProcessPath, uninstallerPath);
41-
if (slimUninstallerResult.IsFailure)
50+
var uninstallerPath = ResolveUninstallerPath(providedUninstaller, uninstallDir);
51+
if (uninstallerPath.HasNoValue)
4252
{
43-
Log.Warning("Slim uninstaller creation failed: {Error}. Using full installer copy instead.", slimUninstallerResult.Error);
44-
File.Copy(Environment.ProcessPath, uninstallerPath, overwrite: true);
53+
uninstallerPath = Maybe<string>.From(Path.Combine(uninstallDir, "Uninstaller.exe"));
54+
var slimUninstallerResult = UninstallerBuilder.CreateSlimCopy(Environment.ProcessPath, uninstallerPath.Value);
55+
if (slimUninstallerResult.IsFailure)
56+
{
57+
Log.Warning("Slim uninstaller creation failed: {Error}. Using full installer copy instead.", slimUninstallerResult.Error);
58+
File.Copy(Environment.ProcessPath, uninstallerPath.Value, overwrite: true);
59+
}
4560
}
46-
Log.Information("Uninstaller copied to: {Path}", uninstallerPath);
61+
62+
Log.Information("Uninstaller copied to: {Path}", uninstallerPath.Value);
4763

4864
WindowsRegistryService.Register(
4965
meta.AppId,
5066
meta.ApplicationName,
5167
meta.Version,
5268
meta.Vendor,
5369
targetDir,
54-
$"\"{uninstallerPath}\" --uninstall",
70+
$"\"{uninstallerPath.Value}\" --uninstall",
5571
mainExePath,
5672
payloadSizeBytes);
5773
}
@@ -95,6 +111,21 @@ private static void PersistLogo(string directory, Maybe<IByteSource> logo)
95111
}
96112
}
97113

114+
private static Maybe<string> ResolveUninstallerPath(Maybe<string> providedUninstaller, string uninstallDir)
115+
{
116+
return providedUninstaller.Bind(path =>
117+
{
118+
if (File.Exists(path))
119+
{
120+
return Maybe<string>.From(path);
121+
}
122+
123+
var fallback = Path.Combine(uninstallDir, "Uninstaller.exe");
124+
Log.Warning("Provided uninstaller path {Path} was not found. Will generate fallback at {Fallback}", path, fallback);
125+
return Maybe<string>.None;
126+
});
127+
}
128+
98129
private static Result<string> ResolveMainExe(string targetDir, InstallerMetadata meta)
99130
{
100131
return Result.Try(() =>

src/DotnetPackaging.Exe.Installer/Core/MetadataFilePayload.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,9 @@ public Task<Result> CopyContents(string targetDirectory, IObserver<Progress>? pr
8787
{
8888
return Task.FromResult(Result.Failure("Disk-only payload does not provide installation content."));
8989
}
90+
91+
public Task<Result<Maybe<string>>> MaterializeUninstaller(string targetDirectory, CancellationToken ct = default)
92+
{
93+
return Task.FromResult(Result.Success(Maybe<string>.None));
94+
}
9095
}

src/DotnetPackaging.Exe.Installer/Core/PayloadExtractor.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
using CSharpFunctionalExtensions;
88
using Zafiro.DivineBytes;
99
using Zafiro.ProgressReporting;
10+
using Path = System.IO.Path;
1011

1112
namespace DotnetPackaging.Exe.Installer.Core;
1213

1314
internal static class PayloadExtractor
1415
{
1516
private const string Magic = "DPACKEXE1"; // legacy footer (concat mode)
16-
private const string BrandingLogoEntry = "Branding/logo";
17+
private const string BrandingLogoEntry = "Branding/logo.png";
18+
private const string SupportUninstallerEntry = "Support/Uninstaller.exe";
1719

1820
public static Result<InstallerPayload> LoadPayload()
1921
{
@@ -200,6 +202,28 @@ public static Result CopyContentTo(InstallerPayload payload, string targetDirect
200202
}, ex => $"Error extracting payload content: {ex.Message}");
201203
}
202204

205+
public static Result<Maybe<string>> CopyUninstallerTo(InstallerPayload payload, string destinationDirectory)
206+
{
207+
return Result.Try(() =>
208+
{
209+
Directory.CreateDirectory(destinationDirectory);
210+
211+
using var stream = payload.Content.ToStreamSeekable();
212+
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
213+
var entry = archive.GetEntry(SupportUninstallerEntry);
214+
if (entry is null)
215+
{
216+
return Maybe<string>.None;
217+
}
218+
219+
var outputPath = Path.Combine(destinationDirectory, "Uninstaller.exe");
220+
using var entryStream = entry.Open();
221+
using var fileStream = File.Create(outputPath);
222+
entryStream.CopyTo(fileStream);
223+
return Maybe<string>.From(outputPath);
224+
}, ex => $"Error extracting uninstaller: {ex.Message}");
225+
}
226+
203227
private static Func<Result<InstallerPayload>> AttemptLoadPayloadFrom(Func<Maybe<PayloadLocation>> extractor, string missingMessage)
204228
{
205229
return () => extractor()

src/DotnetPackaging.Exe.Installer/Flows/Installation/Wizard/Install/InstallViewModel.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,17 @@ private Task<Result<InstallationResult>> DoInstall(InstallerMetadata installerMe
5151
return Result.Failure<InstallationResult>(copyRes.Error);
5252
}
5353

54+
var uninstallerResult = await payload.MaterializeUninstaller(System.IO.Path.Combine(installDirectory, "Uninstall"))
55+
.ConfigureAwait(false);
56+
if (uninstallerResult.IsFailure)
57+
{
58+
Log.Error("Failed to materialize uninstaller: {Error}", uninstallerResult.Error);
59+
return Result.Failure<InstallationResult>(uninstallerResult.Error);
60+
}
61+
5462
Log.Information("Contents copied successfully, registering installation");
5563

56-
var installResult = Core.Installer.Install(installDirectory, installerMetadata, payloadSize, logo)
64+
var installResult = Core.Installer.Install(installDirectory, installerMetadata, payloadSize, logo, uninstallerResult.Value)
5765
.Map(exePath => new InstallationResult(installerMetadata, installDirectory, exePath))
5866
.Bind(result => InstallationRegistry.Register(result).Map(() => result));
5967

src/DotnetPackaging.Exe.Installer/Flows/Installation/Wizard/InstallWizard.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ private static Task<Result<InstallationResult>> InstallApplicationAsync(Installe
5252
{
5353
return Task.Run(() =>
5454
PayloadExtractor.CopyContentTo(payload, installDir, progressObserver)
55-
.Bind(() => Core.Installer.Install(installDir, payload.Metadata, payload.ContentSizeBytes, payload.Logo))
55+
.Bind(() => PayloadExtractor.CopyUninstallerTo(payload, Path.Combine(installDir, "Uninstall")))
56+
.Bind(uninstaller => Core.Installer.Install(installDir, payload.Metadata, payload.ContentSizeBytes, payload.Logo, uninstaller))
5657
.Map(exePath => new InstallationResult(payload.Metadata, installDir, exePath)));
5758
}
5859

src/DotnetPackaging.Exe.Tests/SimpleExePackerTests.cs

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ namespace DotnetPackaging.Exe.Tests;
1515
public class SimpleExePackerTests
1616
{
1717
[SkippableFact]
18-
public async Task Should_Create_Valid_Uninstaller_By_Stripping_Payload()
18+
public async Task Should_Create_Valid_Uninstaller_With_Appended_Payload()
1919
{
2020
Skip.IfNot(OperatingSystem.IsWindows(), "Installer stub is Windows-only.");
2121

2222
// Arrange
2323
var tempDir = Directory.CreateTempSubdirectory("dp-test-strip-");
2424
var publishDir = tempDir.CreateSubdirectory("publish");
2525
var outputInstaller = Path.Combine(tempDir.FullName, "Installer.exe");
26-
var outputUninstaller = Path.Combine(tempDir.FullName, "Uninstall.exe");
26+
var outputUninstaller = Path.Combine(tempDir.FullName, "Uninstaller.exe");
27+
var metadataDump = Path.Combine(tempDir.FullName, "metadata.json");
2728

2829
File.WriteAllText(Path.Combine(publishDir.FullName, "App.exe"), "Dummy App");
2930

@@ -33,39 +34,15 @@ public async Task Should_Create_Valid_Uninstaller_By_Stripping_Payload()
3334
// Act 1: Build Installer (Stub + Payload)
3435
await SimpleExePacker.Build(stubPath, publishDir.FullName, metadata, Maybe<byte[]>.None, outputInstaller);
3536

36-
// Act 2: Strip Payload to create Uninstaller
37-
using (var src = File.OpenRead(outputInstaller))
38-
{
39-
var payloadInfo = FindPayloadInfo(src);
40-
payloadInfo.HasValue.Should().BeTrue();
41-
var (payloadStart, _) = payloadInfo.Value;
42-
43-
src.Position = 0;
44-
using var dst = File.Create(outputUninstaller);
45-
var buffer = new byte[81920];
46-
long remaining = payloadStart;
47-
while (remaining > 0)
48-
{
49-
var toRead = (int)Math.Min(buffer.Length, remaining);
50-
var read = src.Read(buffer, 0, toRead);
51-
if (read == 0) break;
52-
dst.Write(buffer, 0, read);
53-
remaining -= read;
54-
}
55-
}
56-
57-
// Assert
58-
var originalBytes = await File.ReadAllBytesAsync(stubPath);
59-
var strippedBytes = await File.ReadAllBytesAsync(outputUninstaller);
60-
61-
strippedBytes.Length.Should().Be(originalBytes.Length);
62-
strippedBytes.Should().Equal(originalBytes);
37+
File.Exists(outputUninstaller).Should().BeTrue();
38+
PayloadExtractor.GetAppendedPayloadStart(outputUninstaller).HasValue.Should().BeTrue();
6339

6440
var psi = new ProcessStartInfo(outputUninstaller)
6541
{
6642
UseShellExecute = false,
6743
CreateNoWindow = true,
68-
Environment = { ["DP_DUMP_METADATA_JSON"] = "1", ["AVALONIA_HEADLESS"] = "1" }
44+
Arguments = "--uninstall",
45+
Environment = { ["DP_DUMP_METADATA_JSON"] = metadataDump, ["AVALONIA_HEADLESS"] = "1" }
6946
};
7047

7148
using var process = Process.Start(psi);
@@ -75,6 +52,8 @@ public async Task Should_Create_Valid_Uninstaller_By_Stripping_Payload()
7552

7653
process.ExitCode.Should().Be(0);
7754

55+
File.Exists(metadataDump).Should().BeTrue();
56+
7857
try { Directory.Delete(tempDir.FullName, true); } catch { }
7958
}
8059

@@ -129,7 +108,9 @@ public async Task Should_Extract_Payload_Correctly_From_Installer()
129108
{
130109
var entry = zipArchive.GetEntry("Content/App.exe");
131110
entry.Should().NotBeNull();
132-
111+
112+
zipArchive.GetEntry("Support/Uninstaller.exe").Should().NotBeNull();
113+
133114
using var entryStream = entry!.Open();
134115
using var reader = new StreamReader(entryStream);
135116
var content = await reader.ReadToEndAsync();
@@ -189,10 +170,9 @@ public async Task Uninstaller_should_be_stripped_and_bootable()
189170

190171
var tempDir = Directory.CreateTempSubdirectory("dp-uninstall-boot-");
191172
var publishDir = tempDir.CreateSubdirectory("publish");
192-
var uninstallDir = tempDir.CreateSubdirectory("Uninstall");
193173
var outputInstaller = Path.Combine(tempDir.FullName, "Installer.exe");
194-
var outputUninstaller = Path.Combine(uninstallDir.FullName, "Uninstall.exe");
195-
var metadataDump = Path.Combine(uninstallDir.FullName, "metadata.json");
174+
var outputUninstaller = Path.Combine(tempDir.FullName, "Uninstaller.exe");
175+
var metadataDump = Path.Combine(tempDir.FullName, "metadata.json");
196176

197177
File.WriteAllText(Path.Combine(publishDir.FullName, "App.exe"), "Dummy App");
198178

@@ -201,13 +181,8 @@ public async Task Uninstaller_should_be_stripped_and_bootable()
201181

202182
PayloadExtractor.GetAppendedPayloadStart(outputInstaller).HasValue.Should().BeTrue();
203183

204-
var slimCopy = UninstallerBuilder.CreateSlimCopy(outputInstaller, outputUninstaller);
205-
slimCopy.IsSuccess.Should().BeTrue(slimCopy.IsFailure ? slimCopy.Error : string.Empty);
206-
207-
PayloadExtractor.GetAppendedPayloadStart(outputUninstaller).HasValue.Should().BeFalse();
208-
209-
var metadataJson = JsonSerializer.Serialize(metadata);
210-
await File.WriteAllTextAsync(Path.Combine(uninstallDir.FullName, "metadata.json"), metadataJson);
184+
File.Exists(outputUninstaller).Should().BeTrue();
185+
PayloadExtractor.GetAppendedPayloadStart(outputUninstaller).HasValue.Should().BeTrue();
211186

212187
var psi = new ProcessStartInfo(outputUninstaller)
213188
{
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace DotnetPackaging.Exe;
2+
3+
public static class BuildScript
4+
{
5+
public static void Build()
6+
{
7+
var stub = "build/Stub.exe"; // firmado
8+
var installerPayload = "build/installer_payload.zip";
9+
var uninstallerPayload = "build/uninstaller_payload.zip";
10+
11+
PayloadAppender.AppendPayload(stub, uninstallerPayload, "artifacts/Uninstaller.exe");
12+
PayloadAppender.AppendPayload(stub, installerPayload, "artifacts/Installer.exe");
13+
}
14+
}

0 commit comments

Comments
 (0)