Skip to content

Commit bea83b6

Browse files
authored
Refactor EXE packaging to use byte sources (#134)
1 parent 97ab432 commit bea83b6

File tree

3 files changed

+181
-120
lines changed

3 files changed

+181
-120
lines changed
Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1+
using Zafiro.DivineBytes;
2+
13
namespace DotnetPackaging.Exe;
24

35
public static class BuildScript
46
{
5-
public static void Build()
7+
public static async Task Build()
68
{
7-
var stub = "build/Stub.exe"; // firmado
8-
var installerPayload = "build/installer_payload.zip";
9-
var uninstallerPayload = "build/uninstaller_payload.zip";
9+
var stub = ByteSource.FromAsyncStreamFactory(() => Task.FromResult<Stream>(File.OpenRead("build/Stub.exe")));
10+
var installerPayload = ByteSource.FromAsyncStreamFactory(() => Task.FromResult<Stream>(File.OpenRead("build/installer_payload.zip")));
11+
var uninstallerPayload = ByteSource.FromAsyncStreamFactory(() => Task.FromResult<Stream>(File.OpenRead("build/uninstaller_payload.zip")));
12+
13+
Directory.CreateDirectory("artifacts");
1014

11-
PayloadAppender.AppendPayload(stub, uninstallerPayload, "artifacts/Uninstaller.exe");
12-
PayloadAppender.AppendPayload(stub, installerPayload, "artifacts/Installer.exe");
15+
await Persist("artifacts/Uninstaller.exe", PayloadAppender.AppendPayload(stub, uninstallerPayload));
16+
await Persist("artifacts/Installer.exe", PayloadAppender.AppendPayload(stub, installerPayload));
17+
}
18+
19+
private static async Task Persist(string path, IByteSource source)
20+
{
21+
await using var input = source.ToStreamSeekable();
22+
await using var output = File.Create(path);
23+
await input.CopyToAsync(output);
1324
}
1425
}
Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
using System.Text;
2+
using Zafiro.DivineBytes;
23

34
namespace DotnetPackaging.Exe;
45

56
public static class PayloadAppender
67
{
7-
public static void AppendPayload(string signedStubPath, string payloadZipPath, string outputPath)
8+
public static IByteSource AppendPayload(IByteSource signedStub, IByteSource payload)
89
{
9-
var stubBytes = File.ReadAllBytes(signedStubPath);
10-
var payloadBytes = File.ReadAllBytes(payloadZipPath);
11-
var lengthBytes = BitConverter.GetBytes((long)payloadBytes.Length);
12-
var magicBytes = Encoding.ASCII.GetBytes("DPACKEXE1");
10+
return ByteSource.FromAsyncStreamFactory(async () =>
11+
{
12+
var stubStream = signedStub.ToStreamSeekable();
13+
var payloadStream = payload.ToStreamSeekable();
14+
var output = new MemoryStream();
1315

14-
using var output = File.Create(outputPath);
15-
output.Write(stubBytes);
16-
output.Write(payloadBytes);
17-
output.Write(lengthBytes);
18-
output.Write(magicBytes);
16+
await using (stubStream)
17+
await using (payloadStream)
18+
{
19+
await stubStream.CopyToAsync(output);
20+
await payloadStream.CopyToAsync(output);
21+
22+
var lengthBytes = BitConverter.GetBytes(payloadStream.Length);
23+
var magicBytes = Encoding.ASCII.GetBytes("DPACKEXE1");
24+
25+
await output.WriteAsync(lengthBytes, 0, lengthBytes.Length);
26+
await output.WriteAsync(magicBytes, 0, magicBytes.Length);
27+
output.Position = 0;
28+
return output;
29+
}
30+
});
1931
}
2032
}
Lines changed: 142 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System.IO.Compression;
22
using System.Text.Json;
33
using CSharpFunctionalExtensions;
4+
using Zafiro.DivineBytes;
5+
using Zafiro.FileSystem.Core;
46

57
namespace DotnetPackaging.Exe;
68

@@ -15,147 +17,183 @@ public static async Task<Result> Build(
1517
Maybe<byte[]> logoBytes,
1618
string outputPath)
1719
{
18-
var tempRoot = string.Empty;
19-
try
20+
if (!File.Exists(stubPath))
2021
{
21-
if (!File.Exists(stubPath))
22-
{
23-
return Result.Failure($"Stub not found: {stubPath}");
24-
}
25-
26-
if (!Directory.Exists(publishDir))
27-
{
28-
return Result.Failure($"Publish directory not found: {publishDir}");
29-
}
30-
31-
var outputDirectory = Path.GetDirectoryName(outputPath);
32-
if (string.IsNullOrWhiteSpace(outputDirectory))
33-
{
34-
return Result.Failure("Output directory cannot be determined.");
35-
}
36-
37-
Directory.CreateDirectory(outputDirectory);
22+
return Result.Failure($"Stub not found: {stubPath}");
23+
}
3824

39-
tempRoot = Path.Combine(Path.GetTempPath(), "dp-exe-" + Guid.NewGuid());
40-
Directory.CreateDirectory(tempRoot);
25+
if (!Directory.Exists(publishDir))
26+
{
27+
return Result.Failure($"Publish directory not found: {publishDir}");
28+
}
4129

42-
var uninstallerPayloadRoot = Path.Combine(tempRoot, "uninstaller_payload");
43-
Directory.CreateDirectory(uninstallerPayloadRoot);
44-
await WriteMetadata(uninstallerPayloadRoot, metadata);
45-
await WriteSupportStub(uninstallerPayloadRoot, stubPath);
30+
var outputDirectory = Path.GetDirectoryName(outputPath);
31+
if (string.IsNullOrWhiteSpace(outputDirectory))
32+
{
33+
return Result.Failure("Output directory cannot be determined.");
34+
}
4635

47-
var uninstallerPayloadZip = Path.Combine(outputDirectory, "uninstaller_payload.zip");
48-
CreatePayloadZip(uninstallerPayloadRoot, uninstallerPayloadZip);
36+
var publishContainerResult = BuildContainerFromDirectory(publishDir);
37+
if (publishContainerResult.IsFailure)
38+
{
39+
return Result.Failure(publishContainerResult.Error);
40+
}
4941

50-
var uninstallerOutput = Path.Combine(outputDirectory, "Uninstaller.exe");
51-
PayloadAppender.AppendPayload(stubPath, uninstallerPayloadZip, uninstallerOutput);
42+
var stubSource = ByteSource.FromAsyncStreamFactory(() => Task.FromResult<Stream>(File.OpenRead(stubPath)));
43+
var logoSource = logoBytes.Map(bytes => (IByteSource)ByteSource.FromBytes(bytes));
44+
var bundleResult = await Build(stubSource, publishContainerResult.Value, metadata, logoSource);
45+
if (bundleResult.IsFailure)
46+
{
47+
return Result.Failure(bundleResult.Error);
48+
}
5249

53-
var installerPayloadRoot = Path.Combine(tempRoot, "installer_payload");
54-
Directory.CreateDirectory(installerPayloadRoot);
55-
await WriteMetadata(installerPayloadRoot, metadata);
56-
CopyDirectory(publishDir, Path.Combine(installerPayloadRoot, "Content"));
57-
CopySupportBinary(uninstallerOutput, Path.Combine(installerPayloadRoot, "Support"));
58-
await WriteLogo(installerPayloadRoot, logoBytes);
50+
Directory.CreateDirectory(outputDirectory);
51+
var uninstallerPath = Path.Combine(outputDirectory, "Uninstaller.exe");
52+
await Persist(bundleResult.Value.Installer, outputPath);
53+
await Persist(bundleResult.Value.Uninstaller, uninstallerPath);
5954

60-
var installerPayloadZip = Path.Combine(outputDirectory, "installer_payload.zip");
61-
CreatePayloadZip(installerPayloadRoot, installerPayloadZip);
55+
return Result.Success();
56+
}
6257

63-
PayloadAppender.AppendPayload(stubPath, installerPayloadZip, outputPath);
64-
return Result.Success();
65-
}
66-
catch (Exception ex)
58+
public static async Task<Result<SimpleExeBundle>> Build(
59+
IByteSource stub,
60+
IContainer publishContent,
61+
InstallerMetadata metadata,
62+
Maybe<IByteSource> logoBytes)
63+
{
64+
var metadataSource = Serialize(metadata);
65+
var uninstallerPayloadResult = await BuildUninstallerPayload(stub, metadataSource);
66+
if (uninstallerPayloadResult.IsFailure)
6767
{
68-
return Result.Failure(ex.Message);
68+
return Result.Failure<SimpleExeBundle>(uninstallerPayloadResult.Error);
6969
}
70-
finally
70+
71+
var uninstaller = new Resource("Uninstaller.exe", PayloadAppender.AppendPayload(stub, uninstallerPayloadResult.Value));
72+
73+
var installerPayloadResult = await BuildInstallerPayload(metadataSource, publishContent, logoBytes, uninstaller);
74+
if (installerPayloadResult.IsFailure)
7175
{
72-
TryDeleteTempDirectories();
76+
return Result.Failure<SimpleExeBundle>(installerPayloadResult.Error);
7377
}
7478

75-
void TryDeleteTempDirectories()
76-
{
77-
if (string.IsNullOrWhiteSpace(tempRoot))
78-
{
79-
return;
80-
}
79+
var installer = new Resource("Installer.exe", PayloadAppender.AppendPayload(stub, installerPayloadResult.Value));
8180

82-
try
83-
{
84-
Directory.Delete(tempRoot, true);
85-
}
86-
catch
87-
{
88-
// best effort cleanup
89-
}
90-
}
81+
return Result.Success(new SimpleExeBundle(installer, uninstaller));
9182
}
9283

