Skip to content

Commit 0469037

Browse files
committed
Autodetect icons
1 parent 2d50807 commit 0469037

File tree

4 files changed

+306
-26
lines changed

4 files changed

+306
-26
lines changed

src/DotnetPackaging.Dmg/DmgIsoBuilder.cs

Lines changed: 132 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
using Zafiro.DivineBytes;
1+
using System.Buffers.Binary;
2+
using System.Diagnostics;
3+
using System.Reactive.Linq;
24
using System.Text;
5+
using CSharpFunctionalExtensions;
36
using DotnetPackaging.Formats.Dmg.Iso;
47
using DotnetPackaging.Formats.Dmg.Udif;
5-
using System.Diagnostics;
8+
using Zafiro.DivineBytes;
69
using Path = System.IO.Path;
710

811
namespace DotnetPackaging.Dmg;
@@ -13,9 +16,21 @@ namespace DotnetPackaging.Dmg;
1316
/// </summary>
1417
public 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
}

src/DotnetPackaging.Tool/Commands/DmgCommand.cs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
using System.CommandLine;
2+
using System.IO;
23
using CSharpFunctionalExtensions;
4+
using DotnetPackaging;
35
using DotnetPackaging.Dmg;
46
using Serilog;
7+
using Zafiro.DivineBytes;
8+
using Path = System.IO.Path;
59

610
namespace DotnetPackaging.Tool.Commands;
711

@@ -66,7 +70,7 @@ private static Task CreateDmg(DirectoryInfo inputDir, FileInfo outputFile, Optio
6670
logger.Debug("Packaging DMG artifact from {Directory}", inputDir.FullName);
6771
var name = options.Name.GetValueOrDefault(inputDir.Name);
6872
var useDefaultLayout = options.UseDefaultLayout.GetValueOrDefault(true);
69-
return DmgIsoBuilder.Create(inputDir.FullName, outputFile.FullName, name, compress: true, addApplicationsSymlink: true, includeDefaultLayout: useDefaultLayout);
73+
return DmgIsoBuilder.Create(inputDir.FullName, outputFile.FullName, name, compress: true, addApplicationsSymlink: true, includeDefaultLayout: useDefaultLayout, icon: options.Icon);
7074
}
7175

7276
private static void AddDmgFromProjectSubcommand(Command dmgCommand)
@@ -162,11 +166,105 @@ await ExecutionWrapper.ExecuteWithLogging("dmg-from-project", outFile.FullName,
162166
}
163167

164168
var volName = opt.Name.GetValueOrDefault(pub.Value.Name.GetValueOrDefault("App"));
165-
await DmgIsoBuilder.Create(pub.Value.OutputDirectory, outFile.FullName, volName, compressVal, addApplicationsSymlink: true, includeDefaultLayout: useDefaultLayout);
169+
var icon = await ResolveIcon(opt, prj.Directory!, logger);
170+
await DmgIsoBuilder.Create(pub.Value.OutputDirectory, outFile.FullName, volName, compressVal, addApplicationsSymlink: true, includeDefaultLayout: useDefaultLayout, icon: icon);
166171
logger.Information("Success");
167172
});
168173
});
169174

