1- using Zafiro . DivineBytes ;
1+ using System . Buffers . Binary ;
2+ using System . Diagnostics ;
3+ using System . Reactive . Linq ;
24using System . Text ;
5+ using CSharpFunctionalExtensions ;
36using DotnetPackaging . Formats . Dmg . Iso ;
47using DotnetPackaging . Formats . Dmg . Udif ;
5- using System . Diagnostics ;
8+ using Zafiro . DivineBytes ;
69using Path = System . IO . Path ;
710
811namespace DotnetPackaging . Dmg ;
@@ -13,9 +16,21 @@ namespace DotnetPackaging.Dmg;
1316/// </summary>
1417public static class DmgIsoBuilder
1518{
16- public static Task Create ( string sourceFolder , string outputPath , string volumeName , bool compress = false , bool addApplicationsSymlink = false , bool includeDefaultLayout = true )
19+ private static readonly IReadOnlyDictionary < int , string > IcnsChunkTypes = new Dictionary < int , string >
20+ {
21+ [ 16 ] = "icp4" ,
22+ [ 32 ] = "icp5" ,
23+ [ 64 ] = "icp6" ,
24+ [ 128 ] = "ic07" ,
25+ [ 256 ] = "ic08" ,
26+ [ 512 ] = "ic09" ,
27+ [ 1024 ] = "ic10"
28+ } ;
29+
30+ public static async Task Create ( string sourceFolder , string outputPath , string volumeName , bool compress = false , bool addApplicationsSymlink = false , bool includeDefaultLayout = true , Maybe < IIcon > icon = default )
1731 {
18- var stagingRoot = StageContent ( sourceFolder , volumeName , includeDefaultLayout , addApplicationsSymlink ) ;
32+ var stagingRoot = await StageContent ( sourceFolder , volumeName , includeDefaultLayout , addApplicationsSymlink , icon ) ;
33+ var keepStage = Environment . GetEnvironmentVariable ( "DOTNETPACKAGING_KEEP_DMG_STAGE" ) == "1" ;
1934
2035 try
2136 {
@@ -32,10 +47,13 @@ public static Task Create(string sourceFolder, string outputPath, string volumeN
3247 }
3348 finally
3449 {
35- TryDeleteDirectory ( stagingRoot ) ;
50+ if ( ! keepStage )
51+ {
52+ TryDeleteDirectory ( stagingRoot ) ;
53+ }
3654 }
3755
38- return Task . CompletedTask ;
56+ return ;
3957 }
4058
4159 private static void BuildIso ( string stagingRoot , string outputPath , string volumeName , bool compress , bool addApplicationsSymlink )
@@ -125,7 +143,7 @@ private static void BuildWithHdiUtil(string stagingRoot, string outputPath, stri
125143 }
126144 }
127145
128- private static string StageContent ( string sourceFolder , string volumeName , bool includeDefaultLayout , bool addApplicationsSymlink )
146+ private static async Task < string > StageContent ( string sourceFolder , string volumeName , bool includeDefaultLayout , bool addApplicationsSymlink , Maybe < IIcon > icon )
129147 {
130148 var stagingRoot = Path . Combine ( Path . GetTempPath ( ) , "dmgstage-" + Guid . NewGuid ( ) ) ;
131149 Directory . CreateDirectory ( stagingRoot ) ;
@@ -165,16 +183,14 @@ private static string StageContent(string sourceFolder, string volumeName, bool
165183
166184 AddDirectoryContents ( macOs , sourceFolder , shouldSkip : path => path . EndsWith ( ".app" , StringComparison . OrdinalIgnoreCase ) || path . EndsWith ( ".icns" , StringComparison . OrdinalIgnoreCase ) ) ;
167185
168- var appIcon = FindIcnsIcon ( sourceFolder ) ;
169- if ( appIcon != null )
170- {
171- File . Copy ( appIcon , Path . Combine ( resources , Path . GetFileName ( appIcon ) ! ) , overwrite : true ) ;
172- }
186+ var appIcon = await PrepareAppIcon ( sourceFolder , resources , icon ) ;
173187
174188 AddDmgAdornments ( stagingRoot , includeDefaultLayout ) ;
175189
176190 var exeName = GuessExecutableName ( sourceFolder , volumeName ) ;
177- var plist = GenerateMinimalPlist ( volumeName , exeName , appIcon == null ? null : Path . GetFileNameWithoutExtension ( appIcon ) ) ;
191+ var plist = GenerateMinimalPlist ( volumeName , exeName , appIcon . Match (
192+ value => value ,
193+ ( ) => null ) ) ;
178194 File . WriteAllText ( Path . Combine ( contents , "Info.plist" ) , plist , Encoding . UTF8 ) ;
179195 File . WriteAllText ( Path . Combine ( contents , "PkgInfo" ) , "APPL????" , Encoding . ASCII ) ;
180196 }
@@ -357,14 +373,6 @@ private static string GuessExecutableName(string sourceFolder, string volumeName
357373 return match ;
358374 }
359375
360- private static string ? FindIcnsIcon ( string sourceFolder )
361- {
362- var icons = Directory . EnumerateFiles ( sourceFolder , "*.icns" , SearchOption . TopDirectoryOnly )
363- . Where ( path => ! Path . GetFileName ( path ) ! . Equals ( ".VolumeIcon.icns" , StringComparison . OrdinalIgnoreCase ) ) ;
364-
365- return icons . FirstOrDefault ( ) ;
366- }
367-
368376 private static string GenerateMinimalPlist ( string displayName , string executable , string ? iconName )
369377 {
370378 var identifier = $ "com.{ SanitizeBundleName ( displayName ) . Trim ( '-' ) . Trim ( '_' ) . ToLowerInvariant ( ) } ";
@@ -405,4 +413,107 @@ private static string SanitizeBundleName(string name)
405413 var cleaned = new string ( name . Where ( ch => char . IsLetterOrDigit ( ch ) || ch == '_' || ch == '-' ) . ToArray ( ) ) ;
406414 return string . IsNullOrWhiteSpace ( cleaned ) ? "App" : cleaned ;
407415 }
416+
417+ private static async Task < Maybe < string > > PrepareAppIcon ( string sourceFolder , string resources , Maybe < IIcon > providedIcon )
418+ {
419+ if ( providedIcon . HasValue )
420+ {
421+ return await CreateIcns ( providedIcon . Value , resources ) ;
422+ }
423+
424+ var existingIcns = FindIcnsIcon ( sourceFolder ) ;
425+ if ( existingIcns != null )
426+ {
427+ var fileName = Path . GetFileName ( existingIcns ) ! ;
428+ File . Copy ( existingIcns , Path . Combine ( resources , fileName ) , overwrite : true ) ;
429+ return Path . GetFileNameWithoutExtension ( existingIcns ) ;
430+ }
431+
432+ var pngIcon = FindPngIcon ( sourceFolder ) ;
433+ if ( pngIcon != null )
434+ {
435+ var iconResult = await Icon . FromByteSource ( ByteSource . FromStreamFactory ( ( ) => File . OpenRead ( pngIcon ) ) ) ;
436+ if ( iconResult . IsSuccess )
437+ {
438+ return await CreateIcns ( iconResult . Value , resources ) ;
439+ }
440+ }
441+
442+ return Maybe < string > . None ;
443+ }
444+
445+ private static async Task < Maybe < string > > CreateIcns ( IIcon icon , string resources )
446+ {
447+ var iconBytes = await icon . Bytes . ToList ( ) ;
448+ var pngBytes = iconBytes . SelectMany ( bytes => bytes ) . ToArray ( ) ;
449+ var chunkType = SelectChunkType ( icon . Size ) ;
450+ var icnsBytes = BuildIcns ( pngBytes , chunkType ) ;
451+ var iconFileName = "AppIcon.icns" ;
452+ var destination = Path . Combine ( resources , iconFileName ) ;
453+ Directory . CreateDirectory ( resources ) ;
454+ await File . WriteAllBytesAsync ( destination , icnsBytes ) ;
455+ return Path . GetFileNameWithoutExtension ( iconFileName ) ;
456+ }
457+
458+ private static byte [ ] BuildIcns ( IReadOnlyList < byte > pngBytes , string chunkType )
459+ {
460+ var iconChunkLength = 8 + pngBytes . Count ;
461+ var totalLength = 8 + iconChunkLength ;
462+
463+ var buffer = new byte [ totalLength ] ;
464+ Encoding . ASCII . GetBytes ( "icns" ) . CopyTo ( buffer , 0 ) ;
465+ BinaryPrimitives . WriteInt32BigEndian ( buffer . AsSpan ( 4 ) , totalLength ) ;
466+ Encoding . ASCII . GetBytes ( chunkType ) . CopyTo ( buffer , 8 ) ;
467+ BinaryPrimitives . WriteInt32BigEndian ( buffer . AsSpan ( 12 ) , iconChunkLength ) ;
468+ for ( var i = 0 ; i < pngBytes . Count ; i ++ )
469+ {
470+ buffer [ 16 + i ] = pngBytes [ i ] ;
471+ }
472+
473+ return buffer ;
474+ }
475+
476+ private static string SelectChunkType ( int size )
477+ {
478+ if ( IcnsChunkTypes . TryGetValue ( size , out var chunkType ) )
479+ {
480+ return chunkType ;
481+ }
482+
483+ var closest = IcnsChunkTypes
484+ . OrderBy ( entry => Math . Abs ( entry . Key - size ) )
485+ . First ( ) ;
486+
487+ return closest . Value ;
488+ }
489+
490+ private static string ? FindIcnsIcon ( string sourceFolder )
491+ {
492+ var icons = Directory . EnumerateFiles ( sourceFolder , "*.icns" , SearchOption . TopDirectoryOnly )
493+ . Where ( path => ! Path . GetFileName ( path ) ! . Equals ( ".VolumeIcon.icns" , StringComparison . OrdinalIgnoreCase ) ) ;
494+
495+ return icons . FirstOrDefault ( ) ;
496+ }
497+
498+ private static string ? FindPngIcon ( string sourceFolder )
499+ {
500+ var preferredNames = new [ ]
501+ {
502+ "icon-512.png" ,
503+ "icon-256.png" ,
504+ "icon.png" ,
505+ "app.png"
506+ } ;
507+
508+ foreach ( var preferred in preferredNames )
509+ {
510+ var candidate = Path . Combine ( sourceFolder , preferred ) ;
511+ if ( File . Exists ( candidate ) )
512+ {
513+ return candidate ;
514+ }
515+ }
516+
517+ return Directory . EnumerateFiles ( sourceFolder , "*.png" , SearchOption . TopDirectoryOnly ) . FirstOrDefault ( ) ;
518+ }
408519}
0 commit comments