11using System . IO . Compression ;
22using System . Text . Json ;
33using CSharpFunctionalExtensions ;
4+ using Zafiro . DivineBytes ;
5+ using Zafiro . FileSystem . Core ;
46
57namespace 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