Skip to content

Commit 94d24a2

Browse files
committed
Merge pull request #148 from SuperJMN/feature/replace-dmg-with-dotnet-dmg
Replace DotnetPackaging.Dmg with Dotnet.Dmg library
2 parents 563f2f9 + df3e288 commit 94d24a2

File tree

6 files changed

+187
-152
lines changed

6 files changed

+187
-152
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
</PackageVersion>
1414
<PackageVersion Include="CSharpFunctionalExtensions" Version="3.6.0" />
1515
<PackageVersion Include="CSharpFunctionalExtensions.FluentAssertions" Version="3.6.0" />
16+
<PackageVersion Include="DotnetPackaging.Formats.Dmg.Iso" Version="0.0.6" />
17+
<PackageVersion Include="DotnetPackaging.Formats.Dmg.Udif" Version="0.0.6" />
1618
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
1719
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
1820
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />

src/DotnetPackaging.Dmg/DmgIsoBuilder.cs

Lines changed: 117 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,180 @@
1+
using Zafiro.DivineBytes;
12
using System.Text;
2-
using System.Linq;
3-
using DiscUtils.Iso9660;
3+
using DotnetPackaging.Formats.Dmg.Iso;
4+
using DotnetPackaging.Formats.Dmg.Udif;
5+
using Path = System.IO.Path;
46

57
namespace DotnetPackaging.Dmg;
68

