Skip to content

Commit 4c9dbfa

Browse files
authored
Improve uninstaller payload resiliency (#131)
1 parent 580c6b6 commit 4c9dbfa

File tree

6 files changed

+91
-8
lines changed

6 files changed

+91
-8
lines changed

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
using CSharpFunctionalExtensions;
22
using System.Text.Json;
33
using Serilog;
4+
using Zafiro.DivineBytes;
5+
using Path = System.IO.Path;
46

57
namespace DotnetPackaging.Exe.Installer.Core;
68

79
internal static class Installer
810
{
9-
public static Result<string> Install(string targetDir, InstallerMetadata meta, long payloadSizeBytes)
11+
public static Result<string> Install(string targetDir, InstallerMetadata meta, long payloadSizeBytes, Maybe<IByteSource> logo)
1012
{
1113
return ResolveMainExe(targetDir, meta)
1214
.Tap(exePath => ShortcutService.TryCreateStartMenuShortcut(meta.ApplicationName, exePath))
13-
.Tap(exePath => RegisterUninstaller(targetDir, meta, exePath, payloadSizeBytes));
15+
.Tap(exePath => RegisterUninstaller(targetDir, meta, exePath, payloadSizeBytes, logo));
1416
}
1517

16-
private static void RegisterUninstaller(string targetDir, InstallerMetadata meta, string mainExePath, long payloadSizeBytes)
18+
private static void RegisterUninstaller(string targetDir, InstallerMetadata meta, string mainExePath, long payloadSizeBytes, Maybe<IByteSource> logo)
1719
{
1820
if (Environment.ProcessPath is null)
1921
{
2022
return;
2123
}
2224

2325
PersistMetadata(targetDir, meta);
26+
PersistLogo(targetDir, logo);
2427

2528
// Strategy:
2629
// 1. Copy full installer as Uninstall.exe to "Uninstall" subdirectory to avoid DLL locks/conflicts with main app
@@ -31,6 +34,7 @@ private static void RegisterUninstaller(string targetDir, InstallerMetadata meta
3134
var uninstallDir = Path.Combine(targetDir, "Uninstall");
3235
Directory.CreateDirectory(uninstallDir);
3336
PersistMetadata(uninstallDir, meta);
37+
PersistLogo(uninstallDir, logo);
3438

3539
var uninstallerPath = Path.Combine(uninstallDir, "Uninstall.exe");
3640
var slimUninstallerResult = UninstallerBuilder.CreateSlimCopy(Environment.ProcessPath, uninstallerPath);
@@ -71,6 +75,26 @@ private static void PersistMetadata(string directory, InstallerMetadata meta)
7175
}
7276
}
7377

78+
private static void PersistLogo(string directory, Maybe<IByteSource> logo)
79+
{
80+
if (logo.HasNoValue)
81+
{
82+
return;
83+
}
84+
85+
try
86+
{
87+
var logoPath = Path.Combine(directory, "logo.png");
88+
using var source = logo.Value.ToStreamSeekable();
89+
using var destination = File.Open(logoPath, FileMode.Create, FileAccess.Write, FileShare.None);
90+
source.CopyTo(destination);
91+
}
92+
catch (Exception ex)
93+
{
94+
Log.Warning(ex, "Failed to persist logo to {Directory}", directory);
95+
}
96+
}
97+
7498
private static Result<string> ResolveMainExe(string targetDir, InstallerMetadata meta)
7599
{
76100
return Result.Try(() =>

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

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

99
internal sealed class MetadataFilePayload : IInstallerPayload
1010
{
11+
private readonly string directory;
1112
private readonly string metadataPath;
1213

13-
private MetadataFilePayload(string metadataPath)
14+
private MetadataFilePayload(string metadataPath, string directory)
1415
{
1516
this.metadataPath = metadataPath;
17+
this.directory = directory;
1618
}
1719

1820
public static Result<MetadataFilePayload> FromProcessDirectory()
@@ -28,13 +30,23 @@ public static Result<MetadataFilePayload> FromProcessDirectory()
2830
return Result.Failure<MetadataFilePayload>("Process directory cannot be determined.");
2931
}
3032

33+
return FromDirectory(directory);
34+
}
35+
36+
public static Result<MetadataFilePayload> FromDirectory(string directory)
37+
{
38+
if (string.IsNullOrWhiteSpace(directory))
39+
{
40+
return Result.Failure<MetadataFilePayload>("Directory cannot be null or whitespace.");
41+
}
42+
3143
var metadataPath = Path.Combine(directory, "metadata.json");
3244
if (!File.Exists(metadataPath))
3345
{
3446
return Result.Failure<MetadataFilePayload>($"metadata.json not found in {directory}");
3547
}
3648

37-
return Result.Success(new MetadataFilePayload(metadataPath));
49+
return Result.Success(new MetadataFilePayload(metadataPath, directory));
3850
}
3951

4052
public Task<Result<InstallerMetadata>> GetMetadata(CancellationToken ct = default)
@@ -58,7 +70,17 @@ public Task<Result<long>> GetContentSize(CancellationToken ct = default)
5870

5971
public Task<Result<Maybe<IByteSource>>> GetLogo(CancellationToken ct = default)
6072
{
61-
return Task.FromResult(Result.Success(Maybe<IByteSource>.None));
73+
return Task.FromResult(Result.Try(() =>
74+
{
75+
var logoPath = Path.Combine(directory, "logo.png");
76+
if (!File.Exists(logoPath))
77+
{
78+
return Maybe<IByteSource>.None;
79+
}
80+
81+
var bytes = File.ReadAllBytes(logoPath);
82+
return Maybe<IByteSource>.From(ByteSource.FromBytes(bytes));
83+
}, ex => $"Failed to read logo: {ex.Message}"));
6284
}
6385

6486
public Task<Result> CopyContents(string targetDirectory, IObserver<Progress>? progressObserver = null, CancellationToken ct = default)

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using ReactiveUI;
66
using Serilog;
77
using Zafiro.ProgressReporting;
8+
using Zafiro.DivineBytes;
89
using Zafiro.UI.Commands;
910

1011
namespace DotnetPackaging.Exe.Installer.Flows.Installation.Wizard.Install;
@@ -36,6 +37,13 @@ private Task<Result<InstallationResult>> DoInstall(InstallerMetadata installerMe
3637
}
3738
var payloadSize = payloadSizeResult.IsSuccess ? payloadSizeResult.Value : 0;
3839

40+
var logoResult = await payload.GetLogo().ConfigureAwait(false);
41+
if (logoResult.IsFailure)
42+
{
43+
Log.Warning("Failed to load logo from payload: {Error}", logoResult.Error);
44+
}
45+
var logo = logoResult.IsSuccess ? logoResult.Value : Maybe<IByteSource>.None;
46+
3947
var copyRes = await payload.CopyContents(installDirectory, progress).ConfigureAwait(false);
4048
if (copyRes.IsFailure)
4149
{
@@ -45,7 +53,7 @@ private Task<Result<InstallationResult>> DoInstall(InstallerMetadata installerMe
4553

4654
Log.Information("Contents copied successfully, registering installation");
4755

48-
var installResult = Core.Installer.Install(installDirectory, installerMetadata, payloadSize)
56+
var installResult = Core.Installer.Install(installDirectory, installerMetadata, payloadSize, logo)
4957
.Map(exePath => new InstallationResult(installerMetadata, installDirectory, exePath))
5058
.Bind(result => InstallationRegistry.Register(result).Map(() => result));
5159

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ 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))
55+
.Bind(() => Core.Installer.Install(installDir, payload.Metadata, payload.ContentSizeBytes, payload.Logo))
5656
.Map(exePath => new InstallationResult(payload.Metadata, installDir, exePath)));
5757
}
5858

src/DotnetPackaging.Exe.Installer/Flows/Uninstallation/Wizard/Steps/UninstallView.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
<TextBlock TextWrapping="Wrap"
1919
VerticalAlignment="Center"
20+
IsVisible="{Binding InstallationMissing}"
2021
TextTrimming="{x:Static TextTrimming.LeadingCharacterEllipsis}"
2122
Text="{Binding ErrorMessage, StringFormat='Unable to locate an installation for this package: {0}'}" />
2223
</StackPanel>

src/DotnetPackaging.Exe.Tests/SimpleExePackerTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using DotnetPackaging.Exe.Installer.Core;
88
using FluentAssertions;
99
using Xunit;
10+
using Zafiro.DivineBytes;
1011

1112
namespace DotnetPackaging.Exe.Tests;
1213

@@ -276,6 +277,33 @@ public async Task Dispatcher_failures_should_be_logged()
276277
try { Directory.Delete(tempDir.FullName, true); } catch { }
277278
}
278279

