using System.IO.Compression; namespace ReleaseBuilder.CliCommand; public static partial class Build { /// /// Implementations for the package builds /// private static class CreatePackage { /// /// Builds the packages for the specified build targets. /// /// The base directory. /// The build root directory. /// The build targets. /// A flag indicating whether to keep the build files. /// The runtime configuration. /// A task representing the asynchronous operation. public static async Task BuildPackages(string baseDir, string buildRoot, IEnumerable buildTargets, bool keepBuilds, RuntimeConfig rtcfg) { var packagesToBuild = buildTargets.Distinct().ToList(); if (packagesToBuild.Count == 1) Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}"); else Console.WriteLine($"Building {packagesToBuild.Count} packages"); foreach (var target in packagesToBuild) { Console.WriteLine($"Building {target.PackageTargetString} ..."); await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds); Console.WriteLine("Completed!"); } } /// /// Builds the package for the given target /// /// The source folder base /// The release info to use /// The runtime configuration /// A flag that allows re-using existing builds /// A representing the asynchronous operation. static async Task BuildPackage(string baseDir, string buildRoot, PackageTarget target, RuntimeConfig rtcfg, bool keepBuilds) { var packageFolder = Path.Combine(buildRoot, "packages"); if (!Directory.Exists(packageFolder)) Directory.CreateDirectory(packageFolder); var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}"); if (File.Exists(packageFile)) { if (keepBuilds) { Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}"); return; } File.Delete(packageFile); } var tempFile = Path.Combine(packageFolder, $"tmp-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}"); if (File.Exists(tempFile)) File.Delete(tempFile); switch (target.Package) { case PackageType.Zip: await BuildZipPackage(buildRoot, tempFile, target, rtcfg); break; case PackageType.MSI: await BuildMsiPackage(baseDir, buildRoot, tempFile, target, rtcfg); break; case PackageType.DMG: await BuildMacDmgPackage(baseDir, buildRoot, tempFile, target, rtcfg); break; case PackageType.MacPkg: await BuildMacPkgPackage(baseDir, buildRoot, tempFile, target, rtcfg); break; case PackageType.Deb: await BuildDebPackage(baseDir, buildRoot, tempFile, target, rtcfg); break; // case PackageType.SynologySpk: // await BuildZipPackage(buildRoot, tempFile, target, rtcfg); // await SignSynologyPackage(Path.Combine(outputFolder, target.PackageTargetString), rtcfg); // break; default: throw new Exception($"Unsupported package type: {target.Package}"); } File.Move(tempFile, packageFile); } /// /// Builds a zip package asynchronously. /// /// The output folder where the zip package will be created. /// The zip file to generate. /// The package target. /// The runtime configuration. /// A representing the asynchronous operation. static async Task BuildZipPackage(string buildRoot, string zipFile, PackageTarget target, RuntimeConfig rtcfg) { if (File.Exists(zipFile)) File.Delete(zipFile); using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create)) { foreach (var f in Directory.EnumerateFiles(Path.Combine(buildRoot, target.BuildTargetString), "*", SearchOption.AllDirectories)) { var entry = zip.CreateEntry(Path.GetRelativePath(buildRoot, f), CompressionLevel.Optimal); using (var stream = entry.Open()) using (var file = File.OpenRead(f)) await file.CopyToAsync(stream); } } } /// /// Builds an MSI package asynchronously. /// /// The source base directory. /// The root directory of the build. /// The MSI file to generate. /// The package target. /// The runtime configuration. /// A task representing the asynchronous operation. static async Task BuildMsiPackage(string baseDir, string buildRoot, string msiFile, PackageTarget target, RuntimeConfig rtcfg) { var installerDir = Path.Combine(baseDir, "Installer", "Windows"); var binFiles = Path.Combine(installerDir, "binfiles.wxs"); var sourceFiles = Path.Combine(buildRoot, target.BuildTargetString); if (!sourceFiles.EndsWith(Path.DirectorySeparatorChar)) sourceFiles += Path.DirectorySeparatorChar; File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist(sourceFiles)); await ProcessHelper.Execute(new[] { Program.Configuration.Commands.Wix!, "--define", $"HarvestPath={sourceFiles}", "--arch", target.ArchString, "--output", msiFile, Path.Combine(installerDir, "Shortcuts.wxs"), binFiles, Path.Combine(installerDir, "Duplicati.wxs") }, workingDirectory: buildRoot); if (rtcfg.UseAuthenticodeSigning) await rtcfg.AuthenticodeSign(msiFile); } /// /// Builds a DMG package asynchronously. /// /// The source base directory. /// The root directory of the build. /// The DMG file to generate. /// The package target. /// The runtime configuration. /// A task representing the asynchronous operation. static async Task BuildMacDmgPackage(string baseDir, string buildRoot, string dmgFile, PackageTarget target, RuntimeConfig rtcfg) { var mountDir = Path.Combine(buildRoot, "mount"); if (Directory.Exists(mountDir)) { await ProcessHelper.Execute([ "hdiutil", "detach", mountDir, "-quiet", "-force", ], workingDirectory: buildRoot, codeIsError: _ => false); Directory.Delete(mountDir, false); } Directory.CreateDirectory(mountDir); var installerDir = Path.Combine(baseDir, "Installer", "MacOS"); var compressedDmg = Path.Combine(installerDir, "template.dmg.bz2"); if (!File.Exists(compressedDmg)) throw new FileNotFoundException($"Compressed dmg template file not found: {compressedDmg}"); // Remove the bz2 var templateDmg = Path.Combine(buildRoot, Path.GetFileNameWithoutExtension(compressedDmg)); if (File.Exists(templateDmg)) File.Delete(templateDmg); // Decompress the dmg using (var fs = File.Create(templateDmg)) await ProcessHelper.ExecuteWithOutput([ "bzip2", "--decompress", "--keep", "--quiet", "--stdout", compressedDmg ], fs, workingDirectory: buildRoot); if (!File.Exists(templateDmg)) throw new FileNotFoundException($"Decompressed dmg template file not found: {templateDmg}"); await ProcessHelper.ExecuteAll([ ["hdiutil", "resize", "-size", "300M", templateDmg], ["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir] ], workingDirectory: buildRoot); // Change the dmg name var dmgname = $"Duplicati {rtcfg.ReleaseInfo.ReleaseName}"; Console.WriteLine($"Setting dmg name to {dmgname}"); await ProcessHelper.Execute([ "diskutil", "quiet", "rename", mountDir, dmgname ], workingDirectory: mountDir); // Make the Duplicati.app structure, root folder should exist var appFolder = Path.Combine(mountDir, MacOSAppName); if (Directory.Exists(appFolder)) Directory.Delete(appFolder, true); // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true); // Set permissions inside DMG file if (!OperatingSystem.IsWindows()) await EnvHelper.Chown(appFolder, "root", "admin", true); // Unmount the dmg and compress await ProcessHelper.ExecuteAll([ ["hdiutil", "detach", mountDir, "-quiet", "-force"], ["hdiutil", "convert", templateDmg, "-quiet", "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", dmgFile] ], workingDirectory: buildRoot); // Clean up File.Delete(templateDmg); Directory.Delete(mountDir, false); if (rtcfg.UseCodeSignSigning) await rtcfg.Codesign(dmgFile, Path.Combine(installerDir, "Entitlements.plist")); } /// /// Builds the Mac package asynchronously. /// /// The base directory. /// The build root directory. /// The package file path. /// The package target. /// The runtime configuration. /// A task representing the asynchronous operation. static async Task BuildMacPkgPackage(string baseDir, string buildRoot, string pkgFile, PackageTarget target, RuntimeConfig rtcfg) { var tmpFolder = Path.Combine(buildRoot, "tmp-pkg"); if (Directory.Exists(tmpFolder)) Directory.Delete(tmpFolder, true); Directory.CreateDirectory(tmpFolder); var installerDir = Path.Combine(baseDir, "Installer", "MacOS"); var appFolder = Path.Combine(tmpFolder, MacOSAppName); if (Directory.Exists(appFolder)) Directory.Delete(appFolder, true); // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true); // Copy the source script files var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" }; // Copy scripts foreach (var s in scripts) EnvHelper.CopyDirectory(Path.Combine(installerDir, s), Path.Combine(tmpFolder, s), recursive: true); // Set permissions if (!OperatingSystem.IsWindows()) { await EnvHelper.Chown(appFolder, "root", "admin", true); foreach (var f in Directory.EnumerateFiles(Path.Combine(tmpFolder, "daemon"), "*.launchagent.plist", SearchOption.AllDirectories)) await EnvHelper.Chown(f, "root", "wheel", false); var filemode = EnvHelper.GetUnixFileMode("+x"); var allscripts = scripts.Select(x => Path.Combine(tmpFolder, x)).Where(Directory.Exists).SelectMany(x => Directory.EnumerateFiles(x, "*", SearchOption.AllDirectories)); foreach (var x in allscripts) if (File.Exists(x)) EnvHelper.AddFilemode(x, filemode); } var pkgAppFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiApp.pkg"); if (File.Exists(pkgAppFile)) File.Delete(pkgAppFile); var pkgDaemonFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiDaemon.pkg"); if (File.Exists(pkgDaemonFile)) File.Delete(pkgDaemonFile); var distributionFile = Path.Combine(tmpFolder, "Distribution.xml"); File.WriteAllText(distributionFile, File.ReadAllText(Path.Combine(installerDir, "Distribution.xml")) .Replace("DuplicatiApp.pkg", Path.GetFileName(pkgAppFile)) .Replace("DuplicatiDaemon.pkg", Path.GetFileName(pkgDaemonFile)) ); // Make the pkg files await ProcessHelper.ExecuteAll([ ["pkgbuild", "--analyze", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "InstallerComponent.plist"], ["pkgbuild", "--scripts", Path.Combine(tmpFolder, "app-scripts"), "--identifier", "com.duplicati.app", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "--component-plist", "InstallerComponent.plist", pkgAppFile], ["pkgbuild", "--scripts", Path.Combine(tmpFolder, "daemon-scripts"), "--identifier", "com.duplicati.app.daemon", "--root", Path.Combine(tmpFolder, "daemon"), "--install-location", "/Library/LaunchAgents", pkgDaemonFile], ["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile] ], workingDirectory: tmpFolder); // Clean up Directory.Delete(tmpFolder, true); // Sign the pkg file if (rtcfg.UseCodeSignSigning) await rtcfg.Productsign(pkgFile); } /// /// Builds a DEB package using Docker /// /// The base directory. /// The build root directory. /// The DEB file to generate. /// The package target. /// The runtime configuration. /// A task representing the asynchronous operation. static Task BuildDebPackage(string baseDir, string buildRoot, string debFile, PackageTarget target, RuntimeConfig rtcfg) { throw new NotImplementedException(); } } }