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",