280+
[Fact]
281+
public async Task Metadata_payload_should_expose_logo_from_disk()
282+
{
283+
var tempDir = Directory.CreateTempSubdirectory("dp-meta-logo-");
284+
var metadata = new InstallerMetadata("com.test.logo", "Test Logo", "1.0.0", "Test Vendor", HasLogo: true);
285+
var metadataPath = Path.Combine(tempDir.FullName, "metadata.json");
286+
await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata));
287+
288+
var logoBytes = new byte[] { 1, 2, 3, 4, 5 };
289+
var logoPath = Path.Combine(tempDir.FullName, "logo.png");
290+
await File.WriteAllBytesAsync(logoPath, logoBytes);
291+
292+
var payloadResult = MetadataFilePayload.FromDirectory(tempDir.FullName);
293+
payloadResult.IsSuccess.Should().BeTrue(payloadResult.IsFailure ? payloadResult.Error : string.Empty);
294+
295+
var logoResult = await payloadResult.Value.GetLogo();
296+
logoResult.IsSuccess.Should().BeTrue(logoResult.IsFailure ? logoResult.Error : string.Empty);
297+
logoResult.Value.HasValue.Should().BeTrue();
298+
299+
await using var logoStream = logoResult.Value.Value.ToStreamSeekable();
300+
await using var buffer = new MemoryStream();
301+
await logoStream.CopyToAsync(buffer);
302+
buffer.ToArray().Should().Equal(logoBytes);
303+
304+
try { Directory.Delete(tempDir.FullName, true); } catch { }
305+
}
306+
279307
private Maybe<(long Start, long Length)> FindPayloadInfo(FileStream fs)
280308
{
281309
var searchWindow = 4096;

0 commit comments

Comments
 (0)