93-
private static void CreatePayloadZip(string sourceDirectory, string destinationZip)
84+
private static async Task Persist(INamedByteSource artifact, string path)
9485
{
95-
Directory.CreateDirectory(Path.GetDirectoryName(destinationZip)!);
96-
if (File.Exists(destinationZip))
97-
{
98-
File.Delete(destinationZip);
99-
}
100-
101-
ZipFile.CreateFromDirectory(sourceDirectory, destinationZip, CompressionLevel.Optimal, includeBaseDirectory: false);
86+
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
87+
await using var input = artifact.ToStreamSeekable();
88+
await using var output = File.Create(path);
89+
await input.CopyToAsync(output);
10290
}
10391

104-
private static async Task WriteMetadata(string destinationDirectory, InstallerMetadata meta)
92+
private static IByteSource Serialize(InstallerMetadata metadata)
10593
{
106-
Directory.CreateDirectory(destinationDirectory);
107-
var metadataPath = Path.Combine(destinationDirectory, "metadata.json");
108-
await using var stream = File.Create(metadataPath);
109-
await JsonSerializer.SerializeAsync(stream, meta, new JsonSerializerOptions
94+
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
11095
{
11196
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
11297
WriteIndented = false
11398
});
114-
}
11599

116-
private static async Task WriteLogo(string payloadRoot, Maybe<byte[]> logoBytes)
117-
{
118-
await logoBytes.Match(
119-
async bytes =>
120-
{
121-
var brandingDir = Path.Combine(payloadRoot, "Branding");
122-
Directory.CreateDirectory(brandingDir);
123-
var logoPath = Path.Combine(brandingDir, Path.GetFileName(BrandingLogoEntry));
124-
await File.WriteAllBytesAsync(logoPath, bytes);
125-
},
126-
() => Task.CompletedTask);
100+
return ByteSource.FromString(json);
127101
}
128102