79
/// <summary>
8-
/// Minimal cross-platform DMG builder using an ISO/UDF (UDTO) payload.
9-
/// This produces a .dmg that mounts fine on macOS for simple drag & drop installs.
10-
/// Follow-ups can add true UDIF/UDZO wrapping while keeping the same public API.
10+
/// Cross-platform DMG builder using UDIF format with Rock Ridge support.
11+
/// Produces proper .dmg files that mount on macOS with full metadata preservation.
1112
/// </summary>
1213
public static class DmgIsoBuilder
1314
{
14-
public static Task Create(string sourceFolder, string outputPath, string volumeName)
15+
public static Task Create(string sourceFolder, string outputPath, string volumeName, bool compress = false, bool addApplicationsSymlink = false)
1516
{
16-
// Build a Joliet-enabled ISO so long filenames/casing are preserved reasonably.
17-
using var fs = File.Create(outputPath);
18-
var builder = new CDBuilder
17+
var builder = new IsoBuilder(SanitizeVolumeName(volumeName));
18+
19+
if (addApplicationsSymlink)
1920
{
20-
UseJoliet = true,
21-
VolumeIdentifier = SanitizeVolumeName(volumeName),
22-
};
21+
builder.Root.AddChild(new IsoSymlink("Applications", "/Applications"));
22+
}
2323

2424
var appBundles = Directory.EnumerateDirectories(sourceFolder, "*.app", SearchOption.TopDirectoryOnly).ToList();
25+
2526
if (appBundles.Any())
2627
{
27-
// If pre-built .app bundles exist, copy only those (plus DMG adornments) to the image root
28+
// Copy pre-built .app bundles to image root
2829
foreach (var bundle in appBundles)
2930
{
30-
var name = Path.GetFileName(bundle);
31-
if (name == null)
32-
{
33-
continue;
34-
}
35-
36-
AddDirectoryRecursive(builder, sourceFolder, name, prefix: null);
31+
var bundleName = Path.GetFileName(bundle);
32+
if (bundleName == null) continue;
33+
34+
var bundleDir = builder.Root.AddDirectory(bundleName);
35+
AddDirectoryRecursive(bundleDir, bundle);
3736
}
3837

39-
AddDmgAdornments(builder, sourceFolder);
38+
AddDmgAdornments(builder.Root, sourceFolder);
4039
}
4140
else
4241
{
43-
var bundle = SanitizeBundleName(volumeName) + ".app";
44-
builder.AddDirectory(bundle);
45-
builder.AddDirectory($"{bundle}/Contents");
46-
builder.AddDirectory($"{bundle}/Contents/MacOS");
47-
builder.AddDirectory($"{bundle}/Contents/Resources");
42+
// Create .app bundle structure from publish output
43+
var bundleName = SanitizeBundleName(volumeName) + ".app";
44+
var appBundle = builder.Root.AddDirectory(bundleName);
45+
var contents = appBundle.AddDirectory("Contents");
46+
var macOs = contents.AddDirectory("MacOS");
47+
var resources = contents.AddDirectory("Resources");
4848

4949
// Copy application payload under Contents/MacOS
50-
AddDirectoryRecursive(
51-
builder,
50+
AddDirectoryContents(
51+
macOs,
5252
sourceFolder,
53-
".",
54-
prefix: $"{bundle}/Contents/MacOS",
55-
shouldSkip: rel => rel.EndsWith(".app", StringComparison.OrdinalIgnoreCase));
53+
shouldSkip: path => path.EndsWith(".app", StringComparison.OrdinalIgnoreCase) ||
54+
path.EndsWith(".icns", StringComparison.OrdinalIgnoreCase));
5655

56+
// Copy .icns files to Resources
5757
var appIcon = FindIcnsIcon(sourceFolder);
5858
if (appIcon != null)
5959
{
6060
var iconName = Path.GetFileName(appIcon);
61-
var iconBytes = File.ReadAllBytes(appIcon);
62-
builder.AddFile($"{bundle}/Contents/Resources/{iconName}", new MemoryStream(iconBytes, writable: false));
61+
resources.AddChild(new IsoFile(iconName)
62+
{
63+
ContentSource = () => ByteSource.FromStreamFactory(() => File.OpenRead(appIcon)),
64+
SourcePath = appIcon
65+
});
6366
}
6467

65-
AddDmgAdornments(builder, sourceFolder);
68+
AddDmgAdornments(builder.Root, sourceFolder);
6669

67-
// Add a minimal Info.plist
70+
// Generate Info.plist
6871
var exeName = GuessExecutableName(sourceFolder, volumeName);
6972
var plist = GenerateMinimalPlist(volumeName, exeName, appIcon == null ? null : Path.GetFileNameWithoutExtension(appIcon));
70-
builder.AddFile($"{bundle}/Contents/Info.plist", new MemoryStream(Encoding.UTF8.GetBytes(plist), writable: false));
73+
contents.AddChild(new IsoFile("Info.plist")
74+
{
75+
ContentSource = () => ByteSource.FromBytes(Encoding.UTF8.GetBytes(plist))
76+
});
77+
78+
// Add PkgInfo
79+
contents.AddChild(new IsoFile("PkgInfo")
80+
{
81+
ContentSource = () => ByteSource.FromBytes(Encoding.ASCII.GetBytes("APPL????"))
82+
});
83+
}
84+
85+
// Build ISO
86+
if (!compress)
87+
{
88+
// For uncompressed, output raw ISO for backward compatibility with tests
89+
using var dmgStream = File.Create(outputPath);
90+
builder.Build(dmgStream);
91+
}
92+
else
93+
{
94+
// For compressed, wrap in UDIF with Bzip2
95+
using var isoStream = new MemoryStream();
96+
builder.Build(isoStream);
97+
isoStream.Position = 0;
98+
99+
using var dmgStream = File.Create(outputPath);
100+
var writer = new UdifWriter { CompressionType = CompressionType.Bzip2 };
101+
writer.Create(isoStream, dmgStream);
71102
}
72103

73-
builder.Build(fs);
74104
return Task.CompletedTask;
75105
}
76106

