Skip to content

Commit 45ee191

Browse files
authored
Store installer logos in the payload (#126)
1 parent 0f49fdb commit 45ee191

File tree

17 files changed

+237
-38
lines changed

17 files changed

+237
-38
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Avalonia.Media.Imaging;
2+
using Zafiro.DivineBytes;
3+
4+
namespace DotnetPackaging.Exe.Installer.Core;
5+
6+
internal static class BrandingLogoFactory
7+
{
8+
public static IBitmap? FromBytes(IByteSource? bytes)
9+
{
10+
if (bytes is null)
11+
{
12+
return null;
13+
}
14+
15+
try
16+
{
17+
using var stream = bytes.ToStreamSeekable();
18+
return new Bitmap(stream);
19+
}
20+
catch
21+
{
22+
return null;
23+
}
24+
}
25+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using CSharpFunctionalExtensions;
2+
using Zafiro.DivineBytes;
23
using Zafiro.ProgressReporting;
34

45
namespace DotnetPackaging.Exe.Installer.Core;
@@ -14,6 +15,9 @@ public Task<Result<InstallerMetadata>> GetMetadata(CancellationToken ct = defaul
1415
public Task<Result<long>> GetContentSize(CancellationToken ct = default)
1516
=> EnsureLoaded(ct).Map(p => p.ContentSizeBytes);
1617

18+
public Task<Result<Maybe<IByteSource>>> GetLogo(CancellationToken ct = default)
19+
=> EnsureLoaded(ct).Map(p => p.Logo);
20+
1721
public Task<Result> CopyContents(string targetDirectory, IObserver<Progress>? progressObserver = null, CancellationToken ct = default)
1822
=> EnsureLoaded(ct).Bind(p => Task.Run(() =>
1923
PayloadExtractor.CopyContentTo(p, targetDirectory, progressObserver), ct));

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using CSharpFunctionalExtensions;
2+
using Zafiro.DivineBytes;
23
using Zafiro.ProgressReporting;
34

45
namespace DotnetPackaging.Exe.Installer.Core;
@@ -9,6 +10,8 @@ public interface IInstallerPayload
910

1011
Task<Result<long>> GetContentSize(CancellationToken ct = default);
1112

13+
Task<Result<Maybe<IByteSource>>> GetLogo(CancellationToken ct = default);
14+
1215
Task<Result> CopyContents(
1316
string targetDirectory,
1417
IObserver<Progress>? progressObserver = null,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ public sealed record InstallerMetadata(
66
string Version,
77
string Vendor,
88
string? Description = null,
9-
string? ExecutableName = null);
9+
string? ExecutableName = null,
10+
bool HasLogo = false);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using CSharpFunctionalExtensions;
3+
using Zafiro.DivineBytes;
34
using Zafiro.ProgressReporting;
45

56
namespace DotnetPackaging.Exe.Installer.Core;
@@ -54,6 +55,11 @@ public Task<Result<long>> GetContentSize(CancellationToken ct = default)
5455
return Task.FromResult(Result.Failure<long>("Disk-only payload does not provide installation content."));
5556
}
5657

58+
public Task<Result<Maybe<IByteSource>>> GetLogo(CancellationToken ct = default)
59+
{
60+
return Task.FromResult(Result.Success(Maybe<IByteSource>.None));
61+
}
62+
5763
public Task<Result> CopyContents(string targetDirectory, IObserver<Progress>? progressObserver = null, CancellationToken ct = default)
5864
{
5965
return Task.FromResult(Result.Failure("Disk-only payload does not provide installation content."));

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace DotnetPackaging.Exe.Installer.Core;
1212
internal static class PayloadExtractor
1313
{
1414
private const string Magic = "DPACKEXE1"; // legacy footer (concat mode)
15+
private const string BrandingLogoEntry = "Branding/logo";
1516

1617
public static Result<InstallerPayload> LoadPayload()
1718
{
@@ -220,7 +221,8 @@ private static Result<InstallerPayload> CreatePayload(PayloadLocation location)
220221
payloadMetadata.Metadata,
221222
ByteSource.FromBytes(bytes),
222223
location.TempDir,
223-
payloadMetadata.ContentSizeBytes)));
224+
payloadMetadata.ContentSizeBytes,
225+
ExtractLogo(bytes))));
224226

225227
TryDeleteFile(location.ZipPath);
226228

@@ -251,6 +253,29 @@ private static Result<PayloadMetadata> ReadMetadata(byte[] zipBytes)
251253
}, ex => $"Error reading payload metadata: {ex.Message}");
252254
}
253255