129-
private static async Task WriteSupportStub(string payloadRoot, string stubPath)
103+
private static async Task<Result<IByteSource>> BuildUninstallerPayload(IByteSource stub, IByteSource metadataSource)
130104
{
131-
var supportDir = Path.Combine(payloadRoot, "Support");
132-
Directory.CreateDirectory(supportDir);
133-
await using var input = File.OpenRead(stubPath);
134-
await using var output = File.Create(Path.Combine(supportDir, "Uninstaller.exe"));
135-
await input.CopyToAsync(output);
105+
var entries = new Dictionary<string, IByteSource>(StringComparer.Ordinal)
106+
{
107+
["metadata.json"] = metadataSource,
108+
[$"Support/{"Uninstaller.exe"}"] = stub
109+
};
110+
111+
var containerResult = entries.ToRootContainer();
112+
if (containerResult.IsFailure)
113+
{
114+
return Result.Failure<IByteSource>(containerResult.Error);
115+
}
116+
117+
return await CreatePayloadZip(containerResult.Value);
136118
}
137119

138-
private static void CopySupportBinary(string uninstallerPath, string supportRoot)
120+
private static async Task<Result<IByteSource>> BuildInstallerPayload(
121+
IByteSource metadataSource,
122+
IContainer publishContent,
123+
Maybe<IByteSource> logoBytes,
124+
INamedByteSource uninstaller)
139125
{
140-
Directory.CreateDirectory(supportRoot);
141-
var destination = Path.Combine(supportRoot, "Uninstaller.exe");
142-
File.Copy(uninstallerPath, destination, overwrite: true);
126+
var entries = new Dictionary<string, IByteSource>(StringComparer.Ordinal)
127+
{
128+
["metadata.json"] = metadataSource,
129+
[$"Support/{uninstaller.Name}"] = uninstaller
130+
};
131+
132+
foreach (var file in publishContent.ResourcesWithPathsRecursive())
133+
{
134+
var entryPath = $"Content/{file.FullPath().ToString().Replace('\\', '/')}";
135+
entries[entryPath] = file;
136+
}
137+
138+
logoBytes.Execute(bytes => entries[BrandingLogoEntry] = bytes);
139+
140+
var containerResult = entries.ToRootContainer();
141+
if (containerResult.IsFailure)
142+
{
143+
return Result.Failure<IByteSource>(containerResult.Error);
144+
}
145+
146+
return await CreatePayloadZip(containerResult.Value);
143147
}
144148

