Skip to content

Commit 887a2d7

Browse files
authored
Fix DMG bundle layout for generated apps (#120)
1 parent 4a68508 commit 887a2d7

File tree

2 files changed

+60
-15
lines changed

2 files changed

+60
-15
lines changed

src/DotnetPackaging.Dmg/DmgIsoBuilder.cs

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text;
2+
using System.Linq;
23
using DiscUtils.Iso9660;
34

45
namespace DotnetPackaging.Dmg;
@@ -32,10 +33,19 @@ public static Task Create(string sourceFolder, string outputPath, string volumeN
3233
builder.AddDirectory(bundle);
3334
builder.AddDirectory($"{bundle}/Contents");
3435
builder.AddDirectory($"{bundle}/Contents/MacOS");
36+
builder.AddDirectory($"{bundle}/Contents/Resources");
3537

3638
// Copy application payload under Contents/MacOS
3739
AddDirectoryRecursive(builder, sourceFolder, ".", prefix: $"{bundle}/Contents/MacOS");
3840

41+
var appIcon = FindIcnsIcon(sourceFolder);
42+
if (appIcon != null)
43+
{
44+
var iconName = Path.GetFileName(appIcon);
45+
var iconBytes = File.ReadAllBytes(appIcon);
46+
builder.AddFile($"{bundle}/Contents/Resources/{iconName}", new MemoryStream(iconBytes, writable: false));
47+
}
48+
3949
// Hoist DMG adornments (if present) at image root for macOS Finder niceties
4050
var volIcon = Path.Combine(sourceFolder, ".VolumeIcon.icns");
4151
if (File.Exists(volIcon))
@@ -51,18 +61,8 @@ public static Task Create(string sourceFolder, string outputPath, string volumeN
5161

5262
// Add a minimal Info.plist
5363
var exeName = GuessExecutableName(sourceFolder, volumeName);
54-
var plist = GenerateMinimalPlist(volumeName, exeName);
64+
var plist = GenerateMinimalPlist(volumeName, exeName, appIcon == null ? null : Path.GetFileNameWithoutExtension(appIcon));
5565
builder.AddFile($"{bundle}/Contents/Info.plist", new MemoryStream(Encoding.UTF8.GetBytes(plist), writable: false));
56-
57-
// Also place top-level files at the image root (convenience), excluding already-hoisted adornments
58-
foreach (var file in Directory.EnumerateFiles(sourceFolder))
59-
{
60-
var name = Path.GetFileName(file);
61-
if (name is null) continue;
62-
if (name.Equals(".VolumeIcon.icns", StringComparison.OrdinalIgnoreCase)) continue;
63-
var bytes = File.ReadAllBytes(file);
64-
builder.AddFile(name, new MemoryStream(bytes, writable: false));
65-
}
6666
}
6767

6868
builder.Build(fs);
@@ -112,8 +112,17 @@ private static string GuessExecutableName(string sourceFolder, string volumeName
112112
return match;
113113
}
114114

115-
private static string GenerateMinimalPlist(string displayName, string executable)
115+
private static string? FindIcnsIcon(string sourceFolder)
116+
{
117+
var icons = Directory.EnumerateFiles(sourceFolder, "*.icns", SearchOption.TopDirectoryOnly)
118+
.Where(path => !Path.GetFileName(path)!.Equals(".VolumeIcon.icns", StringComparison.OrdinalIgnoreCase));
119+
120+
return icons.FirstOrDefault();
121+
}
122+
123+
private static string GenerateMinimalPlist(string displayName, string executable, string? iconName)
116124
{
125+
var identifier = $"com.{SanitizeBundleName(displayName).Trim('-').Trim('_').ToLowerInvariant()}";
117126
return $"""
118127
<?xml version=\"1.0\" encoding=\"UTF-8\"?>
119128
<!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
@@ -122,7 +131,7 @@ private static string GenerateMinimalPlist(string displayName, string executable
122131
<key>CFBundleName</key>
123132
<string>{System.Security.SecurityElement.Escape(displayName)}</string>
124133
<key>CFBundleIdentifier</key>
125-
<string>com.example.{System.Security.SecurityElement.Escape(displayName).Replace(" ", "")}</string>
134+
<string>{System.Security.SecurityElement.Escape(identifier)}</string>
126135
<key>CFBundleVersion</key>
127136
<string>1.0</string>
128137
<key>CFBundleShortVersionString</key>
@@ -131,6 +140,7 @@ private static string GenerateMinimalPlist(string displayName, string executable
131140
<string>APPL</string>
132141
<key>CFBundleExecutable</key>
133142
<string>{System.Security.SecurityElement.Escape(executable)}</string>
143+
{(iconName == null ? string.Empty : $" <key>CFBundleIconFile</key>\n <string>{System.Security.SecurityElement.Escape(iconName)}</string>\n")}
134144
</dict>
135145
</plist>
136146
""";

test/DotnetPackaging.Dmg.Tests/DmgIsoBuilderTests.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,36 @@ public async Task Creates_dmg_with_app_bundle_and_files()
3737
DirExistsAny(iso, new[]{".background","\\.background","/.background"}).Should().BeTrue();
3838
}
3939

40+
[Fact]
41+
public async Task Wraps_publish_output_into_app_bundle_when_missing()
42+
{
43+
using var tempRoot = new TempDir();
44+
var publish = Path.Combine(tempRoot.Path, "publish");
45+
Directory.CreateDirectory(publish);
46+
47+
await File.WriteAllTextAsync(Path.Combine(publish, "Angor"), "exe");
48+
await File.WriteAllTextAsync(Path.Combine(publish, "Angor.deps.json"), "deps");
49+
await File.WriteAllTextAsync(Path.Combine(publish, "AppIcon.icns"), "icon");
50+
51+
var outDmg = Path.Combine(tempRoot.Path, "Angor.dmg");
52+
await DotnetPackaging.Dmg.DmgIsoBuilder.Create(publish, outDmg, "Angor Avalonia");
53+
54+
using var fs = File.OpenRead(outDmg);
55+
using var iso = new CDReader(fs, true);
56+
57+
FileExistsAny(iso, new[]{"AngorAvalonia.app/Contents/MacOS/Angor","/AngorAvalonia.app/Contents/MacOS/Angor"}).Should().BeTrue();
58+
FileExistsAny(iso, new[]{"AngorAvalonia.app/Contents/Resources/AppIcon.icns","/AngorAvalonia.app/Contents/Resources/AppIcon.icns"}).Should().BeTrue();
59+
FileExistsAny(iso, new[]{"Angor","/Angor","\\Angor"}).Should().BeFalse("payload files must live inside the .app bundle only");
60+
61+
var plistPath = FirstExistingPath(iso, new[]{"AngorAvalonia.app/Contents/Info.plist", "/AngorAvalonia.app/Contents/Info.plist", "\\AngorAvalonia.app\\Contents\\Info.plist"});
62+
plistPath.Should().NotBeNull();
63+
64+
using var plistStream = iso.OpenFile(plistPath!, FileMode.Open);
65+
using var reader = new StreamReader(plistStream);
66+
var plistText = reader.ReadToEnd();
67+
plistText.Should().Contain("CFBundleIconFile");
68+
}
69+
4070
[Fact]
4171
public async Task Volume_name_is_sanitized_reasonably()
4272
{
@@ -50,14 +80,19 @@ public async Task Volume_name_is_sanitized_reasonably()
5080

5181
using var fs = File.OpenRead(outDmg);
5282
using var iso = new CDReader(fs, true);
53-
// We cannot query volume label on this DiscUtils version; just ensure a file can be read
54-
FileExistsAny(iso, new[]{"file.txt","/file.txt","\\file.txt"}).Should().BeTrue();
83+
var bundle = "myappwithspacesunicodetest.app";
84+
FileExistsAny(iso, new[]{
85+
$"{bundle}/Contents/MacOS/file.txt",
86+
$"/{bundle}/Contents/MacOS/file.txt",
87+
$"\\{bundle}\\Contents\\MacOS\\file.txt"}).Should().BeTrue();
5588
}
5689

5790
private static bool FileExistsAny(CDReader iso, IEnumerable<string> candidates)
5891
=> candidates.Any(p => iso.FileExists(p));
5992
private static bool DirExistsAny(CDReader iso, IEnumerable<string> candidates)
6093
=> candidates.Any(p => iso.DirectoryExists(p));
94+
private static string? FirstExistingPath(CDReader iso, IEnumerable<string> candidates)
95+
=> candidates.FirstOrDefault(iso.FileExists);
6196
}
6297

6398
file sealed class TempDir : IDisposable

0 commit comments

Comments
 (0)