256+
private static Maybe<IByteSource> ExtractLogo(byte[] zipBytes)
257+
{
258+
try
259+
{
260+
using var stream = new MemoryStream(zipBytes, writable: false);
261+
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
262+
var entry = archive.GetEntry(BrandingLogoEntry);
263+
if (entry is null)
264+
{
265+
return Maybe<IByteSource>.None;
266+
}
267+
268+
using var entryStream = entry.Open();
269+
using var buffer = new MemoryStream();
270+
entryStream.CopyTo(buffer);
271+
return Maybe<IByteSource>.From(ByteSource.FromBytes(buffer.ToArray()));
272+
}
273+
catch
274+
{
275+
return Maybe<IByteSource>.None;
276+
}
277+
}
278+
254279
private sealed record PayloadMetadata(InstallerMetadata Metadata, long ContentSizeBytes);
255280

256281
private static Maybe<PayloadLocation> TryExtractFromManagedResource()
@@ -613,10 +638,10 @@ private static Result<InstallerPayload> CreateDebugPayload()
613638

614639
var payloadMetadata = payloadMetadataResult.Value;
615640

616-
return new InstallerPayload(metadata, ByteSource.FromBytes(payloadBytes), tempDir, payloadMetadata.ContentSizeBytes);
641+
return new InstallerPayload(metadata, ByteSource.FromBytes(payloadBytes), tempDir, payloadMetadata.ContentSizeBytes, Maybe<IByteSource>.None);
617642
}, ex => $"Failed to create debug payload: {ex.Message}");
618643
}
619644
#endif
620645
}
621646

622-
public sealed record InstallerPayload(InstallerMetadata Metadata, IByteSource Content, string WorkingDirectory, long ContentSizeBytes);
647+
public sealed record InstallerPayload(InstallerMetadata Metadata, IByteSource Content, string WorkingDirectory, long ContentSizeBytes, Maybe<IByteSource> Logo);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
using System.Reactive;
2+
using Avalonia.Media.Imaging;
23
using CSharpFunctionalExtensions;
34
using DotnetPackaging.Exe.Installer.Core;
5+
using Reactive.Bindings;
46
using ReactiveUI;
7+
using Zafiro.DivineBytes;
58

69
namespace DotnetPackaging.Exe.Installer.Installation.Wizard.Welcome;
710

811
public interface IWelcomeViewModel
912
{
1013
Reactive.Bindings.ReactiveProperty<InstallerMetadata?> Metadata { get; }
1114
ReactiveCommand<Unit, Result<InstallerMetadata>> LoadMetadata { get; }
15+
ReactiveCommand<Unit, Result<Maybe<IByteSource>>> LoadLogo { get; }
16+
ReadOnlyReactivePropertySlim<IBitmap?> Logo { get; }
1217
}

src/DotnetPackaging.Exe.Installer/Installation/Wizard/Welcome/WelcomeView.axaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@
1313
<Interaction.Behaviors>
1414
<LoadedTrigger>
1515
<InvokeCommandAction Command="{Binding LoadMetadata}" />
16+
<InvokeCommandAction Command="{Binding LoadLogo}" />
1617
</LoadedTrigger>
1718
</Interaction.Behaviors>
1819

1920
<Loading IsLoading="{Binding LoadMetadata.IsExecuting^}">
2021
<DockPanel VerticalSpacing="12">
22+
<Image DockPanel.Dock="Right"
23+
Width="96"
24+
Height="96"
25+
Source="{Binding Logo}"
26+
Stretch="Uniform" />
2127
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Text="{Binding Metadata.Value.ApplicationName, StringFormat='Welcome to the {0} installation wizard'}" FontSize="20" FontWeight="Bold" />
2228
<TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Right" Text="{Binding Metadata.Value.Version, StringFormat='Version {0}'}" />
2329
<TextBlock DockPanel.Dock="Top" Text="{Binding Metadata.Value.ApplicationName, StringFormat='This wizard will help you install {0} on this system'}" />

src/DotnetPackaging.Exe.Installer/Installation/Wizard/Welcome/WelcomeViewModel.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
using System.Reactive;
1+
using System.Reactive;
2+
using System.Reactive.Linq;
3+
using Avalonia.Media.Imaging;
24
using CSharpFunctionalExtensions;
35
using DotnetPackaging.Exe.Installer.Core;
6+
using Reactive.Bindings;
7+
using Reactive.Bindings.Extensions;
48
using ReactiveUI;
59
using ReactiveUI.Validation.Extensions;
610
using ReactiveUI.Validation.Helpers;
711
using Zafiro.CSharpFunctionalExtensions;
12+
using Zafiro.DivineBytes;
813
using Zafiro.UI;
914