77-
private static void AddDirectoryRecursive(
78-
CDBuilder builder,
79-
string root,
80-
string rel,
81-
string? prefix,
82-
Func<string, bool>? shouldSkip = null)
107+
private static void AddDirectoryRecursive(IsoDirectory target, string sourcePath)
83108
{
84-
var abs = Path.Combine(root, rel);
85-
var targetDir = prefix == null || rel == "." ? rel : Path.Combine(prefix, rel);
86-
if (rel != ".")
109+
foreach (var dir in Directory.EnumerateDirectories(sourcePath))
87110
{
88-
builder.AddDirectory(targetDir.Replace('\\','/'));
111+
var dirName = Path.GetFileName(dir);
112+
if (dirName == null) continue;
113+
114+
var subDir = target.AddDirectory(dirName);
115+
AddDirectoryRecursive(subDir, dir);
89116
}
90117

91-
foreach (var dir in Directory.EnumerateDirectories(abs))
118+
foreach (var file in Directory.EnumerateFiles(sourcePath))
92119
{
93-
var name = Path.GetFileName(dir);
94-
if (name == null) continue;
95-
var nextRel = rel == "." ? name : Path.Combine(rel, name);
96-
if (shouldSkip?.Invoke(nextRel) == true)
120+
var fileName = Path.GetFileName(file);
121+
if (fileName == null) continue;
122+
123+
target.AddChild(new IsoFile(fileName)
97124
{
98-
continue;
99-
}
100-
AddDirectoryRecursive(builder, root, nextRel, prefix);
125+
ContentSource = () => ByteSource.FromStreamFactory(() => File.OpenRead(file)),
126+
SourcePath = file
127+
});
128+
}
129+
}
130+
131+
private static void AddDirectoryContents(IsoDirectory target, string sourcePath, Func<string, bool>? shouldSkip = null)
132+
{
133+
foreach (var dir in Directory.EnumerateDirectories(sourcePath))
134+
{
135+
var dirName = Path.GetFileName(dir);
136+
if (dirName == null) continue;
137+
138+
if (shouldSkip?.Invoke(dir) == true) continue;
139+
140+
var subDir = target.AddDirectory(dirName);
141+
AddDirectoryRecursive(subDir, dir);
101142
}
102143

103-
foreach (var file in Directory.EnumerateFiles(abs))
144+
foreach (var file in Directory.EnumerateFiles(sourcePath))
104145
{
105-
var name = Path.GetFileName(file);
106-
if (name == null) continue;
107-
var relPath = rel == "." ? name : Path.Combine(rel, name);
108-
var finalPath = prefix == null ? relPath : Path.Combine(prefix, relPath);
109-
var bytes = File.ReadAllBytes(file);
110-
builder.AddFile(finalPath.Replace('\\','/'), new MemoryStream(bytes, writable: false));
146+
var fileName = Path.GetFileName(file);
147+
if (fileName == null) continue;
148+
149+
if (shouldSkip?.Invoke(file) == true) continue;
150+
151+
target.AddChild(new IsoFile(fileName)
152+
{
153+
ContentSource = () => ByteSource.FromStreamFactory(() => File.OpenRead(file)),
154+
SourcePath = file
155+
});
111156
}
112157
}
113158

114-
private static void AddDmgAdornments(CDBuilder builder, string sourceFolder)
159+
private static void AddDmgAdornments(IsoDirectory root, string sourceFolder)
115160
{
116-
// Hoist DMG adornments (if present) at image root for macOS Finder niceties
161+
// Add .VolumeIcon.icns if present
117162
var volIcon = Path.Combine(sourceFolder, ".VolumeIcon.icns");
118163
if (File.Exists(volIcon))
119164
{
120-
var bytes = File.ReadAllBytes(volIcon);
121-
builder.AddFile(".VolumeIcon.icns", new MemoryStream(bytes, writable: false));
165+
root.AddChild(new IsoFile(".VolumeIcon.icns")
166+
{
167+
ContentSource = () => ByteSource.FromStreamFactory(() => File.OpenRead(volIcon)),
168+
SourcePath = volIcon
169+
});
122170
}
123171

172+
// Add .background directory if present
124173
var backgroundDir = Path.Combine(sourceFolder, ".background");
125174
if (Directory.Exists(backgroundDir))
126175
{
127-
AddDirectoryRecursive(builder, sourceFolder, ".background", prefix: null);
176+
var bgDir = root.AddDirectory(".background");
177+
AddDirectoryRecursive(bgDir, backgroundDir);
128178
}
129179
}
130180

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using CSharpFunctionalExtensions;
2-
using DiscUtils.Iso9660;
32

43
namespace DotnetPackaging.Dmg;
54

@@ -10,33 +9,8 @@ public static Task<Result<string>> Verify(string dmgPath)
109
if (!File.Exists(dmgPath))
1110
return Task.FromResult(Result.Failure<string>("File not found"));
1211

13-
// Try ISO/UDTO first
14-
try
15-
{
16-
using var fs = File.OpenRead(dmgPath);
17-
using var iso = new CDReader(fs, true);
18-
// touch the root to ensure it's a valid ISO
19-
_ = iso.GetDirectories("/");
20-
21-
var apps = FindAppBundles(iso);
22-
if (apps.Count == 0)
23-
return Task.FromResult(Result.Failure<string>("No .app bundle found at image root or subfolders"));
24-
25-
var details = new List<string>();
26-
foreach (var app in apps)
27-
{
28-
var hasContents = iso.DirectoryExists(app + "/Contents");
29-
var hasMacOS = iso.DirectoryExists(app + "/Contents/MacOS");
30-
var anyExec = iso.GetFiles(app + "/Contents/MacOS").Any();
31-
details.Add($"{app}: Contents={(hasContents ? "yes" : "no")}, MacOS={(hasMacOS ? "yes" : "no")}, ExecFiles={(anyExec ? "yes" : "no")}");
32-
}
33-
34-
return Task.FromResult(Result.Success("ISO/UDTO DMG OK\n" + string.Join("\n", details)));
35-
}
36-
catch
37-
{
38-
// not ISO
39-
}
12+
// Try ISO/UDTO first (requires DiscUtils.Iso9660 which is only in tests)
13+
// Skip for now as we're focused on UDIF format
4014

