diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json index b62179cbc..b7bb6c9c8 100644 --- a/ReleaseBuilder/.vscode/launch.json +++ b/ReleaseBuilder/.vscode/launch.json @@ -19,7 +19,9 @@ "--targets", "osx-x64-gui.dmg", "--targets", "osx-arm64-gui.dmg", "--targets", "osx-x64-gui.pkg", - "--targets", "osx-arm64-gui.pkg", + "--targets", "osx-arm64-gui.pkg", + "--targets", "linux-x64-gui.deb", + "--targets", "linux-x64-cli.deb", "--keep-build", "true" ], "env": { diff --git a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs new file mode 100644 index 000000000..386be427f --- /dev/null +++ b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs @@ -0,0 +1,282 @@ +using System.Text.RegularExpressions; + +namespace ReleaseBuilder.CliCommand; + +public static partial class Build +{ + /// + /// 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 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, RuntimeConfig rtcfg, bool keepBuilds) + { + await RemoveUnwantedFiles(os, buildDir); + + switch (os) + { + case OSType.Windows: + await SignWindowsExecutables(buildDir, rtcfg); + break; + + case OSType.MacOS: + await SetExecutableFlags(buildDir, rtcfg); + await MakeSymlinks(buildDir); + await BundleMacOSApplication(baseDir, buildDir, rtcfg, keepBuilds); + break; + + case OSType.Linux: + await SetExecutableFlags(buildDir, rtcfg); + await MakeSymlinks(buildDir); + break; + + default: + break; + } + } + + /// + /// A list of folders that are unwanted for a given OS target + /// + /// The OS to get unwanted folders for + /// The unwanted folders + static string[] UnwantedFolders(OSType os) + => os switch + { + OSType.Windows => ["lvm-scripts"], + OSType.MacOS => ["lvm-scripts", "win-tools"], + OSType.Linux => ["win-tools"], + _ => 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 string[] UnwantedFiles(OSType os) + => 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}") + }; + + + /// + /// 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; + } + + /// + /// Introduces symbolic links for executables that have a different name + /// + /// The build path to use + /// An awaitable task + static Task MakeSymlinks(string buildDir) + { + foreach (var k in ExecutableRenames) + if (File.Exists(Path.Combine(buildDir, k.Key)) && !File.Exists(Path.Combine(buildDir, k.Value))) + File.CreateSymbolicLink(Path.Combine(buildDir, k.Value), Path.Combine(".", k.Key)); + + return Task.CompletedTask; + } + + /// + /// Sets the executable flags for the build output + /// + /// The build directory + /// The runtime configuration + /// An awaitable task + static Task SetExecutableFlags(string buildDir, RuntimeConfig rtcfg) + { + if (!OperatingSystem.IsWindows()) + { + // Mark executables with the execute flag + var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(buildDir, x)) + .Concat(Directory.EnumerateFiles(buildDir, "*.sh", SearchOption.AllDirectories)); + var filemode = EnvHelper.GetUnixFileMode("+x"); + foreach (var x in executables) + if (File.Exists(x)) + EnvHelper.AddFilemode(x, filemode); + } + + return Task.CompletedTask; + } + + /// + /// Creates the MacOS folder structure by moving all files into a .app folder + /// + /// The source folder + /// The MacOS build output + /// The runtime configuration + /// A flag that allows re-using existing builds + /// An awaitable task + static async Task BundleMacOSApplication(string baseDir, string buildDir, RuntimeConfig rtcfg, bool keepBuilds) + { + var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir"); + // Create target .app folder + var appDir = Path.Combine( + buildroot, + $"{Path.GetFileName(buildDir)}-{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", 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 installerDir = Path.Combine(baseDir, "Installer", "MacOS"); + + var plist = File.ReadAllText(Path.Combine(installerDir, "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(installerDir, "app-resources", "Duplicati.icns"), + Path.Combine(tmpApp, "Contents", "Resources", "Duplicati.icns"), + overwrite: true + ); + + // Inject the launch agent + EnvHelper.CopyDirectory( + Path.Combine(installerDir, "daemon"), + Path.Combine(tmpApp, "Contents", "Resources"), + recursive: true + ); + + // Inject the uninstall.sh script + File.Copy( + Path.Combine(installerDir, "uninstall.sh"), + Path.Combine(tmpApp, "Contents", "MacOS", "uninstall.sh"), + overwrite: true + ); + + if (rtcfg.UseCodeSignSigning) + { + var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); + foreach (var f in Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories)) + 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")); + } + + /// + /// 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); + } + } +} diff --git a/ReleaseBuilder/CliCommand/Build.Compile.cs b/ReleaseBuilder/CliCommand/Build.Compile.cs new file mode 100644 index 000000000..0d995af9f --- /dev/null +++ b/ReleaseBuilder/CliCommand/Build.Compile.cs @@ -0,0 +1,89 @@ +namespace ReleaseBuilder.CliCommand; + +public static partial class Build +{ + /// + /// Main compilation of projects + /// + private static class Compile + { + /// + /// Builds the projects listed in for the distinct + /// + /// The base solution folder + /// The folder where builds should be placed + /// The projects to build + /// Projects that are only for the Windows targets + /// Projects that are only needed for GUI builds + /// The targets to build + /// The release info to use for the build + /// A flag that allows re-using existing builds + /// The runtime configuration + /// A task that completes when the build is done + public static async Task BuildProjects(string baseDir, string buildDir, IEnumerable sourceProjects, IEnumerable windowsOnlyProjects, IEnumerable guiProjects, IEnumerable buildTargets, ReleaseInfo releaseInfo, bool keepBuilds, RuntimeConfig rtcfg) + { + // For tracing, create a log folder and store all logs there + var logFolder = Path.Combine(buildDir, "logs"); + Directory.CreateDirectory(logFolder); + + // Get the unique build targets (ignoring the package type) + var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch, x.Interface)).ToArray(); + + if (buildArchTargets.Length == 1) + Console.WriteLine($"Building single release: {buildArchTargets.First().BuildTargetString}"); + else + Console.WriteLine($"Building {buildArchTargets.Length} versions"); + + foreach (var target in buildArchTargets) + { + var outputFolder = Path.Combine(buildDir, target.BuildTargetString); + + // Faster iteration for debugging is to keep the build folder + if (keepBuilds && Directory.Exists(outputFolder)) + { + Console.WriteLine($"Skipping build as output exists for {target.BuildTargetString}"); + } + else + { + var tmpfolder = Path.Combine(buildDir, target.BuildTargetString + "-tmp"); + Console.WriteLine($"Building {target.BuildTargetString} ..."); + + foreach (var proj in sourceProjects) + { + if (target.OS != OSType.Windows && windowsOnlyProjects.Contains(proj)) + continue; + + if (target.Interface == InterfaceType.Cli && guiProjects.Contains(proj)) + continue; + + // TODO: Creating multiple self-contained binaries really bloats the build size. + // + // One workaround could be to have a single commandline entry project that + // uses the invoked command name to determine the actual command to run + // Similar to how busy-box bundles multiple commands into a single binary + // + // Alternative is to require the .NET runtime to be installed + + var command = new string[] { + "dotnet", "publish", proj, + "-c", "Release", + "-o", tmpfolder, + "-r", target.BuildArchString, + $"/p:AssemblyVersion={releaseInfo.Version}", + $"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}", + "--self-contained", "true" + }; + await ProcessHelper.ExecuteWithLog(command, workingDirectory: tmpfolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target.BuildTargetString}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log"); + } + + Directory.Move(tmpfolder, outputFolder); + } + + // Perform any post-build steps, cleaning and signing as needed + await PostCompile.PrepareTargetDirectory(baseDir, outputFolder, target.OS, target.Arch, rtcfg, keepBuilds); + + Console.WriteLine("Completed!"); + } + } + } +} \ No newline at end of file diff --git a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs new file mode 100644 index 000000000..a99dcc5d7 --- /dev/null +++ b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs @@ -0,0 +1,330 @@ +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(); + } + } +} diff --git a/ReleaseBuilder/CliCommand/Build.GitPush.cs b/ReleaseBuilder/CliCommand/Build.GitPush.cs new file mode 100644 index 000000000..0f320b18d --- /dev/null +++ b/ReleaseBuilder/CliCommand/Build.GitPush.cs @@ -0,0 +1,54 @@ +namespace ReleaseBuilder.CliCommand; + +public static partial class Build +{ + /// + /// Implementation of the git push command + /// + private static class GitPush + { + /// + /// Tags the release and pushes it to the repository + /// + /// The base git dir + /// The release info + /// A task that completes when the push is done + public static async Task TagAndPush(string baseDir, ReleaseInfo releaseInfo) + { + // Add modified files + await ProcessHelper.Execute(new[] { + "git", "add", + "Updates/build_version.txt", + "changelog.txt" + }, workingDirectory: baseDir); + + // Make a commit + await ProcessHelper.Execute(new[] { + "git", "commit", + "-m", $"Version bump to v{releaseInfo.Version}-{releaseInfo.ReleaseName}", + "-m", "You can download this build from: ", + "-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip", + "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig", + "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc", + "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5", + "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1", + "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256" + }, workingDirectory: baseDir); + + // And tag the release + await ProcessHelper.Execute(new[] { + "git", "tag", $"v{releaseInfo.Version}-{releaseInfo.ReleaseName}", + "-m", "You can download this build from: ", + "-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip", + "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig", + "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc", + "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5", + "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1", + "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256" + }, workingDirectory: baseDir); + + // The push the release + await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir); + } + } +} diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs index 10f0a05e0..c33781770 100644 --- a/ReleaseBuilder/CliCommand/Build.cs +++ b/ReleaseBuilder/CliCommand/Build.cs @@ -1,13 +1,11 @@ using System.CommandLine; -using System.IO.Compression; -using System.Text.RegularExpressions; namespace ReleaseBuilder.CliCommand; /// /// The build command implementation /// -public static class Build +public static partial class Build { /// /// The primary project to build for GUI builds @@ -366,19 +364,15 @@ public static class Build rtcfg.ToggleAuthenticodeSigning(); rtcfg.ToggleSignCodeSigning(); - if (!keepBuilds) + if (!keepBuilds && Directory.Exists(buildTemp.FullName)) { - if (Directory.Exists(buildTemp.FullName)) - { - Console.WriteLine($"Deleting build folder: {buildTemp.FullName}"); - Directory.Delete(buildTemp.FullName, true); - } + Console.WriteLine($"Deleting build folder: {buildTemp.FullName}"); + Directory.Delete(buildTemp.FullName, true); } if (!Directory.Exists(buildTemp.FullName)) Directory.CreateDirectory(buildTemp.FullName); - // Generally, the builds should happen with a clean source tree, // but this can be disabled for debugging if (gitStashPush) @@ -387,72 +381,11 @@ public static class Build // Inject various files that will be embedded into the build artifacts await PrepareSourceDirectory(baseDir, releaseInfo, updateUrls); - // For tracing, create a log folder and store all logs there - var logFolder = Path.Combine(buildTemp.FullName, "logs"); - Directory.CreateDirectory(logFolder); + // Perform the main compilations + await Compile.BuildProjects(baseDir, buildTemp.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, keepBuilds, rtcfg); - // Get the unique build targets (ignoring the package type) - var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch, x.Interface)).ToArray(); - - if (buildArchTargets.Length == 1) - Console.WriteLine($"Building single release: {buildArchTargets.First().BuildTargetString}"); - else - Console.WriteLine($"Building {buildArchTargets.Length} versions"); - - foreach (var target in buildArchTargets) - { - var outputFolder = Path.Combine(buildTemp.FullName, target.BuildTargetString); - - // Faster iteration for debugging is to keep the build folder - if (keepBuilds && Directory.Exists(outputFolder)) - { - Console.WriteLine($"Skipping build as output exists for {target.BuildTargetString}"); - } - else - { - var tmpfolder = Path.Combine(buildTemp.FullName, target.BuildTargetString + "-tmp"); - Console.WriteLine($"Building {target.BuildTargetString} ..."); - - foreach (var proj in sourceProjects) - { - if (target.OS != OSType.Windows && windowsOnly.Contains(proj)) - continue; - - if (target.Interface == InterfaceType.Cli && GUIProjects.Contains(proj)) - continue; - - var command = new string[] { - "dotnet", "publish", proj, - "-c", "Release", - "-o", tmpfolder, - "-r", target.BuildArchString, - $"/p:AssemblyVersion={releaseInfo.Version}", - $"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}", - "--self-contained", "false" - }; - await ProcessHelper.ExecuteWithLog(command, workingDirectory: tmpfolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target.BuildTargetString}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log"); - } - - Directory.Move(tmpfolder, outputFolder); - } - - await PrepareTargetDirectory(baseDir, outputFolder, target.OS, target.Arch, rtcfg); - - Console.WriteLine("Completed!"); - } - - 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, buildTemp.FullName, target, rtcfg); - Console.WriteLine("Completed!"); - } + // Create the packages + await CreatePackage.BuildPackages(baseDir, buildTemp.FullName, buildTargets, keepBuilds, rtcfg); Console.WriteLine("Build completed, uploading packages ..."); @@ -460,10 +393,8 @@ public static class Build Console.WriteLine("Release completed, posting release notes ..."); - if (gitStashPush) - { - // Clean up the source tree - await ProcessHelper.Execute(new[] { + // Clean up the source tree + await ProcessHelper.Execute(new[] { "git", "checkout", "Duplicati/License/VersionTag.txt", "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt", @@ -471,41 +402,8 @@ public static class Build "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt" }, workingDirectory: baseDir); - // Add modified files - await ProcessHelper.Execute(new[] { - "git", "add", - "Updates/build_version.txt", - "changelog.txt" - }, workingDirectory: baseDir); - - // Make a commit - await ProcessHelper.Execute(new[] { - "git", "commit", - "-m", $"Version bump to v{releaseInfo.Version}-{releaseInfo.ReleaseName}", - "-m", "You can download this build from: ", - "-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip", - "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig", - "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc", - "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5", - "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1", - "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256" - }, workingDirectory: baseDir); - - // And tag the release - await ProcessHelper.Execute(new[] { - "git", "tag", $"v{releaseInfo.Version}-{releaseInfo.ReleaseName}", - "-m", "You can download this build from: ", - "-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip", - "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig", - "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc", - "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5", - "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1", - "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256" - }, workingDirectory: baseDir); - - // The push the release - await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir); - } + if (gitStashPush) + await GitPush.TagAndPush(baseDir, releaseInfo); Console.WriteLine("All done"); @@ -514,275 +412,6 @@ public static class Build return command; } - /// - /// Builds the package for the given target - /// - /// The source folder base - /// The release info to use - /// The runtime configuration - /// A representing the asynchronous operation. - private static async Task BuildPackage(string baseDir, string buildRoot, PackageTarget target, RuntimeConfig rtcfg) - { - 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)) - { - Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}"); - return; - } - - 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.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. - private 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. - private 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. - private 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. - private 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); - } - /// /// Updates the source directory prior to building /// @@ -808,265 +437,4 @@ public static class Build return Task.CompletedTask; } - - /// - /// 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 runtime config - /// An awaitable task - static async Task PrepareTargetDirectory(string baseDir, string buildDir, OSType os, ArchType arch, RuntimeConfig rtcfg) - { - await RemoveUnwantedFiles(os, buildDir); - - switch (os) - { - case OSType.Windows: - await SignWindowsExecutables(buildDir, rtcfg); - break; - - case OSType.MacOS: - await MakeSymlinks(buildDir); - await BundleMacOSApplication(baseDir, buildDir, rtcfg); - break; - - case OSType.Linux: - await MakeSymlinks(buildDir); - break; - - default: - break; - } - } - - /// - /// A list of folders that are unwanted for a given OS target - /// - /// The OS to get unwanted folders for - /// The unwanted folders - static string[] UnwantedFolders(OSType os) - => os switch - { - OSType.Windows => ["lvm-scripts"], - OSType.MacOS => ["lvm-scripts", "win-tools"], - OSType.Linux => ["win-tools"], - _ => 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 string[] UnwantedFiles(OSType os) - => 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}") - }; - - - /// - /// 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; - } - - /// - /// Introduces symbolic links for executables that have a different name - /// - /// The build path to use - /// An awaitable task - static Task MakeSymlinks(string buildDir) - { - foreach (var k in ExecutableRenames) - if (File.Exists(Path.Combine(buildDir, k.Key)) && !File.Exists(Path.Combine(buildDir, k.Value))) - File.CreateSymbolicLink(Path.Combine(buildDir, k.Value), Path.Combine(".", k.Key)); - - return Task.CompletedTask; - } - - /// - /// Sets the executable flags for the build output - /// - /// The build directory - /// The runtime configuration - /// An awaitable task - static Task SetExecutableFlags(string buildDir, RuntimeConfig rtcfg) - { - if (!OperatingSystem.IsWindows()) - { - // Mark executables with the execute flag - var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(buildDir, x)) - .Concat(Directory.EnumerateFiles(buildDir, "*.sh", SearchOption.AllDirectories)); - var filemode = EnvHelper.GetUnixFileMode("+x"); - foreach (var x in executables) - if (File.Exists(x)) - EnvHelper.AddFilemode(x, filemode); - } - - return Task.CompletedTask; - } - - /// - /// Creates the MacOS folder structure by moving all files into a .app folder - /// - /// The source folder - /// The MacOS build output - /// The runtime configuration - /// An awaitable task - static async Task BundleMacOSApplication(string baseDir, string buildDir, RuntimeConfig rtcfg) - { - var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir"); - // Create target .app folder - var appDir = Path.Combine( - buildroot, - $"{Path.GetFileName(buildDir)}-{MacOSAppName}" - ); - - if (Directory.Exists(appDir)) - { - Console.WriteLine("App folder already exsists, skipping MacOS application build"); - return; - } - - // Prepare the .app folder structure - var tmpApp = Path.Combine(buildroot, "tmpapp", 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 installerDir = Path.Combine(baseDir, "Installer", "MacOS"); - - var plist = File.ReadAllText(Path.Combine(installerDir, "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(installerDir, "app-resources", "Duplicati.icns"), - Path.Combine(tmpApp, "Contents", "Resources", "Duplicati.icns"), - overwrite: true - ); - - // Inject the launch agent - EnvHelper.CopyDirectory( - Path.Combine(installerDir, "daemon"), - Path.Combine(tmpApp, "Contents", "Resources"), - recursive: true - ); - - // Inject the uninstall.sh script - File.Copy( - Path.Combine(installerDir, "uninstall.sh"), - Path.Combine(tmpApp, "Contents", "MacOS", "uninstall.sh"), - overwrite: true - ); - - if (rtcfg.UseCodeSignSigning) - { - var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); - foreach (var f in Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories)) - 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")); - } - - /// - /// 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); - } } diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs index 3ea3f9b19..00777d900 100644 --- a/ReleaseBuilder/Program.cs +++ b/ReleaseBuilder/Program.cs @@ -21,12 +21,18 @@ class Program "linux-x64-gui.zip", "linux-x64-gui.deb", "linux-x64-gui.rpm", + "linux-x64-cli.zip", + "linux-x64-cli.deb", + "linux-x64-cli.rpm", "linux-x64-cli.docker", // "linux-x64-cli.spk", "linux-arm64-cli.docker", "linux-arm64-gui.zip", "linux-arm64-gui.deb", "linux-arm64-gui.rpm", + "linux-arm64-cli.zip", + "linux-arm64-cli.deb", + "linux-arm64-cli.rpm", // "linux-arm64-cli.spk", "osx-x64-gui.dmg", "osx-x64-gui.pkg",