170175
dmgCommand.Add(fromProject);
171176
}
177+
178+
private static async Task<Maybe<IIcon>> ResolveIcon(Options options, DirectoryInfo projectDirectory, ILogger logger)
179+
{
180+
if (options.Icon.HasValue)
181+
{
182+
return Maybe<IIcon>.From(options.Icon.Value);
183+
}
184+
185+
var candidate = FindIconCandidate(projectDirectory);
186+
if (candidate == null)
187+
{
188+
return Maybe<IIcon>.None;
189+
}
190+
191+
var iconResult = await DotnetPackaging.Icon.FromByteSource(ByteSource.FromStreamFactory(() => File.OpenRead(candidate)));
192+
if (iconResult.IsFailure)
193+
{
194+
logger.Warning("Icon autodiscovery failed for {IconPath}: {Error}", candidate, iconResult.Error);
195+
return Maybe<IIcon>.None;
196+
}
197+
198+
return Maybe<IIcon>.From(iconResult.Value);
199+
}
200+
201+
private static string? FindIconCandidate(DirectoryInfo projectDirectory)
202+
{
203+
var preferred = new[]
204+
{
205+
"icon.icns",
206+
"app.icns",
207+
"AppIcon.icns",
208+
"icon.png",
209+
"icon-256.png",
210+
"app.png"
211+
};
212+
213+
foreach (var name in preferred)
214+
{
215+
var directMatch = Path.Combine(projectDirectory.FullName, name);
216+
if (File.Exists(directMatch))
217+
{
218+
return directMatch;
219+
}
220+
}
221+
222+
var assetsDir = Path.Combine(projectDirectory.FullName, "Assets");
223+
if (Directory.Exists(assetsDir))
224+
{
225+
var assetsIcon = FindIconInDirectory(assetsDir);
226+
if (assetsIcon != null)
227+
{
228+
return assetsIcon;
229+
}
230+
}
231+
232+
var icnsFallback = Directory.EnumerateFiles(projectDirectory.FullName, "*.icns", SearchOption.TopDirectoryOnly).FirstOrDefault();
233+
if (icnsFallback != null)
234+
{
235+
return icnsFallback;
236+
}
237+
238+
return Directory.EnumerateFiles(projectDirectory.FullName, "*.png", SearchOption.TopDirectoryOnly).FirstOrDefault();
239+
}
240+
241+
private static string? FindIconInDirectory(string directory)
242+
{
243+
var preferred = new[]
244+
{
245+
"icon.icns",
246+
"app.icns",
247+
"AppIcon.icns",
248+
"icon.png",
249+
"icon-256.png",
250+
"app.png"
251+
};
252+
253+
foreach (var name in preferred)
254+
{
255+
var candidate = Path.Combine(directory, name);
256+
if (File.Exists(candidate))
257+
{
258+
return candidate;
259+
}
260+
}
261+
262+
var icns = Directory.EnumerateFiles(directory, "*.icns", SearchOption.TopDirectoryOnly).FirstOrDefault();
263+
if (icns != null)
264+
{
265+
return icns;
266+
}
267+
268+
return Directory.EnumerateFiles(directory, "*.png", SearchOption.TopDirectoryOnly).FirstOrDefault();
269+
}
172270
}

src/DotnetPackaging.Tool/OptionsBinder.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using System.CommandLine;
22
using System.CommandLine.Parsing;
33
using CSharpFunctionalExtensions;
4+
using DotnetPackaging;
45
using Zafiro.CSharpFunctionalExtensions;
6+
using Zafiro.DivineBytes;
57

68
namespace DotnetPackaging.Tool;
79

@@ -103,9 +105,24 @@ private Maybe<IEnumerable<T>> MaybeList<T>(ParseResult parseResult, Option<IEnum
103105
if (!fileInfo.Exists)
104106
{
105107
result.AddError($"Invalid icon '{iconPath}': File not found");
108+
return null;
106109
}
107110

108-
// For now, do not eagerly parse the icon (async). We rely on auto-detection or later stages.
109-
return null;
111+
try
112+
{
113+
var iconResult = DotnetPackaging.Icon.FromByteSource(ByteSource.FromStreamFactory(() => fileInfo.OpenRead())).GetAwaiter().GetResult();
114+
if (iconResult.IsFailure)
115+
{
116+
result.AddError($"Invalid icon '{iconPath}': {iconResult.Error}");
117+
return null;
118+
}
119+
120+
return iconResult.Value;
121+
}
122+
catch (Exception ex)
123+
{
124+
result.AddError($"Invalid icon '{iconPath}': {ex.Message}");
125+
return null;
126+
}
110127
}
111128
}

0 commit comments

Comments
 (0)