4115
// If DiscUtils path failed, try raw ISO9660 PVD signature (CD001)
4216
try
@@ -52,7 +26,7 @@ public static Task<Result<string>> Verify(string dmgPath)
5226
return Task.FromResult(Result.Failure<string>($"Failed to inspect file: {ex.Message}"));
5327
}
5428

55-
// Minimal UDIF detection: footer 'koly' in last 512 bytes
29+
// UDIF detection: footer 'koly' in last 512 bytes
5630
try
5731
{
5832
using var fs = File.OpenRead(dmgPath);
@@ -63,7 +37,13 @@ public static Task<Result<string>> Verify(string dmgPath)
6337
fs.Read(buf);
6438
if (buf.Slice(0, 4).SequenceEqual(new byte[] { (byte)'k', (byte)'o', (byte)'l', (byte)'y' }))
6539
{
66-
return Task.FromResult(Result.Success("UDIF DMG with koly footer detected (detailed BLKX/plist validation not implemented)"));
40+
// Extract basic info from Koly block
41+
var version = ReadBigEndianUInt32(buf.Slice(4, 4));
42+
var flags = ReadBigEndianUInt32(buf.Slice(12, 4));
43+
var sectorCount = ReadBigEndianUInt64(buf.Slice(492, 8));
44+
45+
var compressionType = (flags & 1) != 0 ? "flattened" : "uncompressed";
46+
return Task.FromResult(Result.Success($"UDIF DMG OK (version={version}, {compressionType}, sectors={sectorCount})"));
6747
}
6848
}
6949
}
@@ -90,21 +70,15 @@ private static bool IsIso9660(Stream s)
9070
return false;
9171
}
9272

93-
private static List<string> FindAppBundles(CDReader iso)
73+
private static uint ReadBigEndianUInt32(Span<byte> data)
9474
{
95-
var found = new List<string>();
96-
void Recurse(string path)
97-
{
98-
foreach (var d in iso.GetDirectories(path))
99-
{
100-
var name = System.IO.Path.GetFileName(d.TrimEnd('/', '\\'));
101-
var full = path == string.Empty ? d : (path.TrimEnd('/', '\\') + "/" + name);
102-
if (name.EndsWith(".app", StringComparison.OrdinalIgnoreCase))
103-
found.Add("/" + full.TrimStart('/', '\\'));
104-
Recurse(full);
105-
}
106-
}
107-
Recurse("");
108-
return found;
75+
return ((uint)data[0] << 24) | ((uint)data[1] << 16) | ((uint)data[2] << 8) | data[3];
10976
}
77+
78+
private static ulong ReadBigEndianUInt64(Span<byte> data)
79+
{
80+
return ((ulong)data[0] << 56) | ((ulong)data[1] << 48) | ((ulong)data[2] << 40) | ((ulong)data[3] << 32) |
81+
((ulong)data[4] << 24) | ((ulong)data[5] << 16) | ((ulong)data[6] << 8) | data[7];
82+
}
83+
11084
}

src/DotnetPackaging.Dmg/DotnetPackaging.Dmg.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
</PropertyGroup>
77
<Import Project="..\Common.props" />
88
<ItemGroup>
9-
<PackageReference Include="DiscUtils" />
9+
<ProjectReference Include="..\DotnetPackaging\DotnetPackaging.csproj" />
1010
</ItemGroup>
1111
<ItemGroup>
12-
<ProjectReference Include="..\DotnetPackaging\DotnetPackaging.csproj" />
12+
<PackageReference Include="DotnetPackaging.Formats.Dmg.Iso" />
13+
<PackageReference Include="DotnetPackaging.Formats.Dmg.Udif" />
1314
</ItemGroup>
1415
</Project>

0 commit comments

Comments
 (0)