145-
private static void CopyDirectory(string sourceDir, string destinationDir)
149+
private static Result<RootContainer> BuildContainerFromDirectory(string root)
146150
{
147-
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
151+
try
148152
{
149-
var relative = Path.GetRelativePath(sourceDir, directory);
150-
Directory.CreateDirectory(Path.Combine(destinationDir, relative));
153+
var files = Directory
154+
.EnumerateFiles(root, "*", SearchOption.AllDirectories)
155+
.ToDictionary(
156+
file => Path.GetRelativePath(root, file).Replace("\\", "/"),
157+
file => (IByteSource)ByteSource.FromAsyncStreamFactory(() => Task.FromResult<Stream>(File.OpenRead(file))),
158+
StringComparer.Ordinal);
159+
160+
return files.ToRootContainer();
151161
}
152-
153-
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
162+
catch (Exception ex)
154163
{
155-
var relative = Path.GetRelativePath(sourceDir, file);
156-
var destination = Path.Combine(destinationDir, relative);
157-
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
158-
File.Copy(file, destination, overwrite: true);
164+
return Result.Failure<RootContainer>($"Failed to read directory '{root}': {ex.Message}");
159165
}
160166
}
167+
168+
private static Task<Result<IByteSource>> CreatePayloadZip(IContainer container)
169+
{
170+
return Task.FromResult(Result.Success(CreateZipSource(container)));
171+
}
172+
173+
private static IByteSource CreateZipSource(IContainer container)
174+
{
175+
return ByteSource.FromAsyncStreamFactory(async () =>
176+
{
177+
var zipStream = new MemoryStream();
178+
179+
await using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
180+
{
181+
foreach (var resource in container.ResourcesWithPathsRecursive())
182+
{
183+
var entry = zip.CreateEntry(resource.FullPath().ToString().Replace('\\', '/'), CompressionLevel.Optimal);
184+
await using var entryStream = entry.Open();
185+
await using var resourceStream = resource.ToStreamSeekable();
186+
await resourceStream.CopyToAsync(entryStream);
187+
}
188+
}
189+
190+
zipStream.Position = 0;
191+
var final = new MemoryStream();
192+
await zipStream.CopyToAsync(final);
193+
final.Position = 0;
194+
return final;
195+
});
196+
}
161197
}
198+
199+
public sealed record SimpleExeBundle(INamedByteSource Installer, INamedByteSource Uninstaller);

0 commit comments

Comments
 (0)