1015
namespace DotnetPackaging.Exe.Installer.Installation.Wizard.Welcome;
@@ -17,13 +22,24 @@ public WelcomeViewModel(IInstallerPayload payload)
1722
{
1823
this.payload = payload;
1924
LoadMetadata = ReactiveCommand.CreateFromTask(() => this.payload.GetMetadata());
20-
Metadata = new Reactive.Bindings.ReactiveProperty<InstallerMetadata?>(LoadMetadata.Successes());
25+
Metadata = new ReactiveProperty<InstallerMetadata?>(LoadMetadata.Successes());
2126
this.ValidationRule(model => model.Metadata.Value, m => m is not null, "Metadata is required");
27+
28+
LoadLogo = ReactiveCommand.CreateFromTask(() => payload.GetLogo());
29+
30+
Logo = LoadLogo
31+
.Successes()
32+
.Select(logoBytes => logoBytes.Match(BrandingLogoFactory.FromBytes, () => (IBitmap?)null))
33+
.ToReadOnlyReactivePropertySlim();
2234
}
2335

24-
public Reactive.Bindings.ReactiveProperty<InstallerMetadata?> Metadata { get; }
36+
public ReactiveProperty<InstallerMetadata?> Metadata { get; }
2537

2638
public ReactiveCommand<Unit, Result<InstallerMetadata>> LoadMetadata { get; }
2739

40+
public ReactiveCommand<Unit, Result<Maybe<IByteSource>>> LoadLogo { get; }
41+
42+
public ReadOnlyReactivePropertySlim<IBitmap?> Logo { get; }
43+
2844
public IObservable<bool> IsValid => this.IsValid();
2945
}
Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,48 @@
1+
using System;
12
using System.Reactive;
3+
using Avalonia.Media.Imaging;
24
using CSharpFunctionalExtensions;
35
using DotnetPackaging.Exe.Installer.Core;
6+
using Reactive.Bindings;
7+
using Reactive.Bindings.Extensions;
48
using ReactiveUI;
9+
using Zafiro.DivineBytes;
510

611
namespace DotnetPackaging.Exe.Installer.Installation.Wizard.Welcome;
712

813
public class WelcomeViewModelMock : IWelcomeViewModel
914
{
15+
private static readonly byte[] SampleLogo = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgNkHFN0AAAAASUVORK5CYII=");
16+
1017
public WelcomeViewModelMock()
1118
{
12-
Metadata = new Reactive.Bindings.ReactiveProperty<InstallerMetadata>(new InstallerMetadata(
19+
Metadata = new ReactiveProperty<InstallerMetadata>(new InstallerMetadata(
1320
"com.example.app",
1421
"Example App",
1522
"1.0.0",
16-
"Example, Inc.", Description: "This is an example app. It does nothing. It's just a demo."));
23+
"Example, Inc.",
24+
Description: "This is an example app. It does nothing. It's just a demo.",
25+
HasLogo: true));
26+
27+
LoadLogo = ReactiveCommand.Create(() => Result.Success(Maybe<IByteSource>.From(ByteSource.FromBytes(SampleLogo))));
28+
29+
Logo = LoadLogo
30+
.Successes()
31+
.Select(logoBytes => logoBytes.Match(BrandingLogoFactory.FromBytes, () => (IBitmap?)null))
32+
.ToReadOnlyReactivePropertySlim();
1733
}
1834

19-
public Reactive.Bindings.ReactiveProperty<InstallerMetadata?> Metadata { get; }
35+
public ReactiveProperty<InstallerMetadata?> Metadata { get; }
36+
37+
public ReactiveCommand<Unit, Result<InstallerMetadata>> LoadMetadata { get; } = ReactiveCommand.Create(() => Result.Success(new InstallerMetadata(
38+
"com.example.app",
39+
"Example App",
40+
"1.0.0",
41+
"Example, Inc.",
42+
Description: "This is an example app. It does nothing. It's just a demo.",
43+
HasLogo: true)));
44+
45+
public ReactiveCommand<Unit, Result<Maybe<IByteSource>>> LoadLogo { get; }
2046

21-
public ReactiveCommand<Unit, Result<InstallerMetadata>> LoadMetadata { get; }
22-
}
47+
public ReadOnlyReactivePropertySlim<IBitmap?> Logo { get; }
48+
}

0 commit comments

Comments
 (0)