using System.IO.Compression; using System.Net; using System.Text.RegularExpressions; using Duplicati.Library.Utility; namespace ReleaseBuilder.Build; public static partial class Command { /// /// Helper methods cleaning and signing build outputs /// private static class PostCompile { /// /// Prepares a target directory with fixes that are done post-build, but before making the individual packages /// /// The source directory /// The output build directory to modify /// The target operating system /// The target architecture /// The build target string os-arch-interface /// The runtime config /// A flag that allows re-using existing builds /// An awaitable task public static async Task PrepareTargetDirectory(string baseDir, string buildDir, OSType os, ArchType arch, string buildTargetString, RuntimeConfig rtcfg, bool keepBuilds) { await RemoveUnwantedFiles(os, buildDir); switch (os) { case OSType.Windows: await SignWindowsExecutables(buildDir, rtcfg); break; case OSType.MacOS: await BundleMacOSApplication(baseDir, buildDir, buildTargetString, rtcfg, keepBuilds); break; case OSType.Linux: break; default: break; } } /// /// Set of files that are unwanted despite the OS /// static readonly IReadOnlyList UnwantedCommonFiles = [ "System.Reactive.xml" // Extra documentation file ]; /// /// Set of folders that are unwanted despite the OS /// static readonly IReadOnlyList UnwantedCommonFolders = [ "Duplicati", // Debug folder that gets picked up during builds "control_dir", // Debug folder for lock files ]; /// /// A list of folders that are unwanted for a given OS target /// /// The OS to get unwanted folders for /// The unwanted folders static IEnumerable UnwantedFolders(OSType os) => UnwantedCommonFolders.Concat(os switch { OSType.Windows => ["lvm-scripts"], OSType.MacOS => ["lvm-scripts"], OSType.Linux => [], _ => throw new Exception($"Not supported os: {os}") }); /// /// A list of files that are unwanted for a given OS target /// /// The OS to get unwanted files for /// The files that are unwanted static IEnumerable UnwantedFiles(OSType os) => UnwantedCommonFiles.Concat(os switch { OSType.Windows => [], OSType.MacOS => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")], OSType.Linux => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")], _ => throw new Exception($"Not supported os: {os}") }) .Distinct(); /// /// The unwanted filenames /// /// The operating system to get the unwanted filenames for /// The list of unwanted filenames static IEnumerable UnwantedFileGlobExps(OSType os) => new[] { "Thumbs.db", "desktop.ini", ".DS_Store", "*.bak", "*.pdb", "*.mdb", "._*", os == OSType.Windows ? "*.sh" : "*.bat" }; /// /// Returns a regular expression mapping files that are not wanted in the build folders /// /// The operating system to get the unwanted filenames for /// A regular expression for matching unwanted filenames static Regex UnwantedFilePatterns(OSType os) => new Regex(@$"^({string.Join("|", UnwantedFileGlobExps(os).Select(x => x.Replace(".", "\\.").Replace("*", ".*")))})$", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// /// Removes unwanted contents from the build folders /// /// The operating system the folder is targeting /// The directory where the build output is placed /// An awaitable task static Task RemoveUnwantedFiles(OSType os, string buildDir) { foreach (var d in UnwantedFolders(os).Select(x => Path.Combine(buildDir, x))) if (Directory.Exists(d)) Directory.Delete(d, true); foreach (var f in UnwantedFiles(os).Select(x => Path.Combine(buildDir, x))) if (File.Exists(f)) File.Delete(f); var patterns = UnwantedFilePatterns(os); foreach (var f in Directory.EnumerateFiles(buildDir, "*", SearchOption.AllDirectories).Where(x => patterns.IsMatch(Path.GetFileName(x)))) if (File.Exists(f)) File.Delete(f); return Task.CompletedTask; } /// /// Creates the MacOS folder structure by moving all files into a .app folder /// /// The source folder /// The MacOS build output /// The build-target string os-arch-interface /// The runtime configuration /// A flag that allows re-using existing builds /// An awaitable task static async Task BundleMacOSApplication(string baseDir, string buildDir, string buildTargetString, RuntimeConfig rtcfg, bool keepBuilds) { var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir"); // Create target .app folder var appDir = Path.Combine( buildroot, $"{buildTargetString}-{rtcfg.MacOSAppName}" ); if (Directory.Exists(appDir)) { if (keepBuilds) { Console.WriteLine("App folder already exsists, skipping MacOS application build"); return; } Directory.Delete(appDir, true); } // Prepare the .app folder structure var tmpApp = Path.Combine(buildroot, "tmpapp", rtcfg.MacOSAppName); var folders = new[] { Path.Combine("Contents"), Path.Combine("Contents", "MacOS"), Path.Combine("Contents", "Resources"), }; if (Directory.Exists(tmpApp)) Directory.Delete(tmpApp, true); Directory.CreateDirectory(tmpApp); foreach (var f in folders) Directory.CreateDirectory(Path.Combine(tmpApp, f)); // Copy the primary contents into the binary folder var binDir = Path.Combine(tmpApp, "Contents", "MacOS"); EnvHelper.CopyDirectory(buildDir, binDir, recursive: true); // Patch the plist and place the icon from the resources var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "MacOS"); var plist = File.ReadAllText(Path.Combine(resourcesDir, "app-resources", "Info.plist")) .Replace("!LONG_VERSION!", rtcfg.ReleaseInfo.ReleaseName) .Replace("!SHORT_VERSION!", rtcfg.ReleaseInfo.Version.ToString()); File.WriteAllText( Path.Combine(tmpApp, "Contents", "Info.plist"), plist ); File.Copy( Path.Combine(resourcesDir, "app-resources", "Duplicati.icns"), Path.Combine(tmpApp, "Contents", "Resources", "Duplicati.icns"), overwrite: true ); // Inject the launch agent EnvHelper.CopyDirectory( Path.Combine(resourcesDir, "daemon"), Path.Combine(tmpApp, "Contents", "Resources"), recursive: true ); // Inject the uninstall.sh script File.Copy( Path.Combine(resourcesDir, "uninstall.sh"), Path.Combine(tmpApp, "Contents", "MacOS", "uninstall.sh"), overwrite: true ); // Rename the executables, as symlinks are not supported in DMG files await PackageSupport.RenameExecutables(binDir); await PackageSupport.SetPermissionFlags(binDir, rtcfg); // Move the licenses out of the code folder as the signing tool trips on it var licenseTarget = Path.Combine(tmpApp, "Contents", "Licenses"); Directory.Move(Path.Combine(binDir, "licenses"), licenseTarget); if (rtcfg.UseCodeSignSigning) { Console.WriteLine("Performing MacOS code signing ..."); // Executables cannot be signed before their dependencies are signed // So they are placed last in the list var executables = ExecutableRenames.Values.Select(x => Path.Combine(binDir, x)); var signtargets = Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories) .Except(executables) .Concat(executables) .Distinct() .ToList(); var entitlementFile = Path.Combine(resourcesDir, "Entitlements.plist"); foreach (var f in signtargets) await rtcfg.Codesign(f, entitlementFile); await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile); } if (!OperatingSystem.IsWindows()) foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly)) File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); Directory.Move(tmpApp, appDir); Directory.Delete(Path.GetDirectoryName(tmpApp) ?? throw new Exception("Unexpected empty path"), true); } /// /// Signs all .exe and .dll files with Authenticode /// /// The folder to sign files in /// The runtime config /// An awaitable task static async Task SignWindowsExecutables(string buildDir, RuntimeConfig rtcfg) { var cfg = Program.Configuration; if (!rtcfg.UseAuthenticodeSigning) return; var filenames = Directory.EnumerateFiles(buildDir, "Duplicati.*", SearchOption.TopDirectoryOnly) .Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) .ToList(); Console.WriteLine($"Performing Authenticode signing of {filenames.Count} files"); foreach (var file in filenames) await rtcfg.AuthenticodeSign(file); } } /// /// Downloads a file from a URL and saves it to a destination path /// /// The URL to download from /// The path to save the file to /// An awaitable task static async Task DownloadFileAsync(string url, string destinationPath) { using var httpClient = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer() }); using var response = await httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); using var tf = new TempFile(); using var fileStream = File.Create(tf); await response.Content.CopyToAsync(fileStream); File.Move(tf, destinationPath); } }