From 65438e98d00b27959c0502aca5937a6134e8b6a4 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 14 Mar 2024 16:55:18 +0100 Subject: [PATCH 01/91] Preparing for updater change --- Duplicati/Library/AutoUpdater/UpdateInfo.cs | 1 + .../Library/AutoUpdater/UpdaterManager.cs | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Duplicati/Library/AutoUpdater/UpdateInfo.cs b/Duplicati/Library/AutoUpdater/UpdateInfo.cs index af6509c46..ae55ec096 100644 --- a/Duplicati/Library/AutoUpdater/UpdateInfo.cs +++ b/Duplicati/Library/AutoUpdater/UpdateInfo.cs @@ -48,6 +48,7 @@ namespace Duplicati.Library.AutoUpdater public class UpdateInfo { + public string UpdateFromV1Url; public string Displayname; public string Version; public DateTime ReleaseTime; diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 0c5113d87..abe61868a 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -932,9 +932,16 @@ namespace Duplicati.Library.AutoUpdater updateDetected = CheckForUpdate(); if (updateDetected != null && downloadUpdate) { - if (!runDuring) - Console.WriteLine("Update to {0} detected, installing...", updateDetected.Displayname); - updateInstalled = DownloadAndUnpackUpdate(updateDetected); + if (!string.IsNullOrWhiteSpace(updateDetected.UpdateFromV1Url)) + { + Console.WriteLine("Update to {0} detected, manual install required: {1}", updateDetected.Displayname, updateDetected.UpdateFromV1Url); + } + else + { + if (!runDuring) + Console.WriteLine("Update to {0} detected, installing...", updateDetected.Displayname); + updateInstalled = DownloadAndUnpackUpdate(updateDetected); + } } }); @@ -958,7 +965,10 @@ namespace Duplicati.Library.AutoUpdater } else if (updateDetected != null) { - Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); + if (!string.IsNullOrWhiteSpace(updateDetected.UpdateFromV1Url)) + Console.WriteLine("Update to {0} detected, manual install required: {1}", updateDetected.Displayname, updateDetected.UpdateFromV1Url); + else + Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); } backgroundChecker = null; @@ -992,7 +1002,10 @@ namespace Duplicati.Library.AutoUpdater } else { - Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); + if (!string.IsNullOrWhiteSpace(updateDetected.UpdateFromV1Url)) + Console.WriteLine("Update to {0} detected, manual install required: {1}", updateDetected.Displayname, updateDetected.UpdateFromV1Url); + else + Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); } } } From 115e8878029e9f0bba364cc49fd764a716d387e8 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 15 Mar 2024 14:18:56 +0100 Subject: [PATCH 02/91] Implemented new updater logic that supports multi-arch distributions. --- .../RESTMethods/BackupDefaults.cs | 2 +- .../RESTMethods/SystemInfo.cs | 1 - .../RESTMethods/Updates.cs | 17 - Duplicati.Library.RestAPI/Runner.cs | 2 +- .../Serializable/ServerStatus.cs | 4 +- Duplicati.Library.RestAPI/UpdatePollThread.cs | 43 +- Duplicati.Library.RestAPI/WebServer/Server.cs | 4 +- .../CommandLine/BackendTester/Program.cs | 56 +- Duplicati/CommandLine/BackendTool/Program.cs | 8 +- Duplicati/CommandLine/CLI/Program.cs | 76 +- Duplicati/CommandLine/RecoveryTool/Program.cs | 14 +- .../GUI/Duplicati.GUI.TrayIcon/Program.cs | 16 +- .../Library/AutoUpdater/AutoUpdateSettings.cs | 6 +- .../{FileEntry.cs => InstallerEntry.cs} | 101 +- Duplicati/Library/AutoUpdater/Program.cs | 77 +- Duplicati/Library/AutoUpdater/UpdateInfo.cs | 82 +- .../Library/AutoUpdater/UpdaterManager.cs | 981 +++--------------- .../Main/Operation/SystemInfoHandler.cs | 4 +- Duplicati/Library/Main/Options.cs | 42 +- Duplicati/Library/Snapshots/LinuxSnapshot.cs | 42 +- .../Implementations/ServerStatus.cs | 44 +- .../Interface/IServerStatus.cs | 44 +- Duplicati/Server/Program.cs | 16 +- .../scripts/controllers/AboutController.js | 8 - .../controllers/UpdateChangelogController.js | 8 - .../scripts/directives/notificationArea.js | 8 - .../ngax/scripts/services/ServerStatus.js | 4 +- .../Server/webroot/ngax/templates/about.html | 7 +- .../ngax/templates/notificationarea.html | 7 +- .../ngax/templates/updatechangelog.html | 3 +- Duplicati/Service/Program.cs | 8 +- Duplicati/UnitTest/BackendToolTests.cs | 4 +- .../UnitTest/CommandLineOperationsTests.cs | 28 +- Duplicati/UnitTest/RecoveryToolTests.cs | 6 +- Duplicati/WebserverCore/DuplicatiWebserver.cs | 4 +- Duplicati/WindowsService/Program.cs | 54 +- 36 files changed, 536 insertions(+), 1295 deletions(-) rename Duplicati/Library/AutoUpdater/{FileEntry.cs => InstallerEntry.cs} (54%) diff --git a/Duplicati.Library.RestAPI/RESTMethods/BackupDefaults.cs b/Duplicati.Library.RestAPI/RESTMethods/BackupDefaults.cs index 9138214b2..4d6675520 100644 --- a/Duplicati.Library.RestAPI/RESTMethods/BackupDefaults.cs +++ b/Duplicati.Library.RestAPI/RESTMethods/BackupDefaults.cs @@ -59,7 +59,7 @@ namespace Duplicati.Server.WebServer.RESTMethods try { // Add install defaults/overrides, if present - var path = SystemIO.IO_OS.PathCombine(Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "newbackup.json"); + var path = SystemIO.IO_OS.PathCombine(Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, "newbackup.json"); if (System.IO.File.Exists(path)) { Newtonsoft.Json.Linq.JObject n; diff --git a/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs b/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs index 465396ea9..0f3fda512 100644 --- a/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs +++ b/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs @@ -77,7 +77,6 @@ namespace Duplicati.Server.WebServer.RESTMethods ServerVersionName = Duplicati.License.VersionNumbers.Version, ServerVersionType = Duplicati.Library.AutoUpdater.UpdaterManager.SelfVersion.ReleaseType, StartedBy = FIXMEGlobal.Origin, - BaseVersionName = Duplicati.Library.AutoUpdater.UpdaterManager.BaseVersion.Displayname, DefaultUpdateChannel = Duplicati.Library.AutoUpdater.AutoUpdateSettings.DefaultUpdateChannel, DefaultUsageReportLevel = Duplicati.Library.UsageReporter.Reporter.DefaultReportLevel, ServerTime = DateTime.Now, diff --git a/Duplicati.Library.RestAPI/RESTMethods/Updates.cs b/Duplicati.Library.RestAPI/RESTMethods/Updates.cs index 5eec5a43b..94abc4491 100644 --- a/Duplicati.Library.RestAPI/RESTMethods/Updates.cs +++ b/Duplicati.Library.RestAPI/RESTMethods/Updates.cs @@ -33,23 +33,6 @@ namespace Duplicati.Server.WebServer.RESTMethods FIXMEGlobal.UpdatePoller.CheckNow(); info.OutputOK(); return; - - case "install": - FIXMEGlobal.UpdatePoller.InstallUpdate(); - info.OutputOK(); - return; - - case "activate": - if (FIXMEGlobal.WorkThread.CurrentTask != null || FIXMEGlobal.WorkThread.CurrentTasks.Count != 0) - { - info.ReportServerError("Cannot activate update while task is running or scheduled"); - } - else - { - FIXMEGlobal.UpdatePoller.ActivateUpdate(); - info.OutputOK(); - } - return; default: info.ReportClientError("No such action", System.Net.HttpStatusCode.NotFound); diff --git a/Duplicati.Library.RestAPI/Runner.cs b/Duplicati.Library.RestAPI/Runner.cs index 1dcdc267c..315ec6c60 100644 --- a/Duplicati.Library.RestAPI/Runner.cs +++ b/Duplicati.Library.RestAPI/Runner.cs @@ -358,7 +358,7 @@ namespace Duplicati.Server var exe = System.IO.Path.Combine( - Library.AutoUpdater.UpdaterManager.InstalledBaseDir, + Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, System.IO.Path.GetFileName( typeof(Duplicati.CommandLine.Commands).Assembly.Location ) diff --git a/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs b/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs index c77ae6027..7b9f9f752 100644 --- a/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs +++ b/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs @@ -55,9 +55,9 @@ namespace Duplicati.Server.Serializable } } - public UpdatePollerStates UpdaterState { get { return FIXMEGlobal.UpdatePoller.ThreadState; } } + public string UpdateDownloadLink => FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion.GetUpdateUrls()?.FirstOrDefault(); - public bool UpdateReady { get { return Duplicati.Library.AutoUpdater.UpdaterManager.HasUpdateInstalled; } } + public UpdatePollerStates UpdaterState { get { return FIXMEGlobal.UpdatePoller.ThreadState; } } public double UpdateDownloadProgress { get { return FIXMEGlobal.UpdatePoller.DownloadProgess; } } diff --git a/Duplicati.Library.RestAPI/UpdatePollThread.cs b/Duplicati.Library.RestAPI/UpdatePollThread.cs index c82bd8d2d..b255be2cd 100644 --- a/Duplicati.Library.RestAPI/UpdatePollThread.cs +++ b/Duplicati.Library.RestAPI/UpdatePollThread.cs @@ -30,7 +30,6 @@ namespace Duplicati.Server { private readonly Thread m_thread; private volatile bool m_terminated = false; - private volatile bool m_download = false; private volatile bool m_forceCheck = false; private readonly object m_lock = new object(); private readonly AutoResetEvent m_waitSignal; @@ -71,25 +70,6 @@ namespace Duplicati.Server } } - public void InstallUpdate() - { - lock(m_lock) - { - m_forceCheck = true; - m_download = true; - m_waitSignal.Set(); - } - } - - public void ActivateUpdate() - { - if (Duplicati.Library.AutoUpdater.UpdaterManager.SetRunUpdate()) - { - IsUpdateRequested = true; - FIXMEGlobal.ApplicationExitEvent.Set(); - } - } - public void Terminate() { lock(m_lock) @@ -175,12 +155,15 @@ namespace Duplicati.Server FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion = null; } - if (FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion != null && Duplicati.Library.AutoUpdater.UpdaterManager.TryParseVersion(FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion.Version) > System.Reflection.Assembly.GetExecutingAssembly().GetName().Version) + var updatedinfo = FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion; + if (updatedinfo != null && Duplicati.Library.AutoUpdater.UpdaterManager.TryParseVersion(updatedinfo.Version) > System.Reflection.Assembly.GetExecutingAssembly().GetName().Version) { + var package = updatedinfo.FindPackage(); + FIXMEGlobal.DataConnection.RegisterNotification( NotificationType.Information, "Found update", - FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion.Displayname, + updatedinfo.Displayname, null, null, "update:new", @@ -194,22 +177,6 @@ namespace Duplicati.Server } } - if (m_download) - { - lock(m_lock) - m_download = false; - - var v = FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion; - if (v != null) - { - ThreadState = UpdatePollerStates.Downloading; - FIXMEGlobal.StatusEventNotifyer.SignalNewEvent(); - - if (Duplicati.Library.AutoUpdater.UpdaterManager.DownloadAndUnpackUpdate(v, (pg) => { DownloadProgess = pg; })) - FIXMEGlobal.StatusEventNotifyer.SignalNewEvent(); - } - } - DownloadProgess = 0; if (ThreadState != UpdatePollerStates.Waiting) diff --git a/Duplicati.Library.RestAPI/WebServer/Server.cs b/Duplicati.Library.RestAPI/WebServer/Server.cs index c8e4408f3..3d72a44b6 100644 --- a/Duplicati.Library.RestAPI/WebServer/Server.cs +++ b/Duplicati.Library.RestAPI/WebServer/Server.cs @@ -225,11 +225,11 @@ namespace Duplicati.Server.WebServer server.Add(new RESTHandler()); string webroot = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); - string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "webroot"); + string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, "webroot"); #if DEBUG // Easy test for extensions while debugging - install_webroot = Library.AutoUpdater.UpdaterManager.InstalledBaseDir; + install_webroot = Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR; if (!System.IO.Directory.Exists(System.IO.Path.Combine(webroot, "webroot"))) { diff --git a/Duplicati/CommandLine/BackendTester/Program.cs b/Duplicati/CommandLine/BackendTester/Program.cs index 5671d7647..7b6973ebd 100644 --- a/Duplicati/CommandLine/BackendTester/Program.cs +++ b/Duplicati/CommandLine/BackendTester/Program.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -31,7 +31,6 @@ namespace Duplicati.CommandLine.BackendTester { public class Program { - /// /// Used to maintain a reference to initialized system settings. /// @@ -63,13 +62,7 @@ namespace Duplicati.CommandLine.BackendTester /// The main entry point for the application. /// [STAThread] - public static int Main(string[] args) - { - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args); - } - - public static void RealMain(string[] _args) + public static int Main(string[] _args) { try { @@ -107,7 +100,7 @@ namespace Duplicati.CommandLine.BackendTester foreach (string s in lines) Console.WriteLine(s); - return; + return 0; } if (options.ContainsKey("tempdir") && !string.IsNullOrEmpty(options["tempdir"])) @@ -129,14 +122,17 @@ namespace Duplicati.CommandLine.BackendTester { Console.WriteLine("Starting run no {0}", i); if (!Run(args, options, i == 0)) - return; + return 1; } Console.WriteLine("Unittest complete!"); + return 0; } catch (Exception ex) { Console.WriteLine("Unittest failed: " + ex); } + + return 1; } static bool Run(List args, Dictionary options, bool first) diff --git a/Duplicati/CommandLine/BackendTool/Program.cs b/Duplicati/CommandLine/BackendTool/Program.cs index b367cddfc..0a8d22f4b 100644 --- a/Duplicati/CommandLine/BackendTool/Program.cs +++ b/Duplicati/CommandLine/BackendTool/Program.cs @@ -34,13 +34,7 @@ namespace Duplicati.CommandLine.BackendTool /// The main entry point for the application. /// [STAThread] - public static int Main(string[] args) - { - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args); - } - - public static int RealMain(string[] _args) + public static int Main(string[] _args) { bool debugoutput = false; try diff --git a/Duplicati/CommandLine/CLI/Program.cs b/Duplicati/CommandLine/CLI/Program.cs index 57c82a57a..6614a419c 100644 --- a/Duplicati/CommandLine/CLI/Program.cs +++ b/Duplicati/CommandLine/CLI/Program.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -39,12 +39,6 @@ namespace Duplicati.CommandLine /// The main entry point for the application. /// public static int Main(string[] args) - { - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args); - } - - public static int RealMain(string[] args) { Library.UsageReporter.Reporter.Initialize(); FROM_COMMANDLINE = true; @@ -113,18 +107,28 @@ namespace Duplicati.CommandLine if (update != null && update.Version != Library.AutoUpdater.UpdaterManager.SelfVersion.Version) { - outwriter.WriteLine("Found update \"{0}\", downloading ...", update.Displayname); - long lastpg = 0; - Library.AutoUpdater.UpdaterManager.DownloadAndUnpackUpdate(update, f => + var package = update.FindPackage(); + if (package == null) { - var npg = (long)(f * 100); - if (Math.Abs(npg - lastpg) >= 5 || (npg == 100 && lastpg != 100)) + outwriter.WriteLine($"Failed to locate a matching package for this machine, please visit this link and select the correct package: {update.GetGenericUpdatePageUrl()}"); + } + else + { + var filename = Path.GetFullPath(package.GetFilename()); + outwriter.WriteLine("Downloading update \"{0}\" to {1} ...", update.Displayname, filename); + + long lastpg = 0; + Library.AutoUpdater.UpdaterManager.DownloadUpdate(update, package, filename, f => { - lastpg = npg; - outwriter.WriteLine("Downloading {0}% ...", npg); - } - }); - outwriter.WriteLine("Update \"{0}\" ({1}) installed, using on next launch", update.Displayname, update.Version); + var npg = (long)(f * 100); + if (Math.Abs(npg - lastpg) >= 5 || (npg == 100 && lastpg != 100)) + { + lastpg = npg; + outwriter.WriteLine("Downloading {0}% ...", npg); + } + }); + outwriter.WriteLine("Update \"{0}\" ({1}) downloaded", update.Displayname, update.Version); + } } } diff --git a/Duplicati/CommandLine/RecoveryTool/Program.cs b/Duplicati/CommandLine/RecoveryTool/Program.cs index adf3ab1e1..78c991834 100644 --- a/Duplicati/CommandLine/RecoveryTool/Program.cs +++ b/Duplicati/CommandLine/RecoveryTool/Program.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -27,19 +27,13 @@ namespace Duplicati.CommandLine.RecoveryTool { public static class Program { + private delegate int CommandRunner(List args, Dictionary options, Library.Utility.IFilter filter); + /// /// The main entry point for the application. /// [STAThread] - public static int Main(string[] args) - { - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args); - } - - private delegate int CommandRunner(List args, Dictionary options, Library.Utility.IFilter filter); - - public static int RealMain(string[] _args) + public static int Main(string[] _args) { try { diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index 857abfeba..ec0d5f528 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -59,18 +59,12 @@ namespace Duplicati.GUI.TrayIcon /// The main entry point for the application. /// [STAThread] - public static int Main(string[] args) - { - Duplicati.Library.AutoUpdater.UpdaterManager.RequiresRespawn = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args, Duplicati.Library.AutoUpdater.AutoUpdateStrategy.Never); - } - - public static void RealMain(string[] _args) + public static int Main(string[] _args) { List args = new List(_args); Dictionary options = Duplicati.Library.Utility.CommandLineParser.ExtractOptions(args); - if (Platform.IsClientWindows && (Duplicati.Library.AutoUpdater.UpdaterManager.IsRunningInUpdateEnvironment || !Duplicati.Library.Utility.Utility.ParseBoolOption(options, DETACHED_PROCESS))) + if (Platform.IsClientWindows && !Duplicati.Library.Utility.Utility.ParseBoolOption(options, DETACHED_PROCESS)) Duplicati.Library.Utility.Win32.AttachConsole(Duplicati.Library.Utility.Win32.ATTACH_PARENT_PROCESS); foreach (string s in args) @@ -97,7 +91,7 @@ namespace Duplicati.GUI.TrayIcon foreach (Library.Interface.ICommandLineArgument arg in Duplicati.Server.Program.SupportedCommands) Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription); - return; + return 0; } options.TryGetValue(BROWSER_COMMAND_OPTION, out _browser_command); @@ -115,7 +109,7 @@ namespace Duplicati.GUI.TrayIcon } catch (Server.SingleInstance.MultipleInstanceException) { - return; + return 1; } // We have a hosted server, if this is the first run, @@ -176,6 +170,8 @@ namespace Duplicati.GUI.TrayIcon serverURL = new Uri(url); StartTray(_args, options, hosted, password, saltedpassword); + + return 0; } private static void StartTray(string[] _args, Dictionary options, HostedInstanceKeeper hosted, string password, bool saltedpassword) diff --git a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs index 70febbec9..48ec8fa43 100644 --- a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs +++ b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -143,10 +143,6 @@ namespace Duplicati.Library.AutoUpdater if (string.IsNullOrWhiteSpace(channelstring)) channelstring = BuildUpdateChannel; - if (string.IsNullOrWhiteSpace(channelstring)) - channelstring = UpdaterManager.BaseVersion.ReleaseType; - - // Update from older builds if (string.Equals(channelstring, "preview", StringComparison.OrdinalIgnoreCase)) channelstring = ReleaseType.Experimental.ToString(); diff --git a/Duplicati/Library/AutoUpdater/FileEntry.cs b/Duplicati/Library/AutoUpdater/InstallerEntry.cs similarity index 54% rename from Duplicati/Library/AutoUpdater/FileEntry.cs rename to Duplicati/Library/AutoUpdater/InstallerEntry.cs index c1386ad00..f36fec324 100644 --- a/Duplicati/Library/AutoUpdater/FileEntry.cs +++ b/Duplicati/Library/AutoUpdater/InstallerEntry.cs @@ -1,35 +1,66 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; - -namespace Duplicati.Library.AutoUpdater -{ - public class FileEntry - { - public string Path; - public string MD5; - public string SHA256; - public DateTime LastWriteTime; - public bool Ignore; - } -} - +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; + +namespace Duplicati.Library.AutoUpdater +{ + /// + /// An installer entry, describing an architecture specific package + /// + public class PackageEntry + { + /// + /// The urls for the updater payload + /// + public string[] RemoteUrls; + /// + /// The length of the payload + /// + public long Length; + /// + /// The MD5 hash of the payload + /// + public string MD5; + /// + /// The SHA256 hash of the payload + /// + public string SHA256; + /// + /// The package type id + /// + public string PackageTypeId; + + /// + /// Gets the name of the package file + /// + /// The filename of the package + public string GetFilename() + { + var guess = Path.GetFileName(new Uri(RemoteUrls[0]).LocalPath); + if (string.IsNullOrWhiteSpace(guess)) + guess = "update.bin"; + + return guess; + } + } +} \ No newline at end of file diff --git a/Duplicati/Library/AutoUpdater/Program.cs b/Duplicati/Library/AutoUpdater/Program.cs index 2283cddc1..73dc50271 100644 --- a/Duplicati/Library/AutoUpdater/Program.cs +++ b/Duplicati/Library/AutoUpdater/Program.cs @@ -20,27 +20,16 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.IO; using System.Linq; -using System.Collections.Generic; -using Duplicati.Library.Common.IO; namespace Duplicati.Library.AutoUpdater { public static class Program { - public static int Main(string[] args) + public static int Main(string[] _args) { - // Ignore webroot during startup verification - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = true; - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args, AutoUpdateStrategy.Never); - } - - public static int RealMain(string[] _args) - { - // Enable webroot checks for the verifier - Duplicati.Library.AutoUpdater.UpdaterManager.IgnoreWebrootFolder = false; - - var args = new List(_args); + var args = _args.ToList(); Duplicati.Library.Utility.CommandLineParser.ExtractOptions(args); if (args.Count == 0) @@ -62,26 +51,6 @@ namespace Duplicati.Library.AutoUpdater case "help": WriteUsage(); return 0; - case "list": - { - var versions = UpdaterManager.FindInstalledVersions(); - var selfdir = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); - if (string.Equals(Util.AppendDirSeparator(selfdir), Util.AppendDirSeparator(UpdaterManager.InstalledBaseDir))) - versions = versions.Union(new KeyValuePair[] { new KeyValuePair(selfdir, UpdaterManager.SelfVersion) }); - Console.WriteLine(string.Join(Environment.NewLine, versions.Select(x => string.Format(" {0} {1} ({2})", (x.Value.Version == UpdaterManager.SelfVersion.Version ? "*" : "-"), x.Value.Displayname, x.Value.Version)))); - return 0; - } - - case "verify": - { - var versions = UpdaterManager.FindInstalledVersions(); - var selfdir = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); - if (string.Equals(Util.AppendDirSeparator(selfdir), Util.AppendDirSeparator(UpdaterManager.InstalledBaseDir))) - versions = versions.Union(new KeyValuePair[] { new KeyValuePair(selfdir, UpdaterManager.SelfVersion) }); - - Console.WriteLine(string.Join(Environment.NewLine, versions.Select(x => string.Format(" {0} {1} ({2}): {3}", (x.Value.Version == UpdaterManager.SelfVersion.Version ? "*" : "-"), x.Value.Displayname, x.Value.Version, UpdaterManager.VerifyUnpackedFolder(x.Key, x.Value) ? "Valid" : "*** Modified ***")))); - return 0; - } case "check": { var update = UpdaterManager.CheckForUpdate(); @@ -94,7 +63,7 @@ namespace Duplicati.Library.AutoUpdater return 0; } - case "install": + case "download": { var update = UpdaterManager.CheckForUpdate(); if (update == null || update.Version == UpdaterManager.SelfVersion.Version) @@ -102,19 +71,29 @@ namespace Duplicati.Library.AutoUpdater Console.WriteLine("You are running the latest version: {0} ({1})", UpdaterManager.SelfVersion.Displayname, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); return 0; } - Console.WriteLine("Downloading update \"{0}\" ...", update.Displayname); - long lastpg = 0; - UpdaterManager.DownloadAndUnpackUpdate(update, f => { - var npg = (long)(f*100); - if (Math.Abs(npg - lastpg) >= 5 || (npg == 100 && lastpg != 100)) - { - lastpg = npg; - Console.WriteLine("Downloading {0}% ...", npg); - } - }); + var package = update.FindPackage(); + if (package == null) + { + Console.WriteLine($"Failed to locate a matching package for this machine, please visit this link and select the correct package: {update.GetGenericUpdatePageUrl()}"); + } + else + { + var filename = Path.GetFullPath(package.GetFilename()); + Console.WriteLine("Downloading update \"{0}\" to {1} ...", update.Displayname, filename); - Console.WriteLine("New version \"{0}\" installed!", update.Displayname); + long lastpg = 0; + UpdaterManager.DownloadUpdate(update, package, filename, f => { + var npg = (long)(f*100); + if (Math.Abs(npg - lastpg) >= 5 || (npg == 100 && lastpg != 100)) + { + lastpg = npg; + Console.WriteLine("Downloading {0}% ...", npg); + } + }); + } + + Console.WriteLine("New version \"{0}\" downloaded!", update.Displayname); return 0; } default: @@ -127,18 +106,16 @@ namespace Duplicati.Library.AutoUpdater private static void WriteUsage() { - Console.WriteLine("Usage:{0}\t{1}{2} [LIST|VERIFY|CHECK|INSTALL|HELP]", Environment.NewLine, Duplicati.Library.Utility.Utility.IsMono ? "mono " : "", System.IO.Path.GetFileName(System.Reflection.Assembly.GetExecutingAssembly().Location)); + Console.WriteLine("Usage:{0}\t{1}{2} [CHECK|DOWNLOAD|HELP]", Environment.NewLine, Duplicati.Library.Utility.Utility.IsMono ? "mono " : "", System.IO.Path.GetFileName(System.Reflection.Assembly.GetExecutingAssembly().Location)); Console.WriteLine(); Console.WriteLine("Environment variables:"); Console.WriteLine(); Console.WriteLine("{0} - Disables updates completely", string.Format(UpdaterManager.SKIPUPDATE_ENVNAME_TEMPLATE, AutoUpdateSettings.AppName)); - Console.WriteLine("{0} - Choose how to handle updates, valid settings: {1}", string.Format(UpdaterManager.UPDATE_STRATEGY_ENVNAME_TEMPLATE, AutoUpdateSettings.AppName), string.Join(", ", Enum.GetNames(typeof(AutoUpdateStrategy)))); Console.WriteLine("{0} - Use alternate updates urls", string.Format(AutoUpdateSettings.UPDATEURL_ENVNAME_TEMPLATE, AutoUpdateSettings.AppName)); Console.WriteLine("{0} - Choose different channel than the default {1}, valid settings: {2}", string.Format(AutoUpdateSettings.UPDATECHANNEL_ENVNAME_TEMPLATE, AutoUpdateSettings.AppName), AutoUpdater.AutoUpdateSettings.DefaultUpdateChannel, string.Join(",", Enum.GetNames(typeof(ReleaseType)).Where( x => x != ReleaseType.Unknown.ToString()))); Console.WriteLine(); Console.WriteLine("Updates are downloaded from: {0}", string.Join(";", AutoUpdateSettings.URLs)); - Console.WriteLine("Updates are installed in: {0}", UpdaterManager.INSTALLDIR); - Console.WriteLine("The base version is \"{0}\" ({1}) and is installed in: {2}", UpdaterManager.BaseVersion.Displayname, UpdaterManager.BaseVersion.Version, UpdaterManager.InstalledBaseDir); + Console.WriteLine("Machine settings are installed in: {0}", UpdaterManager.UPDATEDIR); Console.WriteLine("This version is \"{0}\" ({1}) and is installed in: {2}", UpdaterManager.SelfVersion.Displayname, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version, System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)); Console.WriteLine(); } diff --git a/Duplicati/Library/AutoUpdater/UpdateInfo.cs b/Duplicati/Library/AutoUpdater/UpdateInfo.cs index af6509c46..e2739a9b1 100644 --- a/Duplicati/Library/AutoUpdater/UpdateInfo.cs +++ b/Duplicati/Library/AutoUpdater/UpdateInfo.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Linq; namespace Duplicati.Library.AutoUpdater { @@ -48,23 +49,86 @@ namespace Duplicati.Library.AutoUpdater public class UpdateInfo { + public string UpdateFromV1Url; + public string UpdateFromV2Url; public string Displayname; public string Version; public DateTime ReleaseTime; public string ReleaseType; public string UpdateSeverity; public string ChangeInfo; - public long CompressedSize; - public long UncompressedSize; - public string SHA256; - public string MD5; - public string[] RemoteURLS; - public FileEntry[] Files; + public int PackageUpdaterVersion; + /// + /// List of installer packages + /// + public PackageEntry[] Packages; + /// + /// Link to a generic download page + /// + public string GenericUpdatePageUrl; - public UpdateInfo Clone() + /// + /// Finds a package that matches the + /// + /// The package type id; null uses the currently installed package type id + /// The matching package or null + public PackageEntry? FindPackage(string packageTypeId = null) { - return (UpdateInfo)this.MemberwiseClone(); + if (!string.IsNullOrWhiteSpace(UpdateFromV2Url) || PackageUpdaterVersion != UpdaterManager.SUPPORTED_PACKAGE_UPDATER_VERSION) + return null; + + packageTypeId ??= UpdaterManager.PackageTypeId; + if (string.IsNullOrWhiteSpace(packageTypeId) || Packages == null) + return null; + + return Packages.FirstOrDefault(x => string.Equals(x.PackageTypeId, packageTypeId, StringComparison.OrdinalIgnoreCase)); } + + /// + /// Gets the generic update page url for the + /// + /// The package type id; null uses the currently installed package type id + /// The generic update page url + public string GetGenericUpdatePageUrl(string packageTypeId = null) + { + var baseurl = string.IsNullOrWhiteSpace(UpdateFromV2Url) + ? GenericUpdatePageUrl + : UpdateFromV2Url; + + packageTypeId ??= UpdaterManager.PackageTypeId; + if (string.IsNullOrWhiteSpace(packageTypeId)) + return GenericUpdatePageUrl; + + return GenericUpdatePageUrl + $"{(GenericUpdatePageUrl.IndexOf('?') > 0 ? "&" : "?")}packagetypeid={Uri.EscapeDataString(packageTypeId ?? "")}"; + } + + /// + /// Gets the updated package urls for the + /// + /// The package type id; null uses the currently installed package type id + /// The matching update urls + public string[] GetUpdateUrls(string packageTypeId = null) + { + packageTypeId ??= UpdaterManager.PackageTypeId; + var package = FindPackage(packageTypeId); + if (package != null) + return package.RemoteUrls; + + var generic = GetGenericUpdatePageUrl(packageTypeId); + if (!string.IsNullOrWhiteSpace(generic)) + return [generic]; + + return Array.Empty(); + } + + /// + /// Creates a copy of the instance by serializing and deseriaizing the data + /// + /// A cloned copy + public UpdateInfo Clone() + => System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(this) + ); } } diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 6dc7c80d1..60b7f09c4 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -23,193 +23,131 @@ using System; using System.Linq; using System.Collections.Generic; using System.IO; -using System.Runtime.ExceptionServices; -using System.Threading.Tasks; -using Duplicati.Library.Interface; -using Duplicati.Library.Common.IO; -using Duplicati.Library.Common; using Duplicati.Library.Utility; +using Duplicati.Library.Common; using System.Diagnostics; namespace Duplicati.Library.AutoUpdater { - public enum AutoUpdateStrategy - { - CheckBefore, - CheckDuring, - CheckAfter, - InstallBefore, - InstallDuring, - InstallAfter, - Never - } - public static class UpdaterManager { /// - /// The magic exit code that signals an update has been installed and that the app should restart + /// The RSA key used to sign the manifest /// - public const int MAGIC_EXIT_CODE = 126; - private static readonly System.Security.Cryptography.RSACryptoServiceProvider SIGN_KEY = AutoUpdateSettings.SignKey; + /// + /// Urls to check for updated packages + /// private static readonly string[] MANIFEST_URLS = AutoUpdateSettings.URLs; + /// + /// The app name to show + /// private static readonly string APPNAME = AutoUpdateSettings.AppName; - - public static readonly string INSTALLDIR; - - private static readonly string INSTALLED_BASE_DIR = - string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(string.Format(BASEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))) - ? System.IO.Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location) - : Environment.ExpandEnvironmentVariables(System.Environment.GetEnvironmentVariable(string.Format(BASEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); - - private static readonly bool DISABLE_UPDATE_DOMAIN = Debugger.IsAttached || Utility.Utility.ParseBool(Environment.GetEnvironmentVariable(string.Format(SKIPUPDATE_ENVNAME_TEMPLATE, APPNAME)), false); - - public static bool RequiresRespawn { get; set; } - - public static bool IgnoreWebrootFolder { get; set; } - - private static KeyValuePair? m_hasUpdateInstalled; - + /// + /// The version that the updater supports + /// + public const int SUPPORTED_PACKAGE_UPDATER_VERSION = 2; + /// + /// The folder where the machine id is placed + /// + public static readonly string UPDATEDIR; + /// + /// The directory where the program is running from + /// + public static readonly string INSTALLATIONDIR; + /// + /// Env variable that allows fully disabling all update checks + /// + public static readonly bool DISABLE_UPDATE_CHECK = Debugger.IsAttached || Utility.Utility.ParseBool(Environment.GetEnvironmentVariable(string.Format(SKIPUPDATE_ENVNAME_TEMPLATE, APPNAME)), false); + /// + /// The update information for the running version + /// public static readonly UpdateInfo SelfVersion; - public static readonly UpdateInfo BaseVersion; - + /// + /// Event trigger for errors on update + /// public static event Action OnError; - private const string DATETIME_FORMAT = "yyyymmddhhMMss"; - private const string BASEINSTALLDIR_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_INSTALL_ROOT"; - private const string UPDATEINSTALLDIR_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_UPDATE_ROOT"; - public const string SKIPUPDATE_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_SKIP_UPDATE"; - private const string RUN_UPDATED_FOLDER_PATH = "AUTOUPDATER_LOAD_UPDATE"; - private const string SLEEP_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_SLEEP"; - public const string UPDATE_STRATEGY_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_POLICY"; - private const string UPDATE_MANIFEST_FILENAME = "autoupdate.manifest"; - private const string README_FILE = "README.txt"; - private const string INSTALL_FILE = "installation.txt"; - private const string CURRENT_FILE = "current"; - /// - /// Gets the original directory that this application was installed into + /// Common formatting string for date-time values /// - /// The original directory that this application was installed into - public static string InstalledBaseDir { get { return INSTALLED_BASE_DIR; } } + private const string DATETIME_FORMAT = "yyyymmddhhMMss"; + /// + /// The template for the environment variable name that allows an overriden root folder + /// + private const string UPDATEINSTALLDIR_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_UPDATE_ROOT"; + /// + /// The template for the environment variable that toggles disabling updates + /// + public const string SKIPUPDATE_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_SKIP_UPDATE"; + /// + /// The name of the file that contains the manifest, located in the folder + /// + private const string UPDATE_MANIFEST_FILENAME = "autoupdate.manifest"; + /// + /// The name of the file that contains the package type id, located in the folder + /// + private const string PACKAGE_TYPE_FILE = "package_type_id.txt"; + /// + /// The README file stored in the folder, explaining what the folder is for + /// + private const string README_FILE = "README.txt"; + /// + /// The installation ID filename stored in + /// + private const string INSTALL_FILE = "installation.txt"; /// /// Gets the last version found from an update /// public static UpdateInfo LastUpdateCheckVersion { get; private set; } + /// + /// Performs static initialization of the update manager, populating the readonly fields of the manager + /// static UpdaterManager() { - // Update folder strategy is a bit complicated, - // because it depends on the actual system, - // and because it tries to find a good spot - // by probing for locations - - // The "overrides" paths are checked, - // to see if they exist and are writeable. - // The first existing and writeable path - // for "overrides" is chosen - - // If override was not found, the "legacypaths" - // are checked in the same way to see if - // we have previously used such a folder - // and if that folder has contents, - // which indicates that it has been used. - - // Finally we check the "attempts", - // which are suitable candidates - // for storing the updates on each - // operating system + // Set the installation path + INSTALLATIONDIR = Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location); + // Check for override if (string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME)))) { - string installdir = null; - var programfiles = System.Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - - // The user can override updates by having a local updates folder - var overrides = new List(new string[] { - System.IO.Path.Combine(InstalledBaseDir, "updates"), - }); - + // OS specific folders for probing + var candidates = new List(); if (Platform.IsClientWindows) { - overrides.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPNAME, "updates")); - overrides.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); + candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPNAME, "updates")); + candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); } else { if (Platform.IsClientOSX) - overrides.Add(System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", APPNAME, "updates")); + candidates.Add(System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", APPNAME, "updates")); - overrides.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); + candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); } - // Previous locations that we don't want to use, - // but we keep them active to avoid breaking the update system - var legacypaths = new List(); - - if (!string.IsNullOrWhiteSpace(programfiles)) - legacypaths.Add(System.IO.Path.Combine(programfiles, APPNAME, "updates")); - if (Platform.IsClientPosix) - legacypaths.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPNAME, "updates")); - - // The real attempts that we probe for - var attempts = new List(); - - // We do not want to install anything in the basedir, if the application is installed in "ProgramFiles" - if (!string.IsNullOrWhiteSpace(programfiles) && !InstalledBaseDir.StartsWith(Util.AppendDirSeparator(programfiles), StringComparison.Ordinal)) - attempts.Add(System.IO.Path.Combine(InstalledBaseDir, "updates")); - - if (Platform.IsClientOSX) - attempts.Add(System.IO.Path.Combine("/", "Library", "Application Support", APPNAME, "updates")); - else - attempts.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), APPNAME, "updates")); - - attempts.AddRange(overrides.Skip(1)); - - // Check if the override folder exists, and choose that - foreach (var p in overrides) - if (!string.IsNullOrWhiteSpace(p) && System.IO.Directory.Exists(p) && TestDirectoryIsWriteable(p)) - { - installdir = p; - break; - } - - if (string.IsNullOrWhiteSpace(installdir)) - foreach (var p in legacypaths) - if (!string.IsNullOrWhiteSpace(p) && System.IO.Directory.Exists(p) && System.IO.Directory.EnumerateFiles(p, "*", System.IO.SearchOption.TopDirectoryOnly).Any() && TestDirectoryIsWriteable(p)) - { - installdir = p; - break; - } - - if (string.IsNullOrWhiteSpace(installdir)) - foreach (var p in attempts) - if (!string.IsNullOrWhiteSpace(p) && TestDirectoryIsWriteable(p)) - { - installdir = p; - break; - } - - INSTALLDIR = installdir; + // Find the first writeable directory in the list + UPDATEDIR = candidates.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && System.IO.Directory.Exists(p) && TestDirectoryIsWriteable(p)); } else { - INSTALLDIR = Environment.ExpandEnvironmentVariables(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); + // Use override, no checks + UPDATEDIR = Environment.ExpandEnvironmentVariables(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); } - - if (INSTALLDIR != null) + if (!string.IsNullOrWhiteSpace(UPDATEDIR)) { - if (!System.IO.File.Exists(System.IO.Path.Combine(INSTALLDIR, README_FILE))) - System.IO.File.WriteAllText(System.IO.Path.Combine(INSTALLDIR, README_FILE), AutoUpdateSettings.UpdateFolderReadme); - if (!System.IO.File.Exists(System.IO.Path.Combine(INSTALLDIR, INSTALL_FILE))) - System.IO.File.WriteAllText(System.IO.Path.Combine(INSTALLDIR, INSTALL_FILE), AutoUpdateSettings.UpdateInstallFileText); + if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, README_FILE))) + System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, README_FILE), AutoUpdateSettings.UpdateFolderReadme); + if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE))) + System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE), AutoUpdateSettings.UpdateInstallFileText); } + // Attempt to read the installed manifest file UpdateInfo selfVersion = null; - UpdateInfo baseVersion = null; try { selfVersion = ReadInstalledManifest(System.IO.Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location)); @@ -218,17 +156,10 @@ namespace Duplicati.Library.AutoUpdater { } - try - { - baseVersion = ReadInstalledManifest(InstalledBaseDir); - } - catch - { - } - + // In case the installed manifest is broken, try to set some sane values if (selfVersion == null) { - selfVersion = new UpdateInfo() { + SelfVersion = new UpdateInfo() { Displayname = string.IsNullOrWhiteSpace(Duplicati.License.VersionNumbers.TAG) ? "Current" : Duplicati.License.VersionNumbers.TAG, Version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(), ReleaseTime = new DateTime(0), @@ -239,15 +170,7 @@ namespace Duplicati.Library.AutoUpdater string.IsNullOrWhiteSpace(AutoUpdateSettings.BuildUpdateChannel) ? "Nightly" : AutoUpdateSettings.BuildUpdateChannel #endif }; - - } - - if (baseVersion == null) - baseVersion = selfVersion; - - SelfVersion = selfVersion; - BaseVersion = baseVersion; } public static Version TryParseVersion(string str) @@ -259,28 +182,6 @@ namespace Duplicati.Library.AutoUpdater return new Version(0, 0); } - public static bool HasUpdateInstalled - { - get - { - if (!m_hasUpdateInstalled.HasValue) - { - var selfversion = TryParseVersion(SelfVersion.Version); - - m_hasUpdateInstalled = - (from n in FindInstalledVersions() - let nversion = TryParseVersion(n.Value.Version) - let newerVersion = selfversion < nversion - where newerVersion && VerifyUnpackedFolder(n.Key, n.Value) - orderby nversion descending - select n) - .FirstOrDefault(); - } - - return m_hasUpdateInstalled.Value.Value != null; - } - } - private static bool TestDirectoryIsWriteable(string path) { var p2 = System.IO.Path.Combine(path, "test-" + DateTime.UtcNow.ToString(DATETIME_FORMAT, System.Globalization.CultureInfo.InvariantCulture)); @@ -303,17 +204,39 @@ namespace Duplicati.Library.AutoUpdater return false; } + /// + /// The unique machine installation ID + /// public static string InstallID { get { - try { return System.IO.File.ReadAllText(System.IO.Path.Combine(INSTALLDIR, INSTALL_FILE)).Replace('\r', '\n').Split(new char[] { '\n' }).FirstOrDefault().Trim() ?? ""; } + try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } catch { } return ""; } } + /// + /// The package type ID + /// + public static string PackageTypeId + { + get + { + try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(INSTALLATIONDIR, PACKAGE_TYPE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } + catch { } + +#if DEBUG + return "debug"; +#else + return ""; +#endif + } + } + + public static UpdateInfo CheckForUpdate(ReleaseType channel = ReleaseType.Unknown) { if (channel == ReleaseType.Unknown) @@ -410,27 +333,12 @@ namespace Duplicati.Library.AutoUpdater return null; } - public static IEnumerable> FindInstalledVersions() + public static bool DownloadUpdate(UpdateInfo version, PackageEntry package, string targetPath, Action progress = null) { - var res = new List>(); - if (INSTALLDIR != null) - foreach (var folder in SystemIO.IO_OS.GetDirectories(INSTALLDIR)) - { - var r = ReadInstalledManifest(folder); - if (r != null) - res.Add(new KeyValuePair(folder, r)); - } - - return res; - } - - public static bool DownloadAndUnpackUpdate(UpdateInfo version, Action progress = null) - { - if (INSTALLDIR == null) + if (UPDATEDIR == null) return false; - - var updates = version.RemoteURLS.ToList(); + var updates = package.RemoteUrls.ToList(); // If alternate update URLs are specified, // we look for packages there as well @@ -460,7 +368,7 @@ namespace Duplicati.Library.AutoUpdater { Action cb = null; if (progress != null) - cb = (s) => { progress(Math.Min(1.0, Math.Max(0.0, (double)s / version.CompressedSize))); }; + cb = (s) => { progress(Math.Min(1.0, Math.Max(0.0, (double)s / package.Length))); }; var wreq = (System.Net.HttpWebRequest)System.Net.WebRequest.Create(url); wreq.UserAgent = string.Format("{0} v{1}", APPNAME, SelfVersion.Version); @@ -470,98 +378,28 @@ namespace Duplicati.Library.AutoUpdater using (var resp = areq.GetResponse()) using (var rss = areq.GetResponseStream()) using (var pgs = new Duplicati.Library.Utility.ProgressReportingStream(rss, cb)) - { Duplicati.Library.Utility.Utility.CopyStream(pgs, tempfile); - } var sha256 = System.Security.Cryptography.SHA256.Create(); var md5 = System.Security.Cryptography.MD5.Create(); - if (tempfile.Length != version.CompressedSize) - throw new Exception(string.Format("Invalid file size {0}, expected {1} for {2}", tempfile.Length, version.CompressedSize, url)); + if (tempfile.Length != package.Length) + throw new Exception(string.Format("Invalid file size {0}, expected {1} for {2}", tempfile.Length, package.Length, url)); tempfile.Position = 0; var sha256hash = Convert.ToBase64String(sha256.ComputeHash(tempfile)); - if (sha256hash != version.SHA256) + if (sha256hash != package.SHA256) throw new Exception(string.Format("Damaged or corrupted file, sha256 mismatch for {0}", url)); tempfile.Position = 0; var md5hash = Convert.ToBase64String(md5.ComputeHash(tempfile)); - if (md5hash != version.MD5) - throw new Exception(string.Format("Damaged or corrupted file, md5 mismatch for {0}", url)); - - tempfile.Position = 0; - using (var tempfolder = new Duplicati.Library.Utility.TempFolder()) - using (ICompression zip = new Duplicati.Library.Compression.FileArchiveZip(tempfile, ArchiveMode.Read, new Dictionary())) - { - foreach (var file in zip.ListFilesWithSize("")) - { - if (System.IO.Path.IsPathRooted(file.Key) || file.Key.Trim().StartsWith("..", StringComparison.OrdinalIgnoreCase)) - throw new Exception(string.Format("Out-of-place file path detected: {0}", file.Key)); - - var targetpath = System.IO.Path.Combine(tempfolder, file.Key); - var targetfolder = System.IO.Path.GetDirectoryName(targetpath); - if (!System.IO.Directory.Exists(targetfolder)) - System.IO.Directory.CreateDirectory(targetfolder); - - using (var zs = zip.OpenRead(file.Key)) - using (var fs = System.IO.File.Create(targetpath)) - zs.CopyTo(fs); - } - - if (VerifyUnpackedFolder(tempfolder, version)) - { - var versionstring = TryParseVersion(version.Version).ToString(); - var targetfolder = System.IO.Path.Combine(INSTALLDIR, versionstring); - if (System.IO.Directory.Exists(targetfolder)) - System.IO.Directory.Delete(targetfolder, true); - - System.IO.Directory.CreateDirectory(targetfolder); - - var tempfolderpath = Util.AppendDirSeparator(tempfolder); - var tempfolderlength = tempfolderpath.Length; - - // Would be nice, but does not work :( - //System.IO.Directory.Move(tempfolder, targetfolder); - - foreach (var e in Duplicati.Library.Utility.Utility.EnumerateFileSystemEntries(tempfolder)) - { - var relpath = e.Substring(tempfolderlength); - if (string.IsNullOrWhiteSpace(relpath)) - continue; - - var fullpath = System.IO.Path.Combine(targetfolder, relpath); - if (relpath.EndsWith(Util.DirectorySeparatorString, StringComparison.Ordinal)) - System.IO.Directory.CreateDirectory(fullpath); - else - System.IO.File.Copy(e, fullpath); - } - - // Verification will kick in when we list the installed updates - //VerifyUnpackedFolder(targetfolder, version); - System.IO.File.WriteAllText(System.IO.Path.Combine(INSTALLDIR, CURRENT_FILE), versionstring); - - m_hasUpdateInstalled = null; - - var obsolete = (from n in FindInstalledVersions() - where n.Value.Version != version.Version && n.Value.Version != SelfVersion.Version - let x = TryParseVersion(n.Value.Version) - orderby x descending - select n).Skip(1).ToArray(); - - foreach (var f in obsolete) - try { System.IO.Directory.Delete(f.Key, true); } - catch { } - - return true; - } - else - { - throw new Exception(string.Format("Unable to verify unpacked folder for url: {0}", url)); - } - } + if (md5hash != package.MD5) + throw new Exception(string.Format("Damaged or corrupted file, md5 mismatch for {0}", url)); } + + File.Copy(tempfilename, targetPath, true); + return true; } catch (Exception ex) { @@ -574,130 +412,16 @@ namespace Duplicati.Library.AutoUpdater return false; } - public static bool VerifyUnpackedFolder(string folder, UpdateInfo version = null) - { - try - { - UpdateInfo update; - FileEntry manifest; - - var sha256 = System.Security.Cryptography.SHA256.Create(); - var md5 = System.Security.Cryptography.MD5.Create(); - - using (var fs = System.IO.File.OpenRead(System.IO.Path.Combine(folder, UPDATE_MANIFEST_FILENAME))) - { - using (var ss = new SignatureReadingStream(fs, SIGN_KEY)) - using (var tr = new System.IO.StreamReader(ss)) - using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) - update = new Newtonsoft.Json.JsonSerializer().Deserialize(jr); - - sha256.Initialize(); - md5.Initialize(); - - fs.Position = 0; - var h1 = Convert.ToBase64String(sha256.ComputeHash(fs)); - fs.Position = 0; - var h2 = Convert.ToBase64String(md5.ComputeHash(fs)); - - manifest = new FileEntry() { - Path = UPDATE_MANIFEST_FILENAME, - Ignore = false, - LastWriteTime = update.ReleaseTime, - SHA256 = h1, - MD5 = h2 - }; - } - - if (version != null && (update.Displayname != version.Displayname || update.ReleaseTime != version.ReleaseTime)) - throw new Exception("The found version was not the expected version"); - - var paths = update.Files.Where(x => !x.Ignore).ToDictionary(x => x.Path.Replace('/', System.IO.Path.DirectorySeparatorChar), Library.Utility.Utility.ClientFilenameStringComparer); - paths.Add(manifest.Path, manifest); - - var ignores = (from x in update.Files where x.Ignore select Util.AppendDirSeparator(x.Path.Replace('/', System.IO.Path.DirectorySeparatorChar))).ToList(); - - folder = Util.AppendDirSeparator(folder); - var baselen = folder.Length; - - foreach (var file in Library.Utility.Utility.EnumerateFileSystemEntries(folder)) - { - var relpath = file.Substring(baselen); - if (string.IsNullOrWhiteSpace(relpath)) - continue; - - if (IgnoreWebrootFolder && relpath.StartsWith("webroot", Library.Utility.Utility.ClientFilenameStringComparison)) - continue; - - FileEntry fe; - if (!paths.TryGetValue(relpath, out fe)) - { - var ignore = false; - foreach (var c in ignores) - if (ignore = relpath.StartsWith(c, Library.Utility.Utility.ClientFilenameStringComparison)) - break; - - if (ignore) - continue; - - throw new Exception(string.Format("Found unexpected file: {0}", file)); - } - - paths.Remove(relpath); - - if (fe.Path.EndsWith("/", StringComparison.Ordinal)) - continue; - - sha256.Initialize(); - md5.Initialize(); - - using (var fs = System.IO.File.OpenRead(file)) - { - if (Convert.ToBase64String(sha256.ComputeHash(fs)) != fe.SHA256) - throw new Exception(string.Format("Invalid sha256 hash for file: {0}", file)); - - fs.Position = 0; - if (Convert.ToBase64String(md5.ComputeHash(fs)) != fe.MD5) - throw new Exception(string.Format("Invalid md5 hash for file: {0}", file)); - } - } - - var filteredpaths = paths - .Where(p => !string.IsNullOrWhiteSpace(p.Key) && !p.Key.EndsWith("/", StringComparison.Ordinal)) - .Where(p => !IgnoreWebrootFolder || !p.Key.StartsWith("webroot", Library.Utility.Utility.ClientFilenameStringComparison)) - .Select(p => p.Key) - .ToList(); - - if (filteredpaths.Count == 1) - throw new Exception(string.Format("Folder {0} is missing: {1}", folder, filteredpaths.First())); - else if (filteredpaths.Count > 0) - throw new Exception(string.Format("Folder {0} is missing {1} and {2} other file(s)", folder, filteredpaths.First(), filteredpaths.Count - 1)); - - return true; - } - catch (Exception ex) - { - if (OnError != null) - OnError(ex); - } - - return false; - } - - public static bool SetRunUpdate() - { - if (HasUpdateInstalled) - { - AppDomain.CurrentDomain.SetData(RUN_UPDATED_FOLDER_PATH, m_hasUpdateInstalled.Value.Key); - return true; - } - - return false; - } - - public static void CreateUpdatePackage(System.Security.Cryptography.RSACryptoServiceProvider key, string inputfolder, string outputfolder, string manifest = null) + /// + /// Helper method to create a signed manifest file + /// + /// + /// + /// + /// + public static void CreateSignedManifest(System.Security.Cryptography.RSACryptoServiceProvider key, string inputfolder, string outputfolder, string manifest = null) { // Read the existing manifest - UpdateInfo remoteManifest; var manifestpath = manifest ?? System.IO.Path.Combine(inputfolder, UPDATE_MANIFEST_FILENAME); @@ -707,134 +431,26 @@ namespace Duplicati.Library.AutoUpdater using (var jr = new Newtonsoft.Json.JsonTextReader(sr)) remoteManifest = new Newtonsoft.Json.JsonSerializer().Deserialize(jr); - if (remoteManifest.Files == null) - remoteManifest.Files = new FileEntry[0]; - if (remoteManifest.ReleaseTime.Ticks == 0) remoteManifest.ReleaseTime = DateTime.UtcNow; + + // No files to update with are allowed, as we currently do not use the information + if (remoteManifest.Packages == null) + remoteManifest.Packages = Array.Empty(); - var ignoreFiles = (from n in remoteManifest.Files - where n.Ignore - select n).ToArray(); - - var ignoreMap = ignoreFiles.ToDictionary(k => k.Path, k => "", Duplicati.Library.Utility.Utility.ClientFilenameStringComparer); - - remoteManifest.MD5 = null; - remoteManifest.SHA256 = null; - remoteManifest.Files = null; - remoteManifest.UncompressedSize = 0; - - var localManifest = remoteManifest.Clone(); - localManifest.RemoteURLS = null; - - inputfolder = Util.AppendDirSeparator(inputfolder); - var baselen = inputfolder.Length; - var dirsep = Util.DirectorySeparatorString; - - ignoreMap.Add(UPDATE_MANIFEST_FILENAME, ""); - - var md5 = System.Security.Cryptography.MD5.Create(); - var sha256 = System.Security.Cryptography.SHA256.Create(); - - Func computeStreamMD5 = (stream) => - { - md5.Initialize(); - return Convert.ToBase64String(md5.ComputeHash(stream)); - }; - - Func computeStreamSHA256 = (stream) => - { - sha256.Initialize(); - return Convert.ToBase64String(sha256.ComputeHash(stream)); - }; - - Func computeMD5 = (path) => - { - using (Stream fs = System.IO.File.OpenRead(path)) - return computeStreamMD5(fs); - }; - - Func computeSHA256 = (path) => - { - using (Stream fs = System.IO.File.OpenRead(path)) - return computeStreamSHA256(fs); - }; - - // Build a zip - using (var archive_temp_file = new Duplicati.Library.Utility.TempFile()) - { - using (var archive_temp = System.IO.File.Open(archive_temp_file, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) - { - using (ICompression zipfile = new Duplicati.Library.Compression.FileArchiveZip(archive_temp, ArchiveMode.Write, new Dictionary())) - { - Func addToArchive = (path, relpath) => - { - if (ignoreMap.ContainsKey(relpath)) - return false; - - if (path.EndsWith(dirsep, StringComparison.Ordinal)) - return true; - - using (var source = System.IO.File.OpenRead(path)) - using (var target = zipfile.CreateFile(relpath, - Duplicati.Library.Interface.CompressionHint.Compressible, - System.IO.File.GetLastAccessTimeUtc(path))) - { - source.CopyTo(target); - remoteManifest.UncompressedSize += source.Length; - } - - return true; - }; - - // Build the update manifest - localManifest.Files = - (from fse in Duplicati.Library.Utility.Utility.EnumerateFileSystemEntries(inputfolder) - let relpath = fse.Substring(baselen) - where addToArchive(fse, relpath) - select new FileEntry() - { - Path = relpath, - LastWriteTime = System.IO.File.GetLastAccessTimeUtc(fse), - MD5 = fse.EndsWith(dirsep, StringComparison.Ordinal) ? null : computeMD5(fse), - SHA256 = fse.EndsWith(dirsep, StringComparison.Ordinal) ? null : computeSHA256(fse) - }) - .Union(ignoreFiles).ToArray(); - - // Write a signed manifest with the files - - using (var ms = new System.IO.MemoryStream()) - using (var sw = new System.IO.StreamWriter(ms)) - { - new Newtonsoft.Json.JsonSerializer().Serialize(sw, localManifest); - sw.Flush(); - - using (var ms2 = new System.IO.MemoryStream()) - { - SignatureReadingStream.CreateSignedStream(ms, ms2, key); - ms2.Position = 0; - using (var sigfile = zipfile.CreateFile(UPDATE_MANIFEST_FILENAME, - Duplicati.Library.Interface.CompressionHint.Compressible, - DateTime.UtcNow)) - ms2.CopyTo(sigfile); - - } - } - } - - remoteManifest.CompressedSize = archive_temp.Length; - - archive_temp.Position = 0; - remoteManifest.MD5 = computeStreamMD5(archive_temp); - - archive_temp.Position = 0; - remoteManifest.SHA256 = computeStreamSHA256(archive_temp); - } - System.IO.File.Move(archive_temp_file, System.IO.Path.Combine(outputfolder, "package.zip")); - } + if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) + remoteManifest.UpdateFromV1Url = remoteManifest.GenericUpdatePageUrl; + + if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) + throw new Exception($"Field must be set: {nameof(remoteManifest.UpdateFromV1Url)}"); + if (string.IsNullOrWhiteSpace(remoteManifest.GenericUpdatePageUrl)) + throw new Exception($"Field must be set: {nameof(remoteManifest.GenericUpdatePageUrl)}"); + if (string.IsNullOrWhiteSpace(remoteManifest.Version)) + throw new Exception($"Field must be set: {nameof(remoteManifest.Version)}"); + if (string.IsNullOrWhiteSpace(remoteManifest.ReleaseType)) + throw new Exception($"Field must be set: {nameof(remoteManifest.ReleaseType)}"); // Write a signed manifest for upload - using (var tf = new Duplicati.Library.Utility.TempFile()) { using (var ms = new System.IO.MemoryStream()) @@ -849,327 +465,6 @@ namespace Duplicati.Library.AutoUpdater System.IO.File.Move(tf, System.IO.Path.Combine(outputfolder, UPDATE_MANIFEST_FILENAME)); } - - } - - private static void WrapWithUpdater(AutoUpdateStrategy defaultstrategy, Action wrappedFunction) - { - string optstr = Environment.GetEnvironmentVariable(string.Format(UPDATE_STRATEGY_ENVNAME_TEMPLATE, APPNAME)); - AutoUpdateStrategy strategy; - if (string.IsNullOrWhiteSpace(optstr) || !Enum.TryParse(optstr, true, out strategy)) - strategy = defaultstrategy; - - System.Threading.Thread backgroundChecker = null; - UpdateInfo updateDetected = null; - bool updateInstalled = false; - - bool checkForUpdate; - bool downloadUpdate; - bool runAfter; - bool runDuring; - bool runBefore; - - - switch (strategy) - { - case AutoUpdateStrategy.CheckBefore: - case AutoUpdateStrategy.CheckDuring: - case AutoUpdateStrategy.CheckAfter: - checkForUpdate = true; - downloadUpdate = false; - break; - - case AutoUpdateStrategy.InstallBefore: - case AutoUpdateStrategy.InstallDuring: - case AutoUpdateStrategy.InstallAfter: - checkForUpdate = true; - downloadUpdate = true; - break; - - default: - checkForUpdate = false; - downloadUpdate = false; - break; - } - - switch (strategy) - { - case AutoUpdateStrategy.CheckBefore: - case AutoUpdateStrategy.InstallBefore: - runBefore = true; - runDuring = false; - runAfter = false; - break; - - case AutoUpdateStrategy.CheckAfter: - case AutoUpdateStrategy.InstallAfter: - runBefore = false; - runDuring = false; - runAfter = true; - break; - - case AutoUpdateStrategy.CheckDuring: - case AutoUpdateStrategy.InstallDuring: - runBefore = false; - runDuring = true; - runAfter = false; - break; - - default: - runBefore = false; - runDuring = false; - runAfter = false; - break; - } - - if (checkForUpdate) - { - backgroundChecker = new System.Threading.Thread(() => - { - // Don't run "during" if the task is short - if (runDuring) - System.Threading.Thread.Sleep(TimeSpan.FromSeconds(10)); - - updateDetected = CheckForUpdate(); - if (updateDetected != null && downloadUpdate) - { - if (!runDuring) - Console.WriteLine("Update to {0} detected, installing...", updateDetected.Displayname); - updateInstalled = DownloadAndUnpackUpdate(updateDetected); - } - }); - - backgroundChecker.IsBackground = true; - backgroundChecker.Name = "BackgroundUpdateChecker"; - - if (!runAfter) - backgroundChecker.Start(); - - if (runBefore) - { - Console.WriteLine("Checking for update ..."); - backgroundChecker.Join(); - - if (downloadUpdate) - { - if (updateInstalled) - Console.WriteLine("Install succeeded, running updated version"); - else - Console.WriteLine("Install or download failed, using current version"); - } - else if (updateDetected != null) - { - Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); - } - - backgroundChecker = null; - } - } - - wrappedFunction(); - - if (backgroundChecker != null && runAfter) - { - Console.WriteLine("Checking for update ..."); - - backgroundChecker.Start(); - backgroundChecker.Join(); - } - - if (backgroundChecker != null && updateDetected != null) - { - if (backgroundChecker.IsAlive) - { - Console.WriteLine("Waiting for update \"{0}\" to complete", updateDetected.Displayname); - backgroundChecker.Join(); - } - - if (downloadUpdate) - { - if (updateInstalled) - Console.WriteLine("Install succeeded, running updated version on next launch"); - else - Console.WriteLine("Install or download failed, using current version on next launch"); - } - else - { - Console.WriteLine("Update \"{0}\" detected", updateDetected.Displayname); - } - } - } - - private static int RunMethod(System.Reflection.MethodInfo method, string[] args) - { - try - { - var n = method.Invoke(null, new object[] { args }); - if (method.ReturnType == typeof(int)) - return (int)n; - - return 0; - } - catch (System.Reflection.TargetInvocationException tex) - { - try - { - Console.WriteLine("Crash! {0}{1}", Environment.NewLine, tex); - } - catch - { - } - - try - { - var report_file = System.IO.Path.Combine( - string.IsNullOrEmpty(INSTALLDIR) ? Library.Utility.TempFolder.SystemTempPath : INSTALLDIR, - string.Format("{0}-crashlog.txt", AutoUpdateSettings.AppName) - ); - - System.IO.File.WriteAllText(report_file, tex.ToString()); - } - catch - { - } - - if (tex.InnerException != null) - { - // Unwrap exceptions for nicer display. The ExceptionDispatchInfo class allows us to - // rethrow an exception without changing the stack trace. - ExceptionDispatchInfo.Capture(tex.InnerException).Throw(); - } - - throw; - } - } - - private static KeyValuePair GetBestUpdateVersion(bool forcecheck = false) - { - if (forcecheck) - m_hasUpdateInstalled = null; - - // Check if there are updates installed, otherwise use current - KeyValuePair best = new KeyValuePair(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), SelfVersion); - - if (HasUpdateInstalled) - best = m_hasUpdateInstalled.Value; - - if (INSTALLDIR != null && System.IO.File.Exists(System.IO.Path.Combine(INSTALLDIR, CURRENT_FILE))) - { - try - { - var current = System.IO.File.ReadAllText(System.IO.Path.Combine(INSTALLDIR, CURRENT_FILE)).Trim(); - if (!string.IsNullOrWhiteSpace(current)) - { - var targetfolder = System.IO.Path.Combine(INSTALLDIR, current); - var currentmanifest = ReadInstalledManifest(targetfolder); - if (currentmanifest != null && TryParseVersion(currentmanifest.Version) > TryParseVersion(best.Value.Version) && VerifyUnpackedFolder(targetfolder, currentmanifest)) - best = new KeyValuePair(targetfolder, currentmanifest); - } - } - catch (Exception ex) - { - if (OnError != null) - OnError(ex); - } - } - - return best; - } - - public static bool IsRunningInUpdateEnvironment => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(string.Format(BASEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); - - public static int RunFromMostRecent(System.Reflection.MethodInfo method, string[] cmdargs, AutoUpdateStrategy defaultstrategy = AutoUpdateStrategy.CheckDuring) - { - // TODO: Disabled auto-updater as it does not currently work - return RunMethod(method, cmdargs); - //return RunFromMostRecentSpawn(method, cmdargs, defaultstrategy); - } - - public static int RunFromMostRecentSpawn(System.Reflection.MethodInfo method, string[] cmdargs, AutoUpdateStrategy defaultstrategy = AutoUpdateStrategy.CheckDuring) - { - // If the update is disabled, go straight in - if (DISABLE_UPDATE_DOMAIN) - return RunMethod(method, cmdargs); - - // If we are not the primary entry, just execute - if (IsRunningInUpdateEnvironment) - { - // For some reason this does not work - //if (Platform.IsClientWindows) - //Duplicati.Library.Utility.Win32.AttachConsole(Duplicati.Library.Utility.Win32.ATTACH_PARENT_PROCESS); - - int r = 0; - WrapWithUpdater(defaultstrategy, () => { - r = RunMethod(method, cmdargs); - }); - - return r; - } - - var app = Environment.GetCommandLineArgs().First().Substring(0, Environment.GetCommandLineArgs().First().Length - 3) + "exe"; - var args = Library.Utility.Utility.WrapAsCommandLine(Environment.GetCommandLineArgs().Skip(1), false); - - if (!Path.IsPathRooted(app)) - app = Path.Combine(InstalledBaseDir, app); - - var executable = Path.GetFileName(app); - - while (true) - { - var best = GetBestUpdateVersion(true); - var folder = best.Key; - - var pi = new System.Diagnostics.ProcessStartInfo(Path.Combine(folder, executable), args) - { - CreateNoWindow = true, - UseShellExecute = false, - ErrorDialog = false, - }; - pi.EnvironmentVariables.Clear(); - - var cur = Environment.GetEnvironmentVariables(); - foreach (var e in cur.Keys) - if (e is string s) - pi.EnvironmentVariables[s] = cur[s] as string; - - pi.EnvironmentVariables[string.Format(BASEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME)] = InstalledBaseDir; - pi.EnvironmentVariables["LOCALIZATION_FOLDER"] = InstalledBaseDir; - - // On Windows, we manually redirect the streams - if (Platform.IsClientWindows) - { - pi.RedirectStandardError = true; - pi.RedirectStandardInput = true; - pi.RedirectStandardOutput = true; - } - - var proc = System.Diagnostics.Process.Start(pi); - Task tasks = null; - if (Platform.IsClientWindows) - { - // On Windows, we manually redirect the streams - tasks = Task.WhenAll( - // This does some unwanted buffering that breaks things - //Console.OpenStandardInput().CopyToAsync(proc.StandardInput.BaseStream), - Task.Run(async () => { - var stdin = new StreamReader(Console.OpenStandardInput()); - var line = string.Empty; - while ((line = await stdin.ReadLineAsync().ConfigureAwait(false)) != null) - await proc.StandardInput.WriteLineAsync(line); - }), - proc.StandardOutput.BaseStream.CopyToAsync(Console.OpenStandardOutput()), - proc.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError()) - ); - } - - proc.WaitForExit(); - if (tasks != null) - tasks.Wait(1000); - - if (proc.ExitCode != MAGIC_EXIT_CODE) - return proc.ExitCode; - } - } } } diff --git a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs index 59ccd93d3..820dbe627 100644 --- a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs +++ b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs @@ -39,8 +39,8 @@ namespace Duplicati.Library.Main.Operation yield return string.Format("Duplicati: {0} ({1})", Duplicati.Library.Utility.Utility.getEntryAssembly().FullName, System.Reflection.Assembly.GetExecutingAssembly().FullName); yield return string.Format("Autoupdate urls: {0}", string.Join(";", Duplicati.Library.AutoUpdater.AutoUpdateSettings.URLs)); - yield return string.Format("Update folder: {0}", Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLDIR); - yield return string.Format("Base install folder: {0}", Duplicati.Library.AutoUpdater.UpdaterManager.InstalledBaseDir); + yield return string.Format("Update folder: {0}", Duplicati.Library.AutoUpdater.UpdaterManager.UPDATEDIR); + yield return string.Format("Install folder: {0}", Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR); yield return string.Format("Version name: \"{0}\" ({1})", Duplicati.Library.AutoUpdater.UpdaterManager.SelfVersion.Displayname, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); yield return string.Format("Current Version folder {0}", System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)); diff --git a/Duplicati/Library/Main/Options.cs b/Duplicati/Library/Main/Options.cs index 476e2f65e..5049e71e2 100644 --- a/Duplicati/Library/Main/Options.cs +++ b/Duplicati/Library/Main/Options.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Linq; @@ -191,7 +191,7 @@ namespace Duplicati.Library.Main } - private static readonly string DEFAULT_COMPRESSED_EXTENSION_FILE = System.IO.Path.Combine(Duplicati.Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "default_compressed_extensions.txt"); + private static readonly string DEFAULT_COMPRESSED_EXTENSION_FILE = System.IO.Path.Combine(Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, "default_compressed_extensions.txt"); /// /// Lock that protects the options collection diff --git a/Duplicati/Library/Snapshots/LinuxSnapshot.cs b/Duplicati/Library/Snapshots/LinuxSnapshot.cs index 48e2a2a98..2c7e6ad98 100644 --- a/Duplicati/Library/Snapshots/LinuxSnapshot.cs +++ b/Duplicati/Library/Snapshots/LinuxSnapshot.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; @@ -218,7 +218,7 @@ namespace Duplicati.Library.Snapshots /// A string with the combined output of the stdout and stderr private static string ExecuteCommand(string program, string commandline, int expectedExitCode) { - program = System.IO.Path.Combine(System.IO.Path.Combine(Duplicati.Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "lvm-scripts"), program); + program = System.IO.Path.Combine(System.IO.Path.Combine(Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, "lvm-scripts"), program); var inf = new ProcessStartInfo(program, commandline) { CreateNoWindow = true, diff --git a/Duplicati/Server/Duplicati.Server.Serialization/Implementations/ServerStatus.cs b/Duplicati/Server/Duplicati.Server.Serialization/Implementations/ServerStatus.cs index a843d7829..fe16bc7ac 100644 --- a/Duplicati/Server/Duplicati.Server.Serialization/Implementations/ServerStatus.cs +++ b/Duplicati/Server/Duplicati.Server.Serialization/Implementations/ServerStatus.cs @@ -1,27 +1,25 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; -using System.Linq; -using System.Text; namespace Duplicati.Server.Serialization.Implementations { @@ -39,8 +37,8 @@ namespace Duplicati.Server.Serialization.Implementations public long LastNotificationUpdateID { get; set; } public string UpdatedVersion { get; set; } + public string UpdateDownloadLink { get; set; } public UpdatePollerStates UpdaterState { get; set; } - public bool UpdateReady { get; set; } public double UpdateDownloadProgress { get; set; } } } diff --git a/Duplicati/Server/Duplicati.Server.Serialization/Interface/IServerStatus.cs b/Duplicati/Server/Duplicati.Server.Serialization/Interface/IServerStatus.cs index 0fbb37c9c..46f4850ae 100644 --- a/Duplicati/Server/Duplicati.Server.Serialization/Interface/IServerStatus.cs +++ b/Duplicati/Server/Duplicati.Server.Serialization/Interface/IServerStatus.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -27,7 +27,7 @@ namespace Duplicati.Server.Serialization.Interface { Tuple ActiveTask { get; } LiveControlState ProgramState { get; } - System.Collections.Generic.IList> SchedulerQueueIds { get; } + IList> SchedulerQueueIds { get; } bool HasWarning { get; } bool HasError { get; } SuggestedStatusIcon SuggestedStatusIcon { get; } @@ -37,8 +37,8 @@ namespace Duplicati.Server.Serialization.Interface long LastNotificationUpdateID { get; } string UpdatedVersion { get; } + string UpdateDownloadLink { get; } UpdatePollerStates UpdaterState { get; } - bool UpdateReady { get; } double UpdateDownloadProgress { get; } } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 82dcf0db8..849ffe327 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -43,7 +43,7 @@ namespace Duplicati.Server /// /// The path to the directory that contains the main executable /// - public static readonly string StartupPath = Duplicati.Library.AutoUpdater.UpdaterManager.InstalledBaseDir; + public static readonly string StartupPath = Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR; /// /// The name of the environment variable that holds the path to the data folder used by Duplicati @@ -218,17 +218,8 @@ namespace Duplicati.Server /// The main entry point for the application. /// [STAThread] - public static int Main(string[] args) + public static int Main(string[] _args) { - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args, Duplicati.Library.AutoUpdater.AutoUpdateStrategy.Never); - } - - public static int RealMain(string[] _args) - { -#if DEBUG - System.Diagnostics.Debugger.Launch(); -#endif - //If we are on Windows, append the bundled "win-tools" programs to the search path //We add it last, to allow the user to override with other versions if (Platform.IsClientWindows) @@ -354,9 +345,6 @@ namespace Duplicati.Server LogHandler?.Dispose(); } - if (UpdatePoller != null && UpdatePoller.IsUpdateRequested) - return Library.AutoUpdater.UpdaterManager.MAGIC_EXIT_CODE; - return 0; } diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/AboutController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/AboutController.js index f6ec7fafb..b5e37303e 100644 --- a/Duplicati/Server/webroot/ngax/scripts/controllers/AboutController.js +++ b/Duplicati/Server/webroot/ngax/scripts/controllers/AboutController.js @@ -46,14 +46,6 @@ backupApp.controller('AboutController', function($scope, $location, BrandingServ $location.path('/updatechangelog'); }; - $scope.doStartUpdateDownload = function() { - AppService.post('/updates/install'); - }; - - $scope.doStartUpdateActivate = function() { - AppService.post('/updates/activate').then(function() {}, AppUtils.connectionError(gettextCatalog.getString('Activate failed:') + ' ')); - }; - $scope.doCheckForUpdates = function() { AppService.post('/updates/check'); diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/UpdateChangelogController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/UpdateChangelogController.js index 9f5b0cb55..2aa4051e1 100644 --- a/Duplicati/Server/webroot/ngax/scripts/controllers/UpdateChangelogController.js +++ b/Duplicati/Server/webroot/ngax/scripts/controllers/UpdateChangelogController.js @@ -10,14 +10,6 @@ backupApp.controller('UpdateChangelogController', function($scope, BrandingServi }); }; - $scope.doInstall = function() { - AppService.post('/updates/install').then(function() {}, AppUtils.connectionError(gettextCatalog.getString('Install failed:') + ' ')); - }; - - $scope.doActivate = function() { - AppService.post('/updates/activate').then(function() {}, AppUtils.connectionError(gettextCatalog.getString('Activate failed:') + ' ')); - }; - $scope.doCheck = function() { AppService.post('/updates/check').then(function() { reloadChangeLog(); diff --git a/Duplicati/Server/webroot/ngax/scripts/directives/notificationArea.js b/Duplicati/Server/webroot/ngax/scripts/directives/notificationArea.js index 7ea5cba05..f233c2fdd 100644 --- a/Duplicati/Server/webroot/ngax/scripts/directives/notificationArea.js +++ b/Duplicati/Server/webroot/ngax/scripts/directives/notificationArea.js @@ -54,14 +54,6 @@ backupApp.directive('notificationArea', function() { $location.path('/'); }; - $scope.doInstallUpdate = function(id) { - AppService.post('/updates/install'); - }; - - $scope.doActivateUpdate = function(id) { - AppService.post('/updates/activate').then(function() { $scope.doDismiss(id); }, AppUtils.connectionError('Activate failed: ')); - }; - $scope.doShowUpdate = function(id) { $location.path('/updatechangelog'); }; diff --git a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js index 8092dc04d..dd09dbc9f 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js @@ -18,8 +18,8 @@ backupApp.service('ServerStatus', function($rootScope, $timeout, AppService, App failedConnectionAttempts: 0, lastPgEvent: null, updaterState: 'Waiting', + updateDownloadLink: null, updatedVersion: null, - updateReady: false, updateDownloadProgress: 0, proposedSchedule: [], schedulerQueueIds: [] @@ -218,7 +218,7 @@ backupApp.service('ServerStatus', function($rootScope, $timeout, AppService, App notifyIfChanged(response.data, 'ProgramState', 'programState') | notifyIfChanged(response.data, 'EstimatedPauseEnd', 'estimatedPauseEnd') | notifyIfChanged(response.data, 'UpdaterState', 'updaterState') | - notifyIfChanged(response.data, 'UpdateReady', 'updateReady') | + notifyIfChanged(response.data, 'UpdateDownloadLink', 'updateDownloadLink') | notifyIfChanged(response.data, 'UpdatedVersion', 'updatedVersion')| notifyIfChanged(response.data, 'UpdateDownloadProgress', 'updateDownloadProgress'); diff --git a/Duplicati/Server/webroot/ngax/templates/about.html b/Duplicati/Server/webroot/ngax/templates/about.html index 9a1e0cf3f..e0fb6aa16 100644 --- a/Duplicati/Server/webroot/ngax/templates/about.html +++ b/Duplicati/Server/webroot/ngax/templates/about.html @@ -26,11 +26,8 @@
 
You are currently running {{appname}} {{version}}
-
- Update {{state.updatedVersion}} is available, download now -
-
- Update {{state.updatedVersion}} is installed, activate now +
+ Update {{state.updatedVersion}} is available, download now
Check for updates now diff --git a/Duplicati/Server/webroot/ngax/templates/notificationarea.html b/Duplicati/Server/webroot/ngax/templates/notificationarea.html index 852dbbef3..e91cb6bd9 100644 --- a/Duplicati/Server/webroot/ngax/templates/notificationarea.html +++ b/Duplicati/Server/webroot/ngax/templates/notificationarea.html @@ -19,16 +19,13 @@
-
New update found: {{message}}
+
New update found: {{message}}
diff --git a/Duplicati/Server/webroot/ngax/templates/updatechangelog.html b/Duplicati/Server/webroot/ngax/templates/updatechangelog.html index ee5755dfe..881d5b25e 100644 --- a/Duplicati/Server/webroot/ngax/templates/updatechangelog.html +++ b/Duplicati/Server/webroot/ngax/templates/updatechangelog.html @@ -7,8 +7,7 @@
Loading …
diff --git a/Duplicati/Service/Program.cs b/Duplicati/Service/Program.cs index e01500692..25a6d5006 100644 --- a/Duplicati/Service/Program.cs +++ b/Duplicati/Service/Program.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -27,15 +27,11 @@ namespace Duplicati.Service { [STAThread] public static int Main(string[] args) - { - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args, Duplicati.Library.AutoUpdater.AutoUpdateStrategy.Never); - } - - public static void RealMain(string[] args) { using(var runner = new Runner(args)) runner.Wait(); + return 0; } } } diff --git a/Duplicati/UnitTest/BackendToolTests.cs b/Duplicati/UnitTest/BackendToolTests.cs index 1ec94df54..2b3c4b631 100755 --- a/Duplicati/UnitTest/BackendToolTests.cs +++ b/Duplicati/UnitTest/BackendToolTests.cs @@ -66,7 +66,7 @@ namespace Duplicati.UnitTest { // Absolute path var downloadFileName = Path.Combine(absoluteDownloadFolder, Path.GetFileName(targetFile)); - var status = CommandLine.BackendTool.Program.RealMain(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); + var status = CommandLine.BackendTool.Program.Main(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); Assert.AreEqual(0, status); Assert.IsTrue(File.Exists(downloadFileName)); TestUtils.AssertFilesAreEqual(targetFile, downloadFileName, false, downloadFileName); @@ -83,7 +83,7 @@ namespace Duplicati.UnitTest { // Relative path var downloadFileName = Path.GetFileName(targetFile); - var status = CommandLine.BackendTool.Program.RealMain(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); + var status = CommandLine.BackendTool.Program.Main(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); Assert.AreEqual(0, status); Assert.IsTrue(File.Exists(downloadFileName)); TestUtils.AssertFilesAreEqual(targetFile, downloadFileName, false, downloadFileName); diff --git a/Duplicati/UnitTest/CommandLineOperationsTests.cs b/Duplicati/UnitTest/CommandLineOperationsTests.cs index 86ec197d4..fa387fc9b 100644 --- a/Duplicati/UnitTest/CommandLineOperationsTests.cs +++ b/Duplicati/UnitTest/CommandLineOperationsTests.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -174,17 +174,17 @@ namespace Duplicati.UnitTest ProgressWriteLine("Running backup with {0} data added ...", Duplicati.Library.Utility.Utility.FormatSizeString(size)); using (new Library.Logging.Timer(LOGTAG, "BackupWithDataAdded", string.Format("Backup with {0} data added", Duplicati.Library.Utility.Utility.FormatSizeString(size)))) - Duplicati.CommandLine.Program.RealMain(backupargs); + Duplicati.CommandLine.Program.Main(backupargs); ProgressWriteLine("Testing data ..."); using (new Library.Logging.Timer(LOGTAG, "TestRemoteData", "Test remote data")) - if (Duplicati.CommandLine.Program.RealMain((new string[] { "test", target, "all" }.Union(opts)).ToArray()) != 0) + if (Duplicati.CommandLine.Program.Main((new string[] { "test", target, "all" }.Union(opts)).ToArray()) != 0) throw new Exception("Failed during remote verification"); } ProgressWriteLine("Running unchanged backup ..."); using (new Library.Logging.Timer(LOGTAG, "UnchangedBackup", "Unchanged backup")) - Duplicati.CommandLine.Program.RealMain(backupargs); + Duplicati.CommandLine.Program.Main(backupargs); var datafolders = systemIO.EnumerateDirectories(DATAFOLDER); @@ -195,7 +195,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Running backup with renamed folder..."); using (new Library.Logging.Timer(LOGTAG, "BackupWithRenamedFolder", "Backup with renamed folder")) - Duplicati.CommandLine.Program.RealMain(backupargs); + Duplicati.CommandLine.Program.Main(backupargs); datafolders = systemIO.EnumerateDirectories(DATAFOLDER); @@ -212,11 +212,11 @@ namespace Duplicati.UnitTest ProgressWriteLine("Running backup with deleted data..."); using (new Library.Logging.Timer(LOGTAG, "BackupWithDeletedData", "Backup with deleted data")) - Duplicati.CommandLine.Program.RealMain(backupargs); + Duplicati.CommandLine.Program.Main(backupargs); ProgressWriteLine("Testing the compare method ..."); using (new Library.Logging.Timer(LOGTAG, "CompareMethod", "Compare method")) - Duplicati.CommandLine.Program.RealMain((new string[] { "compare", target, "0", "1" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "compare", target, "0", "1" }.Union(opts)).ToArray()); for (var i = 0; i < 5; i++) { @@ -224,12 +224,12 @@ namespace Duplicati.UnitTest systemIO.FileCopy(LOGFILE, Path.Combine(SOURCEFOLDER, Path.GetFileName(LOGFILE)), true); using (new Library.Logging.Timer(LOGTAG, "BackupWithLogfileChange", string.Format("Backup with logfilechange {0}", i + 1))) - Duplicati.CommandLine.Program.RealMain(backupargs); + Duplicati.CommandLine.Program.Main(backupargs); } ProgressWriteLine("Compacting data ..."); using (new Library.Logging.Timer(LOGTAG, "Compacting", "Compacting")) - Duplicati.CommandLine.Program.RealMain((new string[] { "compact", target, "--small-file-max-count=2" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "compact", target, "--small-file-max-count=2" }.Union(opts)).ToArray()); datafolders = systemIO.EnumerateDirectories(DATAFOLDER); @@ -237,7 +237,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Partial restore of {0} ...", Path.GetFileName(rf)); using (new Library.Logging.Timer(LOGTAG, "PartialRestore", "Partial restore")) - Duplicati.CommandLine.Program.RealMain((new string[] { "restore", target, rf + "*", "--restore-path=\"" + RESTOREFOLDER + "\"" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "restore", target, rf + "*", "--restore-path=\"" + RESTOREFOLDER + "\"" }.Union(opts)).ToArray()); ProgressWriteLine("Verifying partial restore ..."); using (new Library.Logging.Timer(LOGTAG, "VerificationOfPartialRestore", "Verification of partial restored files")) @@ -247,7 +247,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Partial restore of {0} without local db...", Path.GetFileName(rf)); using (new Library.Logging.Timer(LOGTAG, "PartialRestoreWithoutLocalDb", "Partial restore without local db")) - Duplicati.CommandLine.Program.RealMain((new string[] { "restore", target, rf + "*", "--restore-path=\"" + RESTOREFOLDER + "\"", "--no-local-db" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "restore", target, rf + "*", "--restore-path=\"" + RESTOREFOLDER + "\"", "--no-local-db" }.Union(opts)).ToArray()); ProgressWriteLine("Verifying partial restore ..."); using (new Library.Logging.Timer(LOGTAG, "VerificationOfPartialRestore", "Verification of partial restored files")) @@ -257,7 +257,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Full restore ..."); using (new Library.Logging.Timer(LOGTAG, "FullRestore", "Full restore")) - Duplicati.CommandLine.Program.RealMain((new string[] { "restore", target, "*", "--restore-path=\"" + RESTOREFOLDER + "\"" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "restore", target, "*", "--restore-path=\"" + RESTOREFOLDER + "\"" }.Union(opts)).ToArray()); ProgressWriteLine("Verifying full restore ..."); using (new Library.Logging.Timer(LOGTAG, "VerificationOfFullRestore", "Verification of restored files")) @@ -268,7 +268,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Full restore without local db..."); using (new Library.Logging.Timer(LOGTAG, "FullRestoreWithoutDb", "Full restore without local db")) - Duplicati.CommandLine.Program.RealMain((new string[] { "restore", target, "*", "--restore-path=\"" + RESTOREFOLDER + "\"", "--no-local-db" }.Union(opts)).ToArray()); + Duplicati.CommandLine.Program.Main((new string[] { "restore", target, "*", "--restore-path=\"" + RESTOREFOLDER + "\"", "--no-local-db" }.Union(opts)).ToArray()); ProgressWriteLine("Verifying full restore ..."); using (new Library.Logging.Timer(LOGTAG, "VerificationOfFullRestoreWithoutDb", "Verification of restored files")) @@ -277,7 +277,7 @@ namespace Duplicati.UnitTest ProgressWriteLine("Testing data ..."); using (new Library.Logging.Timer(LOGTAG, "TestRemoteData", "Test remote data")) - if (Duplicati.CommandLine.Program.RealMain((new string[] { "test", target, "all" }.Union(opts)).ToArray()) != 0) + if (Duplicati.CommandLine.Program.Main((new string[] { "test", target, "all" }.Union(opts)).ToArray()) != 0) throw new Exception("Failed during final remote verification"); } } diff --git a/Duplicati/UnitTest/RecoveryToolTests.cs b/Duplicati/UnitTest/RecoveryToolTests.cs index f654bbb77..80f9dc1af 100644 --- a/Duplicati/UnitTest/RecoveryToolTests.cs +++ b/Duplicati/UnitTest/RecoveryToolTests.cs @@ -90,17 +90,17 @@ namespace Duplicati.UnitTest // Download the backend files. string downloadFolder = Path.Combine(this.RESTOREFOLDER, "downloadedFiles"); Directory.CreateDirectory(downloadFolder); - int status = CommandLine.RecoveryTool.Program.RealMain(new[] {"download", $"{backendURL}", $"{downloadFolder}", $"--passphrase={options["passphrase"]}"}); + int status = CommandLine.RecoveryTool.Program.Main(new[] {"download", $"{backendURL}", $"{downloadFolder}", $"--passphrase={options["passphrase"]}"}); Assert.AreEqual(0, status); // Create the index. - status = CommandLine.RecoveryTool.Program.RealMain(new[] {"index", $"{downloadFolder}", $"--build-index-with-files={buildIndexWithFiles}"}); + status = CommandLine.RecoveryTool.Program.Main(new[] {"index", $"{downloadFolder}", $"--build-index-with-files={buildIndexWithFiles}"}); Assert.AreEqual(0, status); // Restore to a different folder. string restoreFolder = Path.Combine(this.RESTOREFOLDER, "restoredFiles"); Directory.CreateDirectory(restoreFolder); - status = CommandLine.RecoveryTool.Program.RealMain(new[] {"restore", $"{downloadFolder}", $"--targetpath={restoreFolder}"}); + status = CommandLine.RecoveryTool.Program.Main(new[] {"restore", $"{downloadFolder}", $"--targetpath={restoreFolder}"}); Assert.AreEqual(0, status); // Since this.DATAFOLDER is a folder, Path.GetFileName will return the name of the diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index ca227d420..42935a639 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -18,8 +18,8 @@ namespace Duplicati.WebserverCore app.UseAuthMiddleware(); - string webroot = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); - string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "webroot"); + string webroot = System.IO.Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location); + string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR, "webroot"); webroot = System.IO.Path.Combine(webroot, "webroot"); var webroot_fileprovider = new PhysicalFileProvider(webroot); diff --git a/Duplicati/WindowsService/Program.cs b/Duplicati/WindowsService/Program.cs index d0d425c1a..deb3f9d2c 100644 --- a/Duplicati/WindowsService/Program.cs +++ b/Duplicati/WindowsService/Program.cs @@ -1,32 +1,27 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; -using System.Security.Principal; using System.ServiceProcess; -using System.Text; -using System.Threading.Tasks; namespace Duplicati.WindowsService { @@ -34,11 +29,6 @@ namespace Duplicati.WindowsService { [STAThread] public static int Main(string[] args) - { - return Duplicati.Library.AutoUpdater.UpdaterManager.RunFromMostRecent(typeof(Program).GetMethod("RealMain"), args, Duplicati.Library.AutoUpdater.AutoUpdateStrategy.Never); - } - - public static void RealMain(string[] args) { var install = args != null && args.Any(x => string.Equals("install", x, StringComparison.OrdinalIgnoreCase)); var uninstall = args != null && args.Any(x => string.Equals("uninstall", x, StringComparison.OrdinalIgnoreCase)); @@ -79,6 +69,7 @@ namespace Duplicati.WindowsService catch (Exception ex) { Console.WriteLine("Duplicati service delete failed. Exception: {0}", ex.Message); + return 1; } } if (install) @@ -93,6 +84,7 @@ namespace Duplicati.WindowsService catch (Exception ex) { Console.WriteLine("Duplicati service installation failed. Exception: {0}", ex.Message); + return 1; } } } @@ -100,6 +92,8 @@ namespace Duplicati.WindowsService { ServiceBase.Run(new ServiceBase[] { new ServiceControl(args) }); } + + return 0; } } } From 76983002e6dcfa52d31025830d26b775da4cead4 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 15 Mar 2024 15:05:13 +0100 Subject: [PATCH 03/91] Fixed a release build issue after removing autoupdater --- Duplicati.Library.RestAPI/Serializable/ServerStatus.cs | 2 +- Duplicati.Library.RestAPI/WebServer/Server.cs | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs b/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs index 7b9f9f752..dfe41f5e4 100644 --- a/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs +++ b/Duplicati.Library.RestAPI/Serializable/ServerStatus.cs @@ -55,7 +55,7 @@ namespace Duplicati.Server.Serializable } } - public string UpdateDownloadLink => FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion.GetUpdateUrls()?.FirstOrDefault(); + public string UpdateDownloadLink => FIXMEGlobal.DataConnection.ApplicationSettings.UpdatedVersion?.GetUpdateUrls()?.FirstOrDefault(); public UpdatePollerStates UpdaterState { get { return FIXMEGlobal.UpdatePoller.ThreadState; } } diff --git a/Duplicati.Library.RestAPI/WebServer/Server.cs b/Duplicati.Library.RestAPI/WebServer/Server.cs index 3d72a44b6..04bc5024c 100644 --- a/Duplicati.Library.RestAPI/WebServer/Server.cs +++ b/Duplicati.Library.RestAPI/WebServer/Server.cs @@ -260,13 +260,7 @@ namespace Duplicati.Server.WebServer // in the same folders as the running application, to avoid users // that inadvertently expose top level folders if (!string.IsNullOrWhiteSpace(userroot) - && - ( - userroot.StartsWith(Util.AppendDirSeparator(System.Reflection.Assembly.GetExecutingAssembly().Location), Library.Utility.Utility.ClientFilenameStringComparison) - || - userroot.StartsWith(Util.AppendDirSeparator(Duplicati.Library.AutoUpdater.UpdaterManager.InstalledBaseDir), Library.Utility.Utility.ClientFilenameStringComparison) - ) - ) + && userroot.StartsWith(Util.AppendDirSeparator(Duplicati.Library.Utility.Utility.getEntryAssembly().Location), Library.Utility.Utility.ClientFilenameStringComparison)) #endif { webroot = userroot; From f77f77ad3a151a677f4543b3474ced8f1b0bdf9f Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 15 Mar 2024 15:20:28 +0100 Subject: [PATCH 04/91] Fixed types to allow building release executables --- .../BackendTester/Duplicati.CommandLine.BackendTester.csproj | 1 - .../BackendTool/Duplicati.CommandLine.BackendTool.csproj | 1 - Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj | 1 - 3 files changed, 3 deletions(-) diff --git a/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj b/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj index f15eb653a..72c126e05 100644 --- a/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj +++ b/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj @@ -2,7 +2,6 @@ net8.0 - Exe A backend debugging tool for Duplicati Duplicati.CommandLine.BackendTester.Implementation diff --git a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj index aa642fd86..60564fccf 100644 --- a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj +++ b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj @@ -2,7 +2,6 @@ net8.0 - Exe Duplicati.CommandLine.BackendTool.Implementation diff --git a/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj b/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj index 989b6a5ab..c66266377 100644 --- a/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj +++ b/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj @@ -2,7 +2,6 @@ net8.0 - Exe From 873548723ecf65b45d40483a3fce4b5fb2e8298d Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 20 Mar 2024 09:45:31 +0100 Subject: [PATCH 05/91] Fixed missing license update for MacOS installer --- Installer/OSX/LICENSE.html | 566 ++----------------------------------- 1 file changed, 16 insertions(+), 550 deletions(-) diff --git a/Installer/OSX/LICENSE.html b/Installer/OSX/LICENSE.html index e70d93ad4..cc0f89548 100644 --- a/Installer/OSX/LICENSE.html +++ b/Installer/OSX/LICENSE.html @@ -3,558 +3,24 @@ +

+Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: -

GNU LESSER GENERAL PUBLIC LICENSE

-

-Version 2.1, February 1999 -

+The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -
-Copyright (C) 1991, 1999 Free Software Foundation, Inc.
-51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
-Everyone is permitted to copy and distribute verbatim copies
-of this license document, but changing it is not allowed.
-
-[This is the first released version of the Lesser GPL.  It also counts
- as the successor of the GNU Library Public License, version 2, hence
- the version number 2.1.]
-
- - -

Preamble

- -

- The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -Licenses are intended to guarantee your freedom to share and change -free software--to make sure the software is free for all its users. -

-

- This license, the Lesser General Public License, applies to some -specially designated software packages--typically libraries--of the -Free Software Foundation and other authors who decide to use it. You -can use it too, but we suggest you first think carefully about whether -this license or the ordinary General Public License is the better -strategy to use in any particular case, based on the explanations below. -

-

- When we speak of free software, we are referring to freedom of use, -not price. Our General Public Licenses are designed to make sure that -you have the freedom to distribute copies of free software (and charge -for this service if you wish); that you receive source code or can get -it if you want it; that you can change the software and use pieces of -it in new free programs; and that you are informed that you can do -these things. -

-

- To protect your rights, we need to make restrictions that forbid -distributors to deny you these rights or to ask you to surrender these -rights. These restrictions translate to certain responsibilities for -you if you distribute copies of the library or if you modify it. -

-

- For example, if you distribute copies of the library, whether gratis -or for a fee, you must give the recipients all the rights that we gave -you. You must make sure that they, too, receive or can get the source -code. If you link other code with the library, you must provide -complete object files to the recipients, so that they can relink them -with the library after making changes to the library and recompiling -it. And you must show them these terms so they know their rights. -

-

- We protect your rights with a two-step method: (1) we copyright the -library, and (2) we offer you this license, which gives you legal -permission to copy, distribute and/or modify the library. -

-

- To protect each distributor, we want to make it very clear that -there is no warranty for the free library. Also, if the library is -modified by someone else and passed on, the recipients should know -that what they have is not the original version, so that the original -author's reputation will not be affected by problems that might be -introduced by others. -

-

- Finally, software patents pose a constant threat to the existence of -any free program. We wish to make sure that a company cannot -effectively restrict the users of a free program by obtaining a -restrictive license from a patent holder. Therefore, we insist that -any patent license obtained for a version of the library must be -consistent with the full freedom of use specified in this license. -

-

- Most GNU software, including some libraries, is covered by the -ordinary GNU General Public License. This license, the GNU Lesser -General Public License, applies to certain designated libraries, and -is quite different from the ordinary General Public License. We use -this license for certain libraries in order to permit linking those -libraries into non-free programs. -

-

- When a program is linked with a library, whether statically or using -a shared library, the combination of the two is legally speaking a -combined work, a derivative of the original library. The ordinary -General Public License therefore permits such linking only if the -entire combination fits its criteria of freedom. The Lesser General -Public License permits more lax criteria for linking other code with -the library. -

-

- We call this license the "Lesser" General Public License because it -does Less to protect the user's freedom than the ordinary General -Public License. It also provides other free software developers Less -of an advantage over competing non-free programs. These disadvantages -are the reason we use the ordinary General Public License for many -libraries. However, the Lesser license provides advantages in certain -special circumstances. -

-

- For example, on rare occasions, there may be a special need to -encourage the widest possible use of a certain library, so that it becomes -a de-facto standard. To achieve this, non-free programs must be -allowed to use the library. A more frequent case is that a free -library does the same job as widely used non-free libraries. In this -case, there is little to gain by limiting the free library to free -software only, so we use the Lesser General Public License. -

-

- In other cases, permission to use a particular library in non-free -programs enables a greater number of people to use a large body of -free software. For example, permission to use the GNU C Library in -non-free programs enables many more people to use the whole GNU -operating system, as well as its variant, the GNU/Linux operating -system. -

-

- Although the Lesser General Public License is Less protective of the -users' freedom, it does ensure that the user of a program that is -linked with the Library has the freedom and the wherewithal to run -that program using a modified version of the Library. -

-

- The precise terms and conditions for copying, distribution and -modification follow. Pay close attention to the difference between a -"work based on the library" and a "work that uses the library". The -former contains code derived from the library, whereas the latter must -be combined with the library in order to run. -

- - -

TERMS AND CONDITIONS FOR COPYING, -DISTRIBUTION AND MODIFICATION

- - -

-0. -This License Agreement applies to any software library or other -program which contains a notice placed by the copyright holder or -other authorized party saying it may be distributed under the terms of -this Lesser General Public License (also called "this License"). -Each licensee is addressed as "you". -

-

- A "library" means a collection of software functions and/or data -prepared so as to be conveniently linked with application programs -(which use some of those functions and data) to form executables. -

-

- The "Library", below, refers to any such software library or work -which has been distributed under these terms. A "work based on the -Library" means either the Library or any derivative work under -copyright law: that is to say, a work containing the Library or a -portion of it, either verbatim or with modifications and/or translated -straightforwardly into another language. (Hereinafter, translation is -included without limitation in the term "modification".) -

-

- "Source code" for a work means the preferred form of the work for -making modifications to it. For a library, complete source code means -all the source code for all modules it contains, plus any associated -interface definition files, plus the scripts used to control compilation -and installation of the library. -

-

- Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running a program using the Library is not restricted, and output from -such a program is covered only if its contents constitute a work based -on the Library (independent of the use of the Library in a tool for -writing it). Whether that is true depends on what the Library does -and what the program that uses the Library does. -

-

-1. -You may copy and distribute verbatim copies of the Library's -complete source code as you receive it, in any medium, provided that -you conspicuously and appropriately publish on each copy an -appropriate copyright notice and disclaimer of warranty; keep intact -all the notices that refer to this License and to the absence of any -warranty; and distribute a copy of this License along with the -Library. -

-

- You may charge a fee for the physical act of transferring a copy, -and you may at your option offer warranty protection in exchange for a -fee. -

-

-2. -You may modify your copy or copies of the Library or any portion -of it, thus forming a work based on the Library, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: -

- -
    -
  • a) - The modified work must itself be a software library.
  • -
  • b) - You must cause the files modified to carry prominent notices - stating that you changed the files and the date of any change.
  • - -
  • c) - You must cause the whole of the work to be licensed at no - charge to all third parties under the terms of this License.
  • - -
  • d) - If a facility in the modified Library refers to a function or a - table of data to be supplied by an application program that uses - the facility, other than as an argument passed when the facility - is invoked, then you must make a good faith effort to ensure that, - in the event an application does not supply such function or - table, the facility still operates, and performs whatever part of - its purpose remains meaningful. -

    - (For example, a function in a library to compute square roots has - a purpose that is entirely well-defined independent of the - application. Therefore, Subsection 2d requires that any - application-supplied function or table used by this function must - be optional: if the application does not supply it, the square - root function must still compute square roots.)

  • -
- -

-These requirements apply to the modified work as a whole. If identifiable -sections of that work are not derived from the Library, and can be -reasonably considered independent and separate works in themselves, then -this License, and its terms, do not apply to those sections when you -distribute them as separate works. But when you distribute the same -sections as part of a whole which is a work based on the Library, the -distribution of the whole must be on the terms of this License, whose -permissions for other licensees extend to the entire whole, and thus to -each and every part regardless of who wrote it. -

-

-Thus, it is not the intent of this section to claim rights or contest your -rights to work written entirely by you; rather, the intent is to exercise -the right to control the distribution of derivative or collective works -based on the Library. -

-

-In addition, mere aggregation of another work not based on the Library with -the Library (or with a work based on the Library) on a volume of a storage -or distribution medium does not bring the other work under the scope of -this License. -

-

-3. -You may opt to apply the terms of the ordinary GNU General Public -License instead of this License to a given copy of the Library. To do -this, you must alter all the notices that refer to this License, so -that they refer to the ordinary GNU General Public License, version 2, -instead of to this License. (If a newer version than version 2 of the -ordinary GNU General Public License has appeared, then you can specify -that version instead if you wish.) Do not make any other change in -these notices. -

-

- Once this change is made in a given copy, it is irreversible for -that copy, so the ordinary GNU General Public License applies to all -subsequent copies and derivative works made from that copy. -

-

- This option is useful when you wish to copy part of the code of -the Library into a program that is not a library. -

-

-4. -You may copy and distribute the Library (or a portion or -derivative of it, under Section 2) in object code or executable form -under the terms of Sections 1 and 2 above provided that you accompany -it with the complete corresponding machine-readable source code, which -must be distributed under the terms of Sections 1 and 2 above on a -medium customarily used for software interchange. -

-

- If distribution of object code is made by offering access to copy -from a designated place, then offering equivalent access to copy the -source code from the same place satisfies the requirement to -distribute the source code, even though third parties are not -compelled to copy the source along with the object code. -

-

-5. -A program that contains no derivative of any portion of the -Library, but is designed to work with the Library by being compiled or -linked with it, is called a "work that uses the Library". Such a -work, in isolation, is not a derivative work of the Library, and -therefore falls outside the scope of this License. -

-

- However, linking a "work that uses the Library" with the Library -creates an executable that is a derivative of the Library (because it -contains portions of the Library), rather than a "work that uses the -library". The executable is therefore covered by this License. -Section 6 states terms for distribution of such executables. -

-

- When a "work that uses the Library" uses material from a header file -that is part of the Library, the object code for the work may be a -derivative work of the Library even though the source code is not. -Whether this is true is especially significant if the work can be -linked without the Library, or if the work is itself a library. The -threshold for this to be true is not precisely defined by law. -

-

- If such an object file uses only numerical parameters, data -structure layouts and accessors, and small macros and small inline -functions (ten lines or less in length), then the use of the object -file is unrestricted, regardless of whether it is legally a derivative -work. (Executables containing this object code plus portions of the -Library will still fall under Section 6.) -

-

- Otherwise, if the work is a derivative of the Library, you may -distribute the object code for the work under the terms of Section 6. -Any executables containing that work also fall under Section 6, -whether or not they are linked directly with the Library itself. -

-

-6. -As an exception to the Sections above, you may also combine or -link a "work that uses the Library" with the Library to produce a -work containing portions of the Library, and distribute that work -under terms of your choice, provided that the terms permit -modification of the work for the customer's own use and reverse -engineering for debugging such modifications. -

-

- You must give prominent notice with each copy of the work that the -Library is used in it and that the Library and its use are covered by -this License. You must supply a copy of this License. If the work -during execution displays copyright notices, you must include the -copyright notice for the Library among them, as well as a reference -directing the user to the copy of this License. Also, you must do one -of these things: -

- -
    -
  • a) Accompany the work with the complete - corresponding machine-readable source code for the Library - including whatever changes were used in the work (which must be - distributed under Sections 1 and 2 above); and, if the work is an - executable linked with the Library, with the complete - machine-readable "work that uses the Library", as object code - and/or source code, so that the user can modify the Library and - then relink to produce a modified executable containing the - modified Library. (It is understood that the user who changes the - contents of definitions files in the Library will not necessarily - be able to recompile the application to use the modified - definitions.)
  • - -
  • b) Use a suitable shared library mechanism - for linking with the Library. A suitable mechanism is one that - (1) uses at run time a copy of the library already present on the - user's computer system, rather than copying library functions into - the executable, and (2) will operate properly with a modified - version of the library, if the user installs one, as long as the - modified version is interface-compatible with the version that the - work was made with.
  • - -
  • c) Accompany the work with a written offer, - valid for at least three years, to give the same user the - materials specified in Subsection 6a, above, for a charge no more - than the cost of performing this distribution.
  • - -
  • d) If distribution of the work is made by - offering access to copy from a designated place, offer equivalent - access to copy the above specified materials from the same - place.
  • - -
  • e) Verify that the user has already received - a copy of these materials or that you have already sent this user - a copy.
  • -
- -

- For an executable, the required form of the "work that uses the -Library" must include any data and utility programs needed for -reproducing the executable from it. However, as a special exception, -the materials to be distributed need not include anything that is -normally distributed (in either source or binary form) with the major -components (compiler, kernel, and so on) of the operating system on -which the executable runs, unless that component itself accompanies -the executable. -

-

- It may happen that this requirement contradicts the license -restrictions of other proprietary libraries that do not normally -accompany the operating system. Such a contradiction means you cannot -use both them and the Library together in an executable that you -distribute. -

-

-7. You may place library facilities that are a work -based on the Library side-by-side in a single library together with -other library facilities not covered by this License, and distribute -such a combined library, provided that the separate distribution of -the work based on the Library and of the other library facilities is -otherwise permitted, and provided that you do these two things: -

- -
    -
  • a) Accompany the combined library with a copy - of the same work based on the Library, uncombined with any other - library facilities. This must be distributed under the terms of - the Sections above.
  • - -
  • b) Give prominent notice with the combined - library of the fact that part of it is a work based on the - Library, and explaining where to find the accompanying uncombined - form of the same work.
  • -
- -

-8. You may not copy, modify, sublicense, link with, -or distribute the Library except as expressly provided under this -License. Any attempt otherwise to copy, modify, sublicense, link -with, or distribute the Library is void, and will automatically -terminate your rights under this License. However, parties who have -received copies, or rights, from you under this License will not have -their licenses terminated so long as such parties remain in full -compliance. -

-

-9. -You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Library or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Library (or any work based on the -Library), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Library or works based on it. -

-

-10. -Each time you redistribute the Library (or any work based on the -Library), the recipient automatically receives a license from the -original licensor to copy, distribute, link with or modify the Library -subject to these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties with -this License. -

-

-11. -If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Library at all. For example, if a patent -license would not permit royalty-free redistribution of the Library by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Library. -

-

-If any portion of this section is held invalid or unenforceable under any -particular circumstance, the balance of the section is intended to apply, -and the section as a whole is intended to apply in other circumstances. -

-

-It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. -

-

-This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. -

-

-12. -If the distribution and/or use of the Library is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Library under this License may add -an explicit geographical distribution limitation excluding those countries, -so that distribution is permitted only in or among countries not thus -excluded. In such case, this License incorporates the limitation as if -written in the body of this License. -

-

-13. -The Free Software Foundation may publish revised and/or new -versions of the Lesser General Public License from time to time. -Such new versions will be similar in spirit to the present version, -but may differ in detail to address new problems or concerns. -

-

-Each version is given a distinguishing version number. If the Library -specifies a version number of this License which applies to it and -"any later version", you have the option of following the terms and -conditions either of that version or of any later version published by -the Free Software Foundation. If the Library does not specify a -license version number, you may choose any version ever published by -the Free Software Foundation. -

-

-14. -If you wish to incorporate parts of the Library into other free -programs whose distribution conditions are incompatible with these, -write to the author to ask for permission. For software which is -copyrighted by the Free Software Foundation, write to the Free -Software Foundation; we sometimes make exceptions for this. Our -decision will be guided by the two goals of preserving the free status -of all derivatives of our free software and of promoting the sharing -and reuse of software generally. -

-

-NO WARRANTY -

-

-15. -BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO -WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. -EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR -OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY -KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE -LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME -THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. -

-

-16. -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN -WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY -AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU -FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR -CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE -LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING -RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A -FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF -SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH -DAMAGES. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE.

From 3da45c53df7cf3c57aa3e51b570488f5997f407e Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 20 Mar 2024 16:17:17 +0100 Subject: [PATCH 06/91] Initial work on building package folders. Next step is building the installers. --- .gitignore | 4 +- Installer/{OSX => MacOS}/Distribution.xml | 0 Installer/MacOS/Entitlements.plist | 10 + .../{OSX => MacOS}/InstallerComponent.plist | 0 Installer/{OSX => MacOS}/LICENSE.html | 0 .../app-resources}/Duplicati.icns | Bin .../dmg => MacOS/app-resources}/Info.plist | 4 +- .../{OSX => MacOS}/app-scripts/postinstall | 0 .../{OSX => MacOS}/app-scripts/preinstall | 0 .../{OSX => MacOS}/daemon-scripts/postinstall | 0 .../{OSX => MacOS}/daemon-scripts/preinstall | 0 .../com.duplicati.app.launchagent.plist | 0 Installer/{OSX => MacOS}/dmg/build.sh | 0 Installer/{OSX => MacOS}/dmg/make-dmg.sh | 6 +- Installer/{OSX => MacOS}/dmg/template.dmg.bz2 | Bin Installer/{OSX => MacOS}/uninstall.sh | 0 Installer/OSX/artifact_mac.sh | 26 - Installer/OSX/launchers/compile.sh | 10 - Installer/OSX/launchers/duplicati-cli.m | 13 - Installer/OSX/launchers/duplicati-server.m | 13 - Installer/OSX/launchers/duplicati.m | 13 - Installer/OSX/launchers/run-with-mono.h | 8 - Installer/OSX/launchers/run-with-mono.m | 214 ------ Installer/bundleduplicati.sh | 58 -- Installer/debian/artifact_deb.sh | 28 - ReleaseBuilder/.vscode/launch.json | 30 + ReleaseBuilder/.vscode/tasks.json | 15 + ReleaseBuilder/CliCommand/Build.cs | 640 ++++++++++++++++++ ReleaseBuilder/Configuration.cs | 173 +++++ ReleaseBuilder/ConsoleHelper.cs | 65 ++ ReleaseBuilder/EncryptionHelper.cs | 21 + ReleaseBuilder/EnvHelper.cs | 203 ++++++ ReleaseBuilder/PackageTarget.cs | 203 ++++++ ReleaseBuilder/ProcessHelper.cs | 133 ++++ ReleaseBuilder/ProcessRunner.cs | 73 ++ ReleaseBuilder/Program.cs | 56 ++ ReleaseBuilder/ReleaseBuilder.csproj | 15 + ReleaseBuilder/ReleaseBuilder.sln | 25 + 38 files changed, 1671 insertions(+), 388 deletions(-) rename Installer/{OSX => MacOS}/Distribution.xml (100%) create mode 100644 Installer/MacOS/Entitlements.plist rename Installer/{OSX => MacOS}/InstallerComponent.plist (100%) rename Installer/{OSX => MacOS}/LICENSE.html (100%) rename Installer/{OSX/dmg => MacOS/app-resources}/Duplicati.icns (100%) rename Installer/{OSX/dmg => MacOS/app-resources}/Info.plist (90%) rename Installer/{OSX => MacOS}/app-scripts/postinstall (100%) rename Installer/{OSX => MacOS}/app-scripts/preinstall (100%) rename Installer/{OSX => MacOS}/daemon-scripts/postinstall (100%) rename Installer/{OSX => MacOS}/daemon-scripts/preinstall (100%) rename Installer/{OSX => MacOS}/daemon/com.duplicati.app.launchagent.plist (100%) rename Installer/{OSX => MacOS}/dmg/build.sh (100%) rename Installer/{OSX => MacOS}/dmg/make-dmg.sh (93%) rename Installer/{OSX => MacOS}/dmg/template.dmg.bz2 (100%) rename Installer/{OSX => MacOS}/uninstall.sh (100%) delete mode 100755 Installer/OSX/artifact_mac.sh delete mode 100755 Installer/OSX/launchers/compile.sh delete mode 100644 Installer/OSX/launchers/duplicati-cli.m delete mode 100644 Installer/OSX/launchers/duplicati-server.m delete mode 100644 Installer/OSX/launchers/duplicati.m delete mode 100644 Installer/OSX/launchers/run-with-mono.h delete mode 100644 Installer/OSX/launchers/run-with-mono.m delete mode 100644 Installer/bundleduplicati.sh delete mode 100755 Installer/debian/artifact_deb.sh create mode 100644 ReleaseBuilder/.vscode/launch.json create mode 100644 ReleaseBuilder/.vscode/tasks.json create mode 100644 ReleaseBuilder/CliCommand/Build.cs create mode 100644 ReleaseBuilder/Configuration.cs create mode 100644 ReleaseBuilder/ConsoleHelper.cs create mode 100644 ReleaseBuilder/EncryptionHelper.cs create mode 100644 ReleaseBuilder/EnvHelper.cs create mode 100644 ReleaseBuilder/PackageTarget.cs create mode 100644 ReleaseBuilder/ProcessHelper.cs create mode 100644 ReleaseBuilder/ProcessRunner.cs create mode 100644 ReleaseBuilder/Program.cs create mode 100644 ReleaseBuilder/ReleaseBuilder.csproj create mode 100644 ReleaseBuilder/ReleaseBuilder.sln diff --git a/.gitignore b/.gitignore index 359f8d818..20f24ea49 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ binfiles.wxs Installer/debian/*.buildinfo /*.deb -/*.rpm \ No newline at end of file +/*.rpm + +ReleaseBuilder/build-temp/ \ No newline at end of file diff --git a/Installer/OSX/Distribution.xml b/Installer/MacOS/Distribution.xml similarity index 100% rename from Installer/OSX/Distribution.xml rename to Installer/MacOS/Distribution.xml diff --git a/Installer/MacOS/Entitlements.plist b/Installer/MacOS/Entitlements.plist new file mode 100644 index 000000000..384b033ce --- /dev/null +++ b/Installer/MacOS/Entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.automation.apple-events + + + \ No newline at end of file diff --git a/Installer/OSX/InstallerComponent.plist b/Installer/MacOS/InstallerComponent.plist similarity index 100% rename from Installer/OSX/InstallerComponent.plist rename to Installer/MacOS/InstallerComponent.plist diff --git a/Installer/OSX/LICENSE.html b/Installer/MacOS/LICENSE.html similarity index 100% rename from Installer/OSX/LICENSE.html rename to Installer/MacOS/LICENSE.html diff --git a/Installer/OSX/dmg/Duplicati.icns b/Installer/MacOS/app-resources/Duplicati.icns similarity index 100% rename from Installer/OSX/dmg/Duplicati.icns rename to Installer/MacOS/app-resources/Duplicati.icns diff --git a/Installer/OSX/dmg/Info.plist b/Installer/MacOS/app-resources/Info.plist similarity index 90% rename from Installer/OSX/dmg/Info.plist rename to Installer/MacOS/app-resources/Info.plist index 21d44ef69..cfe7a9c23 100644 --- a/Installer/OSX/dmg/Info.plist +++ b/Installer/MacOS/app-resources/Info.plist @@ -10,7 +10,9 @@ Duplicati.icns CFBundleIdentifier com.duplicati.app - CFBundleInfoDictionaryVersion + NSHighResolutionCapable + + CFBundleInfoDictionaryVersion 6.0 CFBundleName Duplicati diff --git a/Installer/OSX/app-scripts/postinstall b/Installer/MacOS/app-scripts/postinstall similarity index 100% rename from Installer/OSX/app-scripts/postinstall rename to Installer/MacOS/app-scripts/postinstall diff --git a/Installer/OSX/app-scripts/preinstall b/Installer/MacOS/app-scripts/preinstall similarity index 100% rename from Installer/OSX/app-scripts/preinstall rename to Installer/MacOS/app-scripts/preinstall diff --git a/Installer/OSX/daemon-scripts/postinstall b/Installer/MacOS/daemon-scripts/postinstall similarity index 100% rename from Installer/OSX/daemon-scripts/postinstall rename to Installer/MacOS/daemon-scripts/postinstall diff --git a/Installer/OSX/daemon-scripts/preinstall b/Installer/MacOS/daemon-scripts/preinstall similarity index 100% rename from Installer/OSX/daemon-scripts/preinstall rename to Installer/MacOS/daemon-scripts/preinstall diff --git a/Installer/OSX/daemon/com.duplicati.app.launchagent.plist b/Installer/MacOS/daemon/com.duplicati.app.launchagent.plist similarity index 100% rename from Installer/OSX/daemon/com.duplicati.app.launchagent.plist rename to Installer/MacOS/daemon/com.duplicati.app.launchagent.plist diff --git a/Installer/OSX/dmg/build.sh b/Installer/MacOS/dmg/build.sh similarity index 100% rename from Installer/OSX/dmg/build.sh rename to Installer/MacOS/dmg/build.sh diff --git a/Installer/OSX/dmg/make-dmg.sh b/Installer/MacOS/dmg/make-dmg.sh similarity index 93% rename from Installer/OSX/dmg/make-dmg.sh rename to Installer/MacOS/dmg/make-dmg.sh index b89b8b5b0..fe3751996 100755 --- a/Installer/OSX/dmg/make-dmg.sh +++ b/Installer/MacOS/dmg/make-dmg.sh @@ -58,11 +58,11 @@ mkdir "Duplicati.app/Contents/Resources" cp -r $SRC "Duplicati.app/Contents/MacOS" # Install the Info.plist and icon, patch the plist file as well -echo Patching "$SCRIPTDIR/Info.plist" -PLIST=$(cat "$SCRIPTDIR/Info.plist") +echo Patching "$SCRIPTDIR/../app-resources/Info.plist" +PLIST=$(cat "$SCRIPTDIR../app-resources/Info.plist") PLIST=${PLIST//!LONG_VERSION!/${VERSION_NUMBER}} echo "${PLIST}" > "Duplicati.app/Contents/Info.plist" -cp "$SCRIPTDIR/Duplicati.icns" "Duplicati.app/Contents/Resources" +cp "$SCRIPTDIR/../app-resources/Duplicati.icns" "Duplicati.app/Contents/Resources" chmod +x "Duplicati.app/Contents/MacOS/Duplicati.GUI.TrayIcon" diff --git a/Installer/OSX/dmg/template.dmg.bz2 b/Installer/MacOS/dmg/template.dmg.bz2 similarity index 100% rename from Installer/OSX/dmg/template.dmg.bz2 rename to Installer/MacOS/dmg/template.dmg.bz2 diff --git a/Installer/OSX/uninstall.sh b/Installer/MacOS/uninstall.sh similarity index 100% rename from Installer/OSX/uninstall.sh rename to Installer/MacOS/uninstall.sh diff --git a/Installer/OSX/artifact_mac.sh b/Installer/OSX/artifact_mac.sh deleted file mode 100755 index 36118bb88..000000000 --- a/Installer/OSX/artifact_mac.sh +++ /dev/null @@ -1,26 +0,0 @@ -# Installation instructions when building locally (March 2023) -# add ppa https://download.mono-project.com/repo/ubuntu stable-focal main -# install p7zip, build-essential, debhelper, dpkg-dev, mono-devel, -# libappindicator0.1-cil-dev, ca-certificates-mono, gtk-sharp2 - -RELEASE_TIMESTAMP=$(date +%Y-%m-%d) - -RELEASE_INC_VERSION=$(cat Updates/build_version.txt) -RELEASE_INC_VERSION=$((RELEASE_INC_VERSION+1)) - -RELEASE_TYPE="canary" - -RELEASE_VERSION="2.0.7.${RELEASE_INC_VERSION}" -RELEASE_NAME="${RELEASE_VERSION}_${RELEASE_TYPE}_${RELEASE_TIMESTAMP}" - -RELEASE_FILE_NAME="duplicati-${RELEASE_NAME}" - -export RUNTMP=$HOME -bash Installer/bundleduplicati.sh $RELEASE_FILE_NAME -mkdir -p $RUNTMP/artifacts -cp $RUNTMP/$RELEASE_FILE_NAME $RUNTMP/artifacts/$RELEASE_FILE_NAME.zip -cd Installer/OSX -bash make-dmg.sh $RUNTMP/$RELEASE_FILE_NAME -mv *.dmg $RUNTMP/artifacts -mv *.pkg $RUNTMP/artifacts -cd ../.. diff --git a/Installer/OSX/launchers/compile.sh b/Installer/OSX/launchers/compile.sh deleted file mode 100755 index 43f89559b..000000000 --- a/Installer/OSX/launchers/compile.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# -fobjc-arc: enables ARC -# -fmodules: enables modules so you can import with `@import AppKit;` -# -mmacosx-version-min=10.6: support older OS X versions, this might increase the binary size - -if [ ! -d "bin" ]; then mkdir bin; fi - -clang run-with-mono.m duplicati.m -fobjc-arc -fmodules -mmacosx-version-min=11.0 -o bin/duplicati -clang run-with-mono.m duplicati-cli.m -fobjc-arc -fmodules -mmacosx-version-min=11.0 -o bin/duplicati-cli -clang run-with-mono.m duplicati-server.m -fobjc-arc -fmodules -mmacosx-version-min=11.0 -o bin/duplicati-server \ No newline at end of file diff --git a/Installer/OSX/launchers/duplicati-cli.m b/Installer/OSX/launchers/duplicati-cli.m deleted file mode 100644 index eb44cec29..000000000 --- a/Installer/OSX/launchers/duplicati-cli.m +++ /dev/null @@ -1,13 +0,0 @@ -#import "run-with-mono.h" - -NSString * const ASSEMBLY = @"Duplicati.CommandLine.exe"; -NSString * const APP_NAME = @"Duplicati.CommandLine"; -int const MONO_VERSION_MAJOR = 5; -int const MONO_VERSION_MINOR = 0; - -int main() { - @autoreleasepool { - return [RunWithMono runAssemblyWithMono:APP_NAME assembly:ASSEMBLY major:MONO_VERSION_MAJOR minor:MONO_VERSION_MINOR]; - } -} - diff --git a/Installer/OSX/launchers/duplicati-server.m b/Installer/OSX/launchers/duplicati-server.m deleted file mode 100644 index eb204a15f..000000000 --- a/Installer/OSX/launchers/duplicati-server.m +++ /dev/null @@ -1,13 +0,0 @@ -#import "run-with-mono.h" - -NSString * const ASSEMBLY = @"Duplicati.Server.exe"; -NSString * const APP_NAME = @"Duplicati.Server"; -int const MONO_VERSION_MAJOR = 5; -int const MONO_VERSION_MINOR = 0; - -int main() { - @autoreleasepool { - return [RunWithMono runAssemblyWithMono:APP_NAME assembly:ASSEMBLY major:MONO_VERSION_MAJOR minor:MONO_VERSION_MINOR]; - } -} - diff --git a/Installer/OSX/launchers/duplicati.m b/Installer/OSX/launchers/duplicati.m deleted file mode 100644 index dd0491fa4..000000000 --- a/Installer/OSX/launchers/duplicati.m +++ /dev/null @@ -1,13 +0,0 @@ -#import "run-with-mono.h" - -NSString * const ASSEMBLY = @"Duplicati.GUI.TrayIcon.exe"; -NSString * const APP_NAME = @"Duplicati"; -int const MONO_VERSION_MAJOR = 5; -int const MONO_VERSION_MINOR = 0; - -int main() { - @autoreleasepool { - return [RunWithMono runAssemblyWithMono:APP_NAME assembly:ASSEMBLY major:MONO_VERSION_MAJOR minor:MONO_VERSION_MINOR]; - } -} - diff --git a/Installer/OSX/launchers/run-with-mono.h b/Installer/OSX/launchers/run-with-mono.h deleted file mode 100644 index 7131bd6df..000000000 --- a/Installer/OSX/launchers/run-with-mono.h +++ /dev/null @@ -1,8 +0,0 @@ -@import Foundation; - -@interface RunWithMono : NSObject { -} - -+ (int) runAssemblyWithMono:(NSString *)appName assembly:(NSString *)assembly major:(int) major minor:(int) minor; - -@end \ No newline at end of file diff --git a/Installer/OSX/launchers/run-with-mono.m b/Installer/OSX/launchers/run-with-mono.m deleted file mode 100644 index c53b87494..000000000 --- a/Installer/OSX/launchers/run-with-mono.m +++ /dev/null @@ -1,214 +0,0 @@ -#import "run-with-mono.h" - -@import Foundation; -@import AppKit; - -NSString * const VERSION_TITLE = @"Cannot launch %@"; -NSString * const VERSION_MSG = @"%@ requires the Mono Framework version %d.%d or later."; -NSString * const DOWNLOAD_URL = @"http://www.mono-project.com/download/stable/#download-mac"; - -// Helper method to see if the user has requested debug output -bool D() { - NSString* v = [[[NSProcessInfo processInfo]environment]objectForKey:@"DEBUG"]; - if (v == nil || v.length == 0 || [v isEqual:@"0"] || [v isEqual:@"false"] || [v isEqual:@"f"]) - return false; - return true; -} - -// Wrapper method to invoke commandline operations and return the string output -NSString *runCommand(NSString *program, NSArray *arguments) { - NSPipe *pipe = [NSPipe pipe]; - NSFileHandle *file = pipe.fileHandleForReading; - - NSTask *task = [[NSTask alloc] init]; - task.launchPath = program; - task.arguments = arguments; - task.standardOutput = pipe; - - [task launch]; - - NSData *data = [file readDataToEndOfFile]; - [file closeFile]; - [task waitUntilExit]; - - NSString *cmdOutput = [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; - if (cmdOutput == nil || cmdOutput.length == 0) - return nil; - - return [cmdOutput stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -} - -// Checks if the Mono version is greater than or equal to the desired version -bool isValidMono(NSString *mono, int major, int minor) { - NSFileManager *fileManager = [NSFileManager defaultManager]; - - if (mono == nil) - return false; - - if (![fileManager fileExistsAtPath:mono] || ![fileManager isExecutableFileAtPath:mono]) - return false; - - NSString *versionInfo = runCommand(mono, @[@"--version"]); - - NSRange rg = [versionInfo rangeOfString:@"Mono JIT compiler version \\d+\\.\\d+" options:NSRegularExpressionSearch]; - if (rg.location != NSNotFound) { - versionInfo = [versionInfo substringWithRange:rg]; - if (D()) NSLog(@"Matched version: %@", versionInfo); - rg = [versionInfo rangeOfString:@"\\d+\\.\\d+" options:NSRegularExpressionSearch]; - if (rg.location != NSNotFound) { - versionInfo = [versionInfo substringWithRange:rg]; - if (D()) NSLog(@"Matched version: %@", versionInfo); - - NSArray *versionComponents = [versionInfo componentsSeparatedByString:@"."]; - if ([versionComponents[0] intValue] < major) - return false; - if ([versionComponents[1] intValue] < minor) - return false; - - return true; - } - } - - return false; -} - -// Attempts to locate a mono with a valid version -NSString *findMono(int major, int minor) { - NSFileManager *fileManager = [NSFileManager defaultManager]; - - NSString *currentMono = runCommand(@"/usr/bin/which", @[@"mono"]); - if (D()) NSLog(@"which mono: %@", currentMono); - - if (isValidMono(currentMono, major, minor)) { - if (D()) NSLog(@"Found mono with: %@", currentMono); - return currentMono; - } - - NSArray *probepaths = @[@"/usr/local/bin/mono", @"/Library/Frameworks/Mono.framework/Versions/Current/Commands/mono", @"/opt/local/bin/mono"]; - for(NSString* probepath in probepaths) { - if (D()) NSLog(@"Trying mono with: %@", probepath); - if (isValidMono(probepath, major, minor)) { - if (D()) NSLog(@"Found mono with: %@", probepath); - return probepath; - } - } - - if (D()) NSLog(@"Failed to find Mono, returning: %@", nil); - return nil; -} - -// Shows the download dialog, prompting to download Mono -void showDownloadMonoDialog(NSString *appName, int major, int minor) { - NSAlert *alert = [[NSAlert alloc] init]; - [alert setInformativeText:[NSString stringWithFormat:VERSION_MSG, appName, major, minor]]; - [alert setMessageText:[NSString stringWithFormat:VERSION_TITLE, appName]]; - [alert addButtonWithTitle:@"Cancel"]; - [alert addButtonWithTitle:@"Download"]; - NSModalResponse btn = [alert runModal]; - if (btn == NSAlertSecondButtonReturn) { - if (D()) NSLog(@"Clicked download"); - runCommand(@"/usr/bin/open", @[DOWNLOAD_URL]); - //[[UIApplication sharedApplication] openURL:[NSURL URLWithString:DOWNLOAD_URL] options:@{} completionHandler:nil]; - } -} - -// Helper method to copy from source to target -void copyStream(NSFileHandle *source, NSFileHandle* target) { - NSData *data; - do - { - data = [source availableData]; - //NSLog(@"Read some data %d", source.fileDescriptor); - [target writeData: data]; - - } while ([data length] > 0); -} - -// Top-level method, finds Mono with an appropriate version and launches the assembly -int runAssemblyWithMono(NSString *appName, NSString *assembly, int major, int minor) { - NSFileManager *fileManager = [NSFileManager defaultManager]; - - NSString *entryFolder = [[NSBundle mainBundle] resourcePath]; - if (D()) NSLog(@"entryFolder: %@", entryFolder); - - NSString *assemblyPath = [NSString pathWithComponents:@[entryFolder, assembly]]; - if (D()) NSLog(@"assemblyPath: %@", assemblyPath); - - if (![fileManager fileExistsAtPath:assemblyPath]) { - NSLog(@"Assembly file not found: %@", assemblyPath); - return 1; - } - - NSString *currentMono = findMono(major, minor); - if (currentMono == nil) { - NSLog(@"No valid mono found!"); - showDownloadMonoDialog(appName, major, minor); - return 1; - } - - if (D()) NSLog(@"Running %@ %@", currentMono, assemblyPath); - - // Copy commandline arguments - NSMutableArray* arguments = [[NSMutableArray alloc] init]; - [arguments addObjectsFromArray:[[NSProcessInfo processInfo] arguments]]; - - // replace the executable-path with the assembly path - [arguments replaceObjectAtIndex:0 withObject:assemblyPath]; - - NSTask *task = [[NSTask alloc] init]; - task.launchPath = currentMono; - task.arguments = arguments; - - // Setup forwarding of stdout - NSPipe *stdout_pipe = [NSPipe pipe]; - NSPipe *stderr_pipe = [NSPipe pipe]; - NSPipe *stdin_pipe = [NSPipe pipe]; - - [task setStandardOutput:stdout_pipe]; - [task setStandardError:stderr_pipe]; - [task setStandardInput:stdin_pipe]; - - NSFileHandle *stdout_source = [stdout_pipe fileHandleForReading]; - NSFileHandle *stderr_source = [stderr_pipe fileHandleForReading]; - NSFileHandle *stdin_target = [stdin_pipe fileHandleForWriting]; - - NSFileHandle *stdout_target = [NSFileHandle fileHandleWithStandardOutput]; - NSFileHandle *stderr_target = [NSFileHandle fileHandleWithStandardError]; - NSFileHandle *stdin_source = [NSFileHandle fileHandleWithStandardInput]; - - [task launch]; - - if (D()) NSLog(@"Setting up stream forwards"); - dispatch_queue_t bgQueue1 = dispatch_queue_create("bgQueue1", NULL); - dispatch_async(bgQueue1, ^{ - copyStream(stdin_source, stdin_target); - [stdin_source closeFile]; - [stdin_target closeFile]; - }); - dispatch_queue_t bgQueue2 = dispatch_queue_create("bgQueue2", NULL); - dispatch_async(bgQueue2, ^{ - copyStream(stdout_source, stdout_target); - [stdout_source closeFile]; - [stdout_target closeFile]; - }); - dispatch_queue_t bgQueue3 = dispatch_queue_create("bgQueue3", NULL); - dispatch_async(bgQueue3, ^{ - copyStream(stderr_source, stderr_target); - [stderr_source closeFile]; - [stderr_target closeFile]; - }); - - if (D()) NSLog(@"Waiting for exit"); - [task waitUntilExit]; - - if (D()) NSLog(@"Returning status code"); - return [task terminationStatus]; -} - -@implementation RunWithMono -+ (int) runAssemblyWithMono:(NSString *)appName assembly:(NSString *)assembly major:(int) major minor:(int) minor { - return runAssemblyWithMono(appName, assembly, major, minor); -} -@end - diff --git a/Installer/bundleduplicati.sh b/Installer/bundleduplicati.sh deleted file mode 100644 index 99323f06d..000000000 --- a/Installer/bundleduplicati.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# ripped from build-release.sh - -# directory where files are stored -UPDATE_SOURCE="${RUNTMP}/tmpinstduplicati" -# zip output to be used by the installers -ZIPRESULT="${RUNTMP}/$1" - -if [ -e "${UPDATE_SOURCE}" ]; then rm -rf "${UPDATE_SOURCE}"; fi -if [ -f "${ZIPRESULT}" ]; then rm -rf "${ZIPRESULT}"; fi - -mkdir -p "${UPDATE_SOURCE}" - -cp -R Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/* "${UPDATE_SOURCE}" -cp -R Duplicati/Server/webroot "${UPDATE_SOURCE}" - -# We copy some files for alphavss manually as they are not picked up by xbuild -mkdir "${UPDATE_SOURCE}/alphavss" -for FN in Duplicati/Library/Snapshots/bin/Release/AlphaVSS.*.dll; do - cp "${FN}" "${UPDATE_SOURCE}/alphavss/" -done - -# Fix for some support libraries not being picked up -for BACKEND in Duplicati/Library/Backend/*; do - if [ -d "${BACKEND}/bin/Release/" ]; then - cp "${BACKEND}/bin/Release/"*.dll "${UPDATE_SOURCE}" - fi -done - -# Install the assembly redirects for all Duplicati .exe files -find "${UPDATE_SOURCE}" -maxdepth 1 -type f -name Duplicati.*.exe -exec cp Installer/AssemblyRedirects.xml {}.config \; - -# Clean some unwanted build files -for FILE in "control_dir" "Duplicati-server.sqlite" "Duplicati.debug.log" "updates"; do - if [ -e "${UPDATE_SOURCE}/${FILE}" ]; then rm -rf "${UPDATE_SOURCE}/${FILE}"; fi -done - -# Clean the localization spam from Azure -for FILE in "de" "es" "fr" "it" "ja" "ko" "ru" "zh-Hans" "zh-Hant"; do - if [ -e "${UPDATE_SOURCE}/${FILE}" ]; then rm -rf "${UPDATE_SOURCE}/${FILE}"; fi -done - -# Clean debug files, if any -rm -rf "${UPDATE_SOURCE}/"*.mdb; -rm -rf "${UPDATE_SOURCE}/"*.pdb; - -# Remove all library docs files -rm -rf "${UPDATE_SOURCE}/"*.xml; - -# Remove all .DS_Store and Thumbs.db files -find . -type f -name ".DS_Store" | xargs rm -rf -find . -type f -name "Thumbs.db" | xargs rm -rf - -# bundle everything info a zip file -pushd "${UPDATE_SOURCE}" -7z a -tzip -r "${ZIPRESULT}" -popd -rm "${UPDATE_SOURCE}" -rf diff --git a/Installer/debian/artifact_deb.sh b/Installer/debian/artifact_deb.sh deleted file mode 100755 index 07fd349a9..000000000 --- a/Installer/debian/artifact_deb.sh +++ /dev/null @@ -1,28 +0,0 @@ -# Installation instructions when building locally (March 2023) -# add ppa https://download.mono-project.com/repo/ubuntu stable-focal main -# install p7zip, build-essential, debhelper, dpkg-dev, mono-devel, -# libappindicator0.1-cil-dev, ca-certificates-mono, gtk-sharp2 - -RELEASE_TIMESTAMP=$(date +%Y-%m-%d) - -RELEASE_INC_VERSION=$(cat Updates/build_version.txt) -RELEASE_INC_VERSION=$((RELEASE_INC_VERSION+1)) - -RELEASE_TYPE="canary" - -RELEASE_VERSION="2.0.7.${RELEASE_INC_VERSION}" -RELEASE_NAME="${RELEASE_VERSION}_${RELEASE_TYPE}_${RELEASE_TIMESTAMP}" - -RELEASE_FILE_NAME="duplicati-${RELEASE_NAME}" - -export RUNTMP=$HOME -ZIPBUILDFILE=$1 -if [ "$ZIPBUILDFILE" == "" ]; then - bash Installer/bundleduplicati.sh $RELEASE_FILE_NAME - ZIPBUILDFILE=$RUNTMP/$RELEASE_FILE_NAME -fi -cd Installer/debian -bash -x make-binary-package.sh $ZIPBUILDFILE -mkdir -p $RUNTMP/artifacts -mv *.deb $RUNTMP/artifacts -cd ../.. diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json new file mode 100644 index 000000000..01e2f1a77 --- /dev/null +++ b/ReleaseBuilder/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "dotnet: build", + "program": "${workspaceFolder}/bin/Debug/net8.0/ReleaseBuilder.dll", + "args": [ + "build", + "--git-stash", "false", + "--targets", "osx-x64.pkg", + "--targets", "osx-x64.dmg", + "--targets", "osx-arm64.dmg", + "--targets", "win-x64.zip", + "--targets", "linux-x64.zip", + "--keep-build", "true" + ], + "env": { + }, + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "internalConsole" + } + ] +} \ No newline at end of file diff --git a/ReleaseBuilder/.vscode/tasks.json b/ReleaseBuilder/.vscode/tasks.json new file mode 100644 index 000000000..85beec3e7 --- /dev/null +++ b/ReleaseBuilder/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "dotnet", + "task": "build", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [], + "label": "dotnet: build" + } + ] +} \ No newline at end of file diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs new file mode 100644 index 000000000..52904f3ea --- /dev/null +++ b/ReleaseBuilder/CliCommand/Build.cs @@ -0,0 +1,640 @@ +using System.CommandLine; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +namespace ReleaseBuilder.CliCommand; + +/// +/// The build command implementation +/// +public static class Build +{ + /// + /// The primary project to build + /// + private const string PrimaryProject = "Duplicati.GUI.TrayIcon.csproj"; + /// + /// Projects that only makes sense for Windows + /// + private static readonly IReadOnlySet WindowsOnlyProjects = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.WindowsService.csproj" }; + + /// + /// Name of the app bundle for MacOS + /// + private const string MacOSAppName = "Duplicati.app"; + + /// + /// Setup of the current runtime information + /// + /// The release info for the current build + /// The keyfile password + private class RuntimeConfig + { + /// + /// Constructs a new + /// + /// The release info to use + /// The keyfile password to use + /// The executables + public RuntimeConfig(ReleaseInfo releaseInfo, string keyfilePassword, IEnumerable executables) + { + ReleaseInfo = releaseInfo; + KeyfilePassword = keyfilePassword; + ExecutableBinaries = executables; + } + + /// + /// The cached password for the pfx file + /// + private string? _pfxPassword = null; + + /// + /// The release info for this run + /// + public ReleaseInfo ReleaseInfo { get; } + + /// + /// The keyfile password for this run + /// + public string KeyfilePassword { get; } + + /// + /// The executables that should exist in the build folder + /// + public IEnumerable ExecutableBinaries { get; } + + /// + /// Gets the PFX password and throws if not possible + /// + public string PfxPassword + => string.IsNullOrWhiteSpace(_pfxPassword) + ? _pfxPassword = GetAuthenticodePassword(KeyfilePassword) + : _pfxPassword; + + /// + /// Cache value for checking if authenticode signing is enabled + /// + private bool? _useAuthenticodeSigning; + + /// + /// Checks if Authenticode signing should be enabled + /// + public void ToggleAuthenticodeSigning() + { + if (!_useAuthenticodeSigning.HasValue) + { + if (Program.Configuration.IsAuthenticodePossible()) + _useAuthenticodeSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for osslsigncode, continue without signing executables?", "Y", "n") == "Y") + { + _useAuthenticodeSigning = false; + return; + } + + throw new Exception("Configuration is not set up for osslsigncode"); + } + } + } + + /// + /// Cache value for checking if codesign is possible + /// + private bool? _useCodeSignSigning; + + /// + /// Checks if codesign is enabled + /// + public void ToggleSignCodeSigning() + { + if (!_useCodeSignSigning.HasValue) + { + if (!OperatingSystem.IsMacOS()) + _useCodeSignSigning = false; + else if (Program.Configuration.IsCodeSignPossible()) + _useCodeSignSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for signcode, continue without signing executables?", "Y", "n") == "Y") + { + _useCodeSignSigning = false; + return; + } + + throw new Exception("Configuration is not set up for signcode"); + } + } + } + + /// + /// Returns a value indicating if signcode is enabled + /// + public bool UseCodeSignSigning => _useCodeSignSigning!.Value; + + /// + /// Returns a value indicating if authenticode signing is enabled + /// + public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value; + + /// + /// Decrypts the password file and returns the PFX password + /// + /// Password for the password file + /// The Authenticode password + private string GetAuthenticodePassword(string keyfilepassword) + => EncryptionHelper.DecryptPasswordFile(Program.Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword); + + /// + /// Performs authenticode signing if enabled + /// + /// The file to sign + /// An awaitable task + public Task AuthenticodeSign(string file) + => UseAuthenticodeSigning + ? ProcessRunner.OsslCodeSign( + Program.Configuration.Commands.OsslSignCode!, + Program.Configuration.ConfigFiles.AuthenticodePfxFile, + PfxPassword, + file) + : Task.CompletedTask; + + /// + /// Performs codesign on the given identity + /// + /// The file to sign + /// The entitlements to apply + /// An awaitable task + public Task Codesign(string file, string entitlements) + => UseCodeSignSigning + ? ProcessRunner.MacOSCodeSign( + Program.Configuration.Commands.Codesign!, + Program.Configuration.ConfigFiles.CodesignIdentity, + entitlements, + file + ) + : Task.CompletedTask; + + } + + /// + /// Structure for keeping all variables for a single release + /// + /// The version to use + /// The release type + /// The release timestamp + private record ReleaseInfo(Version Version, ReleaseType Type, DateTime Timestamp) + { + /// + /// Gets the string name for the release + /// + public string ReleaseName => $"{Version}_{Type.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}"; + + + /// + /// Create a new release info + /// + /// The release type + /// The incremental version + /// The release info + public static ReleaseInfo Create(ReleaseType type, int incVersion) + => new ReleaseInfo(new Version(2, 0, 0, incVersion), type, DateTime.Today); + } + + /// + /// Creates the build command + /// + /// The command + public static Command Create() + { + var buildTargetOption = new Option( + name: "--targets", + description: "The possible build targets, multiple arguments supported. Use the format os-arch.package, example: x64-win.msi.", + parseArgument: arg => + { + if (!arg.Tokens.Any()) + return Program.SupportedPackageTargets.ToArray(); + + var requested = arg.Tokens.Select(x => PackageTarget.ParsePackageId(x.Value)).Distinct().ToArray(); + var invalid = requested.Where(x => !Program.SupportedPackageTargets.Contains(x)).ToList(); + if (invalid.Any()) + throw new Exception($"Following targets are not supported: {string.Join(", ", invalid.Select(x => x.PackageTargetString))}"); + + return requested; + }); + + var releaseTypeOption = new Argument( + name: "type", + description: "The release type", + getDefaultValue: () => ReleaseType.Canary + ); + + var gitStashPushOption = new Option( + name: "--git-stash", + description: "Performs a git stash command before running the build, and a git commit after updating files", + getDefaultValue: () => true + ); + + var keepBuildsOption = new Option( + name: "--keep-build", + description: "Do not delete the build folders if they already exist (re-use build)", + getDefaultValue: () => false + ); + + var buildTempOption = new Option( + name: "--build-path", + description: "The path to the temporary folder used for builds; will be deleted on startup", + getDefaultValue: () => new DirectoryInfo(Path.GetFullPath("build-temp")) + ); + + var solutionFileOption = new Option( + name: "--solution-path", + description: "Path to the Duplicati.sln file", + getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.sln"))) + ); + + var updateUrlsOption = new Option( + name: "--update-urls", + description: "The updater urls where the client will check for updates", + getDefaultValue: () => "https://updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest;https://alt.updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest" + ); + + var command = new Command("build", "Builds the packages for a release") { + gitStashPushOption, + releaseTypeOption, + buildTempOption, + buildTargetOption, + solutionFileOption, + updateUrlsOption, + keepBuildsOption + }; + + command.SetHandler(async (buildTargets, buildTemp, solutionFile, gitStashPush, releaseType, updateUrls, keepBuilds) => + { + Console.WriteLine($"Building {releaseType} release ..."); + + if (!buildTargets.Any()) + buildTargets = Program.SupportedPackageTargets.ToArray(); + + if (!solutionFile.Exists) + throw new FileNotFoundException($"Solution file not found: {solutionFile.FullName}"); + + var baseDir = Path.GetDirectoryName(solutionFile.FullName) ?? throw new Exception("Path to solution file was invalid"); + var versionFilePath = Path.Combine(baseDir, "Updates", "build_version.txt"); + if (!File.Exists(versionFilePath)) + throw new FileNotFoundException($"Version file not found: {versionFilePath}"); + + var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables", "net8"), "*", SearchOption.TopDirectoryOnly) + .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly)) + .ToList(); + + var primary = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable"); + var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Put primary at the end + sourceProjects.Remove(primary); + sourceProjects.Add(primary); + + if (!File.Exists(primary)) + throw new Exception($"Failed to locate project file: {primary}"); + + var releaseInfo = ReleaseInfo.Create(releaseType, int.Parse(File.ReadAllText(versionFilePath)) + 1); + Console.WriteLine($"Building {releaseInfo.ReleaseName} ..."); + + var keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile password"); + + // Configure runtime environment + var rtcfg = new RuntimeConfig(releaseInfo, keyfilePassword, sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList()); + rtcfg.ToggleAuthenticodeSigning(); + rtcfg.ToggleSignCodeSigning(); + + if (!keepBuilds) + { + if (Directory.Exists(buildTemp.FullName)) + { + Console.WriteLine($"Deleting build folder: {buildTemp.FullName}"); + Directory.Delete(buildTemp.FullName, true); + } + } + + if (!Directory.Exists(buildTemp.FullName)) + Directory.CreateDirectory(buildTemp.FullName); + + + if (gitStashPush) + await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir); + + await PrepareSourceDirectory(baseDir, releaseInfo, updateUrls); + + var logFolder = Path.Combine(buildTemp.FullName, "logs"); + Directory.CreateDirectory(logFolder); + + // Get the unique build targets (ignoring the package type) + var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch)).ToArray(); + + if (buildArchTargets.Length == 1) + Console.WriteLine($"Building single release: {buildArchTargets.First().BuildArchString}"); + else + Console.WriteLine($"Building {buildArchTargets.Length} versions"); + + foreach (var target in buildArchTargets) + { + var outputFolder = Path.Combine(buildTemp.FullName, target.BuildArchString); + if (keepBuilds && Directory.Exists(outputFolder)) + { + Console.WriteLine($"Skipping build as output exists for {target.BuildArchString}"); + } + else + { + Console.WriteLine($"Building {target.BuildArchString} ..."); + + foreach (var proj in sourceProjects) + { + if (target.OS != OSType.Windows && windowsOnly.Contains(proj)) + continue; + + var command = new string[] { + "dotnet", "publish", proj, + "-c", "Release", + "-o", outputFolder, + "-r", target.BuildArchString, + $"/p:AssemblyVersion={releaseInfo.Version}", + $"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}", + "--self-contained", "false" + }; + await ProcessHelper.ExecuteWithLog(command, workingDirectory: outputFolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log"); + } + } + + await PrepareTargetDirectory(baseDir, outputFolder, target.OS, target.Arch, rtcfg); + + Console.WriteLine("Completed!"); + } + + Console.WriteLine("Build completed, building installers..."); + + }, buildTargetOption, buildTempOption, solutionFileOption, gitStashPushOption, releaseTypeOption, updateUrlsOption, keepBuildsOption); + + return command; + } + + /// + /// Updates the source directory prior to building + /// + /// The source folder base + /// The release info to use + /// The urls to check for updates + /// An awaitable task + static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, string updateUrls) + { + updateUrls = updateUrls + .Replace("${RELEASE_TYPE}", releaseInfo.Type.ToString().ToLowerInvariant()) + .Replace("${RELEASE_VERSION}", releaseInfo.Version.ToString()) + .Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd")); + + File.WriteAllText(Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"), releaseInfo.Version.ToString()); + File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Type.ToString().ToLowerInvariant()); + File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), updateUrls); + File.Copy( + Path.Combine(baseDir, "Updates", "release_key.txt"), + Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKey.txt"), + true + ); + + 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 BundleMacOSApplication(baseDir, buildDir, rtcfg); + break; + + case OSType.Linux: + 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; + } + + /// + /// 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 (!OperatingSystem.IsWindows()) + { + // Mark executables with the execute flag + var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(binDir, x)) + .Concat(Directory.EnumerateFiles(binDir, "*.sh", SearchOption.AllDirectories)); + var filemode = EnvHelper.GetUnixFileMode("+x"); + foreach (var x in executables) + if (File.Exists(x)) + EnvHelper.AddFilemode(x, filemode); + } + + 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); + } + + // 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/Configuration.cs b/ReleaseBuilder/Configuration.cs new file mode 100644 index 000000000..f97730e33 --- /dev/null +++ b/ReleaseBuilder/Configuration.cs @@ -0,0 +1,173 @@ +namespace ReleaseBuilder; + +using static EnvHelper; + +/// +/// The release types +/// +public enum ReleaseType +{ + /// + /// The primary release form + /// + Stable, + /// + /// Beta releases + /// + Beta, + /// + /// Experimental are slightly less unstable than canary + /// + Experimental, + /// + /// The regular releases, may have breaking changes + /// + Canary, + /// + /// Nightly, unmonitored builds + /// + Nightly +} + +/// +/// Represents the environment configuration +/// +/// The configuration files +/// The commands +public record Configuration( + ConfigFiles ConfigFiles, + Commands Commands +) +{ + /// + /// Creates a new + /// + /// The new configuration + public static Configuration Create() + => new( + ConfigFiles.Create(), + Commands.Create() + ); + + /// + /// Checks if signing with authenticode is possible given the current configuration + /// + /// A boolean indicating if signing is possible + public bool IsAuthenticodePossible() + { + if (string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePasswordFile) || string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePfxFile) || string.IsNullOrWhiteSpace(Commands.OsslSignCode)) + return false; + + if (!File.Exists(ConfigFiles.AuthenticodePasswordFile) || !File.Exists(ConfigFiles.AuthenticodePfxFile)) + return false; + + return true; + } + + /// + /// Checks if signing with MacOS codesign is possible given the current configuration + /// + /// A boolean indicating if codesign is possible + public bool IsCodeSignPossible() + { + if (!OperatingSystem.IsMacOS()) + return false; + + if (string.IsNullOrWhiteSpace(ConfigFiles.CodesignIdentity) || string.IsNullOrWhiteSpace(Commands.Codesign)) + return false; + + return true; + } +} + +/// +/// Configuration files used by the build script +/// +/// The key file used to sign manifests +/// The GPG key used to build signed hash files +/// The PFX file used to sign binaries +/// The encrypted file containing the password used to unlock the PFX file +/// The token used for Github uploads +/// The token used for Discourse forum announce +/// The identity to use for MacOS signing +/// The username for MacOS notarization +/// The password for MacOS notarization +public record ConfigFiles( + string UpdaterKeyfile, + string GpgKeyfile, + string AuthenticodePfxFile, + string AuthenticodePasswordFile, + string GithubTokenFile, + string DiscourseTokenFile, + string CodesignIdentity, + string NotarizeUsername, + string NotarizePassword +) +{ + /// + /// Generates a new config files instance + /// + /// The config files instance + + public static ConfigFiles Create() + { + var gatekeeperSettingsFile = ExpandEnv("GATEKEEPER_SETTINGS_FILE", "${HOME}/.config/signkeys/Duplicati/macos-gatekeeper"); + if (File.Exists(gatekeeperSettingsFile)) + { + var kvp = File.ReadAllLines(gatekeeperSettingsFile) + .Where(x => !string.IsNullOrWhiteSpace(x) && x.StartsWith("export ")) + .Select(x => x.Substring("export ".Length).Trim().Split("=", 2)) + .Where(x => x.Length == 2) + .Select(x => new { Key = x[0], Value = x[1] }); + + foreach (var k in kvp) + Environment.SetEnvironmentVariable(k.Key, k.Value); + } + + return new( + ExpandEnv("UPDATER_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-release.key"), + ExpandEnv("GPG_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-gpgkey.key"), + ExpandEnv("AUTHENTICODE_PFXFILE", "${HOME}/.config/signkeys/Duplicati/authenticode.pfx"), + ExpandEnv("AUTHENTICODE_PASSWORD", "${HOME}/.config/signkeys/Duplicati/authenticode.key"), + ExpandEnv("GITHUB_TOKEN_FILE", "${HOME}/.config/github-api-token"), + ExpandEnv("DISCOURSE_TOKEN_FILE", "${HOME}/.config/discourse-api-token"), + ExpandEnv("CODESIGN_IDENTITY", ""), + ExpandEnv("NOTARIZE_USERNAME", ""), + ExpandEnv("NOTARIZE_PASSWORD", "@keychain:NOTARIZE_CMDLINE") + ); + } +} + +/// +/// Configuration of commands used by the build script +/// +/// The "build" command +/// The "gpg" command +/// The "aws" command +/// The "github-release" command +/// The "osslsigncode" command +/// The "codesign" command +public record Commands( + string Dotnet, + string? Gpg, + string? AwsCli, + string? GithubRelease, + string? OsslSignCode, + string? Codesign +) +{ + /// + /// Generates a new command instance + /// + /// The command instance + public static Commands Create() + => new( + FindCommand("dotnet", "DOTNET") ?? throw new Exception("Failed to find the \"dotnet\" command"), + FindCommand("gpg2", "GPG", FindCommand("gpg", "GPG")), + FindCommand("aws", "AWSCLI"), + FindCommand("github-release", "GITHUB_RELEASE"), + FindCommand(OperatingSystem.IsWindows() ? "signtool.exe" : "osslsigncode", "SIGNTOOL"), + OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null + ); +} + diff --git a/ReleaseBuilder/ConsoleHelper.cs b/ReleaseBuilder/ConsoleHelper.cs new file mode 100644 index 000000000..50ca2789a --- /dev/null +++ b/ReleaseBuilder/ConsoleHelper.cs @@ -0,0 +1,65 @@ +using System.Runtime.InteropServices; + +namespace ReleaseBuilder; + +public static class ConsoleHelper +{ + /// + /// Helper method to request that the user chooses an option + /// + /// The prompt to display + /// The allowed options + /// The selected option + public static string ReadInput(string prompt, params string[] options) + { + while (true) + { + Console.WriteLine($"{prompt} [{string.Join("/", options)}]:"); + var r = Console.ReadLine(); + if (r == null) + throw new TaskCanceledException(); + r = r.Trim(); + + var m = options.FirstOrDefault(x => string.Equals(x, r, StringComparison.OrdinalIgnoreCase)); + if (m != null) + return m; + + Console.WriteLine($"Input not accepted: {r}"); + } + } + + /// + /// Read a password from the console + /// + /// The text to show the user + /// The password + public static string ReadPassword(string prompt) + { + Console.WriteLine(prompt); + + // From: https://stackoverflow.com/a/3404522 + var pass = string.Empty; + ConsoleKey key; + do + { + var keyInfo = Console.ReadKey(intercept: true); + key = keyInfo.Key; + + if (key == ConsoleKey.Backspace && pass.Length > 0) + { + Console.Write("\b \b"); + pass = pass[0..^1]; + } + else if (!char.IsControl(keyInfo.KeyChar)) + { + if (OperatingSystem.IsWindows()) + Console.Write("*"); + + pass += keyInfo.KeyChar; + } + } while (key != ConsoleKey.Enter); + + return Console.ReadLine() ?? string.Empty; + } + +} diff --git a/ReleaseBuilder/EncryptionHelper.cs b/ReleaseBuilder/EncryptionHelper.cs new file mode 100644 index 000000000..6672ca705 --- /dev/null +++ b/ReleaseBuilder/EncryptionHelper.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace ReleaseBuilder; + +public static class EncryptionHelper +{ + /// + /// Decrypts the contents of the password file, using the given password and returns the file contents as a string + /// + /// The password file to decrypt + /// The password to decrypt with + /// The file contents + public static string DecryptPasswordFile(string passwordfile, string password) + { + using var ms = new MemoryStream(); + using var fs = File.OpenRead(passwordfile); + SharpAESCrypt.SharpAESCrypt.Decrypt(password, fs, ms); + + return Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/ReleaseBuilder/EnvHelper.cs b/ReleaseBuilder/EnvHelper.cs new file mode 100644 index 000000000..011e76184 --- /dev/null +++ b/ReleaseBuilder/EnvHelper.cs @@ -0,0 +1,203 @@ +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +namespace ReleaseBuilder; + +/// +/// Static methods for working with environment variables +/// +public static class EnvHelper +{ + /// + /// Reads the environment key, and expands environment variables inside. + /// If no key is found, the default value is returned + /// + /// The key to use + /// The default value if the key is not set + /// The expanded string + public static string ExpandEnv(string key, string defaultValue) + { + var value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + value = defaultValue ?? string.Empty; + + // Bash-style env expansion "${name}", done after normal env expansion + return Regex.Replace(Environment.ExpandEnvironmentVariables(value), "\\${(?[^}]+)}", m => + Environment.GetEnvironmentVariable(m.Groups["name"].Value) ?? string.Empty + ); + } + + /// + /// Returns an executable path + /// + /// The path to expand + /// The executable path + public static string GetExecutablePath(string path) + => string.IsNullOrWhiteSpace(path) + ? path + : OperatingSystem.IsWindows() + ? Path.ChangeExtension(path, ".exe") + : path; + + /// + /// Returns a value if the path is executable + /// + /// The path to execute + /// true if the path is executable; false otherwise + public static bool IsExecutable(string path) + { + if (!File.Exists(path)) + return false; + + if (OperatingSystem.IsWindows()) + return path.EndsWith(".exe"); + + return File.GetUnixFileMode(path).HasFlag(UnixFileMode.OtherExecute); + } + + /// + /// Attempts to find the executable with the given name + /// + /// The command name + /// The env key for overrides + /// The default value + /// The command, or null + public static string? FindCommand(string command, string? envkey, string? defaultValue = null) + { + if (!string.IsNullOrWhiteSpace(envkey)) + { + var target = GetExecutablePath(ExpandEnv(envkey, "")); + + if (!string.IsNullOrWhiteSpace(target)) + { + if (!File.Exists(target)) + throw new Exception($"Executable specified for {envkey} but not found: {target}"); + if (!IsExecutable(target)) + throw new Exception($"File specified for {envkey} found but is not executable: {target}"); + + return target; + } + } + + var folders = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator); + return folders + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => GetExecutablePath(Path.Combine(x, command))) + .FirstOrDefault(IsExecutable) + ?? defaultValue; + } + + /// + /// Copies the contents of into . + /// The must exist, and the contents are copied, not the folder itself. + /// The can exist, in which case the contents are not deleted, but overwritten (merged) + /// + /// The directory to copy + /// + /// + /// + public static void CopyDirectory(string sourceDir, string targetPath, bool recursive) + { + if (!Directory.Exists(sourceDir)) + throw new Exception($"Directory is missing: {sourceDir}"); + + var sourceStr = sourceDir; + var targetStr = targetPath; + + if (!sourceStr.EndsWith(Path.DirectorySeparatorChar)) + sourceStr += Path.DirectorySeparatorChar; + + if (!targetStr.EndsWith(Path.DirectorySeparatorChar)) + targetStr += Path.DirectorySeparatorChar; + + if (!Directory.Exists(targetPath)) + Directory.CreateDirectory(targetPath); + + foreach (var f in Directory.EnumerateFileSystemEntries(sourceDir, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)) + { + if (File.Exists(f)) + File.Copy(f, targetStr + f.Substring(sourceStr.Length), true); + if (recursive && Directory.Exists(f)) + { + var tg = targetStr + f.Substring(sourceStr.Length); + if (!Directory.Exists(tg)) + Directory.CreateDirectory(tg); + } + } + } + + /// + /// Returns the unix file mode pattern represented by the mode string + /// + /// The unix mode string, e.g. "+x" + /// The unix file mode + public static UnixFileMode GetUnixFileMode(string modestr) + { + var current = UnixFileMode.None; + + var mmatch = Regex.Match(modestr, @"^((?[augo]{0,3})(?\+|\-)(?[rwx]{1,3}))$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + if (!mmatch.Success) + throw new Exception($"Invalid mode string: {modestr}"); + + var who = mmatch.Groups["who"].Value.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(who) || who.Contains('a')) + who = "ugo"; + + var op = mmatch.Groups["op"].Value; + var mode = mmatch.Groups["mode"].Value.ToLowerInvariant(); + + foreach (var m in mode) + foreach (var w in who) + { + var p = $"{w}{m}" switch + { + "ur" => UnixFileMode.UserRead, + "uw" => UnixFileMode.UserWrite, + "ux" => UnixFileMode.UserExecute, + "gr" => UnixFileMode.GroupRead, + "gw" => UnixFileMode.GroupWrite, + "gx" => UnixFileMode.GroupExecute, + "or" => UnixFileMode.OtherRead, + "ow" => UnixFileMode.OtherWrite, + "ox" => UnixFileMode.OtherExecute, + _ => throw new Exception("Unsupported bitflag combo") + }; + + current |= p; + } + + return current; + } + + /// + /// Helper function to add unix filemode bits + /// + /// The path to operate on (must exist) + /// The unix file mode + [UnsupportedOSPlatform("windows")] + public static void AddFilemode(string path, UnixFileMode mode) + => File.SetUnixFileMode(path, File.GetUnixFileMode(path) | mode); + + /// + /// Helper function to remove unix filemode bits + /// + /// The path to operate on (must exist) + /// The unix file mode + [UnsupportedOSPlatform("windows")] + public static void RemoveFilemode(string path, UnixFileMode mode) + => File.SetUnixFileMode(path, File.GetUnixFileMode(path) & ~mode); + + /// + /// Helper function to set unix filemode + /// + /// The path to operate on (must exist) + /// The unix mode string, e.g. "+x" + [UnsupportedOSPlatform("windows")] + public static void SetFilemode(string path, string modestr) + { + if (modestr.Contains("+")) + AddFilemode(path, GetUnixFileMode(modestr)); + else + RemoveFilemode(path, GetUnixFileMode(modestr)); + } +} diff --git a/ReleaseBuilder/PackageTarget.cs b/ReleaseBuilder/PackageTarget.cs new file mode 100644 index 000000000..a107c2795 --- /dev/null +++ b/ReleaseBuilder/PackageTarget.cs @@ -0,0 +1,203 @@ +using System.Text.RegularExpressions; + +namespace ReleaseBuilder; + +/// +/// The operating systems we can build for +/// +public enum OSType +{ + /// + /// The Windows OS + /// + Windows, + /// + /// The MacOS + /// + MacOS, + /// + /// Linux, any variant + /// + Linux +} + +/// +/// The system architecture +/// +public enum ArchType +{ + /// + /// An x86 64-bit architecture + /// + x64, + /// + /// An x86 32-bit architecture + /// + x86, + /// + /// The ARM-64 architecture + /// + Arm64, + /// + /// The ARM v7 architecture + /// + Arm7 +} + +/// +/// The different package types +/// +public enum PackageType +{ + /// + /// The basic zip package + /// + Zip, + /// + /// The Windows installer format + /// + MSI, + /// + /// The debian package format + /// + Deb, + /// + /// The Redhat package manager format + /// + RPM, + /// + /// Docker build + /// + Docker, + /// + /// Apple Disk Image + /// + DMG, + /// + /// The MacOS pkg format + /// + MacPkg, + /// + /// The synology zip format + /// + Synologyzip +} + +/// +/// Mapping of a package target +/// +/// The operating system +/// The CPU architecture +/// The installer package +public record PackageTarget(OSType OS, ArchType Arch, PackageType Package) +{ + /// + /// Returns a string representation of the OS. + /// + /// The operating system + /// The operating system id-string + /// This is using .Net RID: https://learn.microsoft.com/en-us/dotnet/core/rid-catalog + private static string OSToString(OSType os) + => os switch + { + OSType.Windows => "win", + OSType.Linux => "linux", + OSType.MacOS => "osx", + _ => throw new Exception("Not supported OS") + }; + + /// + /// Returns a string representation of the CPU architecture + /// + /// The architecture + /// The CPU architecture id-string + /// This is using .Net RID: https://learn.microsoft.com/en-us/dotnet/core/rid-catalog + private static string ArchToString(ArchType arch) + => arch switch + { + ArchType.x64 => "x64", + ArchType.x86 => "x86", + ArchType.Arm64 => "arm64", + ArchType.Arm7 => "arm7", + _ => throw new Exception("Not supported arch") + }; + + /// + /// Returns a string representation of the package type + /// + /// The package type + /// The package type id-string + private static string PackageToString(PackageType package) + => package switch + { + PackageType.Zip => "zip", + PackageType.MSI => "msi", + PackageType.Deb => "deb", + PackageType.RPM => "rpm", + PackageType.DMG => "dmg", + PackageType.MacPkg => "pkg", + PackageType.Synologyzip => "syno", + PackageType.Docker => "docker", + _ => throw new Exception("Not supported package type") + }; + + + /// + /// Gets the RID string for the operating system + /// + public string OSString => OSToString(OS); + + /// + /// Gets the RID string for the CPU architecture + /// + public string ArchString => ArchToString(Arch); + + /// + /// Gets the id string for the package + /// + public string PackageString => PackageToString(Package); + + /// + /// String map of operating system RIDs + /// + private static Dictionary OSTypeParse = Enum.GetValues().ToDictionary(OSToString, x => x, StringComparer.OrdinalIgnoreCase); + /// + /// String map of architecture RIDs + /// + private static Dictionary ArchTypeParse = Enum.GetValues().ToDictionary(ArchToString, x => x, StringComparer.OrdinalIgnoreCase); + /// + /// String map of package type ids + /// + private static Dictionary PackageTypeParse = Enum.GetValues().ToDictionary(PackageToString, x => x, StringComparer.OrdinalIgnoreCase); + + /// + /// The RID string for .Net build commands + /// + public string BuildArchString => $"{OSString}-{ArchString}"; + + /// + /// The package string for the updater + /// + public string PackageTargetString => $"{BuildArchString}.{PackageString}"; + + /// + /// Parses a string representation of a package target + /// + /// The id to parse + /// The matching the string + public static PackageTarget ParsePackageId(string id) + { + var re = Regex.Match(id, @"(?\w+)-(?\w+)\.(?\w+)"); + if (!re.Success) + throw new Exception($"Invalid package id: {id}"); + + if (!OSTypeParse.TryGetValue(re.Groups["os"].Value, out var os)) + throw new Exception($"Not supported OS type: {re.Groups["os"].Value}"); + if (!ArchTypeParse.TryGetValue(re.Groups["arch"].Value, out var arch)) + throw new Exception($"Not supported Arch type: {re.Groups["arch"].Value}"); + if (!PackageTypeParse.TryGetValue(re.Groups["package"].Value, out var package)) + throw new Exception($"Not supported Package type: {re.Groups["package"].Value}"); + + return new PackageTarget(os, arch, package); + } +} \ No newline at end of file diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs new file mode 100644 index 000000000..705b9a109 --- /dev/null +++ b/ReleaseBuilder/ProcessHelper.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; + +namespace ReleaseBuilder; + +/// +/// Helper methods for executing a commandline program +/// +public static class ProcessHelper +{ + /// + /// Starts a commandline program and waits for it to complete + /// + /// + /// The working directory to run in; null means current directory + /// The cancellation token + /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. + /// Default value is null which will treat anything non-zero as an error + /// An awaitable task + public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null) + { + if (!command.Any()) + throw new ArgumentException("Needs at least one command", nameof(command)); + workingDirectory ??= Environment.CurrentDirectory; + + if (!Directory.Exists(workingDirectory)) + Directory.CreateDirectory(workingDirectory); + + codeIsError ??= (x) => x != 0; + + var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + { + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = workingDirectory, + RedirectStandardError = false, + RedirectStandardOutput = false, + RedirectStandardInput = false, + UseShellExecute = false, + }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (codeIsError(p.ExitCode)) + throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + } + + /// + /// Starts a commandline program and returns the contents of stdout + /// + /// + /// The working directory to run in; null means current directory + /// The cancellation token + /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. + /// Default value is null which will treat anything non-zero as an error + /// The output from stdout + public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null) + { + if (!command.Any()) + throw new ArgumentException("Needs at least one command", nameof(command)); + workingDirectory ??= Environment.CurrentDirectory; + + if (!Directory.Exists(workingDirectory)) + Directory.CreateDirectory(workingDirectory); + + codeIsError ??= (x) => x != 0; + + var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + { + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = workingDirectory, + RedirectStandardError = false, + RedirectStandardOutput = true, + RedirectStandardInput = false, + UseShellExecute = false, + }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + + var t = p.StandardOutput.ReadToEndAsync(cancellationToken); + + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (codeIsError(p.ExitCode)) + throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + + return await t; + } + + /// + /// Starts a commandline program and waits for it to complete + /// + /// + /// The working directory to run in; null means current directory + /// The cancellation token + /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. + /// The folder where the log files are written + /// Function to create custom filenames for the log files + /// Default value is null which will treat anything non-zero as an error + /// The output from stdout + public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null) + { + if (!command.Any()) + throw new ArgumentException("Needs at least one command", nameof(command)); + workingDirectory ??= Environment.CurrentDirectory; + + if (!Directory.Exists(workingDirectory)) + Directory.CreateDirectory(workingDirectory); + + logFolder ??= workingDirectory; + + codeIsError ??= (x) => x != 0; + + var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + { + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = workingDirectory, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = false, + UseShellExecute = false, + }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + + logFilename ??= (pid, isStdOut) => $"{command.First()}-{p.Id}.{(isStdOut ? "stdout" : "stderr")}.log"; + + using var logstdout = File.Create(Path.Combine(logFolder, logFilename(p.Id, true))); + using var logstderr = File.Create(Path.Combine(logFolder, logFilename(p.Id, false))); + + var t1 = p.StandardOutput.BaseStream.CopyToAsync(logstdout, cancellationToken); + var t2 = p.StandardError.BaseStream.CopyToAsync(logstderr, cancellationToken); + + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (codeIsError(p.ExitCode)) + throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + + await t1; + await t2; + } +} diff --git a/ReleaseBuilder/ProcessRunner.cs b/ReleaseBuilder/ProcessRunner.cs new file mode 100644 index 000000000..012120492 --- /dev/null +++ b/ReleaseBuilder/ProcessRunner.cs @@ -0,0 +1,73 @@ +namespace ReleaseBuilder; + +public static class ProcessRunner +{ + /// + /// The hash algorithms used for signing with Authenticode + /// + private static readonly IReadOnlyList OSSLHashAlgs = new[] { "sha1", "sha256" }; + + /// + /// The company name to encode in the Authenticode certificate + /// + private const string OSSLOrganization = "Duplicati"; + /// + /// The url to encode in the Authenticode certificate + /// + private const string OSSLUrl = "https://duplicati.com"; + + /// + /// Performs code signing of the + /// + /// The path to the signcode binary + /// The path to the PFX file + /// The password to decrypt the PFX file + /// The executable to sign, in-place + /// An awaitable task + public static async Task OsslCodeSign(string osslsigncode, string pfxfile, string pfxpassword, string executable) + { + var first = true; + foreach (var hashalg in OSSLHashAlgs) + { + var tmp = Path.GetTempFileName(); + File.Delete(tmp); + + var args = new[] { + osslsigncode, "sign", + "-pkcs12", pfxfile, + "-pass", pfxpassword, + "-n", OSSLOrganization, + "-i", OSSLUrl, + "-h", hashalg, + first ? "" : "-nest", + "-t", $"http://timestamp.digicert.com?alg={hashalg}", + "-in", executable, + "-out", tmp + }; + + await ProcessHelper.Execute(args.Where(x => !string.IsNullOrWhiteSpace(x))); + File.Move(tmp, executable, true); + + first = false; + } + } + + /// + /// Runs MacOS codesign on a single file + /// + /// The path to the codesign binary + /// The identity used for codesign + /// The entitlements to activate for the file + /// The file to sign + /// An awaitable task + public static Task MacOSCodeSign(string codesign, string codesignIdentity, string entitlementFile, string file) + => ProcessHelper.Execute([ + codesign, + "--force", + "--timestamp", + "--options=runtime", + "--entitlements", entitlementFile, + "--sign", codesignIdentity, + file + ]); +} diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs new file mode 100644 index 000000000..e75b9f7f3 --- /dev/null +++ b/ReleaseBuilder/Program.cs @@ -0,0 +1,56 @@ + +using System.CommandLine; + +namespace ReleaseBuilder; + +/// +/// Entry point for the executable +/// +class Program +{ + /// + /// The supported build packages + /// + public static readonly IReadOnlyList SupportedPackageTargets = new[] { + "win-x64.zip", + "win-x64.msi", + "win-x86.zip", + "win-x86.msi", + "win-arm64.zip", + "win-arm64.msi", + "linux-x64.zip", + "linux-x64.deb", + "linux-x64.rpm", + "linux-x64.docker", + "linux-arm64.docker", + "linux-arm64.zip", + "linux-arm64.deb", + "linux-arm64.rpm", + "linux-arm64.syno", + "osx-x64.dmg", + "osx-x64.pkg", + "osx-arm64.dmg", + "osx-arm64.pkg", + } + .Select(x => PackageTarget.ParsePackageId(x)) + .Distinct() + .ToList(); + + /// + /// The environment shared configuration + /// + public static readonly Configuration Configuration = Configuration.Create(); + + /// + /// Invokes the builder + /// + /// + /// + static Task Main(string[] args) + => new RootCommand("Build tool for Duplicati") + { + CliCommand.Build.Create() + }.InvokeAsync(args); + + +} \ No newline at end of file diff --git a/ReleaseBuilder/ReleaseBuilder.csproj b/ReleaseBuilder/ReleaseBuilder.csproj new file mode 100644 index 000000000..64de05873 --- /dev/null +++ b/ReleaseBuilder/ReleaseBuilder.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/ReleaseBuilder/ReleaseBuilder.sln b/ReleaseBuilder/ReleaseBuilder.sln new file mode 100644 index 000000000..9982f8229 --- /dev/null +++ b/ReleaseBuilder/ReleaseBuilder.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReleaseBuilder", "ReleaseBuilder.csproj", "{808A85BD-3A99-4A76-BAE9-D496B5FF7AE2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {808A85BD-3A99-4A76-BAE9-D496B5FF7AE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {808A85BD-3A99-4A76-BAE9-D496B5FF7AE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {808A85BD-3A99-4A76-BAE9-D496B5FF7AE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {808A85BD-3A99-4A76-BAE9-D496B5FF7AE2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F5804D51-D1AA-4F11-9318-0AD316A19E84} + EndGlobalSection +EndGlobal From dbfd564a03cfce4162e75e2bb3f473c525143e88 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 22 Mar 2024 12:15:53 +0100 Subject: [PATCH 07/91] Added support for building MSI, DMG and PKG files --- Installer/MacOS/dmg/build.sh | 12 - Installer/MacOS/dmg/make-dmg.sh | 120 ----- Installer/MacOS/{dmg => }/template.dmg.bz2 | Bin Installer/Windows/Duplicati.wxs | 36 +- Installer/Windows/Shortcuts.wxs | 1 - Installer/Windows/UpdateVersion.exe | Bin 5632 -> 0 bytes Installer/Windows/UpdateVersion/Program.cs | 80 --- .../UpdateVersion/UpdateVersion.csproj | 14 - .../Windows/UpdateVersion/UpdateVersion.sln | 19 - Installer/Windows/artifact_win.bat | 47 -- ReleaseBuilder/.vscode/launch.json | 12 +- ReleaseBuilder/CliCommand/Build.cs | 485 ++++++++++++++++-- ReleaseBuilder/Configuration.cs | 42 +- ReleaseBuilder/EnvHelper.cs | 54 +- ReleaseBuilder/PackageTarget.cs | 59 ++- ReleaseBuilder/ProcessHelper.cs | 77 ++- ReleaseBuilder/ProcessRunner.cs | 22 + ReleaseBuilder/Program.cs | 41 +- ReleaseBuilder/WixHeatBuilder.cs | 105 ++++ 19 files changed, 818 insertions(+), 408 deletions(-) delete mode 100755 Installer/MacOS/dmg/build.sh delete mode 100755 Installer/MacOS/dmg/make-dmg.sh rename Installer/MacOS/{dmg => }/template.dmg.bz2 (100%) delete mode 100644 Installer/Windows/UpdateVersion.exe delete mode 100644 Installer/Windows/UpdateVersion/Program.cs delete mode 100644 Installer/Windows/UpdateVersion/UpdateVersion.csproj delete mode 100644 Installer/Windows/UpdateVersion/UpdateVersion.sln delete mode 100644 Installer/Windows/artifact_win.bat create mode 100644 ReleaseBuilder/WixHeatBuilder.cs diff --git a/Installer/MacOS/dmg/build.sh b/Installer/MacOS/dmg/build.sh deleted file mode 100755 index c6fa73b9a..000000000 --- a/Installer/MacOS/dmg/build.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -#This is a helper to make the macos app. It assumes a bin folder exists a the root of the project with an unzipped install - -SCRIPTDIR=$( cd "$(dirname "$0")" ; pwd -P ) - -VERSION=`grep '' < $SCRIPTDIR/../../../Executables/net8/Duplicati.Server/Duplicati.Server.csproj | sed 's/.*\([^\.]*\.[^\.]*\.[^\.]*\).*<\/Version>.*/\1/'` -VERSION=${VERSION//$'\r\n'} -echo "Building version: ($VERSION)" -export VERSION_NUMBER=$VERSION - -$SCRIPTDIR/make-dmg.sh $SCRIPTDIR/../../../bin/ - diff --git a/Installer/MacOS/dmg/make-dmg.sh b/Installer/MacOS/dmg/make-dmg.sh deleted file mode 100755 index fe3751996..000000000 --- a/Installer/MacOS/dmg/make-dmg.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -# -SCRIPTDIR=$( cd "$(dirname "$0")" ; pwd -P ) -SRC=$1 - -WC_DMG=wc.dmg -WC_DIR=wc -TEMPLATE_DMG=template.dmg -OUTPUT_DMG=Duplicati.dmg -UNWANTED_FILES="win-tools control_dir Duplicati.sqlite Duplicati-server.sqlite run-script-example.bat lvm-scripts Duplicati.debug.log" - -TEMPLATE_DMG_BZ2=$(echo "$TEMPLATE_DMG.bz2") -DELETE_DMG=0 - -if [ -f "$SCRIPTDIR/$TEMPLATE_DMG_BZ2" ]; then - if [ -f "$SCRIPTDIR/$TEMPLATE_DMG" ]; then - rm -rf "$SCRIPTDIR/$TEMPLATE_DMG" - fi - - bzip2 --decompress --keep --quiet "$SCRIPTDIR/$TEMPLATE_DMG_BZ2" - DELETE_DMG=1 -fi - -if [ ! -f "$SCRIPTDIR/$TEMPLATE_DMG" ]; then - echo "Template file $TEMPLATE_DMG not found" - exit -fi - -if [ ! -d "$SRC" ]; then - echo "Please supply a source directory as the first argument" - exit -fi - -OUTPUT_DMG="$SRC/../$OUTPUT_DMG" - -if [ -z ${VERSION_NUMBER+x} ]; then - echo "Please set the VERSION_NUMBER environment variable before calling" - VERSION_NUMBER=0.0.0 -fi - -VERSION_NAME="Duplicati" -if [ -e "${OUTPUT_DMG}" ]; then - rm -rf "${OUTPUT_DMG}" -fi - -# Remove any existing work copy -if [ -e "Duplicati.app" ]; then - sudo rm -rf "Duplicati.app" -fi - -# Create folder structure -mkdir "Duplicati.app" -mkdir "Duplicati.app/Contents" -mkdir "Duplicati.app/Contents/MacOS" -mkdir "Duplicati.app/Contents/Resources" - -# Extract the zip into the MacOS folder -cp -r $SRC "Duplicati.app/Contents/MacOS" - -# Install the Info.plist and icon, patch the plist file as well -echo Patching "$SCRIPTDIR/../app-resources/Info.plist" -PLIST=$(cat "$SCRIPTDIR../app-resources/Info.plist") -PLIST=${PLIST//!LONG_VERSION!/${VERSION_NUMBER}} -echo "${PLIST}" > "Duplicati.app/Contents/Info.plist" -cp "$SCRIPTDIR/../app-resources/Duplicati.icns" "Duplicati.app/Contents/Resources" - -chmod +x "Duplicati.app/Contents/MacOS/Duplicati.GUI.TrayIcon" - -# Remove some of the files that we do not like -for FILE in $UNWANTED_FILES -do - if [ -e "Duplicati.app/Contents/MacOS/${FILE}" ] - then - rm -rf "Duplicati.app/Contents/MacOS/${FILE}" - fi -done - -# Set permissions -sudo chown -R root:admin "Duplicati.app" - -# Prepare a new dmg -echo "Building dmg" -if [ "$DELETE_DMG" -eq "1" ] -then - # If we have just extracted the dmg, use that as working copy - WC_DMG=$SCRIPTDIR/$TEMPLATE_DMG -else - # Otherwise we want a copy so we kan keep the original fresh - cp "$SCRIPTDIR/$TEMPLATE_DMG" "$WC_DMG" -fi - -# Make a mount point and mount the new dmg -mkdir -p "$WC_DIR" -hdiutil resize -size 300M "$WC_DMG" -hdiutil attach "$WC_DMG" -noautoopen -quiet -mountpoint "$WC_DIR" - -# Change the dmg name -echo "Setting dmg name to $VERSION_NAME" -diskutil quiet rename wc "$VERSION_NAME" - -# Make the Duplicati.app structure, root folder should exist -if [ -e "$WC_DIR/Duplicati.app" ] -then - rm -rf "$WC_DIR/Duplicati.app" -fi - -# Move in the prepared folder -sudo mv "Duplicati.app" "$WC_DIR/Duplicati.app" - -# Unmount the dmg -hdiutil detach "$WC_DIR" -quiet -force - -# Compress the dmg -hdiutil convert "$WC_DMG" -quiet -format UDZO -imagekey zlib-level=9 -o "${OUTPUT_DMG}" - -# Clean up -rm -rf "$WC_DMG" -rm -rf "$WC_DIR" - -echo "Done, created ${OUTPUT_DMG}" diff --git a/Installer/MacOS/dmg/template.dmg.bz2 b/Installer/MacOS/template.dmg.bz2 similarity index 100% rename from Installer/MacOS/dmg/template.dmg.bz2 rename to Installer/MacOS/template.dmg.bz2 diff --git a/Installer/Windows/Duplicati.wxs b/Installer/Windows/Duplicati.wxs index 19033e3f4..12f5fed9c 100644 --- a/Installer/Windows/Duplicati.wxs +++ b/Installer/Windows/Duplicati.wxs @@ -5,7 +5,7 @@ - + @@ -23,29 +23,9 @@ - + - - - - - - - - - - - - - - - - - @@ -59,23 +39,19 @@ - - - + + - FORSERVICE = "true" - + - FORSERVICE = "true" - + - FORSERVICE = "true" diff --git a/Installer/Windows/Shortcuts.wxs b/Installer/Windows/Shortcuts.wxs index 83b4ad946..a9e47901f 100644 --- a/Installer/Windows/Shortcuts.wxs +++ b/Installer/Windows/Shortcuts.wxs @@ -65,7 +65,6 @@ - \ No newline at end of file diff --git a/Installer/Windows/UpdateVersion.exe b/Installer/Windows/UpdateVersion.exe deleted file mode 100644 index a92fb4528bf432f5f45c6f76ad835850d932009d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5632 zcmeZ`n!v!!z`(%5z`*eTKLf)K1_*F~PJ~^gh7sC0x=jEm>CjU z85jh>2uuh-#Gw?JlcUYR;16OsFfbfp1u@WY0>l|;qJ+5$aHoUSawsq`cz{_DLa!vX zqJ)8g;RrX>Um(9i-3?Iz_F{rwQE^c+NFB&+Aisk=$_G;cp&b|)9Q2A(bMlj+ZcSid zU;vpV0+EN24h#%VFgA={018(Ih})PLSQ+LhGB8}=1ceS3VyD1D6Irh!9|4 z;F8c{5}TzV$iTp*%U`6$$WQ>{LM6meCB$cGKqbUxX$XM~5C)ki0wP2i7`V8Kf*2VJ z*uaXI#Aj)UF)(m(i}8r@YNxVu&QjH7Q>zmPIi-S;p&(y^fq_fMvuGM4LqV}50|OU3 z=PV5=1_mx+7IscSmV9ZDLP;iuf_xbU1}-+Kd|3ttE||C?Ok6{bfq`q0IFB|Lr+5S} zCwpI}(Nqpy{vvHAh60capw1Pa#jYU_GO!RL4mD0gfq{WbOiPYQLy>`jOUo(P%ZU*b zk_?b|Ei=(G&@(nLHnapq1uH`i0|P?}69a>C9RtG!CI$uxW(Ef3;F6-uy!2v_iWjU5 z3~NC0p}`DOIKVl8LD@aj(}jV7VF?EVgAOADgR)ajeiBGAB*`$ki!w2Ag807}jTywi z@yH+nkpm|wMg|53MFs{2P_hNFK^ThUo1vGHjo|~+X+|~%2By=DTnrl+*D^O+aAYm!MaD(X=qbUQ3 z7XT_z7^X42WSqdj$S?)uF@`n?TP8NBUl%ZOGT1UPG6*tUJPnYb7<8ElyZ7!1Lr zC75&slb&EQ5KKmb$wV;O$>7bPz%Y^FFOw?6L|@YlP-ZyJpvR!iaEn2Y!5B!DKYU0|rqBmIg>_2$M=&21+0d48Ir= zj+BCNvr^~iZL|@6y>LsCYNAJIOi8s7Ggrs78f(P zR%8~JfXzrwEMf3Z%1TWxVF*f1Pptq2qiaQKa%o9sUOFt*Fu3LxlvFY>G`tL9$Y981 zC}vP#C}1dJ$Y)4rC}K!t$YoGqC}k*MNMT51C}Bti%O^4@Fz7LqGgL5SGAJ;lF=R62 zFr+dlFeEbMF{CgkFcdSSGL(Q-mM~<1Wy%;*8HyN+88R928S)qu81fiO8FCqtz@iEa zB@FotAQGas7;G!ZJXBSQcuh=Z$Y)3a+m#8n0pu11hE#?MhE#@RhEj$ShD3%Wusa!S zLm5DJq%)*4STQItR5KVb)G{bA*g#do{9^|egZL65#-Q#7_ir9UK0^sO1o9b5!G1@W z$DkSp_8r2{X$)9Y$p(PKI)$N>A(^3s0aXt}L!$+Azz#|bsSFI9 zFj)pyKX7#6a|b9g6B*J`f<}P>5;2hAX0QxJN>rdk0})qXC}+rI03{m*28f$MNfMH1 zsu>I!Y8e<9Vk~6ye)vi_&-upl^_AlDe+(=NjEo%23Ji>_3XF_`f*>v*7dxwvr7Ry4 zhmb7`10y3J3j>3YC$jIPWMnwDSH*-$nmp)a zWnkox<>O$4_8a6twR{MZSa?xlfnR=}YejM@xR4IXD9SG{X5?T5SM&@FLW~T&sD%^* zE4beBWMq)i^K%V>R}DJQGR?NkM9-X&A%sr^F6EM0T#%CpuAqcKdKBO?3MP8y3=C7C zZEa9hxDLbyw?05TR19mALqy}9^NU<7Qo&^^xVB79)l10%wfz_v{;M)5fI0;bm6VfA zpxzRwo7Dhn^93+4fO=C%#)3kVfx!ZcI7ktiVg^uq0ThTCIMg3uW?-1$z`$Sx>VSd_ zU;wdA7#JAB7=jt%8NwJ`8G;ys89W*M8T=UH89W*M7~CMeHHN(`@Be|!2jRqJVNx*K z9%>G#4Fa+o$^}!Pb}u7C7(*gM5d*9s@npzj02K}(1uEcPa0s~Q&0{EL$N?9-kYW=g z&%m&OWfBv}reKB=h9YpDO2=YS9$1Y50}PrlBrzB;Ffd4g+nmk}`3$)XxeTccdEkN? zVzLsFTmiT+uVnCp7RC@23=A#|d<=|m`&_^kLNeGL1q>z7;vJ+GId`KKY7BM^{77b? z%Q7$+GUzcFFz7LuF_<%$Fc>lDfk{&aO9o?*(F`sOLJW)ypil!gN8 z@BzEaA8H1uv;(;d7Rw-4fK@UwrVd(}o!;zsFTxo-< zzRi+jz+1v4Q@p9QDLL1r&6}dTi#kNYNMR``m$r-7+iN(6P znaM@@#rbI^y2<&uR*A*AdS!-63b~1SnQ5uTC9t9qY@Z!3mx2P^Fwd0Kypqh4N)$Kg z8GziCmzbMstK?hhSWu9Y327DTB^DGY=|fB?DJm^4@ytuhC(=DeN??5o3O2>5$)!b^ zC6!RVf`Uy^YGG+=aY<@QKv8B{W=?8)YB7S z#h!U(`Pr#ON(!Z!j>*ZX#l^NtX^A<-sZdXW4bjKq8mLok^ikYyqYn=RJ6 ", Path.GetFileName(Assembly.GetExecutingAssembly().Location)); - return 2; - } - - if (!File.Exists(args[0])) - { - Console.WriteLine("File not found: {0}", args[0]); - return 2; - } - - if (!File.Exists(args[1])) - { - Console.WriteLine("File not found: {0}", args[1]); - return 2; - } - - var asm = Assembly.ReflectionOnlyLoadFrom(Path.GetFullPath(args[0])); - var version = asm.GetName().Version; - Console.WriteLine("Version found: {0}", version); - - var lines = File.ReadAllLines(args[1]); - var found = false; - for (var i = 0; i < lines.Length; i++) - { - if ((lines[i] ?? string.Empty).Contains("ProductVersion")) - { - var m = new Regex(@"(?
.*ProductVersion\s*=\s*\"")(?[^\""]+)(?\"".*)").Match(lines[i]);
-                    lines[i] = m.Groups["pre"] + version.ToString(4) + m.Groups["post"];
-                    found = true;
-                    break;
-                }
-            }
-
-            if (!found)
-            {
-                Console.WriteLine("No ProductVersion tag found in {0}", args[1]);
-                return 2;
-            }
-
-            File.WriteAllLines(args[1], lines);
-            Console.WriteLine("Updated {0} with verison {1}", args[1], version.ToString(4));
-            return 0;
-        }
-    }
-}
diff --git a/Installer/Windows/UpdateVersion/UpdateVersion.csproj b/Installer/Windows/UpdateVersion/UpdateVersion.csproj
deleted file mode 100644
index e63a14db6..000000000
--- a/Installer/Windows/UpdateVersion/UpdateVersion.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-  
-    net8.0    
-  
-
-  
-    
-      all
-      runtime; build; native; contentfiles; analyzers; buildtransitive
-    
-  
-
-
\ No newline at end of file
diff --git a/Installer/Windows/UpdateVersion/UpdateVersion.sln b/Installer/Windows/UpdateVersion/UpdateVersion.sln
deleted file mode 100644
index bed8fefe1..000000000
--- a/Installer/Windows/UpdateVersion/UpdateVersion.sln
+++ /dev/null
@@ -1,19 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 2012
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UpdateVersion", "UpdateVersion.csproj", "{179ADEC2-1856-47FE-954E-64BCD7213373}"
-EndProject
-Global
-	GlobalSection(SolutionConfigurationPlatforms) = preSolution
-		Debug|x86 = Debug|x86
-		Release|x86 = Release|x86
-		Debug|Any CPU = Debug|Any CPU
-		Release|Any CPU = Release|Any CPU
-	EndGlobalSection
-	GlobalSection(ProjectConfigurationPlatforms) = postSolution
-		{179ADEC2-1856-47FE-954E-64BCD7213373}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{179ADEC2-1856-47FE-954E-64BCD7213373}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{179ADEC2-1856-47FE-954E-64BCD7213373}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{179ADEC2-1856-47FE-954E-64BCD7213373}.Release|Any CPU.Build.0 = Release|Any CPU
-	EndGlobalSection
-EndGlobal
diff --git a/Installer/Windows/artifact_win.bat b/Installer/Windows/artifact_win.bat
deleted file mode 100644
index bf7a9e130..000000000
--- a/Installer/Windows/artifact_win.bat
+++ /dev/null
@@ -1,47 +0,0 @@
-@rem start this batch from the root Duplicati git directory
-
-@rem Installation instructions when building locally (March 2023)
-@rem The life expectancy of any URL on MS servers is about one or two years
-@rem so links can become obsolete fast.
-@rem   - setup a Windows 2022 VM 
-@rem     (should work for Win10 but a 2022 VM is valid for 6 months vs 1 for Win10)
-@rem   - install git from https://git-scm.com/download/win
-@rem   - add to the PATH the directory c:\program files\git
-@rem   - install .NET SDK 4.7 from https://dotnet.microsoft.com/en-us/download/visual-studio-sdks
-@rem   - install msbuild (visual studio community)
-@rem   - install nuget from https://learn.microsoft.com/en-us/nuget/install-nuget-client-tools
-@rem   - install wix 3 from https://wixtoolset.org
-@rem   - add to the PATH msbuild, wix3 and nuget
-
-for /f "tokens=2 delims==" %%a in ('wmic os get localdatetime /value') do set dt=%%a
-set RELEASE_TIMESTAMP=%dt:~0,4%-%dt:~4,2%-%dt:~6,2%
-
-set RELEASE_INC_VERSION=$(cat Updates/build_version.txt)
-for /f %%a in ('type updates\build_version.txt') do set RELEASE_INC_VERSION=%%a
-set /a RELEASE_INC_VERSION=%RELEASE_INC_VERSION%+1
-
-set RELEASE_TYPE=canary
-
-set RELEASE_VERSION=2.0.7.%RELEASE_INC_VERSION%
-set RELEASE_NAME=%RELEASE_VERSION%_%RELEASE_TYPE%_%RELEASE_TIMESTAMP%
-
-set RELEASE_FILE_NAME=duplicati-%RELEASE_NAME%
-
-set RUNTMP=%USERPROFILE%
-set ZIPBUILDFILE=%1
-if "%ZIPBUILDFILE%" == "" (
-  where /q bash.exe
-  if ERRORLEVEL 1 (
-    git-bash -x Installer\bundleduplicati.sh %RELEASE_NAME%
-  ) ELSE (
-    bash -x Installer\bundleduplicati.sh %RELEASE_NAME%
-  )
-  set ZIPBUILDFILE=%RUNTMP%\%RELEASE_NAME%
-)
-cd Installer\Windows
-call build-msi %ZIPBUILDFILE%
-mkdir %RUNTMP%\artifacts
-move duplicati.msi %RUNTMP%\artifacts\duplicati-%RELEASE_NAME%.msi
-move duplicati-32bit.msi %RUNTMP%\artifacts\duplicati-32bit-%RELEASE_NAME%.msi
-cd ..\..
-
diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json
index 01e2f1a77..b62179cbc 100644
--- a/ReleaseBuilder/.vscode/launch.json
+++ b/ReleaseBuilder/.vscode/launch.json
@@ -13,11 +13,13 @@
             "args": [ 
                 "build", 
                 "--git-stash", "false", 
-                "--targets", "osx-x64.pkg", 
-                "--targets", "osx-x64.dmg",
-                "--targets", "osx-arm64.dmg",
-                "--targets", "win-x64.zip",                
-                "--targets", "linux-x64.zip",
+                "--targets", "win-x64-gui.msi",
+                "--targets", "win-x64-gui.zip",                
+                "--targets", "linux-x64-gui.zip",
+                "--targets", "osx-x64-gui.dmg",
+                "--targets", "osx-arm64-gui.dmg",
+                "--targets", "osx-x64-gui.pkg", 
+                "--targets", "osx-arm64-gui.pkg", 
                 "--keep-build", "true" 
             ],
             "env": {                
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index 52904f3ea..11de79718 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -1,5 +1,5 @@
 using System.CommandLine;
-using System.Security.Cryptography.X509Certificates;
+using System.IO.Compression;
 using System.Text.RegularExpressions;
 
 namespace ReleaseBuilder.CliCommand;
@@ -10,14 +10,38 @@ namespace ReleaseBuilder.CliCommand;
 public static class Build
 {
     /// 
-    /// The primary project to build
+    /// The primary project to build for GUI builds
     /// 
-    private const string PrimaryProject = "Duplicati.GUI.TrayIcon.csproj";
+    private const string PrimaryGUIProject = "Duplicati.GUI.TrayIcon.csproj";
+
+    /// 
+    /// The secondary project to build for CLI builds
+    /// 
+    private const string PrimaryCLIProject = "Duplicati.CommandLine.csproj";
+
     /// 
     /// Projects that only makes sense for Windows
     /// 
     private static readonly IReadOnlySet WindowsOnlyProjects = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.WindowsService.csproj" };
 
+    /// 
+    /// Projects the pull in GUI dependencies
+    /// 
+    private static readonly IReadOnlySet GUIProjects = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.GUI.TrayIcon.csproj" };
+
+    /// 
+    /// Some executables have shorter names that follow the Linux convention of all-lowercase
+    /// 
+    private static readonly IDictionary ExecutableRenames = new Dictionary(StringComparer.InvariantCultureIgnoreCase)
+    {
+        { "Duplicati.CommandLine", "duplicati-cli" },
+        { "Duplicati.Server", "duplicati-server"},
+        { "Duplicati.CommandLine.BackendTester", "duplicati-backend-tester"},
+        { "Duplicati.CommandLine.BackendTool", "duplicati-backend-tool" },
+        { "Duplicati.CommandLine.RecoveryTool", "duplicati-recovery-tool" },
+        { "Duplicati.GUI.TrayIcon", "duplicati" }
+    };
+
     /// 
     /// Name of the app bundle for MacOS
     /// 
@@ -160,7 +184,7 @@ public static class Build
                 : Task.CompletedTask;
 
         /// 
-        /// Performs codesign on the given identity
+        /// Performs codesign on the given file
         /// 
         /// The file to sign
         /// The entitlements to apply
@@ -175,6 +199,20 @@ public static class Build
                 )
                 : Task.CompletedTask;
 
+        /// 
+        /// Performs productsign on the given file
+        /// 
+        /// The file to sign
+        /// An awaitable task
+        public Task Productsign(string file)
+            => UseCodeSignSigning
+                ? ProcessRunner.MacOSProductSign(
+                    Program.Configuration.Commands.Productsign!,
+                    Program.Configuration.ConfigFiles.CodesignIdentity,
+                    file
+                )
+                : Task.CompletedTask;
+
     }
 
     /// 
@@ -279,6 +317,18 @@ public static class Build
             if (!solutionFile.Exists)
                 throw new FileNotFoundException($"Solution file not found: {solutionFile.FullName}");
 
+            if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible())
+                throw new Exception("WiX toolset not configured, cannot build MSI files");
+
+            if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible())
+                throw new Exception("Synology SPK files are currently not supported");
+
+            if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible())
+            {
+                Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");
+                buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray();
+            }
+
             var baseDir = Path.GetDirectoryName(solutionFile.FullName) ?? throw new Exception("Path to solution file was invalid");
             var versionFilePath = Path.Combine(baseDir, "Updates", "build_version.txt");
             if (!File.Exists(versionFilePath))
@@ -288,15 +338,20 @@ public static class Build
                 .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
                 .ToList();
 
-            var primary = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
+            var primaryGUI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryGUIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
+            var primaryCLI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryCLIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find cli executable");
             var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
 
             // Put primary at the end
-            sourceProjects.Remove(primary);
-            sourceProjects.Add(primary);
+            sourceProjects.Remove(primaryGUI);
+            sourceProjects.Remove(primaryCLI);
+            sourceProjects.Add(primaryCLI);
+            sourceProjects.Add(primaryGUI);
 
-            if (!File.Exists(primary))
-                throw new Exception($"Failed to locate project file: {primary}");
+            if (!File.Exists(primaryGUI))
+                throw new Exception($"Failed to locate project file: {primaryGUI}");
+            if (!File.Exists(primaryCLI))
+                throw new Exception($"Failed to locate project file: {primaryCLI}");
 
             var releaseInfo = ReleaseInfo.Create(releaseType, int.Parse(File.ReadAllText(versionFilePath)) + 1);
             Console.WriteLine($"Building {releaseInfo.ReleaseName} ...");
@@ -321,49 +376,61 @@ public static class Build
                 Directory.CreateDirectory(buildTemp.FullName);
 
 
+            // Generally, the builds should happen with a clean source tree, 
+            // but this can be disabled for debugging
             if (gitStashPush)
                 await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir);
 
+            // 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);
 
             // Get the unique build targets (ignoring the package type)
-            var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch)).ToArray();
+            var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch, x.Interface)).ToArray();
 
             if (buildArchTargets.Length == 1)
-                Console.WriteLine($"Building single release: {buildArchTargets.First().BuildArchString}");
+                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.BuildArchString);
+                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.BuildArchString}");
+                    Console.WriteLine($"Skipping build as output exists for {target.BuildTargetString}");
                 }
                 else
                 {
-                    Console.WriteLine($"Building {target.BuildArchString} ...");
+                    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", outputFolder,
+                            "-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: outputFolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log");
+                        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);
@@ -371,13 +438,342 @@ public static class Build
                 Console.WriteLine("Completed!");
             }
 
-            Console.WriteLine("Build completed, building installers...");
+            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!");
+            }
+
+            Console.WriteLine("Build completed, uploading packages ...");
+
+            Console.WriteLine("Upload completed, releasing packages ...");
+
+            Console.WriteLine("Release completed, posting release notes ...");
+
+            if (gitStashPush)
+            {
+                // Clean up the source tree
+                await ProcessHelper.Execute(new[] {
+                    "git", "checkout",
+                    "Duplicati/License/VersionTag.txt",
+                    "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt",
+                    "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt",
+                    "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);
+            }
+
+            Console.WriteLine("All done");
 
         }, buildTargetOption, buildTempOption, solutionFileOption, gitStashPushOption, releaseTypeOption, updateUrlsOption, keepBuildsOption);
 
         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);
+
+        // 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", "--synthesize", "--package", pkgAppFile, "DistributionApp.xml"],
+            ["productbuild", "--synthesize", "--package", pkgDaemonFile, "DistributionDaemon.xml"],
+            ["productbuild", "--distribution", "DistributionApp.xml", "--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
     /// 
@@ -424,10 +820,12 @@ public static class Build
                 break;
 
             case OSType.MacOS:
+                await MakeSymlinks(buildDir);
                 await BundleMacOSApplication(baseDir, buildDir, rtcfg);
                 break;
 
             case OSType.Linux:
+                await MakeSymlinks(buildDir);
                 break;
 
             default:
@@ -514,6 +912,42 @@ public static class Build
         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
     /// 
@@ -588,17 +1022,6 @@ public static class Build
             overwrite: true
         );
 
-        if (!OperatingSystem.IsWindows())
-        {
-            // Mark executables with the execute flag
-            var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(binDir, x))
-                .Concat(Directory.EnumerateFiles(binDir, "*.sh", SearchOption.AllDirectories));
-            var filemode = EnvHelper.GetUnixFileMode("+x");
-            foreach (var x in executables)
-                if (File.Exists(x))
-                    EnvHelper.AddFilemode(x, filemode);
-        }
-
         if (rtcfg.UseCodeSignSigning)
         {
             var entitlementFile = Path.Combine(installerDir, "Entitlements.plist");
@@ -608,9 +1031,9 @@ public static class Build
             await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile);
         }
 
-        // foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly))
-        //     File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
-
+        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"));
diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs
index f97730e33..7a1d61122 100644
--- a/ReleaseBuilder/Configuration.cs
+++ b/ReleaseBuilder/Configuration.cs
@@ -73,11 +73,41 @@ public record Configuration(
         if (!OperatingSystem.IsMacOS())
             return false;
 
-        if (string.IsNullOrWhiteSpace(ConfigFiles.CodesignIdentity) || string.IsNullOrWhiteSpace(Commands.Codesign))
+        if (string.IsNullOrWhiteSpace(ConfigFiles.CodesignIdentity) || string.IsNullOrWhiteSpace(Commands.Codesign) || string.IsNullOrWhiteSpace(Commands.Productsign))
             return false;
 
         return true;
     }
+
+    /// 
+    /// Checks if building MSI files is possible given the current configuration
+    /// 
+    /// A boolean indicating if MSI building is possible
+    public bool IsMSIBuildPossible()
+    {
+        if (string.IsNullOrWhiteSpace(Commands.Wix))
+            return false;
+
+        return true;
+    }
+
+    /// 
+    /// Checks if building MacOS packages is possible given the current configuration
+    /// 
+    /// A boolean indicating if MacOS package building is possible
+    public bool IsMacPkgBuildPossible()
+    {
+        if (!OperatingSystem.IsMacOS())
+            return false;
+
+        return true;
+    }
+
+    /// 
+    /// Determines if creating a Synology package is possible.
+    /// 
+    /// true if creating a Synology package is possible; otherwise, false.
+    public bool IsSynologyPkgPossible() => false;
 }
 
 /// 
@@ -147,13 +177,17 @@ public record ConfigFiles(
 /// The "github-release" command
 /// The "osslsigncode" command
 /// The "codesign" command
+/// The "productsign" command
+/// The "wix" command
 public record Commands(
     string Dotnet,
     string? Gpg,
     string? AwsCli,
     string? GithubRelease,
     string? OsslSignCode,
-    string? Codesign
+    string? Codesign,
+    string? Productsign,
+    string? Wix
 )
 {
     /// 
@@ -167,7 +201,9 @@ public record Commands(
             FindCommand("aws", "AWSCLI"),
             FindCommand("github-release", "GITHUB_RELEASE"),
             FindCommand(OperatingSystem.IsWindows() ? "signtool.exe" : "osslsigncode", "SIGNTOOL"),
-            OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null
+            OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null,
+            OperatingSystem.IsMacOS() ? FindCommand("productsign", "PRODUCTSIGN") : null,
+            FindCommand(OperatingSystem.IsWindows() ? "wix" : "wixl", "WIX")
         );
 }
 
diff --git a/ReleaseBuilder/EnvHelper.cs b/ReleaseBuilder/EnvHelper.cs
index 011e76184..eeac719c6 100644
--- a/ReleaseBuilder/EnvHelper.cs
+++ b/ReleaseBuilder/EnvHelper.cs
@@ -101,31 +101,61 @@ public static class EnvHelper
         if (!Directory.Exists(sourceDir))
             throw new Exception($"Directory is missing: {sourceDir}");
 
-        var sourceStr = sourceDir;
-        var targetStr = targetPath;
-
-        if (!sourceStr.EndsWith(Path.DirectorySeparatorChar))
-            sourceStr += Path.DirectorySeparatorChar;
-
-        if (!targetStr.EndsWith(Path.DirectorySeparatorChar))
-            targetStr += Path.DirectorySeparatorChar;
-
         if (!Directory.Exists(targetPath))
             Directory.CreateDirectory(targetPath);
 
         foreach (var f in Directory.EnumerateFileSystemEntries(sourceDir, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly))
         {
             if (File.Exists(f))
-                File.Copy(f, targetStr + f.Substring(sourceStr.Length), true);
-            if (recursive && Directory.Exists(f))
+                File.Copy(f, Path.Combine(targetPath, Path.GetRelativePath(sourceDir, f)), true);
+            else if (recursive && Directory.Exists(f))
             {
-                var tg = targetStr + f.Substring(sourceStr.Length);
+                var tg = Path.Combine(Path.Combine(targetPath, Path.GetRelativePath(sourceDir, f)));
                 if (!Directory.Exists(tg))
                     Directory.CreateDirectory(tg);
             }
         }
     }
 
+    /// 
+    /// Changes ownership of the path to the user and group
+    /// 
+    /// The path to operate on
+    /// The user to change to
+    /// The group to change to
+    /// If the operation should be recursive
+    /// An awaitable task
+    [UnsupportedOSPlatform("windows")]
+    public static Task Chown(string path, string user, string group, bool recursive)
+        // TODO: Requires sudo, and the Docker workaround does not work on MacOS
+        => Task.CompletedTask;
+
+    /// 
+    /// Changes ownership of the path to the user and group
+    /// 
+    /// The path to operate on
+    /// The user to change to
+    /// The group to change to
+    /// If the operation should be recursive
+    /// An awaitable task
+    [UnsupportedOSPlatform("windows")]
+    private static async Task ChownWitDocker(string path, string user, string group, bool recursive)
+    {
+        // Get the numeric UID and GID for use in Docker
+        var uid = int.Parse(await ProcessHelper.ExecuteWithOutput(new[] { "id", "-u", user }));
+        var gid = int.Parse(
+            OperatingSystem.IsMacOS()
+                ? (await ProcessHelper.ExecuteWithOutput(["dscl", ".", "-read", $"/Groups/{group}", "PrimaryGroupID"])).Trim().Split(":", 2)[1].Trim()
+                : (await ProcessHelper.ExecuteWithOutput(new[] { "getent", "group", group })).Trim().Split(":", 3)[2]
+        );
+
+        var baseFolder = Path.GetDirectoryName(path);
+        var targetEntry = Path.GetFileName(path);
+
+        // Use docker to set the ownership
+        await ProcessHelper.Execute(new[] { "docker", "run", "--mount", $"type=bind,source={baseFolder},target=/opt/mount", "alpine:latest", "chown", recursive ? "-R" : "", $"{uid}:{gid}", Path.Combine("/opt/mount", targetEntry) });
+    }
+
     /// 
     /// Returns the unix file mode pattern represented by the mode string
     /// 
diff --git a/ReleaseBuilder/PackageTarget.cs b/ReleaseBuilder/PackageTarget.cs
index a107c2795..b51c8f983 100644
--- a/ReleaseBuilder/PackageTarget.cs
+++ b/ReleaseBuilder/PackageTarget.cs
@@ -78,9 +78,24 @@ public enum PackageType
     /// 
     MacPkg,
     /// 
-    /// The synology zip format
+    /// The synology Spk format
     /// 
-    Synologyzip
+    SynologySpk
+}
+
+/// 
+/// The interface type
+/// 
+public enum InterfaceType
+{
+    /// 
+    /// The GUI interface
+    /// 
+    GUI,
+    /// 
+    /// The commandline interface
+    /// 
+    Cli
 }
 
 /// 
@@ -88,8 +103,9 @@ public enum PackageType
 /// 
 /// The operating system
 /// The CPU architecture
+/// The interface type
 /// The installer package
-public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
+public record PackageTarget(OSType OS, ArchType Arch, InterfaceType Interface, PackageType Package)
 {
     /// 
     /// Returns a string representation of the OS.
@@ -136,11 +152,24 @@ public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
             PackageType.RPM => "rpm",
             PackageType.DMG => "dmg",
             PackageType.MacPkg => "pkg",
-            PackageType.Synologyzip => "syno",
+            PackageType.SynologySpk => "spk",
             PackageType.Docker => "docker",
             _ => throw new Exception("Not supported package type")
         };
 
+    /// 
+    /// Returns a string representation of the interface type
+    /// 
+    /// The interface type
+    /// The interface type id-string
+    private static string InterfaceToString(InterfaceType interfaceType)
+        => interfaceType switch
+        {
+            InterfaceType.GUI => "gui",
+            InterfaceType.Cli => "cli",
+            _ => throw new Exception("Not supported interface type")
+        };
+
 
     /// 
     /// Gets the RID string for the operating system
@@ -152,6 +181,11 @@ public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
     /// 
     public string ArchString => ArchToString(Arch);
 
+    /// 
+    /// Gets the id string for the interface
+    /// 
+    public string InterfaceString => InterfaceToString(Interface);
+
     /// 
     /// Gets the id string for the package
     /// 
@@ -169,16 +203,25 @@ public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
     /// String map of package type ids
     /// 
     private static Dictionary PackageTypeParse = Enum.GetValues().ToDictionary(PackageToString, x => x, StringComparer.OrdinalIgnoreCase);
+    /// 
+    /// String map of interface type ids
+    /// 
+    private static Dictionary InterfaceTypeParse = Enum.GetValues().ToDictionary(InterfaceToString, x => x, StringComparer.OrdinalIgnoreCase);
 
     /// 
     /// The RID string for .Net build commands
     /// 
     public string BuildArchString => $"{OSString}-{ArchString}";
 
+    /// 
+    /// The target string for the builds
+    /// 
+    public string BuildTargetString => $"{BuildArchString}-{InterfaceString}";
+
     /// 
     /// The package string for the updater
     /// 
-    public string PackageTargetString => $"{BuildArchString}.{PackageString}";
+    public string PackageTargetString => $"{BuildTargetString}.{PackageString}";
 
     /// 
     /// Parses a string representation of a package target
@@ -187,7 +230,7 @@ public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
     /// The  matching the string
     public static PackageTarget ParsePackageId(string id)
     {
-        var re = Regex.Match(id, @"(?\w+)-(?\w+)\.(?\w+)");
+        var re = Regex.Match(id, @"(?\w+)-(?\w+)-(?\w+)\.(?\w+)");
         if (!re.Success)
             throw new Exception($"Invalid package id: {id}");
 
@@ -195,9 +238,11 @@ public record PackageTarget(OSType OS, ArchType Arch, PackageType Package)
             throw new Exception($"Not supported OS type: {re.Groups["os"].Value}");
         if (!ArchTypeParse.TryGetValue(re.Groups["arch"].Value, out var arch))
             throw new Exception($"Not supported Arch type: {re.Groups["arch"].Value}");
+        if (!InterfaceTypeParse.TryGetValue(re.Groups["int"].Value, out var interfaceType))
+            throw new Exception($"Not supported Interface type: {re.Groups["int"].Value}");
         if (!PackageTypeParse.TryGetValue(re.Groups["package"].Value, out var package))
             throw new Exception($"Not supported Package type: {re.Groups["package"].Value}");
 
-        return new PackageTarget(os, arch, package);
+        return new PackageTarget(os, arch, interfaceType, package);
     }
 }
\ No newline at end of file
diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs
index 705b9a109..f8045b8b4 100644
--- a/ReleaseBuilder/ProcessHelper.cs
+++ b/ReleaseBuilder/ProcessHelper.cs
@@ -31,15 +31,35 @@ public static class ProcessHelper
         {
             WindowStyle = ProcessWindowStyle.Hidden,
             WorkingDirectory = workingDirectory,
-            RedirectStandardError = false,
+            RedirectStandardError = true,
             RedirectStandardOutput = false,
             RedirectStandardInput = false,
             UseShellExecute = false,
         }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
 
+        // Forward error messages to stderr
+        var t = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
+
         await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
         if (codeIsError(p.ExitCode))
             throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}");
+
+        await t.ConfigureAwait(false);
+    }
+
+    /// 
+    /// Runs all commandline tasks in sequence
+    /// 
+    /// The commands to run
+    /// The working directory to run in; null means current directory
+    /// The cancellation token
+    /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
+    /// Default value is null which will treat anything non-zero as an error
+    /// An awaitable task
+    public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    {
+        foreach (var c in commands)
+            await Execute(c, workingDirectory, cancellationToken, codeIsError).ConfigureAwait(false);
     }
 
     /// 
@@ -66,19 +86,64 @@ public static class ProcessHelper
         {
             WindowStyle = ProcessWindowStyle.Hidden,
             WorkingDirectory = workingDirectory,
-            RedirectStandardError = false,
+            RedirectStandardError = true,
             RedirectStandardOutput = true,
             RedirectStandardInput = false,
             UseShellExecute = false,
         }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
 
-        var t = p.StandardOutput.ReadToEndAsync(cancellationToken);
+        var tstdout = p.StandardOutput.ReadToEndAsync(cancellationToken);
+        var tstderr = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
 
         await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
         if (codeIsError(p.ExitCode))
             throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}");
 
-        return await t;
+        await tstderr.ConfigureAwait(false);
+        return await tstdout.ConfigureAwait(false);
+    }
+
+
+    /// 
+    /// Starts a commandline program and returns the contents of stdout
+    /// 
+    /// 
+    /// The stream to write the output to
+    /// The working directory to run in; null means current directory
+    /// The cancellation token
+    /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
+    /// Default value is null which will treat anything non-zero as an error
+    /// The output from stdout
+    public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    {
+        if (!command.Any())
+            throw new ArgumentException("Needs at least one command", nameof(command));
+        workingDirectory ??= Environment.CurrentDirectory;
+
+        if (!Directory.Exists(workingDirectory))
+            Directory.CreateDirectory(workingDirectory);
+
+        codeIsError ??= (x) => x != 0;
+
+        var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1))
+        {
+            WindowStyle = ProcessWindowStyle.Hidden,
+            WorkingDirectory = workingDirectory,
+            RedirectStandardError = true,
+            RedirectStandardOutput = true,
+            RedirectStandardInput = false,
+            UseShellExecute = false,
+        }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
+
+        var tstdout = p.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken);
+        var tstderr = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
+
+        await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+        if (codeIsError(p.ExitCode))
+            throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}");
+
+        await tstderr.ConfigureAwait(false);
+        await tstdout.ConfigureAwait(false);
     }
 
     /// 
@@ -127,7 +192,7 @@ public static class ProcessHelper
         if (codeIsError(p.ExitCode))
             throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}");
 
-        await t1;
-        await t2;
+        await t1.ConfigureAwait(false);
+        await t2.ConfigureAwait(false);
     }
 }
diff --git a/ReleaseBuilder/ProcessRunner.cs b/ReleaseBuilder/ProcessRunner.cs
index 012120492..93a05179a 100644
--- a/ReleaseBuilder/ProcessRunner.cs
+++ b/ReleaseBuilder/ProcessRunner.cs
@@ -70,4 +70,26 @@ public static class ProcessRunner
             "--sign", codesignIdentity,
             file
         ]);
+
+    /// 
+    /// Runs MacOS codesign on a single file
+    /// 
+    /// The path to the productsign binary
+    /// The identity used for codesign
+    /// The entitlements to activate for the file
+    /// The file to sign
+    /// An awaitable task
+    public static async Task MacOSProductSign(string productsign, string codesignIdentity, string file)
+    {
+        var outputfile = file + ".signed";
+
+        await ProcessHelper.Execute([
+            productsign,
+            "--sign", codesignIdentity,
+            file,
+            outputfile
+        ]);
+
+        File.Move(outputfile, file, true);
+    }
 }
diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs
index e75b9f7f3..3ea3f9b19 100644
--- a/ReleaseBuilder/Program.cs
+++ b/ReleaseBuilder/Program.cs
@@ -12,25 +12,26 @@ class Program
     /// The supported build packages
     /// 
     public static readonly IReadOnlyList SupportedPackageTargets = new[] {
-        "win-x64.zip",
-        "win-x64.msi",
-        "win-x86.zip",
-        "win-x86.msi",
-        "win-arm64.zip",
-        "win-arm64.msi",
-        "linux-x64.zip",
-        "linux-x64.deb",
-        "linux-x64.rpm",
-        "linux-x64.docker",
-        "linux-arm64.docker",
-        "linux-arm64.zip",
-        "linux-arm64.deb",
-        "linux-arm64.rpm",
-        "linux-arm64.syno",
-        "osx-x64.dmg",
-        "osx-x64.pkg",
-        "osx-arm64.dmg",
-        "osx-arm64.pkg",
+        "win-x64-gui.zip",
+        "win-x64-gui.msi",
+        "win-x86-gui.zip",
+        "win-x86-gui.msi",
+        "win-arm64-gui.zip",
+        "win-arm64-gui.msi",
+        "linux-x64-gui.zip",
+        "linux-x64-gui.deb",
+        "linux-x64-gui.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.spk",
+        "osx-x64-gui.dmg",
+        "osx-x64-gui.pkg",
+        "osx-arm64-gui.dmg",
+        "osx-arm64-gui.pkg",
     }
     .Select(x => PackageTarget.ParsePackageId(x))
     .Distinct()
@@ -51,6 +52,4 @@ class Program
         {
             CliCommand.Build.Create()
         }.InvokeAsync(args);
-
-
 }
\ No newline at end of file
diff --git a/ReleaseBuilder/WixHeatBuilder.cs b/ReleaseBuilder/WixHeatBuilder.cs
new file mode 100644
index 000000000..2a27fe091
--- /dev/null
+++ b/ReleaseBuilder/WixHeatBuilder.cs
@@ -0,0 +1,105 @@
+using System.Xml;
+
+namespace ReleaseBuilder;
+
+/// 
+/// Implemenation of the "heat" command line tool from Wix
+/// 
+public static class WixHeatBuilder
+{
+    /// 
+    /// Creates a Wix filelist from a directory
+    /// 
+    /// The source folder to create the filelist from
+    /// The name of the directory reference
+    /// The name of the component group
+    /// A function to generate file IDs.
+    /// The wix file xml contents
+    public static string CreateWixFilelist(string sourceFolder, string folderPrefix = "$(var.HarvestPath)", string directoryRefName = "INSTALLLOCATION", string componentGroupId = "DUPLICATIBIN", Func? fileIdGenerator = null)
+    {
+        var itemIds = new Dictionary();
+        fileIdGenerator ??= (x) => Path.GetRelativePath(sourceFolder, x).Replace("\\", "_").Replace("/", "_").Replace(":", "_").Replace(" ", "_");
+        Func pathTransformer = (x) => $"{folderPrefix}{Path.GetRelativePath(sourceFolder, x)}";
+
+        var doc = new XmlDocument();
+        doc.LoadXml("");
+        var root = doc.DocumentElement!;
+        var fragment = doc.CreateElement("Fragment");
+        root.AppendChild(fragment);
+        var directoryRef = doc.CreateElement("DirectoryRef");
+        directoryRef.SetAttribute("Id", directoryRefName);
+        fragment.AppendChild(directoryRef);
+
+        foreach (var f in Directory.EnumerateFileSystemEntries(sourceFolder))
+            if (File.Exists(f))
+                AddFile(doc, directoryRef, f, itemIds, fileIdGenerator, pathTransformer);
+            else if (Directory.Exists(f))
+                AddDirectory(doc, directoryRef, f, itemIds, fileIdGenerator, pathTransformer);
+
+        var fragment2 = doc.CreateElement("Fragment");
+        root.AppendChild(fragment2);
+        var componentGroup = doc.CreateElement("ComponentGroup");
+        componentGroup.SetAttribute("Id", componentGroupId);
+        fragment2.AppendChild(componentGroup);
+
+        foreach (var file in itemIds.Keys)
+        {
+            var componentRef = doc.CreateElement("ComponentRef");
+            componentRef.SetAttribute("Id", itemIds[file]);
+            componentGroup.AppendChild(componentRef);
+        }
+
+        return doc.OuterXml;
+    }
+
+
+    /// 
+    /// Adds a file to the XML document.
+    /// 
+    /// The XML document.
+    /// The XML element representing the directory reference.
+    /// The file to be added.
+    /// The dictionary containing the item IDs.
+    /// The function to generate file IDs.
+    private static void AddFile(XmlDocument doc, XmlElement directoryRef, string file, Dictionary itemIds, Func fileIdGenerator, Func pathTransformer)
+    {
+        var id = fileIdGenerator.Invoke(file);
+        itemIds.Add(file, id);
+
+        var component = doc.CreateElement("Component");
+        component.SetAttribute("Id", id);
+        component.SetAttribute("Guid", "*");
+        directoryRef.AppendChild(component);
+
+        var fileElement = doc.CreateElement("File");
+        fileElement.SetAttribute("Id", id);
+        fileElement.SetAttribute("KeyPath", "yes");
+        fileElement.SetAttribute("Source", pathTransformer(file));
+        component.AppendChild(fileElement);
+    }
+
+    /// 
+    /// Recursively adds a directory and its contents to an XML document.
+    /// 
+    /// The XML document to add the directory to.
+    /// The parent directory reference element.
+    /// The directory path to add.
+    /// A dictionary to store the mapping between directory paths and their generated IDs.
+    /// A function to generate file IDs.
+    private static void AddDirectory(XmlDocument doc, XmlElement directoryRef, string dir, Dictionary itemIds, Func fileIdGenerator, Func pathTransformer)
+    {
+        var id = fileIdGenerator.Invoke(dir);
+
+        var dirName = Path.GetFileName(dir);
+        var directory = doc.CreateElement("Directory");
+        directory.SetAttribute("Id", id);
+        directory.SetAttribute("Name", dirName);
+        directoryRef.AppendChild(directory);
+
+        foreach (var file in Directory.GetFiles(dir))
+            AddFile(doc, directory, file, itemIds, fileIdGenerator, pathTransformer);
+
+        foreach (var subDir in Directory.GetDirectories(dir))
+            AddDirectory(doc, directory, subDir, itemIds, fileIdGenerator, pathTransformer);
+    }
+}
\ No newline at end of file

From 0b0a4491de6d47bbaa94bf7de568ce0e1258dd36 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Fri, 22 Mar 2024 12:16:19 +0100
Subject: [PATCH 08/91] Dangling changes

---
 ReleaseBuilder/CliCommand/Build.cs | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index 11de79718..03bcf0c1a 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -105,6 +105,10 @@ public static class Build
         /// 
         public void ToggleAuthenticodeSigning()
         {
+#if DEBUG
+            _useAuthenticodeSigning = false;
+            return;
+#endif            
             if (!_useAuthenticodeSigning.HasValue)
             {
                 if (Program.Configuration.IsAuthenticodePossible())
@@ -132,6 +136,10 @@ public static class Build
         /// 
         public void ToggleSignCodeSigning()
         {
+#if DEBUG
+            _useCodeSignSigning = false;
+            return;
+#endif
             if (!_useCodeSignSigning.HasValue)
             {
                 if (!OperatingSystem.IsMacOS())
@@ -317,12 +325,15 @@ public static class Build
             if (!solutionFile.Exists)
                 throw new FileNotFoundException($"Solution file not found: {solutionFile.FullName}");
 
+            // This could be fixed, so we will throw an exception if the build is not possible
             if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible())
                 throw new Exception("WiX toolset not configured, cannot build MSI files");
 
+            // This will be fixed in the future, but requires a new http-interface for Synology DSM
             if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible())
                 throw new Exception("Synology SPK files are currently not supported");
 
+            // This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages
             if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible())
             {
                 Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");

From 9c4dcb275cd6e099ce0e84f4d82d1f8e97a1dfc1 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Fri, 22 Mar 2024 12:16:55 +0100
Subject: [PATCH 09/91] Removed debug code

---
 ReleaseBuilder/CliCommand/Build.cs | 8 --------
 1 file changed, 8 deletions(-)

diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index 03bcf0c1a..f830075f7 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -105,10 +105,6 @@ public static class Build
         /// 
         public void ToggleAuthenticodeSigning()
         {
-#if DEBUG
-            _useAuthenticodeSigning = false;
-            return;
-#endif            
             if (!_useAuthenticodeSigning.HasValue)
             {
                 if (Program.Configuration.IsAuthenticodePossible())
@@ -136,10 +132,6 @@ public static class Build
         /// 
         public void ToggleSignCodeSigning()
         {
-#if DEBUG
-            _useCodeSignSigning = false;
-            return;
-#endif
             if (!_useCodeSignSigning.HasValue)
             {
                 if (!OperatingSystem.IsMacOS())

From b4aad976973192c020264c5cacff6f797bc2f750 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Fri, 22 Mar 2024 12:27:13 +0100
Subject: [PATCH 10/91] Fixed pkg build issues

---
 Installer/MacOS/InstallerComponent.plist |  5 -----
 ReleaseBuilder/CliCommand/Build.cs       | 12 +++++++++---
 2 files changed, 9 insertions(+), 8 deletions(-)
 delete mode 100644 Installer/MacOS/InstallerComponent.plist

diff --git a/Installer/MacOS/InstallerComponent.plist b/Installer/MacOS/InstallerComponent.plist
deleted file mode 100644
index 5dd5da85f..000000000
--- a/Installer/MacOS/InstallerComponent.plist
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index f830075f7..10f0a05e0 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -759,14 +759,20 @@ public static class Build
         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", "--synthesize", "--package", pkgAppFile, "DistributionApp.xml"],
-            ["productbuild", "--synthesize", "--package", pkgDaemonFile, "DistributionDaemon.xml"],
-            ["productbuild", "--distribution", "DistributionApp.xml", "--package-path", ".", "--resources", ".", pkgFile]
+            ["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile]
         ], workingDirectory: tmpFolder);
 
         // Clean up

From 3c65db405a551a0218fdfa7d81a0c449c3faac4e Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Fri, 22 Mar 2024 14:24:16 +0100
Subject: [PATCH 11/91] Refactor build code into steps

---
 ReleaseBuilder/.vscode/launch.json            |   4 +-
 .../CliCommand/Build.Compile.Post.cs          | 282 ++++++++
 ReleaseBuilder/CliCommand/Build.Compile.cs    |  89 +++
 .../CliCommand/Build.CreatePackage.cs         | 330 +++++++++
 ReleaseBuilder/CliCommand/Build.GitPush.cs    |  54 ++
 ReleaseBuilder/CliCommand/Build.cs            | 656 +-----------------
 ReleaseBuilder/Program.cs                     |   6 +
 7 files changed, 776 insertions(+), 645 deletions(-)
 create mode 100644 ReleaseBuilder/CliCommand/Build.Compile.Post.cs
 create mode 100644 ReleaseBuilder/CliCommand/Build.Compile.cs
 create mode 100644 ReleaseBuilder/CliCommand/Build.CreatePackage.cs
 create mode 100644 ReleaseBuilder/CliCommand/Build.GitPush.cs

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

From 3fa6977b1f0166d7ca42d28a98c0718646beaf0c Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Fri, 22 Mar 2024 15:20:45 +0100
Subject: [PATCH 12/91] Updated to handle more than 7 options in the command

---
 ReleaseBuilder/.vscode/launch.json   |   7 +-
 ReleaseBuilder/CliCommand/Build.cs   | 290 ++++++++++++++++++---------
 ReleaseBuilder/Configuration.cs      |  23 ++-
 ReleaseBuilder/ConsoleHelper.cs      |   2 -
 ReleaseBuilder/ProcessHelper.cs      |  32 ++-
 ReleaseBuilder/ReleaseBuilder.csproj |   1 +
 6 files changed, 246 insertions(+), 109 deletions(-)

diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json
index b7bb6c9c8..b8794164f 100644
--- a/ReleaseBuilder/.vscode/launch.json
+++ b/ReleaseBuilder/.vscode/launch.json
@@ -12,7 +12,7 @@
             "program": "${workspaceFolder}/bin/Debug/net8.0/ReleaseBuilder.dll",
             "args": [ 
                 "build", 
-                "--git-stash", "false", 
+                "--git-stash-push", "false", 
                 "--targets", "win-x64-gui.msi",
                 "--targets", "win-x64-gui.zip",                
                 "--targets", "linux-x64-gui.zip",
@@ -22,7 +22,10 @@
                 "--targets", "osx-arm64-gui.pkg",
                 "--targets", "linux-x64-gui.deb", 
                 "--targets", "linux-x64-cli.deb", 
-                "--keep-build", "true" 
+                "--keep-builds", "true",
+                "--disable-authenticode", "true",
+                "--disable-signcode", "true",
+                "--password", "unused",
             ],
             "env": {                
             },
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index c33781770..0c0c3aef7 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -1,4 +1,5 @@
 using System.CommandLine;
+using System.CommandLine.NamingConventionBinder;
 
 namespace ReleaseBuilder.CliCommand;
 
@@ -101,10 +102,17 @@ public static partial class Build
         /// 
         /// Checks if Authenticode signing should be enabled
         /// 
-        public void ToggleAuthenticodeSigning()
+        /// If signing should be disabled
+        public void ToggleAuthenticodeSigning(bool disabled)
         {
             if (!_useAuthenticodeSigning.HasValue)
             {
+                if (disabled)
+                {
+                    _useAuthenticodeSigning = false;
+                    return;
+                }
+
                 if (Program.Configuration.IsAuthenticodePossible())
                     _useAuthenticodeSigning = true;
                 else
@@ -128,10 +136,17 @@ public static partial class Build
         /// 
         /// Checks if codesign is enabled
         /// 
-        public void ToggleSignCodeSigning()
+        /// If signing should be disabled
+        public void ToggleSignCodeSigning(bool disabled)
         {
             if (!_useCodeSignSigning.HasValue)
             {
+                if (disabled)
+                {
+                    _useCodeSignSigning = false;
+                    return;
+                }
+
                 if (!OperatingSystem.IsMacOS())
                     _useCodeSignSigning = false;
                 else if (Program.Configuration.IsCodeSignPossible())
@@ -149,6 +164,37 @@ public static partial class Build
             }
         }
 
+        /// 
+        /// Cache value for checking if docker build is enabled
+        /// 
+        private bool? _dockerBuild;
+
+        /// 
+        /// Checks if docker build is enabled
+        /// 
+        public async Task ToggleDockerBuild()
+        {
+            if (!_dockerBuild.HasValue)
+            {
+                try
+                {
+                    var res = await ProcessHelper.ExecuteWithOutput([Program.Configuration.Commands.Docker!, "ps"], suppressStdErr: true);
+                    _dockerBuild = true;
+                }
+                catch
+                {
+
+                    if (ConsoleHelper.ReadInput("Docker does not seem to be running, continue without docker builds?", "Y", "n") == "Y")
+                    {
+                        _dockerBuild = false;
+                        return;
+                    }
+
+                    throw new Exception("Docker is not running, and is required for building Docker images");
+                }
+            }
+        }
+
         /// 
         /// Returns a value indicating if signcode is enabled
         /// 
@@ -159,6 +205,11 @@ public static partial class Build
         /// 
         public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value;
 
+        /// 
+        /// Returns a value indicating if docker build is enabled
+        /// 
+        public bool UseDockerBuild => _dockerBuild!.Value;
+
         /// 
         /// Decrypts the password file and returns the PFX password
         /// 
@@ -219,7 +270,7 @@ public static partial class Build
     /// The version to use
     /// The release type
     /// The release timestamp
-    private record ReleaseInfo(Version Version, ReleaseType Type, DateTime Timestamp)
+    private record ReleaseInfo(Version Version, ReleaseChannel Type, DateTime Timestamp)
     {
         /// 
         /// Gets the string name for the release
@@ -233,7 +284,7 @@ public static partial class Build
         /// The release type
         /// The incremental version
         /// The release info
-        public static ReleaseInfo Create(ReleaseType type, int incVersion)
+        public static ReleaseInfo Create(ReleaseChannel type, int incVersion)
             => new ReleaseInfo(new Version(2, 0, 0, incVersion), type, DateTime.Today);
     }
 
@@ -259,20 +310,20 @@ public static partial class Build
                 return requested;
             });
 
-        var releaseTypeOption = new Argument(
-            name: "type",
-            description: "The release type",
-            getDefaultValue: () => ReleaseType.Canary
+        var releaseChannelOption = new Argument(
+            name: "channel",
+            description: "The release channel",
+            getDefaultValue: () => ReleaseChannel.Canary
         );
 
         var gitStashPushOption = new Option(
-            name: "--git-stash",
+            name: "--git-stash-push",
             description: "Performs a git stash command before running the build, and a git commit after updating files",
             getDefaultValue: () => true
         );
 
         var keepBuildsOption = new Option(
-            name: "--keep-build",
+            name: "--keep-builds",
             description: "Do not delete the build folders if they already exist (re-use build)",
             getDefaultValue: () => false
         );
@@ -284,7 +335,7 @@ public static partial class Build
         );
 
         var solutionFileOption = new Option(
-            name: "--solution-path",
+            name: "--solution-file",
             description: "Path to the Duplicati.sln file",
             getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.sln")))
         );
@@ -295,106 +346,169 @@ public static partial class Build
             getDefaultValue: () => "https://updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest;https://alt.updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest"
         );
 
+        var disableAuthenticodeOption = new Option(
+            name: "--disable-authenticode",
+            description: "Disables authenticode signing",
+            getDefaultValue: () => false
+        );
+
+        var disableCodeSignOption = new Option(
+            name: "--disable-signcode",
+            description: "Disables Apple signcode signing",
+            getDefaultValue: () => false
+        );
+
+        var passwordOption = new Option(
+            name: "--password",
+            description: "The password to use for the keyfile",
+            getDefaultValue: () => string.Empty
+        );
+
         var command = new Command("build", "Builds the packages for a release") {
             gitStashPushOption,
-            releaseTypeOption,
+            releaseChannelOption,
             buildTempOption,
             buildTargetOption,
             solutionFileOption,
             updateUrlsOption,
-            keepBuildsOption
+            keepBuildsOption,
+            disableAuthenticodeOption,
+            disableCodeSignOption,
+            passwordOption
         };
 
-        command.SetHandler(async (buildTargets, buildTemp, solutionFile, gitStashPush, releaseType, updateUrls, keepBuilds) =>
+        command.Handler = CommandHandler.Create(DoBuild);
+        return command;
+    }
+
+    /// 
+    /// The input for the build command
+    /// 
+    /// The build targets
+    /// The build path
+    /// The solution path
+    /// If the git stash should be performed
+    /// The release channel
+    /// The update urls
+    /// If the builds should be kept
+    /// If authenticode signing should be disabled
+    /// If signcode should be disabled
+    /// The password to use for the keyfile
+    record CommandInput(
+        PackageTarget[] Targets,
+        DirectoryInfo BuildPath,
+        FileInfo SolutionFile,
+        bool GitStashPush,
+        ReleaseChannel Channel,
+        string UpdateUrls,
+        bool KeepBuilds,
+        bool DisableAuthenticode,
+        bool DisableSignCode,
+        string Password
+    );
+
+    static async Task DoBuild(CommandInput input)
+    {
+        Console.WriteLine($"Building {input.Channel} release ...");
+
+        var buildTargets = input.Targets;
+
+        if (!buildTargets.Any())
+            buildTargets = Program.SupportedPackageTargets.ToArray();
+
+        if (!input.SolutionFile.Exists)
+            throw new FileNotFoundException($"Solution file not found: {input.SolutionFile.FullName}");
+
+        // This could be fixed, so we will throw an exception if the build is not possible
+        if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible())
+            throw new Exception("WiX toolset not configured, cannot build MSI files");
+
+        // This will be fixed in the future, but requires a new http-interface for Synology DSM
+        if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible())
+            throw new Exception("Synology SPK files are currently not supported");
+
+        // This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages
+        if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible())
         {
-            Console.WriteLine($"Building {releaseType} release ...");
+            Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");
+            buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray();
+        }
 
-            if (!buildTargets.Any())
-                buildTargets = Program.SupportedPackageTargets.ToArray();
+        var baseDir = Path.GetDirectoryName(input.SolutionFile.FullName) ?? throw new Exception("Path to solution file was invalid");
+        var versionFilePath = Path.Combine(baseDir, "Updates", "build_version.txt");
+        if (!File.Exists(versionFilePath))
+            throw new FileNotFoundException($"Version file not found: {versionFilePath}");
 
-            if (!solutionFile.Exists)
-                throw new FileNotFoundException($"Solution file not found: {solutionFile.FullName}");
+        var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables", "net8"), "*", SearchOption.TopDirectoryOnly)
+            .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
+            .ToList();
 
-            // This could be fixed, so we will throw an exception if the build is not possible
-            if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible())
-                throw new Exception("WiX toolset not configured, cannot build MSI files");
+        var primaryGUI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryGUIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
+        var primaryCLI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryCLIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find cli executable");
+        var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
 
-            // This will be fixed in the future, but requires a new http-interface for Synology DSM
-            if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible())
-                throw new Exception("Synology SPK files are currently not supported");
+        // Put primary at the end
+        sourceProjects.Remove(primaryGUI);
+        sourceProjects.Remove(primaryCLI);
+        sourceProjects.Add(primaryCLI);
+        sourceProjects.Add(primaryGUI);
 
-            // This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages
-            if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible())
-            {
-                Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");
-                buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray();
-            }
+        if (!File.Exists(primaryGUI))
+            throw new Exception($"Failed to locate project file: {primaryGUI}");
+        if (!File.Exists(primaryCLI))
+            throw new Exception($"Failed to locate project file: {primaryCLI}");
 
-            var baseDir = Path.GetDirectoryName(solutionFile.FullName) ?? throw new Exception("Path to solution file was invalid");
-            var versionFilePath = Path.Combine(baseDir, "Updates", "build_version.txt");
-            if (!File.Exists(versionFilePath))
-                throw new FileNotFoundException($"Version file not found: {versionFilePath}");
+        var releaseInfo = ReleaseInfo.Create(input.Channel, int.Parse(File.ReadAllText(versionFilePath)) + 1);
+        Console.WriteLine($"Building {releaseInfo.ReleaseName} ...");
 
-            var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables", "net8"), "*", SearchOption.TopDirectoryOnly)
-                .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
-                .ToList();
+        var keyfilePassword = string.IsNullOrEmpty(input.Password)
+            ? ConsoleHelper.ReadPassword("Enter keyfile password")
+            : input.Password;
 
-            var primaryGUI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryGUIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
-            var primaryCLI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryCLIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find cli executable");
-            var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
+        // Configure runtime environment
+        var rtcfg = new RuntimeConfig(releaseInfo, keyfilePassword, sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList());
+        rtcfg.ToggleAuthenticodeSigning(input.DisableAuthenticode);
+        rtcfg.ToggleSignCodeSigning(input.DisableSignCode);
+        await rtcfg.ToggleDockerBuild();
 
-            // Put primary at the end
-            sourceProjects.Remove(primaryGUI);
-            sourceProjects.Remove(primaryCLI);
-            sourceProjects.Add(primaryCLI);
-            sourceProjects.Add(primaryGUI);
+        if (!rtcfg.UseDockerBuild)
+        {
+            var unsupportedBuilds = buildTargets.Where(x => x.Package == PackageType.Docker || x.Package == PackageType.Deb || x.Package == PackageType.RPM).ToList();
+            if (unsupportedBuilds.Any())
+                throw new Exception($"Docker build requested but not enabled, and the following packages are not supported: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}");
+        }
 
-            if (!File.Exists(primaryGUI))
-                throw new Exception($"Failed to locate project file: {primaryGUI}");
-            if (!File.Exists(primaryCLI))
-                throw new Exception($"Failed to locate project file: {primaryCLI}");
+        if (!input.KeepBuilds && Directory.Exists(input.BuildPath.FullName))
+        {
+            Console.WriteLine($"Deleting build folder: {input.BuildPath.FullName}");
+            Directory.Delete(input.BuildPath.FullName, true);
+        }
 
-            var releaseInfo = ReleaseInfo.Create(releaseType, int.Parse(File.ReadAllText(versionFilePath)) + 1);
-            Console.WriteLine($"Building {releaseInfo.ReleaseName} ...");
+        if (!Directory.Exists(input.BuildPath.FullName))
+            Directory.CreateDirectory(input.BuildPath.FullName);
 
-            var keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile password");
+        // Generally, the builds should happen with a clean source tree, 
+        // but this can be disabled for debugging
+        if (input.GitStashPush)
+            await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir);
 
-            // Configure runtime environment
-            var rtcfg = new RuntimeConfig(releaseInfo, keyfilePassword, sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList());
-            rtcfg.ToggleAuthenticodeSigning();
-            rtcfg.ToggleSignCodeSigning();
+        // Inject various files that will be embedded into the build artifacts
+        await PrepareSourceDirectory(baseDir, releaseInfo, input.UpdateUrls);
 
-            if (!keepBuilds && Directory.Exists(buildTemp.FullName))
-            {
-                Console.WriteLine($"Deleting build folder: {buildTemp.FullName}");
-                Directory.Delete(buildTemp.FullName, true);
-            }
+        // Perform the main compilations
+        await Compile.BuildProjects(baseDir, input.BuildPath.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, input.KeepBuilds, rtcfg);
 
-            if (!Directory.Exists(buildTemp.FullName))
-                Directory.CreateDirectory(buildTemp.FullName);
+        // Create the packages
+        await CreatePackage.BuildPackages(baseDir, input.BuildPath.FullName, buildTargets, input.KeepBuilds, rtcfg);
 
-            // Generally, the builds should happen with a clean source tree, 
-            // but this can be disabled for debugging
-            if (gitStashPush)
-                await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir);
+        Console.WriteLine("Build completed, uploading packages ...");
 
-            // Inject various files that will be embedded into the build artifacts
-            await PrepareSourceDirectory(baseDir, releaseInfo, updateUrls);
+        Console.WriteLine("Upload completed, releasing packages ...");
 
-            // Perform the main compilations
-            await Compile.BuildProjects(baseDir, buildTemp.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, keepBuilds, rtcfg);
+        Console.WriteLine("Release completed, posting release notes ...");
 
-            // Create the packages
-            await CreatePackage.BuildPackages(baseDir, buildTemp.FullName, buildTargets, keepBuilds, rtcfg);
-
-            Console.WriteLine("Build completed, uploading packages ...");
-
-            Console.WriteLine("Upload completed, releasing packages ...");
-
-            Console.WriteLine("Release completed, posting release notes ...");
-
-            // 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",
@@ -402,14 +516,10 @@ public static partial class Build
                     "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
                 }, workingDirectory: baseDir);
 
-            if (gitStashPush)
-                await GitPush.TagAndPush(baseDir, releaseInfo);
+        if (input.GitStashPush)
+            await GitPush.TagAndPush(baseDir, releaseInfo);
 
-            Console.WriteLine("All done");
-
-        }, buildTargetOption, buildTempOption, solutionFileOption, gitStashPushOption, releaseTypeOption, updateUrlsOption, keepBuildsOption);
-
-        return command;
+        Console.WriteLine("All done");
     }
 
     /// 
diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs
index 7a1d61122..c8b626cf0 100644
--- a/ReleaseBuilder/Configuration.cs
+++ b/ReleaseBuilder/Configuration.cs
@@ -3,9 +3,9 @@ namespace ReleaseBuilder;
 using static EnvHelper;
 
 /// 
-/// The release types
+/// The release channels
 /// 
-public enum ReleaseType
+public enum ReleaseChannel
 {
     /// 
     /// The primary release form
@@ -103,6 +103,18 @@ public record Configuration(
         return true;
     }
 
+    /// 
+    /// Checks if building Docker images is possible given the current configuration
+    /// 
+    /// A boolean indicating if Docker image building is possible
+    public bool IsDockerBuildPossible()
+    {
+        if (string.IsNullOrWhiteSpace(Commands.Docker))
+            return false;
+
+        return true;
+    }
+
     /// 
     /// Determines if creating a Synology package is possible.
     /// 
@@ -179,6 +191,7 @@ public record ConfigFiles(
 /// The "codesign" command
 /// The "productsign" command
 /// The "wix" command
+/// The "docker" command
 public record Commands(
     string Dotnet,
     string? Gpg,
@@ -187,7 +200,8 @@ public record Commands(
     string? OsslSignCode,
     string? Codesign,
     string? Productsign,
-    string? Wix
+    string? Wix,
+    string? Docker
 )
 {
     /// 
@@ -203,7 +217,8 @@ public record Commands(
             FindCommand(OperatingSystem.IsWindows() ? "signtool.exe" : "osslsigncode", "SIGNTOOL"),
             OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null,
             OperatingSystem.IsMacOS() ? FindCommand("productsign", "PRODUCTSIGN") : null,
-            FindCommand(OperatingSystem.IsWindows() ? "wix" : "wixl", "WIX")
+            FindCommand(OperatingSystem.IsWindows() ? "wix" : "wixl", "WIX"),
+            FindCommand("docker", "DOCKER")
         );
 }
 
diff --git a/ReleaseBuilder/ConsoleHelper.cs b/ReleaseBuilder/ConsoleHelper.cs
index 50ca2789a..f8609b34c 100644
--- a/ReleaseBuilder/ConsoleHelper.cs
+++ b/ReleaseBuilder/ConsoleHelper.cs
@@ -1,5 +1,3 @@
-using System.Runtime.InteropServices;
-
 namespace ReleaseBuilder;
 
 public static class ConsoleHelper
diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs
index f8045b8b4..ebf388bae 100644
--- a/ReleaseBuilder/ProcessHelper.cs
+++ b/ReleaseBuilder/ProcessHelper.cs
@@ -15,8 +15,9 @@ public static class ProcessHelper
     /// The cancellation token
     /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
     /// Default value is null which will treat anything non-zero as an error
+    /// If true, stderr is not forwarded to the console
     /// An awaitable task
-    public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false)
     {
         if (!command.Any())
             throw new ArgumentException("Needs at least one command", nameof(command));
@@ -31,14 +32,16 @@ public static class ProcessHelper
         {
             WindowStyle = ProcessWindowStyle.Hidden,
             WorkingDirectory = workingDirectory,
-            RedirectStandardError = true,
+            RedirectStandardError = !suppressStdErr,
             RedirectStandardOutput = false,
             RedirectStandardInput = false,
             UseShellExecute = false,
         }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
 
         // Forward error messages to stderr
-        var t = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
+        var t = suppressStdErr
+            ? Task.CompletedTask
+            : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
 
         await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
         if (codeIsError(p.ExitCode))
@@ -55,11 +58,12 @@ public static class ProcessHelper
     /// The cancellation token
     /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
     /// Default value is null which will treat anything non-zero as an error
+    /// If true, stderr is not forwarded to the console
     /// An awaitable task
-    public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false)
     {
         foreach (var c in commands)
-            await Execute(c, workingDirectory, cancellationToken, codeIsError).ConfigureAwait(false);
+            await Execute(c, workingDirectory, cancellationToken, codeIsError, suppressStdErr).ConfigureAwait(false);
     }
 
     /// 
@@ -70,8 +74,9 @@ public static class ProcessHelper
     /// The cancellation token
     /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
     /// Default value is null which will treat anything non-zero as an error
+    /// If true, stderr is not forwarded to the console
     /// The output from stdout
-    public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false)
     {
         if (!command.Any())
             throw new ArgumentException("Needs at least one command", nameof(command));
@@ -86,14 +91,16 @@ public static class ProcessHelper
         {
             WindowStyle = ProcessWindowStyle.Hidden,
             WorkingDirectory = workingDirectory,
-            RedirectStandardError = true,
+            RedirectStandardError = !suppressStdErr,
             RedirectStandardOutput = true,
             RedirectStandardInput = false,
             UseShellExecute = false,
         }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
 
         var tstdout = p.StandardOutput.ReadToEndAsync(cancellationToken);
-        var tstderr = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
+        var tstderr = suppressStdErr
+            ? Task.CompletedTask
+            : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
 
         await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
         if (codeIsError(p.ExitCode))
@@ -113,8 +120,9 @@ public static class ProcessHelper
     /// The cancellation token
     /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
     /// Default value is null which will treat anything non-zero as an error
+    /// If true, stderr is not forwarded to the console
     /// The output from stdout
-    public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null)
+    public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false)
     {
         if (!command.Any())
             throw new ArgumentException("Needs at least one command", nameof(command));
@@ -129,14 +137,16 @@ public static class ProcessHelper
         {
             WindowStyle = ProcessWindowStyle.Hidden,
             WorkingDirectory = workingDirectory,
-            RedirectStandardError = true,
+            RedirectStandardError = !suppressStdErr,
             RedirectStandardOutput = true,
             RedirectStandardInput = false,
             UseShellExecute = false,
         }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
 
         var tstdout = p.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken);
-        var tstderr = p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
+        var tstderr = suppressStdErr
+            ? Task.CompletedTask
+            : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken);
 
         await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
         if (codeIsError(p.ExitCode))
diff --git a/ReleaseBuilder/ReleaseBuilder.csproj b/ReleaseBuilder/ReleaseBuilder.csproj
index 64de05873..9d8b2471c 100644
--- a/ReleaseBuilder/ReleaseBuilder.csproj
+++ b/ReleaseBuilder/ReleaseBuilder.csproj
@@ -10,6 +10,7 @@
   
     
     
+    
   
 
 

From 544dc93dffb2b55db11b88525822174fe5cfa129 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Sun, 24 Mar 2024 17:34:20 +0100
Subject: [PATCH 13/91] Added support for building `deb` files. Reworked the
 way symlinks are used, as zip files have no symlink support.

---
 Installer/debian/Dockerfile.build             |   9 +-
 Installer/debian/bin-rules.sh                 |  72 --------
 Installer/debian/build-package.sh             |  18 --
 Installer/debian/build.sh                     |  13 --
 Installer/debian/changelog.template.txt       |   6 +
 .../debian/control => control.template.txt}   |  25 ++-
 Installer/debian/docker-build-binary.sh       |  56 ------
 Installer/debian/docker-build-package.sh      |  24 ---
 Installer/debian/docker/Dockerfile            |  24 ---
 Installer/debian/docker/debian/changelog      |   6 -
 Installer/debian/docker/debian/compat         |   1 -
 Installer/debian/docker/debian/copyright      |   8 -
 Installer/debian/docker/debian/docs           |   1 -
 .../debian/docker/debian/duplicati.default    |  10 --
 .../debian/docker/debian/duplicati.install    |   4 -
 .../debian/docker/debian/duplicati.service    |  13 --
 Installer/debian/docker/debian/make.sh        |   2 -
 Installer/debian/docker/debian/patches/series |   1 -
 Installer/debian/docker/debian/rules          |  60 -------
 Installer/debian/docker/runner.sh             |  12 --
 .../debian/duplicati-make-git-snapshot.sh     |  96 ----------
 Installer/debian/make-binary-package.sh       |  47 -----
 .../CliCommand/Build.Compile.Post.cs          |  40 -----
 .../CliCommand/Build.CreatePackage.cs         | 166 +++++++++++++++++-
 .../CliCommand/Build.PackageSupport.cs        |  46 +++++
 ReleaseBuilder/CliCommand/Build.cs            |   9 +
 26 files changed, 233 insertions(+), 536 deletions(-)
 delete mode 100755 Installer/debian/bin-rules.sh
 delete mode 100755 Installer/debian/build-package.sh
 delete mode 100755 Installer/debian/build.sh
 create mode 100644 Installer/debian/changelog.template.txt
 rename Installer/debian/{docker/debian/control => control.template.txt} (68%)
 delete mode 100755 Installer/debian/docker-build-binary.sh
 delete mode 100755 Installer/debian/docker-build-package.sh
 delete mode 100644 Installer/debian/docker/Dockerfile
 delete mode 100644 Installer/debian/docker/debian/changelog
 delete mode 100644 Installer/debian/docker/debian/compat
 delete mode 100644 Installer/debian/docker/debian/copyright
 delete mode 100644 Installer/debian/docker/debian/docs
 delete mode 100644 Installer/debian/docker/debian/duplicati.default
 delete mode 100644 Installer/debian/docker/debian/duplicati.install
 delete mode 100644 Installer/debian/docker/debian/duplicati.service
 delete mode 100755 Installer/debian/docker/debian/make.sh
 delete mode 100644 Installer/debian/docker/debian/patches/series
 delete mode 100644 Installer/debian/docker/debian/rules
 delete mode 100755 Installer/debian/docker/runner.sh
 delete mode 100755 Installer/debian/duplicati-make-git-snapshot.sh
 delete mode 100755 Installer/debian/make-binary-package.sh
 create mode 100644 ReleaseBuilder/CliCommand/Build.PackageSupport.cs

diff --git a/Installer/debian/Dockerfile.build b/Installer/debian/Dockerfile.build
index 791475461..35cbbe627 100644
--- a/Installer/debian/Dockerfile.build
+++ b/Installer/debian/Dockerfile.build
@@ -12,16 +12,9 @@ RUN set -uex; \
     apt-get install --no-install-suggests --no-install-recommends -y \
       build-essential \
       debhelper \
-      dpkg-dev \
-      mono-devel \
-      libappindicator0.1-cil-dev \
-      ca-certificates-mono \
-      gtk-sharp2 \
-      nuget; \
+      dpkg-dev; \
     apt-get clean all
 
-RUN nuget update -self
-
 label org.label-schema.name = "duplicati/debian-build" \
       org.label-schema.version = "20161230" \
       org.label-schema.vendor="Deployable" \
diff --git a/Installer/debian/bin-rules.sh b/Installer/debian/bin-rules.sh
deleted file mode 100755
index db14d2a8a..000000000
--- a/Installer/debian/bin-rules.sh
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/make -f
-# -*- makefile -*-
-# Sample debian/rules that uses debhelper.
-#
-# This file was originally written by Joey Hess and Craig Small.
-# As a special exception, when this file is copied by dh-make into a
-# dh-make output file, you may use that output file without restriction.
-# This special exception was added by Craig Small in version 0.37 of dh-make.
-#
-# Modified to make a template file for a multi-binary package with separated
-# build-arch and build-indep targets  by Bill Allombert 2001
-
-# Uncomment this to turn on verbose mode.
-#export DH_VERBOSE=1
-
-# This has to be exported to make some magic below work.
-export DH_OPTIONS
-
-%:
-	dh $@ 
-
-override_dh_clean:
-	dh_clean
-	find -type d -name bin | xargs rm -rf
-	find -type d -name obj | xargs rm -rf
-	find -maxdepth 1 -type d -name build | xargs rm -rf
-
-override_dh_auto_build:
-	echo "Not building, using binary package"
-
-override_dh_builddeb:
-	dh_builddeb -- -Zgzip
-
-override_dh_auto_install:
-	mkdir ../temp
-	cp -r * ../temp
-	mkdir build
-	mkdir build/bin
-	mkdir build/lib
-	mkdir build/lib/duplicati
-	mkdir build/lib/duplicati/SQLite
-	mkdir build/share
-	mkdir build/share/applications
-	mkdir build/share/pixmaps
-	cp -r ../temp/* build/lib/duplicati
-	rm -rf ../temp
-	cp ../duplicati-launcher.sh build/bin/duplicati
-	cp ../duplicati-commandline-launcher.sh build/bin/duplicati-cli
-	cp ../duplicati-server-launcher.sh build/bin/duplicati-server
-	cp ../duplicati.desktop build/share/applications
-	cp ../duplicati.xpm build/share/pixmaps
-	cp ../duplicati.png build/share/pixmaps
-	cp ../duplicati.svg build/share/pixmaps
-	rm -rf build/lib/duplicati/win-tools
-	rm -rf build/lib/duplicati/SQLite/win64
-	rm -rf build/lib/duplicati/SQLite/win32
-	rm -rf build/lib/duplicati/MonoMac.dll
-	rm -rf build/lib/duplicati/OSX\ Icons
-	rm -rf build/lib/duplicati/OSXTrayHost
-	rm -rf build/lib/duplicati/licenses/MonoMac
-	rm -rf build/lib/duplicati/licenses/gpg
-	rm -rf build/lib/duplicati/win-x64/storj_uplink.dll
-	rm -rf build/lib/duplicati/win-x86/storj_uplink.dll
-	rm -rf build/lib/duplicati/libstorj_uplink.dylib
-	find build/lib/duplicati/* -type f | xargs chmod 644
-	find build/lib/duplicati/* -type d | xargs chmod 755
-	find build/lib/duplicati/* -type f -name \*.exe | xargs chmod 755
-	find build/lib/duplicati/* -type f -name \*.sh | xargs chmod 755
-	dh_install
-	
-override_dh_systemd_enable:
-	dh_systemd_enable --no-enable
diff --git a/Installer/debian/build-package.sh b/Installer/debian/build-package.sh
deleted file mode 100755
index f0ab0d3c3..000000000
--- a/Installer/debian/build-package.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/bash
-
-DATE=$(date +%Y%m%d)
-VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-GITTAG=$(git rev-parse --short HEAD)
-RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-
-DIRNAME="duplicati-$VERSION"
-
-git pull
-bash duplicati-make-git-snapshot.sh "${GITTAG}" "${DATE}" "${VERSION}" "${RELEASETYPE}" "${BUILDTAG}-${GITTAG}"
-
-cd "$DIRNAME"
-touch releasenotes.txt
-rm -rf .git
-dpkg-buildpackage
-cd ..
diff --git a/Installer/debian/build.sh b/Installer/debian/build.sh
deleted file mode 100755
index 8fabea0ca..000000000
--- a/Installer/debian/build.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-#This is a helper to make the release linux zip. Mainly for testing outside of ci/cd
-
-SCRIPTDIR=$( cd "$(dirname "$0")" ; pwd -P )
-
-docker build $SCRIPTDIR/docker -t duplicati-debian
-
-VERSION=`grep '' < $SCRIPTDIR/../../Executables/net8/Duplicati.Server/Duplicati.Server.csproj | sed 's/.*\(.*\)<\/Version>.*/\1/'`
-VERSION=${VERSION//$'\r\n'}
-echo "Building version: ($VERSION)"
-
-export MSYS_NO_PATHCONV=1
-docker run --rm -eVERSION=$VERSION -v $SCRIPTDIR/../../:/sources duplicati-debian
\ No newline at end of file
diff --git a/Installer/debian/changelog.template.txt b/Installer/debian/changelog.template.txt
new file mode 100644
index 000000000..146d4f07a
--- /dev/null
+++ b/Installer/debian/changelog.template.txt
@@ -0,0 +1,6 @@
+duplicati (%VERSION%) unstable; urgency=low
+
+  * New upstream release
+  * See changelog.txt for changes
+
+ -- Duplicati Team   %DATE%
diff --git a/Installer/debian/docker/debian/control b/Installer/debian/control.template.txt
similarity index 68%
rename from Installer/debian/docker/debian/control
rename to Installer/debian/control.template.txt
index 077ac30b6..1f2b4faeb 100644
--- a/Installer/debian/docker/debian/control
+++ b/Installer/debian/control.template.txt
@@ -2,23 +2,22 @@ Source: duplicati
 Section: utils
 Priority: extra
 Maintainer: Kenneth Skovhede 
-Build-Depends: debhelper (>= 8.0.0), dotnet-sdk-6.0
+Build-Depends: debhelper (>= 9.0.0)
 Standards-Version: 3.9.4
-Homepage: http://www.duplicati.com
+Homepage: http://duplicati.com
 Vcs-Git: https://github.com/duplicati/duplicati.git
-
 Package: duplicati
-Architecture: all
-Depends: 
+Architecture: %ARCH%
+Depends: %DEPENDS%
+Version: %VERSION%
 Description: Backup client for encrypted online backups
- Duplicati is a free backup client that securely stores encrypted, incremental, 
- compressed backups on cloud storage services and remote file servers. It 
+ Duplicati is a free open-source backup client that securely stores encrypted, incremental,
+ compressed backups on cloud storage services and remote file servers. It
  supports targets like Amazon S3, Windows Live SkyDrive, Rackspace Cloud Files
- or WebDAV, SSH, FTP (and many more). 
+ or WebDAV, SSH, FTP (and many more).
  .
- Duplicati has built-in AES-256 encryption and backups be can signed using GNU 
- Privacy Guard. A built-in scheduler makes sure that backups are always 
- up-to-date. Last but not least, Duplicati provides various options and tweaks 
- like filters, deletion rules, transfer and bandwidth options to run backups 
+ Duplicati has built-in AES-256 encryption and backups be can signed using GNU
+ Privacy Guard. A built-in scheduler makes sure that backups are always
+ up-to-date. Last but not least, Duplicati provides various options and tweaks
+ like filters, deletion rules, transfer and bandwidth options to run backups
  for specific purposes.
-
diff --git a/Installer/debian/docker-build-binary.sh b/Installer/debian/docker-build-binary.sh
deleted file mode 100755
index 8da81e7d9..000000000
--- a/Installer/debian/docker-build-binary.sh
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/bin/bash
-
-if [ ! -f "$1" ]; then
-	echo "Please provide the filename of an existing zip build as the first argument"
-	exit
-fi
-
-FILENAME=$(basename $1)
-DIRNAME=$(echo "${FILENAME}" | cut -d "_" -f 1)
-VERSION=$(echo "${DIRNAME}" | cut -d "-" -f 2)
-DATE_STAMP=$(LANG=C date -R)
-
-if [ -d "${DIRNAME}" ]; then
-	rm -rf "${DIRNAME}"
-fi
-
-unzip -d "${DIRNAME}" "$1"
-
-for n in "../oem" "../../oem" "../../../oem"
-do
-    if [ -d $n ]; then
-        echo "Installing OEM files"
-        cp -R $n "${DIRNAME}/webroot/"
-    fi
-done
-
-for n in "oem-app-name.txt" "oem-update-url.txt" "oem-update-key.txt" "oem-update-readme.txt" "oem-update-installid.txt"
-do
-    for p in "../$n" "../../$n" "../../../$n"
-    do
-        if [ -f $p ]; then
-            echo "Installing OEM override file"
-            cp $p "${DIRNAME}"
-        fi
-    done
-done
-
-cp -R "debian" "${DIRNAME}"
-cp "bin-rules.sh" "${DIRNAME}/debian/rules"
-sed -e "s;%VERSION%;$VERSION;g" -e "s;%DATE%;$DATE_STAMP;g" "debian/changelog" > "${DIRNAME}/debian/changelog"
-
-touch "${DIRNAME}/releasenotes.txt"
-
-docker build -t "duplicati/debian-build:latest" - < Dockerfile.build
-
-# Weirdness with time not being synced in Docker instance
-sleep 5
-docker run  --workdir "/builddir/${DIRNAME}" --volume `pwd`:/builddir:rw "duplicati/debian-build:latest" dpkg-buildpackage
-
-rm -rf "${DIRNAME}"
-for filename in "duplicati_${VERSION}-1_amd64.changes" "duplicati_${VERSION}-1.dsc"  "duplicati_${VERSION}-1.tar.gz" 
-do
-    if [ -f "${filename}" ]; then
-        rm "${filename}"
-    fi
-done
diff --git a/Installer/debian/docker-build-package.sh b/Installer/debian/docker-build-package.sh
deleted file mode 100755
index b1d2957bc..000000000
--- a/Installer/debian/docker-build-package.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/bash
-
-DATE=$(date +%Y%m%d)
-VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-GITTAG=$(git rev-parse --short HEAD)
-RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-
-DIRNAME="duplicati-$VERSION"
-CWD=$(pwd)
-
-git pull
-bash duplicati-make-git-snapshot.sh "${GITTAG}" "${DATE}" "${VERSION}" "${RELEASETYPE}" "${BUILDTAG}-${GITTAG}"
-
-touch "${DIRNAME}/releasenotes.txt"
-rm -rf "${DIRNAME}/.git"
-
-docker build -t "duplicati/debian-build:latest" - < Dockerfile.build
-
-# Weirdness with time not being synced in Docker instance
-sleep 5
-docker run  --workdir "/buildroot/${DIRNAME}" --volume "${CWD}":"/buildroot":"rw" "duplicati/debian-build:latest" dpkg-buildpackage
-
-rm -rf "${DIRNAME}"
\ No newline at end of file
diff --git a/Installer/debian/docker/Dockerfile b/Installer/debian/docker/Dockerfile
deleted file mode 100644
index 049814cd4..000000000
--- a/Installer/debian/docker/Dockerfile
+++ /dev/null
@@ -1,24 +0,0 @@
-FROM ubuntu:20.04
-
-ENV DEBIAN_FRONTEND noninteractive
-
-# Install common build tools
-RUN set -uex; \
-    apt-get update; \
-    apt-get install --no-install-suggests --no-install-recommends -y \
-      build-essential \
-      debhelper \
-      dpkg-dev \
-    ; \
-    apt-get clean all
-RUN apt-get update && apt-get install -y curl
-RUN curl -sL -o ~/packages-microsoft-prod.deb https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb
-RUN dpkg -i ~/packages-microsoft-prod.deb
-RUN apt-get update && apt-get install -y dotnet-sdk-6.0
-
-ADD debian /deb/debian
-RUN chmod -x /deb/debian/duplicati.install
-RUN chmod -x /deb/debian/docs
-
-ADD runner.sh /
-CMD /runner.sh
diff --git a/Installer/debian/docker/debian/changelog b/Installer/debian/docker/debian/changelog
deleted file mode 100644
index 7e44f9886..000000000
--- a/Installer/debian/docker/debian/changelog
+++ /dev/null
@@ -1,6 +0,0 @@
-duplicati (%VERSION%-1) unstable; urgency=low
-
-  * Packaged release
-  * See changelog.txt for changes
-
- -- Kenneth Skovhede   %DATE%
diff --git a/Installer/debian/docker/debian/compat b/Installer/debian/docker/debian/compat
deleted file mode 100644
index ec635144f..000000000
--- a/Installer/debian/docker/debian/compat
+++ /dev/null
@@ -1 +0,0 @@
-9
diff --git a/Installer/debian/docker/debian/copyright b/Installer/debian/docker/debian/copyright
deleted file mode 100644
index 48eb05d7a..000000000
--- a/Installer/debian/docker/debian/copyright
+++ /dev/null
@@ -1,8 +0,0 @@
-
-It was downloaded from https://github.com/duplicati/duplicati
-
-Upstream Author: Kenneth Skovhede 
-
-Copyright (C) 2005-2024 Kenneth Skovhede 
-
-Duplicati is licensed under the MIT license, see COPYING for more details.
diff --git a/Installer/debian/docker/debian/docs b/Installer/debian/docker/debian/docs
deleted file mode 100644
index 68905cd33..000000000
--- a/Installer/debian/docker/debian/docs
+++ /dev/null
@@ -1 +0,0 @@
-changelog.txt
diff --git a/Installer/debian/docker/debian/duplicati.default b/Installer/debian/docker/debian/duplicati.default
deleted file mode 100644
index b4d55a61d..000000000
--- a/Installer/debian/docker/debian/duplicati.default
+++ /dev/null
@@ -1,10 +0,0 @@
-# Defaults for duplicati initscript
-# sourced by /etc/init.d/duplicati
-# installed at /etc/default/duplicati by the maintainer scripts
-
-#
-# This is a POSIX shell fragment
-#
-
-# Additional options that are passed to the Daemon.
-DAEMON_OPTS=""
diff --git a/Installer/debian/docker/debian/duplicati.install b/Installer/debian/docker/debian/duplicati.install
deleted file mode 100644
index 4520679c2..000000000
--- a/Installer/debian/docker/debian/duplicati.install
+++ /dev/null
@@ -1,4 +0,0 @@
-build/bin/* usr/bin
-build/lib/duplicati/* usr/lib/duplicati
-build/share/applications/* usr/share/applications
-build/share/pixmaps/* usr/share/pixmaps
diff --git a/Installer/debian/docker/debian/duplicati.service b/Installer/debian/docker/debian/duplicati.service
deleted file mode 100644
index d7ace0697..000000000
--- a/Installer/debian/docker/debian/duplicati.service
+++ /dev/null
@@ -1,13 +0,0 @@
-[Unit]
-Description=Duplicati web-server
-After=network.target
-
-[Service]
-Nice=19
-IOSchedulingClass=idle
-EnvironmentFile=-/etc/default/duplicati
-ExecStart=/usr/bin/duplicati-server $DAEMON_OPTS
-Restart=always
-
-[Install]
-WantedBy=multi-user.target
diff --git a/Installer/debian/docker/debian/make.sh b/Installer/debian/docker/debian/make.sh
deleted file mode 100755
index be0f97563..000000000
--- a/Installer/debian/docker/debian/make.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/bash
-make --debug=b -f $@
\ No newline at end of file
diff --git a/Installer/debian/docker/debian/patches/series b/Installer/debian/docker/debian/patches/series
deleted file mode 100644
index 8b1378917..000000000
--- a/Installer/debian/docker/debian/patches/series
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/Installer/debian/docker/debian/rules b/Installer/debian/docker/debian/rules
deleted file mode 100644
index 1995c5ed3..000000000
--- a/Installer/debian/docker/debian/rules
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/usr/bin/make -f
-# -*- makefile -*-
-# Sample debian/rules that uses debhelper.
-#
-# This file was originally written by Joey Hess and Craig Small.
-# As a special exception, when this file is copied by dh-make into a
-# dh-make output file, you may use that output file without restriction.
-# This special exception was added by Craig Small in version 0.37 of dh-make.
-#
-# Modified to make a template file for a multi-binary package with separated
-# build-arch and build-indep targets  by Bill Allombert 2001
-
-# Uncomment this to turn on verbose mode.
-export DH_VERBOSE=1
-VERBOSE := 1
-# This has to be exported to make some magic below work.
-export DH_OPTIONS
-export VERSION
-
-%:
-	dh $@
-
-override_dh_clean:
-	dh_clean
-	#dotnet clean does stupid things with nuget, so just delete the folders
-	find /sources -type d -name bin | xargs rm -rf
-	find /sources -type d -name obj | xargs rm -rf
-
-override_dh_auto_build:
-	dotnet publish -c Release --runtime=linux-x64  -p:DefineConstants=ENABLE_GTK -o build/lib/duplicati/ /sources/Duplicati.sln
-
-override_dh_auto_install:
-	mkdir build/bin
-	mkdir build/share
-	mkdir build/share/applications
-	mkdir build/share/pixmaps
-	ln -sf /usr/lib/duplicati/Duplicati.GUI.TrayIcon build/bin/duplicati
-	ln -sf /usr/lib/duplicati/Duplicati.CommandLine build/bin/duplicati-cli
-	ln -sf /usr/lib/duplicati/Duplicati.Server build/bin/duplicati-server
-	cp /sources/Tools/Verification/DuplicatiVerify.py build/lib/duplicati/
-	cp /sources/Installer/debian/duplicati.desktop build/share/applications
-	cp /sources/Installer/debian/duplicati.xpm build/share/pixmaps
-	cp /sources/Installer/debian/duplicati.png build/share/pixmaps
-	cp /sources/Installer/debian/duplicati.svg build/share/pixmaps	
-	rm -rf build/lib/duplicati/win-tools
-	rm -rf build/lib/duplicati/OSX\ Icons
-	rm -rf build/lib/duplicati/OSXTrayHost
-	rm -rf build/lib/duplicati/licenses/MonoMac
-	rm -rf build/lib/duplicati/licenses/gpg
-	find build/lib/duplicati/* -type f | xargs chmod 644
-	find build/lib/duplicati/* -type d | xargs chmod 755
-	find build/lib/duplicati/* -type f -name \*.sh | xargs chmod 755
-	find build/lib/duplicati/* -type f -name \*.py | xargs chmod 755
-	chmod 755 build/lib/duplicati/Duplicati.GUI.TrayIcon
-	chmod 755 build/lib/duplicati/Duplicati.CommandLine
-	chmod 755 build/lib/duplicati/Duplicati.Server
-	dh_install
-
-override_dh_systemd_enable:
-	dh_systemd_enable --no-enable
diff --git a/Installer/debian/docker/runner.sh b/Installer/debian/docker/runner.sh
deleted file mode 100755
index e817442bf..000000000
--- a/Installer/debian/docker/runner.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-cd /deb
-
-DATE_STAMP=$(LANG=C date -R)
-sed -e "s;%VERSION%;$VERSION;g" -e "s;%DATE%;$DATE_STAMP;g" -i "debian/changelog"
-
-cat debian/changelog
-
-cp /sources/changelog.txt ./
-
-dpkg-buildpackage -b --no-sign
-
-cp /*.deb /sources/
\ No newline at end of file
diff --git a/Installer/debian/duplicati-make-git-snapshot.sh b/Installer/debian/duplicati-make-git-snapshot.sh
deleted file mode 100755
index b7586f058..000000000
--- a/Installer/debian/duplicati-make-git-snapshot.sh
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/bin/sh
-
-# Usage: ./duplicati-make-git-snapshot.sh [COMMIT] [DATE] [VERSION] [RELEASETYPE] [BUILDTAG]
-#
-# to make a snapshot of the given tag/branch.  Defaults to HEAD.
-# Point env var REF to a local duplicati repo to reduce clone time.
-
-if [ -z $2 ]; then
-  DATE=$(date +%Y%m%d)
-else
-  DATE=$2
-fi
-
-if [ -z $3 ]; then
-  VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-else
-  VERSION=$3
-fi
-
-if [ -z $4 ]; then
-  RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-else
-  RELEASETYPE=$4
-fi
-
-if [ -z $5 ]; then
-  BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-else
-  BUILDTAG=$5
-fi
-
-
-DIRNAME="duplicati-$VERSION"
-DATE_STAMP=$(LANG=C date -R)
-UPDATE_URLS="http://updates.duplicati.com/${RELEASETYPE}/latest.manifest;http://alt.updates.duplicati.com/${RELEASETYPE}/latest.manifest"
-
-echo REF ${REF:+--reference $REF}
-echo DIRNAME $DIRNAME
-echo COMMIT ${1:-HEAD}
-echo RELEASETYPE ${RELEASETYPE}
-echo URLS ${UPDATE_URLS}
-echo BUILDTAG ${BUILDTAG}
-
-
-rm -rf $DIRNAME
-
-git clone ${REF:+--reference $REF} \
-         `git config --get remote.origin.url` $DIRNAME
-
-cd "$DIRNAME"
-
-git checkout -b fedora-build ${1:-HEAD}
-
-echo "${BUILDTAG}" > "Duplicati/License/VersionTag.txt"
-echo "${RELEASETYPE}" > "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
-echo "${UPDATE_URLS}" > "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt"
-cp "Updates/release_key.txt" "Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt"
-
-git add "Duplicati/License/VersionTag.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt"
-git commit -m "Updated auto-update properties"
-
-for n in "../../oem" "../../../oem" "../../../../oem"
-do
-    if [ -d $n ]; then
-        echo "Installing OEM files"
-        cp -R $n Duplicati/Server/webroot/
-        git add Duplicati/Server/webroot/*
-        git commit -m "Added OEM files"
-    fi
-done
-
-for n in "oem-app-name.txt" "oem-update-url.txt" "oem-update-key.txt" "oem-update-readme.txt" "oem-update-installid.txt"
-do
-    for p in "../../$n" "../../../$n" "../../../../$n"
-    do
-        if [ -f $p ]; then
-            echo "Installing OEM override file"
-            cp $p .
-            git add ./$n
-            git commit -m "Added OEM override file"
-        fi
-    done
-done
-
-cp -R "../debian" .
-
-sed -e "s;%VERSION%;$VERSION;g" -e "s;%DATE%;$DATE_STAMP;g" "../debian/changelog" > "debian/changelog"
-
-echo "${VERSION}" > version
-git add version
-git commit -m "Added version file"
-
-cd ..
\ No newline at end of file
diff --git a/Installer/debian/make-binary-package.sh b/Installer/debian/make-binary-package.sh
deleted file mode 100755
index 98b31a4b1..000000000
--- a/Installer/debian/make-binary-package.sh
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/bash
-
-if [ ! -f "$1" ]; then
-	echo "Please provide the filename of an existing zip build as the first argument"
-	exit
-fi
-
-FILENAME=$(basename $1)
-DIRNAME=$(echo "${FILENAME}" | cut -d "_" -f 1)
-VERSION=$(echo "${DIRNAME}" | cut -d "-" -f 2)
-DATE_STAMP=$(LANG=C date -R)
-
-if [ -d "${DIRNAME}" ]; then
-	rm -rf "${DIRNAME}"
-fi
-
-unzip -d "${DIRNAME}" "$1"
-
-for n in "../oem" "../../oem" "../../../oem"
-do
-    if [ -d $n ]; then
-        echo "Installing OEM files"
-        cp -R $n "${DIRNAME}/webroot/"
-    fi
-done
-
-for n in "oem-app-name.txt" "oem-update-url.txt" "oem-update-key.txt" "oem-update-readme.txt" "oem-update-installid.txt"
-do
-    for p in "../$n" "../../$n" "../../../$n"
-    do
-        if [ -f $p ]; then
-            echo "Installing OEM override file"
-            cp $p "${DIRNAME}"
-        fi
-    done
-done
-
-cp -R "debian/" "${DIRNAME}"
-cp "bin-rules.sh" "${DIRNAME}/debian/rules"
-sed -e "s;%VERSION%;$VERSION;g" -e "s;%DATE%;$DATE_STAMP;g" "debian/changelog" > "${DIRNAME}/debian/changelog"
-
-touch "${DIRNAME}/releasenotes.txt"
-
-cd "${DIRNAME}"
-dpkg-buildpackage
-cd ..
-rm -rf "${DIRNAME}"
diff --git a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
index 386be427f..05f395d27 100644
--- a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
+++ b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
@@ -30,14 +30,10 @@ public static partial class Build
                     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:
@@ -124,42 +120,6 @@ public static partial class Build
             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
         /// 
diff --git a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
index a99dcc5d7..7e61f7672 100644
--- a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
+++ b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
 using System.IO.Compression;
 
 namespace ReleaseBuilder.CliCommand;
@@ -49,6 +50,9 @@ public static partial class Build
                 Directory.CreateDirectory(packageFolder);
 
             var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
+            if (target.Package == PackageType.Deb)
+                packageFile = $"duplicati-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.deb";
+
             if (File.Exists(packageFile))
             {
                 if (keepBuilds)
@@ -67,7 +71,7 @@ public static partial class Build
             switch (target.Package)
             {
                 case PackageType.Zip:
-                    await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
+                    await BuildZipPackage(Path.Combine(buildRoot, $"{target.BuildTargetString}"), rtcfg.ReleaseInfo.ReleaseName, tempFile, target, rtcfg);
                     break;
 
                 case PackageType.MSI:
@@ -102,24 +106,44 @@ public static partial class Build
         /// Builds a zip package asynchronously.
         /// 
         /// The output folder where the zip package will be created.
+        /// The directory name to use as the root zip name.
         /// 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)
+        static async Task BuildZipPackage(string buildRoot, string dirName, 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))
+                foreach (var f in Directory.EnumerateFiles(buildRoot, "*", SearchOption.AllDirectories))
                 {
-                    var entry = zip.CreateEntry(Path.GetRelativePath(buildRoot, f), CompressionLevel.Optimal);
+                    var relpath = Path.GetRelativePath(buildRoot, f);
+
+                    // Use more friendly names for executables on non-Windows platforms
+                    if (target.OS != OSType.Windows && ExecutableRenames.ContainsKey(relpath))
+                        relpath = ExecutableRenames[relpath];
+
+                    var entry = zip.CreateEntry(Path.Combine(dirName, relpath), CompressionLevel.Optimal);
                     using (var stream = entry.Open())
                     using (var file = File.OpenRead(f))
                         await file.CopyToAsync(stream);
                 }
+                if (target.OS != OSType.Windows)
+                {
+                    using (var stream = zip.CreateEntry(Path.Combine(dirName, "set-permissions.sh"), CompressionLevel.Optimal).Open())
+                    using (var writer = new StreamWriter(stream))
+                    {
+                        writer.WriteLine("#!/bin/sh");
+                        writer.WriteLine("# This script sets the executable flags for the Duplicati binaries");
+                        writer.WriteLine("set -e");
+                        foreach (var x in ExecutableRenames.Values)
+                            writer.WriteLine($"chmod +x {x}");
+                    }
+                }
+
             }
         }
 
@@ -200,7 +224,7 @@ public static partial class Build
 
             await ProcessHelper.ExecuteAll([
                 ["hdiutil", "resize", "-size", "300M", templateDmg],
-            ["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir]
+                ["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir]
             ], workingDirectory: buildRoot);
 
             // Change the dmg name
@@ -217,6 +241,8 @@ public static partial class Build
 
             // Place the prepared folder
             EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
+            await PackageSupport.SetExecutableFlags(appFolder, rtcfg);
+            await PackageSupport.MakeSymlinks(appFolder);
 
             // Set permissions inside DMG file
             if (!OperatingSystem.IsWindows())
@@ -260,6 +286,8 @@ public static partial class Build
 
             // Place the prepared folder
             EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
+            await PackageSupport.SetExecutableFlags(appFolder, rtcfg);
+            await PackageSupport.MakeSymlinks(appFolder);
 
             // Copy the source script files
             var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" };
@@ -322,9 +350,133 @@ public static partial class Build
         /// 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)
+        static async Task BuildDebPackage(string baseDir, string buildRoot, string debFile, PackageTarget target, RuntimeConfig rtcfg)
         {
-            throw new NotImplementedException();
+            // The approach here is based on:
+            // https://www.internalpointers.com/post/build-binary-deb-package-practical-guide
+            // 
+            // It is not the recommended way to build a package,
+            // but since the build is from a pre-build binary,
+            // it is easier than trying to hack debhelper.
+
+            var debroot = Path.Combine(buildRoot, "deb");
+            if (Path.Exists(debroot))
+                Directory.Delete(debroot, true);
+            Directory.CreateDirectory(debroot);
+
+            // Make the package structure
+            var debpkgdir = $"duplicati-{rtcfg.ReleaseInfo.Version}_{target.ArchString}";
+            var pkgroot = Path.Combine(debroot, debpkgdir);
+
+            Directory.CreateDirectory(pkgroot);
+            Directory.CreateDirectory(Path.Combine(pkgroot, "DEBIAN"));
+            Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "lib"));
+            Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "bin"));
+            Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "share", "applications"));
+            Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "share", "pixmaps"));
+
+            // Copy main files
+            EnvHelper.CopyDirectory(
+                Path.Combine(buildRoot, target.BuildTargetString),
+                Path.Combine(pkgroot, "usr", "lib", "duplicati"),
+                recursive: true);
+
+            // TODO: For improved Windows support, this can be done in Docker
+            if (!OperatingSystem.IsWindows())
+            {
+                var roflags = UnixFileMode.OtherRead | UnixFileMode.GroupRead | UnixFileMode.UserRead | UnixFileMode.UserWrite;
+                var exflags = roflags | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+
+                // Set permissions
+                foreach (var f in Directory.EnumerateFileSystemEntries(Path.Combine(pkgroot, "usr", "lib", "duplicati"), "*", SearchOption.AllDirectories))
+                {
+                    if (File.Exists(f))
+                        File.SetUnixFileMode(f,
+                            ExecutableRenames.ContainsKey(Path.GetFileName(f))
+                                ? exflags
+                                : roflags);
+                    else if (Directory.Exists(f))
+                        File.SetUnixFileMode(f, exflags);
+                }
+
+                foreach (var e in ExecutableRenames)
+                {
+                    var exefile = Path.Combine(pkgroot, "usr", "lib", "duplicati", e.Key);
+                    if (File.Exists(exefile))
+                        await ProcessHelper.Execute([
+                            "ln", "-s",
+                            exefile,
+                            Path.Combine(pkgroot, "usr", "bin", e.Value)
+                        ]);
+                }
+            }
+
+            // Copy debian files
+            var installerDir = Path.Combine(baseDir, "Installer", "debian");
+
+            // Write in the release notes
+            // File.WriteAllText(Path.Combine(debroot, "releasenotes.txt"), rtcfg.ReleaseNotes); 
+            // touch "${DIRNAME}/releasenotes.txt"
+
+            // Write a custom changelog file
+            File.WriteAllText(
+                Path.Combine(pkgroot, "DEBIAN", "changelog"),
+                File.ReadAllText(Path.Combine(installerDir, "changelog.template.txt"))
+                    .Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
+                    .Replace("%DATE%", DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss +0000", CultureInfo.InvariantCulture))
+            );
+
+            // Write a custom control file
+            File.WriteAllText(
+                Path.Combine(pkgroot, "DEBIAN", "control"),
+                File.ReadAllText(Path.Combine(installerDir, "control.template.txt"))
+                    .Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
+                    .Replace("%ARCH%", target.ArchString)
+                    .Replace("%DEPENDS%", string.Join(", ", target.Interface == InterfaceType.GUI
+                        ? DebianGUIDepends
+                        : DebianCLIDepends))
+            );
+
+            // Install various helper files
+            File.Copy(
+                Path.Combine(installerDir, "duplicati.desktop"),
+                Path.Combine(pkgroot, "usr", "share", "applications", "duplicati.desktop"),
+                true
+            );
+
+            foreach (var f in new[] { "duplicati.png", "duplicati.svg", "duplicati.xpm" })
+                File.Copy(
+                    Path.Combine(installerDir, f),
+                    Path.Combine(pkgroot, "usr", "share", "pixmaps", f),
+                    true
+                );
+
+            // Install the Docker build file
+            File.Copy(
+                Path.Combine(installerDir, "Dockerfile.build"),
+                Path.Combine(debroot, "Dockerfile"),
+                true
+            );
+
+            // Build a Docker image to build with
+            await ProcessHelper.Execute([
+                "docker", "build",
+                "-t", "duplicati/debian-build:latest",
+                debroot
+            ], workingDirectory: debroot);
+
+            var debpkgname = $"{debpkgdir}.deb";
+
+            // Build in Docker
+            await ProcessHelper.Execute([
+                    "docker", "run",
+                    "--workdir", $"/build",
+                    "--volume", $"{debroot}:/build:rw", "duplicati/debian-build:latest",
+                    "dpkg-deb", "--build", "--root-owner-group", debpkgdir
+            ]);
+
+            File.Move(Path.Combine(debroot, debpkgname), debFile);
+            Directory.Delete(debroot, true);
         }
     }
 }
diff --git a/ReleaseBuilder/CliCommand/Build.PackageSupport.cs b/ReleaseBuilder/CliCommand/Build.PackageSupport.cs
new file mode 100644
index 000000000..45ec395d0
--- /dev/null
+++ b/ReleaseBuilder/CliCommand/Build.PackageSupport.cs
@@ -0,0 +1,46 @@
+namespace ReleaseBuilder.CliCommand;
+
+public static partial class Build
+{
+    /// 
+    /// Support for building packages
+    /// 
+    private static class PackageSupport
+    {
+        /// 
+        /// Introduces symbolic links for executables that have a different name
+        /// 
+        /// The build path to use
+        /// An awaitable task
+        public 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
+        public 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;
+        }
+    }
+}
\ No newline at end of file
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index 0c0c3aef7..8e7d28f38 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -46,6 +46,15 @@ public static partial class Build
     /// 
     private const string MacOSAppName = "Duplicati.app";
 
+    /// 
+    /// The packages that are required for GUI builds
+    /// 
+    private static readonly IReadOnlyList DebianGUIDepends = ["libice6", "libsm6", "libfontconfig1"];
+    /// 
+    /// The packages that are required for CLI builds
+    /// 
+    private static readonly IReadOnlyList DebianCLIDepends = [];
+
     /// 
     /// Setup of the current runtime information
     /// 

From e64478474bd21682f138c542a7cfd9ff058f5926 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Mon, 25 Mar 2024 07:49:52 +0100
Subject: [PATCH 14/91] Showing link for manual update

---
 Duplicati/Server/UpdatePollThread.cs | 29 ++++++++++++++++++++++++----
 1 file changed, 25 insertions(+), 4 deletions(-)

diff --git a/Duplicati/Server/UpdatePollThread.cs b/Duplicati/Server/UpdatePollThread.cs
index a4f6c0404..7c5263228 100644
--- a/Duplicati/Server/UpdatePollThread.cs
+++ b/Duplicati/Server/UpdatePollThread.cs
@@ -205,11 +205,32 @@ namespace Duplicati.Server
                     var v = Program.DataConnection.ApplicationSettings.UpdatedVersion;
                     if (v != null)
                     {
-                        ThreadState = UpdatePollerStates.Downloading;
-                        Program.StatusEventNotifyer.SignalNewEvent();
-
-                        if (Duplicati.Library.AutoUpdater.UpdaterManager.DownloadAndUnpackUpdate(v, (pg) => { DownloadProgess = pg; }))
+                        if (string.IsNullOrWhiteSpace(v.UpdateFromV1Url))
+                        {
+                            ThreadState = UpdatePollerStates.Downloading;
                             Program.StatusEventNotifyer.SignalNewEvent();
+
+                            if (Duplicati.Library.AutoUpdater.UpdaterManager.DownloadAndUnpackUpdate(v, (pg) => { DownloadProgess = pg; }))
+                                Program.StatusEventNotifyer.SignalNewEvent();
+                        }
+                        else
+                        {
+                            Program.DataConnection.RegisterNotification(
+                                    NotificationType.Error,
+                                    "Manual update required",
+                                    $"{v.UpdateFromV1Url}",
+                                    null,
+                                    null,
+                                    "update:new",
+                                    null,
+                                    "NewUpdateFound",
+                                    null,
+                                    (self, all) =>
+                                    {
+                                        return all.FirstOrDefault(x => x.Action == "update:new") ?? self;
+                                    }
+                                );
+                        }
                     }
                 }
 

From 4e837a865c9e84b66c09bc1f54d8b2ba95d2540a Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Mon, 25 Mar 2024 11:53:52 +0100
Subject: [PATCH 15/91] Added support for Docker images. Added more commandline
 options. Added the package id to builds.

---
 Installer/Docker/Dockerfile                   |  20 +++
 Installer/Docker/mono_image.txt               |   1 -
 ReleaseBuilder/.vscode/launch.json            |   6 +
 .../CliCommand/Build.Compile.Post.cs          |   4 +-
 ReleaseBuilder/CliCommand/Build.Compile.cs    |  18 +-
 .../CliCommand/Build.CreatePackage.cs         | 156 ++++++++++++++++--
 ReleaseBuilder/CliCommand/Build.GitPush.cs    |  14 +-
 .../CliCommand/Build.PackageSupport.cs        |  16 +-
 ReleaseBuilder/CliCommand/Build.cs            |  97 ++++++++---
 ReleaseBuilder/Program.cs                     |   1 +
 10 files changed, 272 insertions(+), 61 deletions(-)
 create mode 100644 Installer/Docker/Dockerfile
 delete mode 100644 Installer/Docker/mono_image.txt

diff --git a/Installer/Docker/Dockerfile b/Installer/Docker/Dockerfile
new file mode 100644
index 000000000..c11abadf2
--- /dev/null
+++ b/Installer/Docker/Dockerfile
@@ -0,0 +1,20 @@
+FROM --platform=$TARGETPLATFORM alpine:latest
+
+ENV TINI_VERSION v0.19.0
+ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
+RUN chmod +x /tini
+ENTRYPOINT ["/tini", "--"]
+
+ENV XDG_CONFIG_HOME=/data
+VOLUME /data
+
+ARG CHANNEL=
+ARG VERSION=
+ENV DUPLICATI_CHANNEL=${CHANNEL}
+ENV DUPLICATI_VERSION=${VERSION}
+
+ARG TARGETARCH
+COPY ./${TARGETARCH} /opt/duplicati
+
+EXPOSE 8200
+CMD ["/opt/duplicati/duplicati-server", "--webservice-port=8200", "--webservice-interface=any"]
diff --git a/Installer/Docker/mono_image.txt b/Installer/Docker/mono_image.txt
deleted file mode 100644
index 62ea9e2ab..000000000
--- a/Installer/Docker/mono_image.txt
+++ /dev/null
@@ -1 +0,0 @@
-mono:6
diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json
index b8794164f..c1e59be7c 100644
--- a/ReleaseBuilder/.vscode/launch.json
+++ b/ReleaseBuilder/.vscode/launch.json
@@ -12,6 +12,7 @@
             "program": "${workspaceFolder}/bin/Debug/net8.0/ReleaseBuilder.dll",
             "args": [ 
                 "build", 
+                "--disable-docker-push", "true",
                 "--git-stash-push", "false", 
                 "--targets", "win-x64-gui.msi",
                 "--targets", "win-x64-gui.zip",                
@@ -22,6 +23,11 @@
                 "--targets", "osx-arm64-gui.pkg",
                 "--targets", "linux-x64-gui.deb", 
                 "--targets", "linux-x64-cli.deb", 
+                "--targets", "linux-arm64-gui.deb", 
+                "--targets", "linux-arm64-cli.deb", 
+                "--targets", "linux-x64-cli.docker", 
+                "--targets", "linux-arm64-cli.docker", 
+                "--targets", "linux-arm7-cli.docker", 
                 "--keep-builds", "true",
                 "--disable-authenticode", "true",
                 "--disable-signcode", "true",
diff --git a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
index 05f395d27..0b7f62ee2 100644
--- a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
+++ b/ReleaseBuilder/CliCommand/Build.Compile.Post.cs
@@ -134,7 +134,7 @@ public static partial class Build
             // Create target .app folder
             var appDir = Path.Combine(
                 buildroot,
-                $"{Path.GetFileName(buildDir)}-{MacOSAppName}"
+                $"{Path.GetFileName(buildDir)}-{rtcfg.MacOSAppName}"
             );
 
             if (Directory.Exists(appDir))
@@ -149,7 +149,7 @@ public static partial class Build
             }
 
             // Prepare the .app folder structure
-            var tmpApp = Path.Combine(buildroot, "tmpapp", MacOSAppName);
+            var tmpApp = Path.Combine(buildroot, "tmpapp", rtcfg.MacOSAppName);
 
             var folders = new[] {
             Path.Combine("Contents"),
diff --git a/ReleaseBuilder/CliCommand/Build.Compile.cs b/ReleaseBuilder/CliCommand/Build.Compile.cs
index 0d995af9f..65f4001f2 100644
--- a/ReleaseBuilder/CliCommand/Build.Compile.cs
+++ b/ReleaseBuilder/CliCommand/Build.Compile.cs
@@ -56,21 +56,23 @@ public static partial class Build
                         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
-                        //
+                        // TODO: Self contained builds are bloating the build size
                         // Alternative is to require the .NET runtime to be installed
 
+                        // Fix any RIDs that differ from .NET SDK
+                        var archstring = target.Arch switch
+                        {
+                            ArchType.Arm7 => $"{target.OSString}-arm",
+                            _ => target.BuildArchString
+                        };
+
                         var command = new string[] {
                             "dotnet", "publish", proj,
                             "-c", "Release",
                             "-o", tmpfolder,
-                            "-r", target.BuildArchString,
+                            "-r", archstring,
                             $"/p:AssemblyVersion={releaseInfo.Version}",
-                            $"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}",
+                            $"/p:Version={releaseInfo.Version}-{releaseInfo.Channel}-{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");
diff --git a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
index 7e61f7672..4928103a4 100644
--- a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
+++ b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
@@ -27,12 +27,40 @@ public static partial class Build
             else
                 Console.WriteLine($"Building {packagesToBuild.Count} packages");
 
-            foreach (var target in packagesToBuild)
+            // Build the packages, but skip Docker builds as they are bundled
+            foreach (var target in packagesToBuild.Where(x => x.Package != PackageType.Docker))
             {
                 Console.WriteLine($"Building {target.PackageTargetString} ...");
                 await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds);
                 Console.WriteLine("Completed!");
             }
+
+            // Build the Docker images with buildx for multi-arch support
+            var dockerTargets = packagesToBuild.Where(x => x.Package == PackageType.Docker).ToList();
+            if (dockerTargets.Count > 0)
+            {
+                var packageFolder = Path.Combine(buildRoot, "packages");
+                if (!Directory.Exists(packageFolder))
+                    Directory.CreateDirectory(packageFolder);
+
+
+                var packageFiles = dockerTargets.Select(x => Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{x.PackageTargetString}"))
+                    .ToList();
+
+                if (packageFiles.All(File.Exists))
+                {
+                    Console.WriteLine("All docker images already exist, skipping Docker build");
+                }
+                else
+                {
+                    Console.WriteLine($"Building {dockerTargets.Count} Docker images ...");
+                    await BuildDockerImages(baseDir, buildRoot, dockerTargets, rtcfg);
+
+                    // Create the files
+                    foreach (var f in packageFiles)
+                        File.WriteAllText(f, "");
+                }
+            }
         }
 
         /// 
@@ -51,7 +79,7 @@ public static partial class Build
 
             var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
             if (target.Package == PackageType.Deb)
-                packageFile = $"duplicati-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.deb";
+                packageFile = Path.Combine(packageFolder, $"duplicati-{target.InterfaceString}-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.deb");
 
             if (File.Exists(packageFile))
             {
@@ -131,6 +159,12 @@ public static partial class Build
                     using (var file = File.OpenRead(f))
                         await file.CopyToAsync(stream);
                 }
+
+                // Write the package type identifier
+                using (var stream = zip.CreateEntry(Path.Combine(dirName, "package_type_id.txt"), CompressionLevel.Optimal).Open())
+                using (var writer = new StreamWriter(stream))
+                    writer.WriteLine(target.PackageTargetString);
+
                 if (target.OS != OSType.Windows)
                 {
                     using (var stream = zip.CreateEntry(Path.Combine(dirName, "set-permissions.sh"), CompressionLevel.Optimal).Open())
@@ -143,7 +177,6 @@ public static partial class Build
                             writer.WriteLine($"chmod +x {x}");
                     }
                 }
-
             }
         }
 
@@ -161,21 +194,28 @@ public static partial class Build
             var installerDir = Path.Combine(baseDir, "Installer", "Windows");
             var binFiles = Path.Combine(installerDir, "binfiles.wxs");
 
-            var sourceFiles = Path.Combine(buildRoot, target.BuildTargetString);
+            var buildTmp = Path.Combine(buildRoot, "tmp-msi");
+            if (Directory.Exists(buildTmp))
+                Directory.Delete(buildTmp, true);
+
+            EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), buildTmp, recursive: true);
+            await PackageSupport.InstallPackageIdentifier(buildTmp, target);
+
+            var sourceFiles = buildTmp;
             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);
+            await ProcessHelper.Execute([
+                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);
@@ -235,12 +275,13 @@ public static partial class Build
             ], workingDirectory: mountDir);
 
             // Make the Duplicati.app structure, root folder should exist
-            var appFolder = Path.Combine(mountDir, MacOSAppName);
+            var appFolder = Path.Combine(mountDir, rtcfg.MacOSAppName);
             if (Directory.Exists(appFolder))
                 Directory.Delete(appFolder, true);
 
             // Place the prepared folder
-            EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
+            EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
+            await PackageSupport.InstallPackageIdentifier(appFolder, target);
             await PackageSupport.SetExecutableFlags(appFolder, rtcfg);
             await PackageSupport.MakeSymlinks(appFolder);
 
@@ -280,12 +321,13 @@ public static partial class Build
 
             var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
 
-            var appFolder = Path.Combine(tmpFolder, MacOSAppName);
+            var appFolder = Path.Combine(tmpFolder, rtcfg.MacOSAppName);
             if (Directory.Exists(appFolder))
                 Directory.Delete(appFolder, true);
 
             // Place the prepared folder
-            EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
+            EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
+            await PackageSupport.InstallPackageIdentifier(appFolder, target);
             await PackageSupport.SetExecutableFlags(appFolder, rtcfg);
             await PackageSupport.MakeSymlinks(appFolder);
 
@@ -381,6 +423,8 @@ public static partial class Build
                 Path.Combine(pkgroot, "usr", "lib", "duplicati"),
                 recursive: true);
 
+            await PackageSupport.InstallPackageIdentifier(Path.Combine(pkgroot, "usr", "lib", "duplicati"), target);
+
             // TODO: For improved Windows support, this can be done in Docker
             if (!OperatingSystem.IsWindows())
             {
@@ -467,6 +511,9 @@ public static partial class Build
 
             var debpkgname = $"{debpkgdir}.deb";
 
+            // Docker desktop has some sync issues
+            await Task.Delay(TimeSpan.FromSeconds(5));
+
             // Build in Docker
             await ProcessHelper.Execute([
                     "docker", "run",
@@ -479,4 +526,79 @@ public static partial class Build
             Directory.Delete(debroot, true);
         }
     }
+
+    /// 
+    /// Builds the Docker images for the specified targets with buildx
+    /// 
+    /// The base directory.
+    /// The build root directory.
+    /// The package target.
+    /// The runtime configuration.
+    /// A task representing the asynchronous operation.
+    private static async Task BuildDockerImages(string baseDir, string buildRoot, IEnumerable targets, RuntimeConfig rtcfg)
+    {
+        var installerDir = Path.Combine(baseDir, "Installer", "Docker");
+        var dockerArchs = targets.Select(target => target switch
+        {
+            PackageTarget { Arch: ArchType.x64, OS: OSType.Linux, Interface: InterfaceType.Cli } => "linux/amd64",
+            PackageTarget { Arch: ArchType.Arm64, OS: OSType.Linux, Interface: InterfaceType.Cli } => "linux/arm64",
+            PackageTarget { Arch: ArchType.Arm7, OS: OSType.Linux, Interface: InterfaceType.Cli } => "linux/arm/v7",
+            _ => throw new Exception($"Unsupported Docker target: {target.OS}/{target.Arch} ({target.Interface})")
+        });
+
+        var tmpbuild = Path.Combine(buildRoot, "tmp-docker");
+        if (Directory.Exists(tmpbuild))
+            Directory.Delete(tmpbuild, true);
+        Directory.CreateDirectory(tmpbuild);
+
+        // Copy in the source data
+        foreach (var target in targets)
+        {
+            // Mapping to the Docker TARGETARCH value
+            var dockerShortArch = target.Arch switch
+            {
+                ArchType.x64 => "amd64",
+                ArchType.Arm64 => "arm64",
+                ArchType.Arm7 => "arm",
+                _ => throw new Exception($"Unsupported Docker target: {target.Arch}")
+            };
+
+            var tgfolder = Path.Combine(tmpbuild, dockerShortArch);
+
+            EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), tgfolder, recursive: true);
+            await PackageSupport.InstallPackageIdentifier(tgfolder, target);
+            await PackageSupport.SetExecutableFlags(tgfolder, rtcfg);
+            await PackageSupport.MakeSymlinks(tgfolder);
+        }
+
+        var tags = new List { rtcfg.ReleaseInfo.Channel.ToString(), rtcfg.ReleaseInfo.Version.ToString() };
+        if (rtcfg.ReleaseInfo.Channel == ReleaseChannel.Stable)
+            tags.Add("latest");
+
+        // Make sure any dangling buildx instances are removed
+        try { await ProcessHelper.Execute([Program.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"], codeIsError: _ => false); }
+        catch { }
+
+        // Prepare multi-build
+        await ProcessHelper.Execute(new[] { Program.Configuration.Commands.Docker!, "buildx", "create", "--use", "--name", "duplicati-builder" });
+
+        // Build the images
+        var args = new List { Program.Configuration.Commands.Docker!, "buildx", "build" };
+        args.AddRange(tags.SelectMany(x => new[] { "-t", $"{rtcfg.DockerRepo}:{x}" }));
+        args.AddRange([
+            "--platform", string.Join(",", dockerArchs),
+            "--build-arg", $"VERSION={rtcfg.ReleaseInfo.Version}",
+            "--build-arg", $"CHANNEL={rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}",
+            "--file", Path.Combine(installerDir, "Dockerfile"),
+            "--output", $"type=image,push={rtcfg.PushToDocker.ToString().ToLowerInvariant()}",
+            "."
+        ]);
+
+        // Run the build
+        await ProcessHelper.Execute(args, workingDirectory: tmpbuild);
+
+        // Clean up
+        await ProcessHelper.Execute(new[] { Program.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder" });
+        Directory.Delete(tmpbuild, true);
+    }
 }
diff --git a/ReleaseBuilder/CliCommand/Build.GitPush.cs b/ReleaseBuilder/CliCommand/Build.GitPush.cs
index 0f320b18d..b44a5ad48 100644
--- a/ReleaseBuilder/CliCommand/Build.GitPush.cs
+++ b/ReleaseBuilder/CliCommand/Build.GitPush.cs
@@ -23,13 +23,15 @@ public static partial class Build
                 }, workingDirectory: baseDir);
 
             // Make a commit
+
+            // TODO: Since there is no longer a single binary, use github releases?
             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", $"Binaries: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip",
+                    "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip.sig",
+                    "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip.sig.asc",
                     "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
                     "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
                     "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
@@ -39,9 +41,9 @@ public static partial class Build
             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", $"Binaries: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip",
+                    "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip.sig",
+                    "-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Channel}/{releaseInfo.ReleaseName}.zip.sig.asc",
                     "-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
                     "-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
                     "-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
diff --git a/ReleaseBuilder/CliCommand/Build.PackageSupport.cs b/ReleaseBuilder/CliCommand/Build.PackageSupport.cs
index 45ec395d0..2298ce2e6 100644
--- a/ReleaseBuilder/CliCommand/Build.PackageSupport.cs
+++ b/ReleaseBuilder/CliCommand/Build.PackageSupport.cs
@@ -11,12 +11,13 @@ public static partial class Build
         /// Introduces symbolic links for executables that have a different name
         /// 
         /// The build path to use
+        /// The target directory to create the symlinks in
         /// An awaitable task
-        public static Task MakeSymlinks(string buildDir)
+        public static Task MakeSymlinks(string buildDir, string? targetDir = null)
         {
             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));
+                    File.CreateSymbolicLink(Path.Combine(buildDir, k.Value), Path.Combine(targetDir ?? ".", k.Key));
 
             return Task.CompletedTask;
         }
@@ -32,7 +33,7 @@ public static partial class Build
             if (!OperatingSystem.IsWindows())
             {
                 // Mark executables with the execute flag
-                var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(buildDir, x))
+                var executables = ExecutableRenames.Keys.Select(x => Path.Combine(buildDir, x))
                     .Concat(Directory.EnumerateFiles(buildDir, "*.sh", SearchOption.AllDirectories));
                 var filemode = EnvHelper.GetUnixFileMode("+x");
                 foreach (var x in executables)
@@ -42,5 +43,14 @@ public static partial class Build
 
             return Task.CompletedTask;
         }
+
+        /// 
+        /// Writes the package type identifier to the build directory
+        /// 
+        /// The build directory to update
+        /// The target configuration
+        /// An awaitable task
+        public static Task InstallPackageIdentifier(string buildDir, PackageTarget target)
+            => File.WriteAllTextAsync(Path.Combine(buildDir, "package_type_id.txt"), target.PackageTargetString);
     }
 }
\ No newline at end of file
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index 8e7d28f38..fdf93a534 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -41,11 +41,6 @@ public static partial class Build
         { "Duplicati.GUI.TrayIcon", "duplicati" }
     };
 
-    /// 
-    /// Name of the app bundle for MacOS
-    /// 
-    private const string MacOSAppName = "Duplicati.app";
-
     /// 
     /// The packages that are required for GUI builds
     /// 
@@ -68,11 +63,13 @@ public static partial class Build
         /// The release info to use
         /// The keyfile password to use
         /// The executables
-        public RuntimeConfig(ReleaseInfo releaseInfo, string keyfilePassword, IEnumerable executables)
+        /// The command input
+        public RuntimeConfig(ReleaseInfo releaseInfo, string keyfilePassword, IEnumerable executables, CommandInput input)
         {
             ReleaseInfo = releaseInfo;
             KeyfilePassword = keyfilePassword;
             ExecutableBinaries = executables;
+            Input = input;
         }
 
         /// 
@@ -80,6 +77,11 @@ public static partial class Build
         /// 
         private string? _pfxPassword = null;
 
+        /// 
+        /// The commandline input
+        /// 
+        private CommandInput Input { get; }
+
         /// 
         /// The release info for this run
         /// 
@@ -93,6 +95,8 @@ public static partial class Build
         /// 
         /// The executables that should exist in the build folder
         /// 
+        /// 
+        // TODO: Remove this?
         public IEnumerable ExecutableBinaries { get; }
 
         /// 
@@ -111,12 +115,11 @@ public static partial class Build
         /// 
         /// Checks if Authenticode signing should be enabled
         /// 
-        /// If signing should be disabled
-        public void ToggleAuthenticodeSigning(bool disabled)
+        public void ToggleAuthenticodeSigning()
         {
             if (!_useAuthenticodeSigning.HasValue)
             {
-                if (disabled)
+                if (Input.DisableAuthenticode)
                 {
                     _useAuthenticodeSigning = false;
                     return;
@@ -145,12 +148,11 @@ public static partial class Build
         /// 
         /// Checks if codesign is enabled
         /// 
-        /// If signing should be disabled
-        public void ToggleSignCodeSigning(bool disabled)
+        public void ToggleSignCodeSigning()
         {
             if (!_useCodeSignSigning.HasValue)
             {
-                if (disabled)
+                if (Input.DisableSignCode)
                 {
                     _useCodeSignSigning = false;
                     return;
@@ -219,6 +221,21 @@ public static partial class Build
         /// 
         public bool UseDockerBuild => _dockerBuild!.Value;
 
+        /// 
+        /// Gets the MacOS app bundle name
+        /// 
+        public string MacOSAppName => Input.MacOSAppName;
+
+        /// 
+        /// The docker repository to use
+        /// 
+        public string DockerRepo => Input.DockerRepo;
+
+        /// 
+        /// Gets a value indicating if pushing should be enabled
+        /// 
+        public bool PushToDocker => !Input.DisableDockerPush;
+
         /// 
         /// Decrypts the password file and returns the PFX password
         /// 
@@ -277,14 +294,14 @@ public static partial class Build
     /// Structure for keeping all variables for a single release
     /// 
     /// The version to use
-    /// The release type
+    /// The release channel
     /// The release timestamp
-    private record ReleaseInfo(Version Version, ReleaseChannel Type, DateTime Timestamp)
+    private record ReleaseInfo(Version Version, ReleaseChannel Channel, DateTime Timestamp)
     {
         /// 
         /// Gets the string name for the release
         /// 
-        public string ReleaseName => $"{Version}_{Type.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}";
+        public string ReleaseName => $"{Version}_{Channel.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}";
 
 
         /// 
@@ -373,6 +390,24 @@ public static partial class Build
             getDefaultValue: () => string.Empty
         );
 
+        var disableDockerPushOption = new Option(
+            name: "--disable-docker-push",
+            description: "Disables pushing the docker image to the repository",
+            getDefaultValue: () => false
+        );
+
+        var macOsAppNameOption = new Option(
+            name: "--macos-app-name",
+            description: "The name of the MacOS app bundle",
+            getDefaultValue: () => "Duplicati.app"
+        );
+
+        var dockerRepoOption = new Option(
+            name: "--docker-repo",
+            description: "The docker repository to push to",
+            getDefaultValue: () => "duplicati/duplicati"
+        );
+
         var command = new Command("build", "Builds the packages for a release") {
             gitStashPushOption,
             releaseChannelOption,
@@ -383,7 +418,10 @@ public static partial class Build
             keepBuildsOption,
             disableAuthenticodeOption,
             disableCodeSignOption,
-            passwordOption
+            passwordOption,
+            macOsAppNameOption,
+            disableDockerPushOption,
+            dockerRepoOption
         };
 
         command.Handler = CommandHandler.Create(DoBuild);
@@ -396,13 +434,16 @@ public static partial class Build
     /// The build targets
     /// The build path
     /// The solution path
-    /// If the git stash should be performed
-    /// The release channel
+    /// If the git stash should be performed
+    /// The release channel
     /// The update urls
     /// If the builds should be kept
     /// If authenticode signing should be disabled
     /// If signcode should be disabled
     /// The password to use for the keyfile
+    /// If the docker push should be disabled
+    /// The name of the MacOS app bundle
+    /// The docker repository to push to
     record CommandInput(
         PackageTarget[] Targets,
         DirectoryInfo BuildPath,
@@ -413,7 +454,10 @@ public static partial class Build
         bool KeepBuilds,
         bool DisableAuthenticode,
         bool DisableSignCode,
-        string Password
+        string Password,
+        bool DisableDockerPush,
+        string MacOSAppName,
+        string DockerRepo
     );
 
     static async Task DoBuild(CommandInput input)
@@ -475,9 +519,14 @@ public static partial class Build
             : input.Password;
 
         // Configure runtime environment
-        var rtcfg = new RuntimeConfig(releaseInfo, keyfilePassword, sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList());
-        rtcfg.ToggleAuthenticodeSigning(input.DisableAuthenticode);
-        rtcfg.ToggleSignCodeSigning(input.DisableSignCode);
+        var rtcfg = new RuntimeConfig(
+            releaseInfo,
+            keyfilePassword,
+            sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList(),
+            input);
+
+        rtcfg.ToggleAuthenticodeSigning();
+        rtcfg.ToggleSignCodeSigning();
         await rtcfg.ToggleDockerBuild();
 
         if (!rtcfg.UseDockerBuild)
@@ -541,12 +590,12 @@ public static partial class Build
     static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, string updateUrls)
     {
         updateUrls = updateUrls
-            .Replace("${RELEASE_TYPE}", releaseInfo.Type.ToString().ToLowerInvariant())
+            .Replace("${RELEASE_TYPE}", releaseInfo.Channel.ToString().ToLowerInvariant())
             .Replace("${RELEASE_VERSION}", releaseInfo.Version.ToString())
             .Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd"));
 
         File.WriteAllText(Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"), releaseInfo.Version.ToString());
-        File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Type.ToString().ToLowerInvariant());
+        File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Channel.ToString().ToLowerInvariant());
         File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), updateUrls);
         File.Copy(
             Path.Combine(baseDir, "Updates", "release_key.txt"),
diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs
index 00777d900..f11a2dee6 100644
--- a/ReleaseBuilder/Program.cs
+++ b/ReleaseBuilder/Program.cs
@@ -34,6 +34,7 @@ class Program
         "linux-arm64-cli.deb",
         "linux-arm64-cli.rpm",
         // "linux-arm64-cli.spk",
+        "linux-arm7-cli.docker",
         "osx-x64-gui.dmg",
         "osx-x64-gui.pkg",
         "osx-arm64-gui.dmg",

From 760eea01c814be568d01499ec4d114eb23e58ea6 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Mon, 25 Mar 2024 11:54:33 +0100
Subject: [PATCH 16/91] Removed leftovers for Docker

---
 Installer/Docker/build-images.sh          | 67 -----------------------
 Installer/Docker/context/Dockerfile       | 37 -------------
 Installer/Docker/context/duplicati-cli    |  4 --
 Installer/Docker/context/duplicati-server |  4 --
 4 files changed, 112 deletions(-)
 delete mode 100755 Installer/Docker/build-images.sh
 delete mode 100644 Installer/Docker/context/Dockerfile
 delete mode 100755 Installer/Docker/context/duplicati-cli
 delete mode 100755 Installer/Docker/context/duplicati-server

diff --git a/Installer/Docker/build-images.sh b/Installer/Docker/build-images.sh
deleted file mode 100755
index 68143999a..000000000
--- a/Installer/Docker/build-images.sh
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/bin/bash
-
-if [ ! -f "$1" ]; then
-    echo "Please provide the filename of an existing zip build as the first argument"
-    exit
-fi
-
-PLATFORMS="linux/amd64,linux/arm/v7,linux/arm64"
-DEFAULT_CHANNEL=beta
-REPOSITORY=duplicati/duplicati
-PUSH_TO_REGISTRY=${PUSH_TO_REGISTRY:-true}
-
-ARCHIVE_NAME=$(basename -s .zip $1)
-VERSION=$(echo "${ARCHIVE_NAME}" | cut -d "-" -f 2-)
-CHANNEL=$(echo "${ARCHIVE_NAME}" | cut -d "_" -f 2)
-DIRNAME=duplicati
-
-if [ -d "${DIRNAME}" ]; then
-    rm -rf "${DIRNAME}"
-fi
-
-unzip -d "${DIRNAME}" "$1"
-
-for n in "../oem" "../../oem" "../../../oem"
-do
-    if [ -d $n ]; then
-        echo "Installing OEM files"
-        cp -R $n "${DIRNAME}/webroot/"
-    fi
-done
-
-for n in "oem-app-name.txt" "oem-update-url.txt" "oem-update-key.txt" "oem-update-readme.txt" "oem-update-installid.txt"
-do
-    for p in "../$n" "../../$n" "../../../$n"
-    do
-        if [ -f $p ]; then
-            echo "Installing OEM override file"
-            cp $p "${DIRNAME}"
-        fi
-    done
-done
-
-tags="${VERSION} ${CHANNEL}"
-if [ ${CHANNEL} = ${DEFAULT_CHANNEL} ]; then
-    tags="latest ${tags}"
-fi
-
-args=""
-for tag in ${tags}; do
-    args="-t ${REPOSITORY}:${tag} ${args}"
-done
-
-docker buildx create --use --name duplicati-multiarch
-
-docker buildx build \
-    ${args} \
-    --platform ${PLATFORMS} \
-    --build-arg PARENT_IMAGE="$(cat mono_image.txt)-slim" \
-    --build-arg VERSION=${VERSION} \
-    --build-arg CHANNEL=${CHANNEL} \
-    --file context/Dockerfile \
-    --output type=image,push=${PUSH_TO_REGISTRY} \
-    .
-
-docker buildx rm duplicati-multiarch
-
-rm -rf "${DIRNAME}"
diff --git a/Installer/Docker/context/Dockerfile b/Installer/Docker/context/Dockerfile
deleted file mode 100644
index f7e1c5c0c..000000000
--- a/Installer/Docker/context/Dockerfile
+++ /dev/null
@@ -1,37 +0,0 @@
-ARG PARENT_IMAGE
-FROM --platform=$TARGETPLATFORM ${PARENT_IMAGE}
-
-RUN apt-get update && \
-    apt-get install -y --no-install-recommends \
-        curl \
-        mono-complete \
-        libmono-sqlite4.0-cil \
-        libmono-system-drawing4.0-cil \
-        libmono-system-net-http-webrequest4.0-cil \
-        libmono-system-web4.0-cil \
-        referenceassemblies-pcl && \
-    rm -rf /var/lib/apt/lists && \
-    cert-sync /etc/ssl/certs/ca-certificates.crt && \
-    # this obsolete cert can mess the certificate chain because of a Mono bug
-    rm -f /usr/share/ca-certificates/mozilla/DST_Root_CA_X3.crt && \
-    update-ca-certificates
-
-ENV TINI_VERSION v0.16.1
-RUN curl -L -o /usr/sbin/tini https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-$(dpkg --print-architecture) && \
-    chmod 0755 /usr/sbin/tini
-ENTRYPOINT ["/usr/sbin/tini", "--"]
-
-ENV XDG_CONFIG_HOME=/data
-VOLUME /data
-
-COPY context/duplicati-cli context/duplicati-server /usr/bin/
-RUN chmod 0755 /usr/bin/duplicati-cli /usr/bin/duplicati-server
-
-ARG CHANNEL=
-ARG VERSION=
-ENV DUPLICATI_CHANNEL=${CHANNEL}
-ENV DUPLICATI_VERSION=${VERSION}
-COPY duplicati /opt/duplicati
-
-EXPOSE 8200
-CMD ["/usr/bin/duplicati-server", "--webservice-port=8200", "--webservice-interface=any"]
diff --git a/Installer/Docker/context/duplicati-cli b/Installer/Docker/context/duplicati-cli
deleted file mode 100755
index 5bec19f01..000000000
--- a/Installer/Docker/context/duplicati-cli
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/bash
-EXE_FILE=/opt/duplicati/Duplicati.CommandLine.exe
-APP_NAME=Duplicati.CommandLine
-exec -a "$APP_NAME" mono "$EXE_FILE" "$@"
diff --git a/Installer/Docker/context/duplicati-server b/Installer/Docker/context/duplicati-server
deleted file mode 100755
index c603da265..000000000
--- a/Installer/Docker/context/duplicati-server
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/bash
-EXE_FILE=/opt/duplicati/Duplicati.Server.exe
-APP_NAME=DuplicatiServer
-exec -a "$APP_NAME" mono "$EXE_FILE" "$@"

From e97783382d200c41af85dbfa66dfb8971d001d19 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Tue, 26 Mar 2024 11:56:07 +0100
Subject: [PATCH 17/91] Added support for RPM builds

---
 Installer/fedora/Dockerfile.build             |  14 +-
 Installer/fedora/build-package.sh             |  25 ---
 Installer/fedora/build.sh                     |  13 --
 Installer/fedora/docker-build-package.sh      |  52 ------
 Installer/fedora/docker/Dockerfile            |  17 --
 .../fedora/docker/buildroot/duplicati.spec    | 155 ------------------
 Installer/fedora/docker/runner.sh             |  28 ----
 .../duplicati-0001-remove-unittest.patch      |  24 ---
 Installer/fedora/duplicati-binary.spec        |  76 +++------
 .../fedora/duplicati-install-binaries.sh      |  10 ++
 .../fedora/duplicati-make-git-snapshot.sh     |  93 -----------
 Installer/fedora/duplicati.desktop            |  12 ++
 Installer/fedora/duplicati.png                | Bin 0 -> 579 bytes
 .../{docker/buildroot => }/duplicati.xpm      |   0
 Installer/fedora/inside-docker.sh             |  12 ++
 ReleaseBuilder/.vscode/launch.json            |   4 +
 .../CliCommand/Build.CreatePackage.cs         | 125 ++++++++++++++
 ReleaseBuilder/CliCommand/Build.cs            |   9 +
 18 files changed, 200 insertions(+), 469 deletions(-)
 delete mode 100755 Installer/fedora/build-package.sh
 delete mode 100755 Installer/fedora/build.sh
 delete mode 100755 Installer/fedora/docker-build-package.sh
 delete mode 100644 Installer/fedora/docker/Dockerfile
 delete mode 100644 Installer/fedora/docker/buildroot/duplicati.spec
 delete mode 100755 Installer/fedora/docker/runner.sh
 delete mode 100644 Installer/fedora/duplicati-0001-remove-unittest.patch
 create mode 100755 Installer/fedora/duplicati-install-binaries.sh
 delete mode 100755 Installer/fedora/duplicati-make-git-snapshot.sh
 create mode 100644 Installer/fedora/duplicati.desktop
 create mode 100644 Installer/fedora/duplicati.png
 rename Installer/fedora/{docker/buildroot => }/duplicati.xpm (100%)
 create mode 100755 Installer/fedora/inside-docker.sh

diff --git a/Installer/fedora/Dockerfile.build b/Installer/fedora/Dockerfile.build
index 773a9ba03..a70457033 100644
--- a/Installer/fedora/Dockerfile.build
+++ b/Installer/fedora/Dockerfile.build
@@ -9,20 +9,12 @@ RUN dnf -y install deltarpm
 RUN dnf -y upgrade
 RUN dnf -y --allowerasing install @"Minimal Install" @buildsys-build yum-utils rpm-sign gnupg rpmdevtools
 
-# Install real mono for building
-# Instructions from here: https://www.mono-project.com/download/stable/#download-lin-fedora
-RUN rpm --import "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF"
-RUN curl https://download.mono-project.com/repo/centos8-stable.repo | tee /etc/yum.repos.d/mono-centos8-stable.repo
 RUN dnf -y update
 
-# Install mono things
-RUN dnf -y install mono-devel gnome-sharp-devel dos2unix git nuget desktop-file-utils
+# Install build things
+RUN dnf -y install desktop-file-utils
 
-# Fix nuget
-RUN cert-sync /etc/pki/tls/certs/ca-bundle.crt
-RUN nuget update -self
-
-label org.label-schema.name = "duplicati/fedora-build" \
+LABEL org.label-schema.name = "duplicati/fedora-build" \
       org.label-schema.version = "20161230" \
       org.label-schema.vendor="Deployable" \
       org.label-schema.docker.cmd="docker run -ti duplicati/fedora-build" \
diff --git a/Installer/fedora/build-package.sh b/Installer/fedora/build-package.sh
deleted file mode 100755
index 4b4621df4..000000000
--- a/Installer/fedora/build-package.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-
-git pull
-
-DATE=$(date +%Y%m%d)
-VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-GITTAG=$(git rev-parse --short HEAD)
-RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-
-
-bash duplicati-make-git-snapshot.sh "${GITTAG}" "${DATE}" "${VERSION}" "${RELEASETYPE}" "${BUILDTAG}-${GITTAG}"
-mv duplicati-$DATE.tar.bz2 ~/rpmbuild/SOURCES/ 
-cp *.sh ~/rpmbuild/SOURCES/
-cp *.patch ~/rpmbuild/SOURCES/
-cp duplicati.xpm ~/rpmbuild/SOURCES/
-cp build-package.sh ~/rpmbuild/SOURCES/duplicati-build-package.sh
-
-echo "%global _gittag ${GITTAG}" > ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _builddate ${DATE}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _buildversion ${VERSION}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _releasetype ${RELEASETYPE}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-
-rpmbuild -bs duplicati.spec
-rpmbuild -bb duplicati.spec
diff --git a/Installer/fedora/build.sh b/Installer/fedora/build.sh
deleted file mode 100755
index f4866025d..000000000
--- a/Installer/fedora/build.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-#This is a helper to make the release linux zip. Mainly for testing outside of ci/cd
-
-SCRIPTDIR=$( cd "$(dirname "$0")" ; pwd -P )
-
-docker build $SCRIPTDIR/docker -t duplicati-fedora
-
-VERSION=`grep '' < $SCRIPTDIR/../../Executables/net8/Duplicati.Server/Duplicati.Server.csproj | sed 's/.*\(.*\)<\/Version>.*/\1/'`
-VERSION=${VERSION//$'\r\n'}
-echo "Building version: ($VERSION)"
-
-export MSYS_NO_PATHCONV=1
-docker run --rm -eVERSION=$VERSION -v $SCRIPTDIR/../../:/sources duplicati-fedora
\ No newline at end of file
diff --git a/Installer/fedora/docker-build-package.sh b/Installer/fedora/docker-build-package.sh
deleted file mode 100755
index a2cb69f24..000000000
--- a/Installer/fedora/docker-build-package.sh
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/bin/bash
-
-git pull
-
-DATE=$(date +%Y%m%d)
-VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-GITTAG=$(git rev-parse --short HEAD)
-RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-CWD=$(pwd)
-
-bash duplicati-make-git-snapshot.sh "${GITTAG}" "${DATE}" "${VERSION}" "${RELEASETYPE}" "${BUILDTAG}-${GITTAG}"
-
-RPMBUILD="${CWD}/${BUILDTAG}-rpmbuild"
-if [ -d "${RPMBUILD}" ]; then
-    rm -rf "${RPMBUILD}"
-fi
-
-mkdir -p "${RPMBUILD}"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
-
-mv duplicati-$DATE.tar.bz2 "${RPMBUILD}/SOURCES/"
-cp *.sh "${RPMBUILD}/SOURCES/"
-cp *.patch "${RPMBUILD}/SOURCES/"
-cp duplicati.xpm "${RPMBUILD}/SOURCES/"
-cp build-package.sh "${RPMBUILD}/SOURCES/duplicati-build-package.sh"
-
-echo "%global _gittag ${GITTAG}" > "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-echo "%global _builddate ${DATE}" >> "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-echo "%global _buildversion ${VERSION}" >> "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-echo "%global _releasetype ${RELEASETYPE}" >> "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-
-docker build -t "duplicati/fedora-build:latest" - < Dockerfile.build
-
-# Weirdness with time not being synced in Docker instance
-sleep 5
-docker run  \
-    --workdir "/buildroot" \
-    --volume "${CWD}":"/buildroot":"rw" \
-    --volume "${RPMBUILD}":"/root/rpmbuild":"rw" \
-    "duplicati/fedora-build:latest" \
-    rpmbuild -bs duplicati.spec
-
-docker run  \
-    --workdir "/buildroot" \
-    --volume "${CWD}":"/buildroot":"rw" \
-    --volume "${RPMBUILD}":"/root/rpmbuild":"rw" \
-    "duplicati/fedora-build:latest" \
-    rpmbuild -bb duplicati.spec
-
-mv "${RPMBUILD}/RPMS/noarch/"*.rpm .
-mv "${RPMBUILD}/SRPMS/"*.rpm .
-rm -rf "${RPMBUILD}"
diff --git a/Installer/fedora/docker/Dockerfile b/Installer/fedora/docker/Dockerfile
deleted file mode 100644
index 1c256066d..000000000
--- a/Installer/fedora/docker/Dockerfile
+++ /dev/null
@@ -1,17 +0,0 @@
-FROM fedora:36
-
-# Install common build tools
-RUN dnf -y install deltarpm
-RUN dnf -y upgrade
-
-#No Longer needed
-#RUN rpm --import https://packages.microsoft.com/keys/microsoft.asc
-#RUN curl --output /etc/yum.repos.d/microsoft-prod.repo https://packages.microsoft.com/config/fedora/33/prod.repo
-
-RUN dnf check-update
-RUN dnf -y --allowerasing install @"Minimal Install" @buildsys-build yum-utils rpm-sign gnupg rpmdevtools desktop-file-utils dos2unix dotnet-sdk-6.0
-
-ADD buildroot /buildroot
-
-ADD runner.sh /
-CMD /runner.sh
diff --git a/Installer/fedora/docker/buildroot/duplicati.spec b/Installer/fedora/docker/buildroot/duplicati.spec
deleted file mode 100644
index 06653174c..000000000
--- a/Installer/fedora/docker/buildroot/duplicati.spec
+++ /dev/null
@@ -1,155 +0,0 @@
-# TODO:
-# - check where Tools/* scripts should really be
-# - try to fix every mono compiler warning
-# - fix rpmlint warnings
-
-# Set up some defaults
-%global namer duplicati
-%global debug_package %{nil}
-%global alphatag .git
-
-# Then load overrides
-%include %{_topdir}/SOURCES/%{namer}-buildinfo.spec
-
-Name:	%{namer}
-Version:	%{_buildversion}
-Release:	%{_gittag}%{?alphatag}%{?dist}
-Icon: duplicati.xpm
-#BuildArch:  noarch
-#Should work, but does not allow building noarch
-#ExclusiveArch: % {mono_arches}
-
-# Disable auto dependencies as it picks up .Net 2.0 profile
-#   and does not support supplying them with 4.5
-# Also, all thirdparty libraries are given as "provides" but they
-#   are not installed for use externally
-AutoReqProv: no
-
-Summary:	Backup client for encrypted online backups
-License:	MIT
-URL:	http://www.duplicati.com
-#Source0:	http://duplicati.googlecode.com/files/Duplicati%20% {_buildversion}.tgz
-Source0:	duplicati-%{_buildversion}.tar.bz2
-
-# based on libdrm's make-git-snapshot.sh 
-# sh duplicati-make-git-snapshot.sh  <_builddate>
-Source1:	%{namer}-build-package.sh
-Source2:	%{namer}-make-git-snapshot.sh
-Source3:	%{namer}-buildinfo.spec
-
-BuildRequires:  desktop-file-utils
-BuildRequires:  dos2unix
-BuildRequires:  systemd
-
-Requires:	desktop-file-utils
-Requires:	bash
-Requires:	libappindicator
-
-Provides:	duplicati
-Provides:	duplicati-cli
-Provides:	duplicati-server
-
-%description
-Duplicati is a free backup client that securely stores encrypted,
-incremental, compressed backups on cloud storage services and remote file
-servers.  It supports targets like Amazon S3, Windows Live SkyDrive,
-Rackspace Cloud Files or WebDAV, SSH, FTP (and many more).
- 
-Duplicati has built-in AES-256 encryption and backups be can signed using
-GNU Privacy Guard.  A built-in scheduler makes sure that backups are always
-up-to-date.  Last but not least, Duplicati provides various options and
-tweaks like filters, deletion rules, transfer and bandwidth options to run
-backups for specific purposes.
-
-%prep
-%setup -q
-
-find -type f -name "*dll" -or -name "*DLL" -or -name "*exe"
-
-%build
-
-dotnet publish -c Release --runtime=linux-x64 -p:DefineConstants=ENABLE_GTK -o publish Duplicati.sln
-
-%install
-
-# removing non-platform thirdparty binaries:
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/win-tools
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/SQLite/win64
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/SQLite/win32
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/MonoMac.dll
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/OSX\ Icons
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/OSXTrayHost
-
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/licenses/MonoMac
-rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Release/licenses/gpg
-
-# Mono binaries are installed in /usr/lib, not /usr/lib64, even on x86_64:
-# https://fedoraproject.org/wiki/Packaging:Mono
-
-install -d %{buildroot}%{_datadir}/pixmaps/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/dark/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/light/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/licenses/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/webroot/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/lvm-scripts/
-
-install -d %{buildroot}%{_unitdir}/
-install -d %{buildroot}%{_sysconfdir}/sysconfig/
-install -d %{buildroot}%{_bindir}/
-
-ln -sf /usr/lib/%{namer}/Duplicati.GUI.TrayIcon %{buildroot}%{_bindir}/%{namer}
-ln -sf /usr/lib/%{namer}/Duplicati.CommandLine %{buildroot}%{_bindir}/%{namer}-cli
-ln -sf /usr/lib/%{namer}/Duplicati.Server %{buildroot}%{_bindir}/%{namer}-server
-
-# Install oem overrides
-if [ -f "oem-app-name.txt" ]; then install -p -m 644 "oem-app-name.txt" %{buildroot}%{_exec_prefix}/lib/%{namer}/; fi
-if [ -f "oem-update-url.txt" ]; then install -p -m 644 "oem-update-url.txt" %{buildroot}%{_exec_prefix}/lib/%{namer}/; fi
-if [ -f "oem-update-key.txt" ]; then install -p -m 644 "oem-update-key.txt" %{buildroot}%{_exec_prefix}/lib/%{namer}/; fi
-if [ -f "oem-update-readme.txt" ]; then install -p -m 644 "oem-update-readme.txt" %{buildroot}%{_exec_prefix}/lib/%{namer}/; fi
-if [ -f "oem-update-installid.txt" ]; then install -p -m 644 "oem-update-installid.txt" %{buildroot}%{_exec_prefix}/lib/%{namer}/; fi
-
-/bin/bash Installer/fedora/%{namer}-install-recursive.sh "publish/" "%{buildroot}%{_exec_prefix}/lib/%{namer}/"
-
-install -p Installer/debian/%{namer}.png %{buildroot}%{_datadir}/pixmaps/
-
-chmod 755 %{buildroot}%{_exec_prefix}/lib/%{namer}/Duplicati.GUI.TrayIcon
-chmod 755 %{buildroot}%{_exec_prefix}/lib/%{namer}/Duplicati.CommandLine
-chmod 755 %{buildroot}%{_exec_prefix}/lib/%{namer}/Duplicati.Server
-find "%{buildroot}%{_exec_prefix}/lib/%{namer}"/* -type f -name \*.sh | xargs chmod 755
-
-desktop-file-install Installer/debian/%{namer}.desktop 
-
-mv Duplicati/Library/Snapshots/lvm-scripts/remove-lvm-snapshot.sh Tools/
-mv Duplicati/Library/Snapshots/lvm-scripts/create-lvm-snapshot.sh Tools/
-mv Duplicati/Library/Snapshots/lvm-scripts/find-volume.sh Tools/
-mv Duplicati/Library/Modules/Builtin/run-script-example.sh Tools/
-
-# Install the service:
-install -p -D -m 755 Installer/fedora/%{namer}.service %{buildroot}%{_unitdir}/
-install -p -D -m 644 Installer/fedora/%{namer}.default %{buildroot}%{_sysconfdir}/sysconfig/
-
-
-%post
-%systemd_post %{namer}.service
-
-%preun
-%systemd_preun %{namer}.service
-
-%postun
-%systemd_postun %{namer}.service
-
-%files
-%doc changelog.txt Duplicati/license.txt Tools
-%{_bindir}/*
-%{_datadir}/*/*
-%{_exec_prefix}/lib/*
-%{_sysconfdir}/sysconfig/*
-
-
-%changelog
-* Fri Jan 01 2021 Kenneth Skovhede 2.0
-- Packaged release
-- See changelog.txt for changes
-
diff --git a/Installer/fedora/docker/runner.sh b/Installer/fedora/docker/runner.sh
deleted file mode 100755
index 4c48f6d5d..000000000
--- a/Installer/fedora/docker/runner.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-mkdir -p ~/rpmbuild/SOURCES/
-
-
-
-cp /buildroot/duplicati.xpm ~/rpmbuild/SOURCES/
-# cp make-binary-package.sh ~/rpmbuild/SOURCES/duplicati-make-binary-package.sh
-# cp duplicati-install-recursive.sh ~/rpmbuild/SOURCES/duplicati-install-recursive.sh
-# cp duplicati.service ~/rpmbuild/SOURCES/duplicati.service
-# cp duplicati.default ~/rpmbuild/SOURCES/duplicati.default
-
-
-BUILDDATE=$(LANG=C date -R)
-GITTAG="1"
-
-echo Creating Tar...
-(cd /sources/ && tar --exclude="./.git" --transform "s,^./,duplicati-${VERSION}/," -cjf ~/rpmbuild/SOURCES/duplicati-${VERSION}.tar.bz2 .)
-tar -tf ~/rpmbuild/SOURCES/duplicati-${VERSION}.tar.bz2 | head
-echo Done...
-
-echo "%global _builddate ${BUILDDATE}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _buildversion ${VERSION}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _gittag ${GITTAG}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-
-cd /buildroot
-dos2unix duplicati.spec
-rpmbuild -bb duplicati.spec
-
-cp /root/rpmbuild/RPMS/*/*.rpm /sources/
\ No newline at end of file
diff --git a/Installer/fedora/duplicati-0001-remove-unittest.patch b/Installer/fedora/duplicati-0001-remove-unittest.patch
deleted file mode 100644
index 6d147de70..000000000
--- a/Installer/fedora/duplicati-0001-remove-unittest.patch
+++ /dev/null
@@ -1,24 +0,0 @@
-diff --git a/Duplicati.sln b/Duplicati.sln
-index eec5b10..3384993 100644
---- a/Duplicati.sln
-+++ b/Duplicati.sln
-@@ -59,8 +59,6 @@
- EndProject
- Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.AzureBlob", "Duplicati\Library\Backend\AzureBlob\Duplicati.Library.Backend.AzureBlob.csproj", "{8E4CECFB-0413-4B00-AB93-78D1C3902BD5}"
- EndProject
--Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.UnitTest", "Duplicati\UnitTest\Duplicati.UnitTest.csproj", "{ECB63D1C-1724-442D-9228-DEABF14F2EA3}"
--EndProject
- Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.OAuthHelper", "Duplicati\Library\Backend\OAuthHelper\Duplicati.Library.OAuthHelper.csproj", "{D4C37C33-5E73-4B56-B2C3-DC4A6BAA36BB}"
- EndProject
- Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.GoogleServices", "Duplicati\Library\Backend\GoogleServices\Duplicati.Library.Backend.GoogleServices.csproj", "{5489181D-950C-44AF-873C-45EB0A3B6BD2}"
-@@ -225,10 +223,6 @@
- 		{8E4CECFB-0413-4B00-AB93-78D1C3902BD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
- 		{8E4CECFB-0413-4B00-AB93-78D1C3902BD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
- 		{8E4CECFB-0413-4B00-AB93-78D1C3902BD5}.Release|Any CPU.Build.0 = Release|Any CPU
--		{ECB63D1C-1724-442D-9228-DEABF14F2EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
--		{ECB63D1C-1724-442D-9228-DEABF14F2EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
--		{ECB63D1C-1724-442D-9228-DEABF14F2EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
--		{ECB63D1C-1724-442D-9228-DEABF14F2EA3}.Release|Any CPU.Build.0 = Release|Any CPU
- 		{D4C37C33-5E73-4B56-B2C3-DC4A6BAA36BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- 		{D4C37C33-5E73-4B56-B2C3-DC4A6BAA36BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- 		{D4C37C33-5E73-4B56-B2C3-DC4A6BAA36BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
diff --git a/Installer/fedora/duplicati-binary.spec b/Installer/fedora/duplicati-binary.spec
index 0de8cc9e7..cba2f7ff0 100644
--- a/Installer/fedora/duplicati-binary.spec
+++ b/Installer/fedora/duplicati-binary.spec
@@ -1,24 +1,19 @@
 # TODO:
 # - check where Tools/* scripts should really be
-# - try to fix every mono compiler warning
 # - fix rpmlint warnings
 
 # Set up some defaults
 %global namer duplicati
 %global debug_package %{nil}
 %global alphatag .git
-
-# Then load overrides
-%include %{_topdir}/SOURCES/%{namer}-buildinfo.spec
-
-# Make sure it does not break because we have som arch-dependant libraries bundled
-%define _binaries_in_noarch_packages_terminate_build 0
+%global _builddate %BUILDDATE%
+%global _buildversion %BUILDVERSION%
+%global _buildtag %BUILDTAG%
 
 Name:	%{namer}
 Version:	%{_buildversion}
 Release:	%{_buildtag}
 Icon: duplicati.xpm
-BuildArch:  noarch
 
 # Disable auto dependencies as it picks up .Net 2.0 profile
 #   and does not support supplying them with 4.5
@@ -28,25 +23,23 @@ AutoReqProv: no
 
 Summary:	Backup client for encrypted online backups
 License:	MIT
-URL:	http://www.duplicati.com
+URL:	https://duplicati.com
 Source0:	duplicati-%{_buildversion}.tar.bz2
-Source1:	%{namer}-make-binary-package.sh
-Source2: 	%{namer}-install-recursive.sh
+Source1: 	%{namer}-install-recursive.sh
+Source2:  %{namer}-install-binaries.sh
 Source3: 	%{namer}.service
 Source4: 	%{namer}.default
+Source5: 	%{namer}.png
+Source6: 	%{namer}.desktop
 
 BuildRequires:  desktop-file-utils
-BuildRequires:  dos2unix
 BuildRequires:  systemd
-BuildRequires:  dotnet
 
 Requires:	desktop-file-utils
 Requires:	bash
-Requires:	libappindicator
+%DEPENDS%
 
-Provides:	duplicati
-Provides:	duplicati-cli
-Provides:	duplicati-server
+%PROVIDES%
 
 %description 
 Duplicati is a free backup client that securely stores encrypted,
@@ -65,52 +58,31 @@ backups for specific purposes.
 
 %build
 
-# removing non-platform thirdparty binaries:
-rm -rf win-tools
-rm -rf SQLite/win64
-rm -rf SQLite/win32
-rm -rf MonoMac.dll
-rm -rf OSX\ Icons
-rm -rf OSXTrayHost
-rm -rf licenses/MonoMac
-rm -rf licenses/gpg
-rm -rf win-x64\storj_uplink.dll
-rm -rf win-x86\storj_uplink.dll
-rm -rf libstorj_uplink.dylib
-
+# Build is expected to be complete
+# so no build action is performed
 
 %install
 
 install -d %{buildroot}%{_datadir}/pixmaps/
 install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/dark/
-install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/SVGIcons/light/
 install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/licenses/
 install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/webroot/
 install -d %{buildroot}%{_exec_prefix}/lib/%{namer}/lvm-scripts/
+install -d %{buildroot}%{_exec_prefix}/bin/
 
+# Remove packaging artifacts
+find . -type f -name ._\* | xargs rm -rf
+
+# Install all files, but the list is too long to be in the script itself :/
 /bin/bash %{_topdir}/SOURCES/%{namer}-install-recursive.sh "." "%{buildroot}%{_exec_prefix}/lib/%{namer}/"
 
-# We do not want these files in the lib folder
-rm "%{buildroot}%{_exec_prefix}/lib/%{namer}/%{namer}-launcher.sh"
-rm "%{buildroot}%{_exec_prefix}/lib/%{namer}/%{namer}-commandline-launcher.sh"
-rm "%{buildroot}%{_exec_prefix}/lib/%{namer}/%{namer}-server-launcher.sh"
-rm "%{buildroot}%{_exec_prefix}/lib/%{namer}/%{namer}.png"
-rm "%{buildroot}%{_exec_prefix}/lib/%{namer}/%{namer}.desktop"
+# Move the icon in to place
+install -p  %{_topdir}/SOURCES/%{namer}.png %{buildroot}%{_datadir}/pixmaps/
 
-# Then we install them in the correct spots
-install -p -D -m 755 %{namer}-launcher.sh %{buildroot}%{_bindir}/%{namer}
-install -p -D -m 755 %{namer}-commandline-launcher.sh %{buildroot}%{_bindir}/%{namer}-cli
-install -p -D -m 755 %{namer}-server-launcher.sh %{buildroot}%{_bindir}/%{namer}-server
-install -p  %{namer}.png %{buildroot}%{_datadir}/pixmaps/
+# Fix executable permissions and install symlinks
+/bin/bash %{_topdir}/SOURCES/%{namer}-install-binaries.sh "%{buildroot}%{_exec_prefix}/lib/%{namer}/" "%{_exec_prefix}/bin/"
 
-# And fix permissions
-find "%{buildroot}%{_exec_prefix}/lib/%{namer}"/* -type f -name \*.exe | xargs chmod 755
-find "%{buildroot}%{_exec_prefix}/lib/%{namer}"/* -type f -name \*.sh | xargs chmod 755
-#find "%{buildroot}%{_exec_prefix}/lib/%{namer}"/* -type f -name \*.py | xargs chmod 755
-
-desktop-file-install %{namer}.desktop
+desktop-file-install %{_topdir}/SOURCES/%{namer}.desktop
 
 # Install the service:
 install -p -D -m 755 %{_topdir}/SOURCES/%{namer}.service %{_unitdir}
@@ -137,12 +109,14 @@ install -p -D -m 644 %{_topdir}/SOURCES/%{namer}.default %{_sysconfdir}/sysconfi
 
 %files
 %doc changelog.txt licenses/license.txt
-%{_bindir}/*
 %{_datadir}/*/*
 %{_exec_prefix}/lib/*
 
 
 %changelog
+* Mon Mar 25 2024 Kenneth Skovhede  - 2.0.0-0.20240325.git
+- Updated to build from arch-specific .Net8 binaries
+
 * Wed Jun 21 2017 Kenneth Skovhede  - 2.0.0-0.20170621.git
 - Added the service file to the install
 
diff --git a/Installer/fedora/duplicati-install-binaries.sh b/Installer/fedora/duplicati-install-binaries.sh
new file mode 100755
index 000000000..6068dd189
--- /dev/null
+++ b/Installer/fedora/duplicati-install-binaries.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# This file helps mark executables as executable and symlink them to the correct location
+# Lines starting with REPL: are repeated for each file in the executable list
+# The values %SOURCE% and %TARGET% are replaced with the source and target (symlink) file names
+
+# Fix permissions
+REPL: chmod 755 $1/%SOURCE%
+
+# Setup symlinks
+REPL: ln -s $1/lib/%SOURCE% $2/%TARGET%
diff --git a/Installer/fedora/duplicati-make-git-snapshot.sh b/Installer/fedora/duplicati-make-git-snapshot.sh
deleted file mode 100755
index 75e0a360d..000000000
--- a/Installer/fedora/duplicati-make-git-snapshot.sh
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/bin/sh
-
-# Usage: ./duplicati-make-git-snapshot.sh [COMMIT] [DATE] [VERSION] [RELEASETYPE] [BUILDTAG]
-#
-# to make a snapshot of the given tag/branch.  Defaults to HEAD.
-# Point env var REF to a local duplicati repo to reduce clone time.
-
-if [ -z $2 ]; then
-  DATE=$(date +%Y%m%d)
-else
-  DATE=$2
-fi
-
-if [ -z $3 ]; then
-  VERSION=$(git describe --tags | cut -d '-' -f 1 | cut -d 'v' -f 2)
-else
-  VERSION=$3
-fi
-
-if [ -z $4 ]; then
-  RELEASETYPE=$(git describe --tags | cut -d '_' -f 2)
-else
-  RELEASETYPE=$4
-fi
-
-if [ -z $5 ]; then
-  BUILDTAG=$(git describe --tags | cut -d '-' -f 2-4)
-else
-  BUILDTAG=$5
-fi
-
-
-DIRNAME="duplicati-$DATE"
-UPDATE_URLS="http://updates.duplicati.com/${RELEASETYPE}/latest.manifest;http://alt.updates.duplicati.com/${RELEASETYPE}/latest.manifest"
-
-echo DIRNAME $DIRNAME
-echo COMMIT ${1:-HEAD}
-echo RELEASETYPE ${RELEASETYPE}
-echo URLS ${UPDATE_URLS}
-echo BUILDTAG ${BUILDTAG}
-
-rm -rf $DIRNAME
-
-git clone ${REF:+--reference $REF} \
-	$(git config --get remote.origin.url) $DIRNAME
-
-cd "$DIRNAME"
-git checkout -b fedora-build ${1:-HEAD}
-
-echo "${BUILDTAG}" > "Duplicati/License/VersionTag.txt"
-echo "${RELEASETYPE}" > "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
-echo "${UPDATE_URLS}" > "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt"
-cp "Updates/release_key.txt" "Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt"
-
-git add "Duplicati/License/VersionTag.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt"
-git add "Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt"
-git commit -m "Updated auto-update properties"
-
-for n in "../../oem" "../../../oem" "../../../../oem"
-do
-    if [ -d $n ]; then
-        echo "Installing OEM files"
-        cp -R $n Duplicati/Server/webroot/
-        git add Duplicati/Server/webroot/*
-        git commit -m "Added OEM files"
-    fi
-done
-
-for n in "oem-app-name.txt" "oem-update-url.txt" "oem-update-key.txt" "oem-update-readme.txt" "oem-update-installid.txt"
-do
-    for p in "../../$n" "../../../$n" "../../../../$n"
-    do
-        if [ -f $p ]; then
-            echo "Installing OEM override file"
-            cp $p .
-            git add ./$n
-            git commit -m "Added OEM override file"
-        fi
-    done
-done
-
-echo "${VERSION}" > version
-git add version
-git commit -m "Added version file"
-
-git archive --format=tar --prefix=$DIRNAME/ HEAD \
-        | bzip2 > ../$DIRNAME.tar.bz2
-
-cd ..
-rm -rf $DIRNAME
-
diff --git a/Installer/fedora/duplicati.desktop b/Installer/fedora/duplicati.desktop
new file mode 100644
index 000000000..ba427606f
--- /dev/null
+++ b/Installer/fedora/duplicati.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Categories=System;Archiving;FileTools;Filesystem;
+Type=Application
+Name=Duplicati
+GenericName= Backup tool
+GenericName[es]= Copias de respaldo
+Comment= Create and maintain local and remote backup copies of your data
+Comment[es]= Cree y mantenga copias de seguridad locales y remotas
+Exec=duplicati
+Icon=duplicati
+Terminal=false
+StartupNotify=true
diff --git a/Installer/fedora/duplicati.png b/Installer/fedora/duplicati.png
new file mode 100644
index 0000000000000000000000000000000000000000..5184f62c69bee445da207dc5be60d284c28fb097
GIT binary patch
literal 579
zcmeAS@N?(olHy`uVBq!ia0y~yU@!n-4mJh`hH$2z?Fixf#C9nIx(p;|Q{6qcYlLW;U
za9!ZHVA-Qwd5@93ai7D}6-TeL_)U~mZ;%RH_eXQn^NJPLj9-p_c>3C0P5-9nVm{#&
zu~s@4Pgtty-|d_ed{MpUKq60(gJs36nVWLNyP6YO?j>d1sB?P7ROEN`_5-G_knZq{
z5scaitZN!XH!y`A;EG_>p0L;9gJ0D=mZtJc#VQM|U$Tl`nEU1ZnL8B|Ha%ROaijKM
z{O5m{#d%g4&ySNTpV9GX>$@e!*^d=9FPWz2+Z$
zOIZ2dmRapE{pIY=1Fj2J$u~}wICsCs=j4IZjUw?z8IsTQZ53JU8%p0D)o%Fp%dqO(
zFWcGQVkRsreR1Mnk<8KZ1Wtpis>zLd&t~$oT{u5O{UHC6o3l+Ev>(WQEE3O{`R!Aq
oQG$ShY0jB{ODp_cqU)Gkr$2H%71ce3fq{X+)78&qol`;+0Dkce00000

literal 0
HcmV?d00001

diff --git a/Installer/fedora/docker/buildroot/duplicati.xpm b/Installer/fedora/duplicati.xpm
similarity index 100%
rename from Installer/fedora/docker/buildroot/duplicati.xpm
rename to Installer/fedora/duplicati.xpm
diff --git a/Installer/fedora/inside-docker.sh b/Installer/fedora/inside-docker.sh
new file mode 100755
index 000000000..e36d798f4
--- /dev/null
+++ b/Installer/fedora/inside-docker.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# This script is a workaround for Docker desktop not handling
+# permissions for mounted volumes correctly. 
+
+# This script is intended to be run inside a Docker container
+
+mkdir /build-temp
+cp -R $1/* /build-temp
+
+rpmbuild -bb --target $2 --define "_topdir /build-temp" SOURCES/duplicati.spec
+mv /build-temp/RPMS/$2/*.rpm /$1/build.rpm
\ No newline at end of file
diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json
index c1e59be7c..c77691ff5 100644
--- a/ReleaseBuilder/.vscode/launch.json
+++ b/ReleaseBuilder/.vscode/launch.json
@@ -28,6 +28,10 @@
                 "--targets", "linux-x64-cli.docker", 
                 "--targets", "linux-arm64-cli.docker", 
                 "--targets", "linux-arm7-cli.docker", 
+                "--targets", "linux-x64-gui.rpm", 
+                "--targets", "linux-arm64-gui.rpm", 
+                "--targets", "linux-x64-cli.rpm", 
+                "--targets", "linux-arm64-cli.rpm", 
                 "--keep-builds", "true",
                 "--disable-authenticode", "true",
                 "--disable-signcode", "true",
diff --git a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
index 4928103a4..1aa18081b 100644
--- a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
+++ b/ReleaseBuilder/CliCommand/Build.CreatePackage.cs
@@ -118,6 +118,10 @@ public static partial class Build
                     await BuildDebPackage(baseDir, buildRoot, tempFile, target, rtcfg);
                     break;
 
+                case PackageType.RPM:
+                    await BuildRpmPackage(baseDir, buildRoot, tempFile, target, rtcfg);
+                    break;
+
                 // case PackageType.SynologySpk:
                 //     await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
                 //     await SignSynologyPackage(Path.Combine(outputFolder, target.PackageTargetString), rtcfg);
@@ -219,6 +223,8 @@ public static partial class Build
 
             if (rtcfg.UseAuthenticodeSigning)
                 await rtcfg.AuthenticodeSign(msiFile);
+
+            Directory.Delete(buildTmp, true);
         }
 
         /// 
@@ -527,6 +533,125 @@ public static partial class Build
         }
     }
 
+    /// 
+    /// Builds the RPM package using Docker
+    /// 
+    /// The base directory.
+    /// The build root directory.
+    /// The RPM file to generate.
+    /// The package target.
+    /// The runtime configuration.
+    /// A task representing the asynchronous operation.
+    static async Task BuildRpmPackage(string baseDir, string buildRoot, string rpmFile, PackageTarget target, RuntimeConfig rtcfg)
+    {
+        var installerDir = Path.Combine(baseDir, "Installer", "fedora");
+        var tmpbuild = Path.Combine(buildRoot, "tmp-fedora");
+        if (Directory.Exists(tmpbuild))
+            Directory.Delete(tmpbuild, true);
+        Directory.CreateDirectory(tmpbuild);
+
+        var tarsrc = Path.Combine(tmpbuild, $"duplicati-{rtcfg.ReleaseInfo.Version}");
+        EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), tarsrc, recursive: true);
+        await PackageSupport.InstallPackageIdentifier(tarsrc, target);
+        await PackageSupport.SetExecutableFlags(tarsrc, rtcfg);
+
+        // Create the tarball
+        var tarfile = Path.Combine(tmpbuild, $"duplicati-{rtcfg.ReleaseInfo.Version}.tar.bz2");
+        await ProcessHelper.Execute(
+            ["tar", "-cjf", tarfile, Path.GetFileName(tarsrc)],
+            workingDirectory: Path.GetDirectoryName(tarsrc)
+        );
+        Directory.Delete(tarsrc, true);
+
+        // Create rpmbuild structure
+        var sources = Path.Combine(tmpbuild, "SOURCES");
+        Directory.CreateDirectory(sources);
+
+        File.Move(tarfile, Path.Combine(sources, Path.GetFileName(tarfile)));
+        foreach (var f in new[] { "duplicati-install-recursive.sh", "duplicati.service", "duplicati.default", "duplicati.xpm", "duplicati.png", "duplicati.desktop" })
+            File.Copy(Path.Combine(installerDir, f), Path.Combine(sources, f));
+
+        var executables = ExecutableRenames.AsEnumerable();
+        if (target.Interface == InterfaceType.Cli)
+            executables = executables.Where(x => !GUIProjects.Contains(x.Key));
+
+        // Build custom script to install files
+        File.WriteAllLines(
+            Path.Combine(sources, "duplicati-install-binaries.sh"),
+            File.ReadAllLines(Path.Combine(installerDir, "duplicati-install-binaries.sh"))
+                .SelectMany(line =>
+                {
+                    if (line.StartsWith("REPL: "))
+                        return executables.Select(str => line.Substring("REPL: ".Length).Replace("%SOURCE%", str.Key).Replace("%TARGET%", str.Value));
+                    return [line];
+                })
+        );
+
+        var rpmarch = target.Arch switch
+        {
+            ArchType.x64 => "x86_64",
+            ArchType.Arm64 => "aarch64",
+            ArchType.Arm7 => "armv7hl",
+            _ => throw new Exception($"Unsupported arch: {target.Arch}")
+        };
+
+        File.WriteAllText(
+            Path.Combine(sources, "duplicati.spec"),
+            File.ReadAllText(Path.Combine(installerDir, "duplicati-binary.spec"))
+                .Replace("%BUILDDATE%", DateTime.UtcNow.ToString("yyyyMMdd"))
+                .Replace("%BUILDVERSION%", rtcfg.ReleaseInfo.Version.ToString())
+                .Replace("%BUILDTAG%", rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant())
+                .Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
+                .Replace("%PROVIDES%", string.Join("\n", executables.Select(x => $"Provides:\t{x.Value}")))
+                .Replace("%DEPENDS%", string.Join("\n",
+                    (target.Interface == InterfaceType.GUI
+                        ? FedoraGUIDepends
+                        : FedoraCLIDepends).Select(x => $"Requires:\t{x}")))
+        );
+
+        // Install the Docker build file
+        File.Copy(
+            Path.Combine(installerDir, "Dockerfile.build"),
+            Path.Combine(tmpbuild, "Dockerfile"),
+            true
+        );
+
+        // Install the build script
+        File.Copy(
+            Path.Combine(installerDir, "inside-docker.sh"),
+            Path.Combine(tmpbuild, "inside-docker.sh"),
+            true
+        );
+
+        // Build a Docker image to build with
+        await ProcessHelper.Execute([
+            "docker", "build",
+                "-t", "duplicati/fedora-build:latest",
+                tmpbuild
+        ], workingDirectory: tmpbuild);
+
+        // Then build the package itself
+        await ProcessHelper.Execute([
+            "docker", "run",
+                "--workdir", "/build",
+                "--volume", $"{tmpbuild}:/build:rw",
+                "duplicati/fedora-build:latest",
+
+                "/bin/bash", "/build/inside-docker.sh", "/build", rpmarch
+
+                // Sadly, Docker desktop has some issues with permissions that causes wrong exe bits
+                // which breaks the build checks, and produces incorrect packages
+                // "rpmbuild", "-bb", "--target", rpmarch,
+                // "--define", $"_topdir /build", "SOURCES/duplicati.spec"
+        ]);
+
+        File.Move(Path.Combine(tmpbuild, "build.rpm"), rpmFile);
+
+        // Clean up
+        Directory.Delete(tmpbuild, true);
+    }
+
+
     /// 
     /// Builds the Docker images for the specified targets with buildx
     /// 
diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/CliCommand/Build.cs
index fdf93a534..fda748201 100644
--- a/ReleaseBuilder/CliCommand/Build.cs
+++ b/ReleaseBuilder/CliCommand/Build.cs
@@ -50,6 +50,15 @@ public static partial class Build
     /// 
     private static readonly IReadOnlyList DebianCLIDepends = [];
 
+    /// 
+    /// The packages that are required for GUI builds
+    /// 
+    private static readonly IReadOnlyList FedoraGUIDepends = ["libice6", "libsm6", "libfontconfig1"];
+    /// 
+    /// The packages that are required for CLI builds
+    /// 
+    private static readonly IReadOnlyList FedoraCLIDepends = [];
+
     /// 
     /// Setup of the current runtime information
     /// 

From 0e714c9fb5e517700b9cb86fd968b976a8ace5b9 Mon Sep 17 00:00:00 2001
From: Kenneth Skovhede 
Date: Tue, 26 Mar 2024 15:42:47 +0100
Subject: [PATCH 18/91] Improved support for updating with manual update
 process

---
 Duplicati/Server/UpdatePollThread.cs          | 56 ++++++++++++-------
 .../ngax/templates/notificationarea.html      | 17 ++++--
 2 files changed, 48 insertions(+), 25 deletions(-)

diff --git a/Duplicati/Server/UpdatePollThread.cs b/Duplicati/Server/UpdatePollThread.cs
index 7c5263228..9c36fc3e1 100644
--- a/Duplicati/Server/UpdatePollThread.cs
+++ b/Duplicati/Server/UpdatePollThread.cs
@@ -180,26 +180,43 @@ namespace Duplicati.Server
 
                     if (Program.DataConnection.ApplicationSettings.UpdatedVersion != null && Duplicati.Library.AutoUpdater.UpdaterManager.TryParseVersion(Program.DataConnection.ApplicationSettings.UpdatedVersion.Version) > System.Reflection.Assembly.GetExecutingAssembly().GetName().Version)
                     {
-                        Program.DataConnection.RegisterNotification(
-                                    NotificationType.Information,
-                                    "Found update",
-                                    Program.DataConnection.ApplicationSettings.UpdatedVersion.Displayname,
-                                    null,
-                                    null,
-                                    "update:new",
-                                    null,
-                                    "NewUpdateFound",
-                                    null,
-                                    (self, all) => {
-                                        return all.FirstOrDefault(x => x.Action == "update:new") ?? self;
-                                    }
-                                );
+                        if (string.IsNullOrWhiteSpace(Program.DataConnection.ApplicationSettings.UpdatedVersion.UpdateFromV1Url))
+                        {
+                            Program.DataConnection.RegisterNotification(
+                                NotificationType.Information,
+                                "Found update",
+                                Program.DataConnection.ApplicationSettings.UpdatedVersion.Displayname,
+                                null,
+                                null,
+                                "update:new",
+                                null,
+                                "NewUpdateFound",
+                                null,
+                                (self, all) => all.FirstOrDefault(x => x.Action == "update:new") ?? self
+                            );
+                        }
+                        else
+                        {
+                            Program.DataConnection.RegisterNotification(
+                                NotificationType.Information,
+                                "Manual update required",
+                                Program.DataConnection.ApplicationSettings.UpdatedVersion.UpdateFromV1Url,
+                                null,
+                                null,
+                                "update:manual",
+                                null,
+                                "NewUpdateFound",
+                                null,
+                                (self, all) => all.FirstOrDefault(x => x.Action == "update:manual") ?? self
+                            );
+
+                        }
                     }
                 }
 
                 if (m_download)
                 {
-                    lock(m_lock)
+                    lock (m_lock)
                         m_download = false;
 
                     var v = Program.DataConnection.ApplicationSettings.UpdatedVersion;
@@ -218,17 +235,14 @@ namespace Duplicati.Server
                             Program.DataConnection.RegisterNotification(
                                     NotificationType.Error,
                                     "Manual update required",
-                                    $"{v.UpdateFromV1Url}",
+                                    v.UpdateFromV1Url,
                                     null,
                                     null,
-                                    "update:new",
+                                    "update:manual",
                                     null,
                                     "NewUpdateFound",
                                     null,
-                                    (self, all) =>
-                                    {
-                                        return all.FirstOrDefault(x => x.Action == "update:new") ?? self;
-                                    }
+                                    (self, all) => all.FirstOrDefault(x => x.Action == "update:manual") ?? self
                                 );
                         }
                     }
diff --git a/Duplicati/Server/webroot/ngax/templates/notificationarea.html b/Duplicati/Server/webroot/ngax/templates/notificationarea.html
index 852dbbef3..b67315e45 100644
--- a/Duplicati/Server/webroot/ngax/templates/notificationarea.html
+++ b/Duplicati/Server/webroot/ngax/templates/notificationarea.html
@@ -2,11 +2,11 @@
     
  • {{item.Title}}
    -
    {{item.Message}}
    +
    {{item.Message}}
    If the backup file was not downloaded automatically, right click and choose "Save as …"
    -
    +
    Dismiss Show @@ -17,9 +17,8 @@
    -
    -
    New update found: {{message}}
    +
    New update found: {{message}}
    @@ -33,6 +32,16 @@
    + +
    +
    Manual update found: {{item.Message}}
    + +
    + Dismiss + Show +
    +
    +
  • From cc1f91bf87fa9b0c679ecbd5b8eabaa572a17733 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 26 Mar 2024 15:43:03 +0100 Subject: [PATCH 19/91] Stability improvement for future manifests --- Duplicati/Library/AutoUpdater/UpdaterManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index abe61868a..90755af94 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -425,7 +425,7 @@ namespace Duplicati.Library.AutoUpdater public static bool DownloadAndUnpackUpdate(UpdateInfo version, Action progress = null) { - if (INSTALLDIR == null) + if (INSTALLDIR == null || version == null || version.RemoteURLS == null) return false; From a1a4042198d8612d89e126b8b0dac8cf6920b3e9 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 27 Mar 2024 11:24:49 +0100 Subject: [PATCH 20/91] Added support for having multiple valid keys for a manifest --- .../Library/AutoUpdater/AutoUpdateSettings.cs | 35 +++++++++++--- .../Library/AutoUpdater/UpdaterManager.cs | 8 ++-- .../Library/Utility/SignatureReadingStream.cs | 46 ++++++++++++++++++- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs index 48ec8fa43..9bb922551 100644 --- a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs +++ b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -177,21 +177,42 @@ namespace Duplicati.Library.AutoUpdater get { return string.Format(ReadResourceText(UPDATE_INSTALL_FILE, OEM_UPDATE_INSTALL_FILE), Guid.NewGuid().ToString("N")); } } - public static System.Security.Cryptography.RSACryptoServiceProvider SignKey + public static System.Security.Cryptography.RSACryptoServiceProvider[] SignKeys { get - { + { + var keys = new List(); + try { - var key = System.Security.Cryptography.RSA.Create(); - key.FromXmlString(ReadResourceText(UPDATE_KEY, OEM_UPDATE_KEY)); - return (System.Security.Cryptography.RSACryptoServiceProvider)key; + var src = ReadResourceText(UPDATE_KEY, OEM_UPDATE_KEY); + + // Allow multiple keys, one per line + // For fallback, read the whole string as a key, in case there are old ones with line breaks + + var keystrings = src.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Prepend(src.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(); + + foreach (var str in keystrings) + { + try + { + var key = System.Security.Cryptography.RSA.Create(); + key.FromXmlString(str); + keys.Add((System.Security.Cryptography.RSACryptoServiceProvider)key); + } + catch + { } + } } catch { } - return null; + return keys.ToArray(); } } } diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 60b7f09c4..48e980034 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -34,7 +34,7 @@ namespace Duplicati.Library.AutoUpdater /// /// The RSA key used to sign the manifest /// - private static readonly System.Security.Cryptography.RSACryptoServiceProvider SIGN_KEY = AutoUpdateSettings.SignKey; + private static readonly System.Security.Cryptography.RSACryptoServiceProvider[] SIGN_KEYS = AutoUpdateSettings.SignKeys; /// /// Urls to check for updated packages /// @@ -273,7 +273,7 @@ namespace Duplicati.Library.AutoUpdater wc.DownloadFile(url, tmpfile); using (var fs = System.IO.File.OpenRead(tmpfile)) - using (var ss = new SignatureReadingStream(fs, SIGN_KEY)) + using (var ss = new SignatureReadingStream(fs, SIGN_KEYS)) using (var tr = new System.IO.StreamReader(ss)) using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) { @@ -318,7 +318,7 @@ namespace Duplicati.Library.AutoUpdater try { using (var fs = System.IO.File.OpenRead(manifest)) - using (var ss = new SignatureReadingStream(fs, SIGN_KEY)) + using (var ss = new SignatureReadingStream(fs, SIGN_KEYS)) using (var tr = new System.IO.StreamReader(ss)) using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) return new Newtonsoft.Json.JsonSerializer().Deserialize(jr); diff --git a/Duplicati/Library/Utility/SignatureReadingStream.cs b/Duplicati/Library/Utility/SignatureReadingStream.cs index c887b8c68..dde66bea6 100644 --- a/Duplicati/Library/Utility/SignatureReadingStream.cs +++ b/Duplicati/Library/Utility/SignatureReadingStream.cs @@ -19,6 +19,7 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; namespace Duplicati.Library.Utility { @@ -39,14 +40,49 @@ namespace Duplicati.Library.Utility { } - public SignatureReadingStream(System.IO.Stream stream, System.Security.Cryptography.RSACryptoServiceProvider key) + /// + /// Creates a new stream that reads from the given stream and verifies the signature using any of the given keys + /// + /// The stream with a signature + /// The allowed keys + public SignatureReadingStream(System.IO.Stream stream, IEnumerable keys) { - if (!VerifySignature(stream, key)) + if (!VerifySignature(stream, keys)) throw new System.IO.InvalidDataException("Unable to verify signature"); m_stream = stream; this.Position = 0; } + /// + /// Wraps trying the keys one by one, returning true if any of the keys validate + /// + /// The stream to verify + /// The keys to try + /// true if the stream is valid; false otherwise + private static bool VerifySignature(System.IO.Stream stream, IEnumerable keys) + { + if (keys == null) + return false; + + foreach (var key in keys) + try + { + if (VerifySignature(stream, key)) + return true; + } + catch + { + } + + return false; + } + + /// + /// Verifies the signature of the stream using the given key + /// + /// The stream to verify + /// The key to validate with + /// true if the stream signature matches the key; false otherwise private static bool VerifySignature(System.IO.Stream stream, System.Security.Cryptography.RSACryptoServiceProvider key) { stream.Position = 0; @@ -73,6 +109,12 @@ namespace Duplicati.Library.Utility return key.VerifyHash(hash, OID, signature); } + /// + /// Creates a signed stream from the given data stream and writes the signature to the signed stream + /// + /// The stream to sign + /// The stream with the signature + /// The key used to sign it public static void CreateSignedStream(System.IO.Stream datastream, System.IO.Stream signedstream, System.Security.Cryptography.RSACryptoServiceProvider key) { var sha256 = System.Security.Cryptography.SHA256.Create(); From cc091f06fe4613a269e862214ac0117e0b9ac4cb Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 27 Mar 2024 11:25:16 +0100 Subject: [PATCH 21/91] Minor fix to increase compatibility with older clients --- Duplicati/Library/AutoUpdater/UpdateInfo.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Duplicati/Library/AutoUpdater/UpdateInfo.cs b/Duplicati/Library/AutoUpdater/UpdateInfo.cs index e2739a9b1..8b69c1854 100644 --- a/Duplicati/Library/AutoUpdater/UpdateInfo.cs +++ b/Duplicati/Library/AutoUpdater/UpdateInfo.cs @@ -59,6 +59,11 @@ namespace Duplicati.Library.AutoUpdater public string ChangeInfo; public int PackageUpdaterVersion; /// + /// Legacy entry, do not use + /// + [Obsolete("Only kept to avoid v2.0.7.x and earlier releases from crashing on nulls")] + public string[] RemoteURLS = Array.Empty(); + /// /// List of installer packages /// public PackageEntry[] Packages; From f1e767b9cc305b44860061e1561a38f6080f94ce Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 27 Mar 2024 19:52:40 +0100 Subject: [PATCH 22/91] Work on creating the signed manifests --- .../AutoUpdateBuilder.csproj | 27 -- .../AutoUpdateBuilder/AutoUpdateBuilder.sln | 74 ---- BuildTools/AutoUpdateBuilder/Program.cs | 184 ---------- ...dateSignKey.txt => AutoUpdateSignKeys.txt} | 0 .../Duplicati.Library.AutoUpdater.csproj | 2 +- .../{InstallerEntry.cs => PackageEntry.cs} | 2 +- .../Library/AutoUpdater/UpdaterManager.cs | 51 ++- .../Library/Utility/SignatureReadingStream.cs | 20 +- ReleaseBuilder/.vscode/launch.json | 7 +- .../Command.Compile.Post.cs} | 4 +- .../Command.Compile.cs} | 4 +- .../Command.CreatePackage.cs} | 33 +- .../Command.GitPush.cs} | 4 +- .../Command.PackageSupport.cs} | 4 +- .../{CliCommand/Build.cs => Build/Command.cs} | 331 +++++++++++++++--- ReleaseBuilder/Configuration.cs | 61 +++- ReleaseBuilder/CreateKey/Command.cs | 47 +++ ReleaseBuilder/EnvHelper.cs | 16 + ReleaseBuilder/Program.cs | 16 +- ReleaseBuilder/ReleaseBuilder.csproj | 4 + ReleaseBuilder/SharedOptions.cs | 19 + ReleaseBuilder/testfile.key | Bin 0 -> 1981 bytes ReleaseBuilder/testfile.key2 | Bin 0 -> 1981 bytes Updates/beta.manifest | 3 - Updates/canary.manifest | 3 - Updates/debug.manifest | 3 - Updates/debug_changeinfo.txt | 1 - Updates/debug_key.txt | 1 - Updates/experimental.manifest | 3 - Updates/nightly.manifest | 3 - Updates/release_changeinfo.txt | 1 - Updates/release_key.txt | 1 - Updates/stable.manifest | 3 - build-debug-update.sh | 2 +- build-release.sh | 2 +- deploy-debug.sh | 2 +- 36 files changed, 517 insertions(+), 421 deletions(-) delete mode 100644 BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.csproj delete mode 100644 BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.sln delete mode 100644 BuildTools/AutoUpdateBuilder/Program.cs rename Duplicati/Library/AutoUpdater/{AutoUpdateSignKey.txt => AutoUpdateSignKeys.txt} (100%) rename Duplicati/Library/AutoUpdater/{InstallerEntry.cs => PackageEntry.cs} (98%) rename ReleaseBuilder/{CliCommand/Build.Compile.Post.cs => Build/Command.Compile.Post.cs} (99%) rename ReleaseBuilder/{CliCommand/Build.Compile.cs => Build/Command.Compile.cs} (98%) rename ReleaseBuilder/{CliCommand/Build.CreatePackage.cs => Build/Command.CreatePackage.cs} (96%) rename ReleaseBuilder/{CliCommand/Build.GitPush.cs => Build/Command.GitPush.cs} (97%) rename ReleaseBuilder/{CliCommand/Build.PackageSupport.cs => Build/Command.PackageSupport.cs} (97%) rename ReleaseBuilder/{CliCommand/Build.cs => Build/Command.cs} (63%) create mode 100644 ReleaseBuilder/CreateKey/Command.cs create mode 100644 ReleaseBuilder/SharedOptions.cs create mode 100644 ReleaseBuilder/testfile.key create mode 100644 ReleaseBuilder/testfile.key2 delete mode 100644 Updates/beta.manifest delete mode 100644 Updates/canary.manifest delete mode 100644 Updates/debug.manifest delete mode 100644 Updates/debug_changeinfo.txt delete mode 100644 Updates/debug_key.txt delete mode 100644 Updates/experimental.manifest delete mode 100644 Updates/nightly.manifest delete mode 100644 Updates/release_changeinfo.txt delete mode 100644 Updates/release_key.txt delete mode 100644 Updates/stable.manifest diff --git a/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.csproj b/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.csproj deleted file mode 100644 index 86143c38b..000000000 --- a/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0 - Exe - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - \ No newline at end of file diff --git a/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.sln b/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.sln deleted file mode 100644 index 3750b01e7..000000000 --- a/BuildTools/AutoUpdateBuilder/AutoUpdateBuilder.sln +++ /dev/null @@ -1,74 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 2012 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AutoUpdateBuilder", "AutoUpdateBuilder.csproj", "{17E3EB29-87FD-4BE9-A531-8DAD8B295494}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Compression", "..\..\Duplicati\Library\Compression\Duplicati.Library.Compression.csproj", "{19ECCE09-B5EB-406C-8C57-BAC66997D469}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Utility", "..\..\Duplicati\Library\Utility\Duplicati.Library.Utility.csproj", "{DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Interface", "..\..\Duplicati\Library\Interface\Duplicati.Library.Interface.csproj", "{C5899F45-B0FF-483C-9D38-24A9FCAAB237}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Logging", "..\..\Duplicati\Library\Logging\Duplicati.Library.Logging.csproj", "{D10A5FC0-11B4-4E70-86AA-8AEA52BD9798}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Encryption", "..\..\Duplicati\Library\Encryption\Duplicati.Library.Encryption.csproj", "{94484FDB-2EFA-4CF0-9BE6-A561157B4F87}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.AutoUpdater", "..\..\Duplicati\Library\AutoUpdater\Duplicati.Library.AutoUpdater.csproj", "{7E119745-1F62-43F0-936C-F312A1912C0B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.License", "..\..\Duplicati\License\Duplicati.License.csproj", "{4D012CB1-4B92-47F4-89B7-BF80A73A2E99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Localization", "..\..\Duplicati\Library\Localization\Duplicati.Library.Localization.csproj", "{B68F2214-951F-4F78-8488-66E1ED3F50BF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Common", "..\..\Duplicati\Library\Common\Duplicati.Library.Common.csproj", "{D63E53E4-A458-4C2F-914D-92F715F58ACF}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {17E3EB29-87FD-4BE9-A531-8DAD8B295494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {17E3EB29-87FD-4BE9-A531-8DAD8B295494}.Debug|Any CPU.Build.0 = Debug|Any CPU - {17E3EB29-87FD-4BE9-A531-8DAD8B295494}.Release|Any CPU.ActiveCfg = Release|Any CPU - {17E3EB29-87FD-4BE9-A531-8DAD8B295494}.Release|Any CPU.Build.0 = Release|Any CPU - {19ECCE09-B5EB-406C-8C57-BAC66997D469}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {19ECCE09-B5EB-406C-8C57-BAC66997D469}.Debug|Any CPU.Build.0 = Debug|Any CPU - {19ECCE09-B5EB-406C-8C57-BAC66997D469}.Release|Any CPU.ActiveCfg = Release|Any CPU - {19ECCE09-B5EB-406C-8C57-BAC66997D469}.Release|Any CPU.Build.0 = Release|Any CPU - {4D012CB1-4B92-47F4-89B7-BF80A73A2E99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4D012CB1-4B92-47F4-89B7-BF80A73A2E99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4D012CB1-4B92-47F4-89B7-BF80A73A2E99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4D012CB1-4B92-47F4-89B7-BF80A73A2E99}.Release|Any CPU.Build.0 = Release|Any CPU - {7E119745-1F62-43F0-936C-F312A1912C0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E119745-1F62-43F0-936C-F312A1912C0B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E119745-1F62-43F0-936C-F312A1912C0B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E119745-1F62-43F0-936C-F312A1912C0B}.Release|Any CPU.Build.0 = Release|Any CPU - {94484FDB-2EFA-4CF0-9BE6-A561157B4F87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {94484FDB-2EFA-4CF0-9BE6-A561157B4F87}.Debug|Any CPU.Build.0 = Debug|Any CPU - {94484FDB-2EFA-4CF0-9BE6-A561157B4F87}.Release|Any CPU.ActiveCfg = Release|Any CPU - {94484FDB-2EFA-4CF0-9BE6-A561157B4F87}.Release|Any CPU.Build.0 = Release|Any CPU - {B68F2214-951F-4F78-8488-66E1ED3F50BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B68F2214-951F-4F78-8488-66E1ED3F50BF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B68F2214-951F-4F78-8488-66E1ED3F50BF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B68F2214-951F-4F78-8488-66E1ED3F50BF}.Release|Any CPU.Build.0 = Release|Any CPU - {C5899F45-B0FF-483C-9D38-24A9FCAAB237}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5899F45-B0FF-483C-9D38-24A9FCAAB237}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5899F45-B0FF-483C-9D38-24A9FCAAB237}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5899F45-B0FF-483C-9D38-24A9FCAAB237}.Release|Any CPU.Build.0 = Release|Any CPU - {D10A5FC0-11B4-4E70-86AA-8AEA52BD9798}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D10A5FC0-11B4-4E70-86AA-8AEA52BD9798}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D10A5FC0-11B4-4E70-86AA-8AEA52BD9798}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D10A5FC0-11B4-4E70-86AA-8AEA52BD9798}.Release|Any CPU.Build.0 = Release|Any CPU - {DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}.Release|Any CPU.Build.0 = Release|Any CPU - {D63E53E4-A458-4C2F-914D-92F715F58ACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D63E53E4-A458-4C2F-914D-92F715F58ACF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D63E53E4-A458-4C2F-914D-92F715F58ACF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D63E53E4-A458-4C2F-914D-92F715F58ACF}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(MonoDevelopProperties) = preSolution - StartupItem = AutoUpdateBuilder.csproj - EndGlobalSection -EndGlobal diff --git a/BuildTools/AutoUpdateBuilder/Program.cs b/BuildTools/AutoUpdateBuilder/Program.cs deleted file mode 100644 index 6d170e20e..000000000 --- a/BuildTools/AutoUpdateBuilder/Program.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.Security.Cryptography; - -namespace AutoUpdateBuilder -{ - public class Program - { - private static RSACryptoServiceProvider privkey; - - private static string keyfile; - private static string keyfilepassword; - - private static void CompareToManifestPublicKey() - { - if (Duplicati.Library.AutoUpdater.AutoUpdateSettings.SignKey == null || privkey.ToXmlString(false) != Duplicati.Library.AutoUpdater.AutoUpdateSettings.SignKey.ToXmlString(false)) - { - Console.WriteLine("The public key in the project is not the same as the public key from the file"); - Console.WriteLine("Try setting the key to: "); - Console.WriteLine(privkey.ToXmlString(false)); - System.Environment.Exit(5); - } - } - - private static void LoadKeyFromFile() - { - using (var enc = new Duplicati.Library.Encryption.AESEncryption(keyfilepassword, new Dictionary())) - using (var ms = new System.IO.MemoryStream()) - using (var fs = System.IO.File.OpenRead(keyfile)) - { - enc.Decrypt(fs, ms); - ms.Position = 0; - - using (var sr = new System.IO.StreamReader(ms)) - privkey.FromXmlString(sr.ReadToEnd()); - } - } - - public static int Main(string[] _args) - { - var args = new List(_args); - var opts = Duplicati.Library.Utility.CommandLineParser.ExtractOptions(args); - - opts.TryGetValue("input", out string inputfolder); - opts.TryGetValue("output", out string outputfolder); - opts.TryGetValue("allow-new-key", out string allowNewKey); - opts.TryGetValue("keyfile", out keyfile); - opts.TryGetValue("manifest", out string manifestfile); - opts.TryGetValue("keyfile-password", out keyfilepassword); - - var usedoptions = new [] { "allow-new-key", "input", "output", "keyfile", "manifest", "keyfile-password" }; - - if (string.IsNullOrWhiteSpace(inputfolder)) - { - Console.WriteLine("Missing input folder"); - return 4; - } - - if (string.IsNullOrWhiteSpace(outputfolder)) - { - Console.WriteLine("Missing output folder"); - return 4; - } - - if (string.IsNullOrWhiteSpace(keyfile)) - { - Console.WriteLine("Missing keyfile"); - return 4; - } - - if (!System.IO.Directory.Exists(inputfolder)) - { - Console.WriteLine("Input folder not found"); - return 4; - } - - if (string.IsNullOrWhiteSpace(keyfilepassword)) - { - Console.WriteLine("Enter keyfile passphrase: "); - keyfilepassword = Console.ReadLine().Trim(); - } - - if (!System.IO.File.Exists(keyfile)) - { - Console.WriteLine("Keyfile not found, creating new"); - var newkey = RSA.Create().ToXmlString(true); - using (var enc = new Duplicati.Library.Encryption.AESEncryption(keyfilepassword, new Dictionary())) - using (var fs = System.IO.File.OpenWrite(keyfile)) - using (var ms = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(newkey))) - enc.Encrypt(ms, fs); - } - - if (!System.IO.Directory.Exists(outputfolder)) - System.IO.Directory.CreateDirectory(outputfolder); - - privkey = (RSACryptoServiceProvider) RSA.Create(); - - LoadKeyFromFile(); - - if (!Boolean.TryParse(allowNewKey, out Boolean newKeyAllowed) || !newKeyAllowed) - { - CompareToManifestPublicKey(); - } - - Duplicati.Library.AutoUpdater.UpdateInfo updateInfo; - - using (var fs = System.IO.File.OpenRead(manifestfile)) - using (var sr = new System.IO.StreamReader(fs)) - using (var jr = new Newtonsoft.Json.JsonTextReader(sr)) - updateInfo = new Newtonsoft.Json.JsonSerializer().Deserialize(jr); - - var isopts = new Dictionary(opts, StringComparer.InvariantCultureIgnoreCase); - foreach (var usedopt in usedoptions) - { - isopts.Remove(usedopt); - } - - foreach (var k in updateInfo.GetType().GetFields()) - { - if (!isopts.ContainsKey(k.Name)) - { - continue; - } - try - { - //Console.WriteLine("Setting {0} to {1}", k.Name, isopts[k.Name]); - if (k.FieldType == typeof(string[])) - k.SetValue(updateInfo, isopts[k.Name].Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries)); - else if (k.FieldType == typeof(Version)) - k.SetValue(updateInfo, new Version(isopts[k.Name])); - else if (k.FieldType == typeof(int)) - k.SetValue(updateInfo, int.Parse(isopts[k.Name])); - else if (k.FieldType == typeof(long)) - k.SetValue(updateInfo, long.Parse(isopts[k.Name])); - else - k.SetValue(updateInfo, isopts[k.Name]); - } - catch (Exception ex) - { - Console.WriteLine("Failed setting {0} to {1}: {2}", k.Name, isopts[k.Name], ex.Message); - } - - isopts.Remove(k.Name); - } - - foreach (var opt in isopts) - { - Console.WriteLine("Warning! unused option: {0} = {1}", opt.Key, opt.Value); - } - - using (var tf = new Duplicati.Library.Utility.TempFile()) - { - using (var fs = System.IO.File.OpenWrite(tf)) - using (var tw = new System.IO.StreamWriter(fs)) - new Newtonsoft.Json.JsonSerializer().Serialize(tw, updateInfo); - - Duplicati.Library.AutoUpdater.UpdaterManager.CreateUpdatePackage(privkey, inputfolder, outputfolder, tf); - } - - return 0; - } - } -} \ No newline at end of file diff --git a/Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt b/Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt similarity index 100% rename from Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt rename to Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt diff --git a/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj b/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj index ff3a38146..3c7e2234b 100644 --- a/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj +++ b/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj @@ -17,7 +17,7 @@ - + diff --git a/Duplicati/Library/AutoUpdater/InstallerEntry.cs b/Duplicati/Library/AutoUpdater/PackageEntry.cs similarity index 98% rename from Duplicati/Library/AutoUpdater/InstallerEntry.cs rename to Duplicati/Library/AutoUpdater/PackageEntry.cs index f36fec324..be53c9151 100644 --- a/Duplicati/Library/AutoUpdater/InstallerEntry.cs +++ b/Duplicati/Library/AutoUpdater/PackageEntry.cs @@ -48,7 +48,7 @@ namespace Duplicati.Library.AutoUpdater /// /// The package type id /// - public string PackageTypeId; + public string PackageTypeId; /// /// Gets the name of the package file diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 48e980034..17629c68b 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -34,7 +34,7 @@ namespace Duplicati.Library.AutoUpdater /// /// The RSA key used to sign the manifest /// - private static readonly System.Security.Cryptography.RSACryptoServiceProvider[] SIGN_KEYS = AutoUpdateSettings.SignKeys; + private static readonly System.Security.Cryptography.RSA[] SIGN_KEYS = AutoUpdateSettings.SignKeys; /// /// Urls to check for updated packages /// @@ -159,7 +159,8 @@ namespace Duplicati.Library.AutoUpdater // In case the installed manifest is broken, try to set some sane values if (selfVersion == null) { - SelfVersion = new UpdateInfo() { + SelfVersion = new UpdateInfo() + { Displayname = string.IsNullOrWhiteSpace(Duplicati.License.VersionNumbers.TAG) ? "Current" : Duplicati.License.VersionNumbers.TAG, Version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(), ReleaseTime = new DateTime(0), @@ -395,7 +396,7 @@ namespace Duplicati.Library.AutoUpdater tempfile.Position = 0; var md5hash = Convert.ToBase64String(md5.ComputeHash(tempfile)); if (md5hash != package.MD5) - throw new Exception(string.Format("Damaged or corrupted file, md5 mismatch for {0}", url)); + throw new Exception(string.Format("Damaged or corrupted file, md5 mismatch for {0}", url)); } File.Copy(tempfilename, targetPath, true); @@ -415,32 +416,44 @@ namespace Duplicati.Library.AutoUpdater /// /// Helper method to create a signed manifest file /// - /// - /// - /// - /// - public static void CreateSignedManifest(System.Security.Cryptography.RSACryptoServiceProvider key, string inputfolder, string outputfolder, string manifest = null) + /// The key used for signing the manifest + /// The template content in JSON format + /// The folder where the signed manifest will be written to + /// The version of the manifest + /// The URL to use for V1 updates + /// The URL to use for generic updates + public static void CreateSignedManifest(System.Security.Cryptography.RSA key, string sourcedata, string outputfolder, string version = null, string updateFromV1Url = null, string genericUpdatePageUrl = null, string releaseType = null, IEnumerable packages = null) { // Read the existing manifest - UpdateInfo remoteManifest; - - var manifestpath = manifest ?? System.IO.Path.Combine(inputfolder, UPDATE_MANIFEST_FILENAME); - - using (var s = System.IO.File.OpenRead(manifestpath)) - using (var sr = new System.IO.StreamReader(s)) - using (var jr = new Newtonsoft.Json.JsonTextReader(sr)) - remoteManifest = new Newtonsoft.Json.JsonSerializer().Deserialize(jr); + var remoteManifest = Newtonsoft.Json.JsonConvert.DeserializeObject(string.IsNullOrWhiteSpace(sourcedata) ? "{}" : sourcedata); if (remoteManifest.ReleaseTime.Ticks == 0) remoteManifest.ReleaseTime = DateTime.UtcNow; - + // No files to update with are allowed, as we currently do not use the information if (remoteManifest.Packages == null) remoteManifest.Packages = Array.Empty(); + // Disable the warning as we enforce the field to be set to the default value +#pragma warning disable CS0618 // Type or member is obsolete + if (remoteManifest.RemoteURLS == null) + remoteManifest.RemoteURLS = Array.Empty(); +#pragma warning restore CS0618 // Type or member is obsolete + + if (version != null) + remoteManifest.Version = version.ToString(); + if (!string.IsNullOrWhiteSpace(updateFromV1Url)) + remoteManifest.UpdateFromV1Url = updateFromV1Url; + if (!string.IsNullOrWhiteSpace(genericUpdatePageUrl)) + remoteManifest.GenericUpdatePageUrl = genericUpdatePageUrl; + if (!string.IsNullOrWhiteSpace(releaseType)) + remoteManifest.ReleaseType = releaseType; + if (packages != null) + remoteManifest.Packages = packages.ToArray(); + if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) remoteManifest.UpdateFromV1Url = remoteManifest.GenericUpdatePageUrl; - + if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) throw new Exception($"Field must be set: {nameof(remoteManifest.UpdateFromV1Url)}"); if (string.IsNullOrWhiteSpace(remoteManifest.GenericUpdatePageUrl)) diff --git a/Duplicati/Library/Utility/SignatureReadingStream.cs b/Duplicati/Library/Utility/SignatureReadingStream.cs index dde66bea6..f597b0f4d 100644 --- a/Duplicati/Library/Utility/SignatureReadingStream.cs +++ b/Duplicati/Library/Utility/SignatureReadingStream.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.Security.Cryptography; namespace Duplicati.Library.Utility { @@ -29,7 +30,7 @@ namespace Duplicati.Library.Utility /// The size of the SHA256 output hash in bytes /// /// - internal const int SIGNED_HASH_SIZE = 128; + internal const int SIGNED_HASH_SIZE = 256; /// /// The stream to read from @@ -45,7 +46,7 @@ namespace Duplicati.Library.Utility /// /// The stream with a signature /// The allowed keys - public SignatureReadingStream(System.IO.Stream stream, IEnumerable keys) + public SignatureReadingStream(System.IO.Stream stream, IEnumerable keys) { if (!VerifySignature(stream, keys)) throw new System.IO.InvalidDataException("Unable to verify signature"); @@ -59,7 +60,7 @@ namespace Duplicati.Library.Utility /// The stream to verify /// The keys to try /// true if the stream is valid; false otherwise - private static bool VerifySignature(System.IO.Stream stream, IEnumerable keys) + private static bool VerifySignature(System.IO.Stream stream, IEnumerable keys) { if (keys == null) return false; @@ -83,7 +84,7 @@ namespace Duplicati.Library.Utility /// The stream to verify /// The key to validate with /// true if the stream signature matches the key; false otherwise - private static bool VerifySignature(System.IO.Stream stream, System.Security.Cryptography.RSACryptoServiceProvider key) + private static bool VerifySignature(System.IO.Stream stream, System.Security.Cryptography.RSA key) { stream.Position = 0; var signature = new byte[SIGNED_HASH_SIZE]; @@ -105,8 +106,7 @@ namespace Duplicati.Library.Utility sha256.TransformFinalBlock(buf, 0, 0); var hash = sha256.Hash; - var OID = System.Security.Cryptography.CryptoConfig.MapNameToOID("SHA256"); - return key.VerifyHash(hash, OID, signature); + return key.VerifyHash(hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } /// @@ -115,7 +115,7 @@ namespace Duplicati.Library.Utility /// The stream to sign /// The stream with the signature /// The key used to sign it - public static void CreateSignedStream(System.IO.Stream datastream, System.IO.Stream signedstream, System.Security.Cryptography.RSACryptoServiceProvider key) + public static void CreateSignedStream(System.IO.Stream datastream, System.IO.Stream signedstream, System.Security.Cryptography.RSA key) { var sha256 = System.Security.Cryptography.SHA256.Create(); @@ -139,9 +139,9 @@ namespace Duplicati.Library.Utility sha256.TransformFinalBlock(buf, 0, 0); var hash = sha256.Hash; - var OID = System.Security.Cryptography.CryptoConfig.MapNameToOID("SHA256"); - var signature = key.SignHash(hash, OID); - + var signature = key.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + if (signature.Length != SIGNED_HASH_SIZE) + throw new System.IO.InvalidDataException("Unexpected signature length"); signedstream.Position = 0; signedstream.Write(signature, 0, signature.Length); diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json index c77691ff5..f903eb3ef 100644 --- a/ReleaseBuilder/.vscode/launch.json +++ b/ReleaseBuilder/.vscode/launch.json @@ -10,6 +10,7 @@ "request": "launch", "preLaunchTask": "dotnet: build", "program": "${workspaceFolder}/bin/Debug/net8.0/ReleaseBuilder.dll", + // "args": ["create-key", "testfile.key", "--password", "test1234"], "args": [ "build", "--disable-docker-push", "true", @@ -35,9 +36,11 @@ "--keep-builds", "true", "--disable-authenticode", "true", "--disable-signcode", "true", - "--password", "unused", + "--disable-notarize-signing", "true", + "--password", "test1234", ], - "env": { + "env": { + "UPDATER_KEYFILE": "${workspaceFolder}/testfile.key:${workspaceFolder}/testfile.key2", }, "cwd": "${workspaceFolder}", "stopAtEntry": false, diff --git a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs b/ReleaseBuilder/Build/Command.Compile.Post.cs similarity index 99% rename from ReleaseBuilder/CliCommand/Build.Compile.Post.cs rename to ReleaseBuilder/Build/Command.Compile.Post.cs index 0b7f62ee2..680c31f6e 100644 --- a/ReleaseBuilder/CliCommand/Build.Compile.Post.cs +++ b/ReleaseBuilder/Build/Command.Compile.Post.cs @@ -1,8 +1,8 @@ using System.Text.RegularExpressions; -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; -public static partial class Build +public static partial class Command { /// /// Helper methods cleaning and signing build outputs diff --git a/ReleaseBuilder/CliCommand/Build.Compile.cs b/ReleaseBuilder/Build/Command.Compile.cs similarity index 98% rename from ReleaseBuilder/CliCommand/Build.Compile.cs rename to ReleaseBuilder/Build/Command.Compile.cs index 65f4001f2..f876149ed 100644 --- a/ReleaseBuilder/CliCommand/Build.Compile.cs +++ b/ReleaseBuilder/Build/Command.Compile.cs @@ -1,6 +1,6 @@ -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; -public static partial class Build +public static partial class Command { /// /// Main compilation of projects diff --git a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs similarity index 96% rename from ReleaseBuilder/CliCommand/Build.CreatePackage.cs rename to ReleaseBuilder/Build/Command.CreatePackage.cs index 1aa18081b..5c60fc552 100644 --- a/ReleaseBuilder/CliCommand/Build.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -1,15 +1,22 @@ using System.Globalization; using System.IO.Compression; -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; -public static partial class Build +public static partial class Command { /// /// Implementations for the package builds /// private static class CreatePackage { + /// + /// Representation of a build package + /// + /// The target package + /// The created package file path + public record BuiltPackage(PackageTarget Target, string CreatedFile); + /// /// Builds the packages for the specified build targets. /// @@ -19,8 +26,10 @@ public static partial class Build /// 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) + public static async Task> BuildPackages(string baseDir, string buildRoot, IEnumerable buildTargets, bool keepBuilds, RuntimeConfig rtcfg) { + var builtPackages = new List(); + var packagesToBuild = buildTargets.Distinct().ToList(); if (packagesToBuild.Count == 1) Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}"); @@ -31,7 +40,7 @@ public static partial class Build foreach (var target in packagesToBuild.Where(x => x.Package != PackageType.Docker)) { Console.WriteLine($"Building {target.PackageTargetString} ..."); - await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds); + builtPackages.Add(new BuiltPackage(target, await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds))); Console.WriteLine("Completed!"); } @@ -61,6 +70,8 @@ public static partial class Build File.WriteAllText(f, ""); } } + + return builtPackages; } /// @@ -71,22 +82,26 @@ public static partial class Build /// 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) + 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}"); + + // Fix up non-conforming package names if (target.Package == PackageType.Deb) packageFile = Path.Combine(packageFolder, $"duplicati-{target.InterfaceString}-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.deb"); + if (target.Package == PackageType.RPM) + packageFile = Path.Combine(packageFolder, $"duplicati-{target.InterfaceString}-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.rpm"); if (File.Exists(packageFile)) { if (keepBuilds) { Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}"); - return; + return packageFile; } File.Delete(packageFile); @@ -132,6 +147,8 @@ public static partial class Build } File.Move(tempFile, packageFile); + + return packageFile; } /// @@ -465,8 +482,8 @@ public static partial class Build var installerDir = Path.Combine(baseDir, "Installer", "debian"); // Write in the release notes - // File.WriteAllText(Path.Combine(debroot, "releasenotes.txt"), rtcfg.ReleaseNotes); - // touch "${DIRNAME}/releasenotes.txt" + if (!string.IsNullOrEmpty(rtcfg.ChangelogNews)) + File.WriteAllText(Path.Combine(debroot, "releasenotes.txt"), rtcfg.ChangelogNews); // Write a custom changelog file File.WriteAllText( diff --git a/ReleaseBuilder/CliCommand/Build.GitPush.cs b/ReleaseBuilder/Build/Command.GitPush.cs similarity index 97% rename from ReleaseBuilder/CliCommand/Build.GitPush.cs rename to ReleaseBuilder/Build/Command.GitPush.cs index b44a5ad48..38a39b3a5 100644 --- a/ReleaseBuilder/CliCommand/Build.GitPush.cs +++ b/ReleaseBuilder/Build/Command.GitPush.cs @@ -1,6 +1,6 @@ -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; -public static partial class Build +public static partial class Command { /// /// Implementation of the git push command diff --git a/ReleaseBuilder/CliCommand/Build.PackageSupport.cs b/ReleaseBuilder/Build/Command.PackageSupport.cs similarity index 97% rename from ReleaseBuilder/CliCommand/Build.PackageSupport.cs rename to ReleaseBuilder/Build/Command.PackageSupport.cs index 2298ce2e6..0a1512028 100644 --- a/ReleaseBuilder/CliCommand/Build.PackageSupport.cs +++ b/ReleaseBuilder/Build/Command.PackageSupport.cs @@ -1,6 +1,6 @@ -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; -public static partial class Build +public static partial class Command { /// /// Support for building packages diff --git a/ReleaseBuilder/CliCommand/Build.cs b/ReleaseBuilder/Build/Command.cs similarity index 63% rename from ReleaseBuilder/CliCommand/Build.cs rename to ReleaseBuilder/Build/Command.cs index fda748201..53350519a 100644 --- a/ReleaseBuilder/CliCommand/Build.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -1,12 +1,16 @@ using System.CommandLine; using System.CommandLine.NamingConventionBinder; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; +using Duplicati.Library.AutoUpdater; -namespace ReleaseBuilder.CliCommand; +namespace ReleaseBuilder.Build; /// /// The build command implementation /// -public static partial class Build +public static partial class Command { /// /// The primary project to build for GUI builds @@ -71,13 +75,15 @@ public static partial class Build /// /// The release info to use /// The keyfile password to use - /// The executables + /// The sign keys + /// The changelog news /// The command input - public RuntimeConfig(ReleaseInfo releaseInfo, string keyfilePassword, IEnumerable executables, CommandInput input) + public RuntimeConfig(ReleaseInfo releaseInfo, IEnumerable signKeys, string keyfilePassword, string changelogNews, CommandInput input) { ReleaseInfo = releaseInfo; + SignKeys = signKeys; KeyfilePassword = keyfilePassword; - ExecutableBinaries = executables; + ChangelogNews = changelogNews; Input = input; } @@ -99,14 +105,17 @@ public static partial class Build /// /// The keyfile password for this run /// + public IEnumerable SignKeys { get; } + + /// + /// The primary password + /// public string KeyfilePassword { get; } /// - /// The executables that should exist in the build folder + /// The changelog news /// - /// - // TODO: Remove this? - public IEnumerable ExecutableBinaries { get; } + public string ChangelogNews { get; } /// /// Gets the PFX password and throws if not possible @@ -216,7 +225,42 @@ public static partial class Build } /// - /// Returns a value indicating if signcode is enabled + /// Cache value for checking if notarize is enabled + /// + private bool? _useNotarizeSigning; + + /// + /// Checks if notarize signing is enabled + /// + public void ToggleNotarizeSigning() + { + if (!_useNotarizeSigning.HasValue) + { + if (Input.DisableNotarizeSigning) + { + _useNotarizeSigning = false; + return; + } + + if (!OperatingSystem.IsMacOS()) + _useNotarizeSigning = false; + else if (Program.Configuration.IsNotarizePossible()) + _useNotarizeSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for notarize, continue without notarizing executables?", "Y", "n") == "Y") + { + _useNotarizeSigning = false; + return; + } + + throw new Exception("Configuration is not set up for notarize"); + } + } + } + + /// + /// Returns a value indicating if codesign is enabled /// public bool UseCodeSignSigning => _useCodeSignSigning!.Value; @@ -225,6 +269,11 @@ public static partial class Build /// public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value; + /// + /// Returns a value indicating if notarize is enabled + /// + public bool UseNotarizeSigning => _useNotarizeSigning!.Value; + /// /// Returns a value indicating if docker build is enabled /// @@ -327,7 +376,7 @@ public static partial class Build /// Creates the build command /// /// The command - public static Command Create() + public static System.CommandLine.Command Create() { var buildTargetOption = new Option( name: "--targets", @@ -375,12 +424,6 @@ public static partial class Build getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.sln"))) ); - var updateUrlsOption = new Option( - name: "--update-urls", - description: "The updater urls where the client will check for updates", - getDefaultValue: () => "https://updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest;https://alt.updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest" - ); - var disableAuthenticodeOption = new Option( name: "--disable-authenticode", description: "Disables authenticode signing", @@ -393,11 +436,7 @@ public static partial class Build getDefaultValue: () => false ); - var passwordOption = new Option( - name: "--password", - description: "The password to use for the keyfile", - getDefaultValue: () => string.Empty - ); + var passwordOption = SharedOptions.passwordOption; var disableDockerPushOption = new Option( name: "--disable-docker-push", @@ -417,20 +456,33 @@ public static partial class Build getDefaultValue: () => "duplicati/duplicati" ); - var command = new Command("build", "Builds the packages for a release") { + var changelogFileOption = new Option( + name: "--changelog-file", + description: "The path to the changelog news file. Contents from this file are prepended to the changelog.", + getDefaultValue: () => new FileInfo(Path.GetFullPath("changelog-news.txt")) + ); + + var disableNotarizeSigningOption = new Option( + name: "--disable-notarize-signing", + description: "Disables notarize signing for MacOS packages", + getDefaultValue: () => false + ); + + var command = new System.CommandLine.Command("build", "Builds the packages for a release") { gitStashPushOption, releaseChannelOption, buildTempOption, buildTargetOption, solutionFileOption, - updateUrlsOption, keepBuildsOption, disableAuthenticodeOption, disableCodeSignOption, passwordOption, macOsAppNameOption, disableDockerPushOption, - dockerRepoOption + dockerRepoOption, + changelogFileOption, + disableNotarizeSigningOption }; command.Handler = CommandHandler.Create(DoBuild); @@ -445,7 +497,6 @@ public static partial class Build /// The solution path /// If the git stash should be performed /// The release channel - /// The update urls /// If the builds should be kept /// If authenticode signing should be disabled /// If signcode should be disabled @@ -453,20 +504,23 @@ public static partial class Build /// If the docker push should be disabled /// The name of the MacOS app bundle /// The docker repository to push to + /// The path to the changelog file + /// If notarize signing should be disabled record CommandInput( PackageTarget[] Targets, DirectoryInfo BuildPath, FileInfo SolutionFile, bool GitStashPush, ReleaseChannel Channel, - string UpdateUrls, bool KeepBuilds, bool DisableAuthenticode, bool DisableSignCode, string Password, bool DisableDockerPush, string MacOSAppName, - string DockerRepo + string DockerRepo, + FileInfo ChangelogFile, + bool DisableNotarizeSigning ); static async Task DoBuild(CommandInput input) @@ -520,6 +574,21 @@ public static partial class Build if (!File.Exists(primaryCLI)) throw new Exception($"Failed to locate project file: {primaryCLI}"); + if (!input.ChangelogFile.Exists) + { + Console.WriteLine($"Changelog news file not found: {input.ChangelogFile.FullName}"); + Console.WriteLine($"Create an empty file if you want a release without changes"); + if (OperatingSystem.IsWindows()) + Console.WriteLine($"> type nul > {input.ChangelogFile.FullName}"); + else + Console.WriteLine($"> touch {input.ChangelogFile.FullName}"); + + Program.ReturnCode = 1; + return; + } + + var changelogNews = File.ReadAllText(input.ChangelogFile.FullName); + var releaseInfo = ReleaseInfo.Create(input.Channel, int.Parse(File.ReadAllText(versionFilePath)) + 1); Console.WriteLine($"Building {releaseInfo.ReleaseName} ..."); @@ -527,15 +596,22 @@ public static partial class Build ? ConsoleHelper.ReadPassword("Enter keyfile password") : input.Password; + var primarySignKey = LoadKeyFile(Program.Configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault(), keyfilePassword, false); + var additionalKeys = Program.Configuration.ConfigFiles.UpdaterKeyfile + .Skip(1) + .Select(x => LoadKeyFile(x, keyfilePassword, true)); + // Configure runtime environment var rtcfg = new RuntimeConfig( releaseInfo, + additionalKeys.Prepend(primarySignKey).ToList(), keyfilePassword, - sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList(), + changelogNews, input); rtcfg.ToggleAuthenticodeSigning(); rtcfg.ToggleSignCodeSigning(); + rtcfg.ToggleNotarizeSigning(); await rtcfg.ToggleDockerBuild(); if (!rtcfg.UseDockerBuild) @@ -557,18 +633,65 @@ public static partial class Build // Generally, the builds should happen with a clean source tree, // but this can be disabled for debugging if (input.GitStashPush) - await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir); + await ProcessHelper.Execute(["git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}"], workingDirectory: baseDir); // Inject various files that will be embedded into the build artifacts - await PrepareSourceDirectory(baseDir, releaseInfo, input.UpdateUrls); + await PrepareSourceDirectory(baseDir, releaseInfo, rtcfg); + + // Inject a version tag into the html files + var revertableFiles = InjectVersionIntoFiles(baseDir, releaseInfo); // Perform the main compilations await Compile.BuildProjects(baseDir, input.BuildPath.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, input.KeepBuilds, rtcfg); // Create the packages - await CreatePackage.BuildPackages(baseDir, input.BuildPath.FullName, buildTargets, input.KeepBuilds, rtcfg); + var builtPackages = await CreatePackage.BuildPackages(baseDir, input.BuildPath.FullName, buildTargets, input.KeepBuilds, rtcfg); + + if (rtcfg.UseNotarizeSigning && builtPackages.Any(x => x.Target.Package == PackageType.DMG || x.Target.Package == PackageType.MacPkg)) + { + // # Notarize and staple takes a while... + Console.WriteLine("Performing notarize and staple ..."); + foreach (var p in builtPackages.Where(x => x.Target.Package == PackageType.DMG || x.Target.Package == PackageType.MacPkg)) + { + await ProcessHelper.Execute(["xcrun", "notarytool", "submit", p.CreatedFile, "--keychain-profile", Program.Configuration.ConfigFiles.NotarizeProfile, "--wait"]); + await ProcessHelper.Execute(["xcrun", "stapler", "staple", p.CreatedFile]); + } + } + + // Build the signed manifest to be uploaded to remote storage + Console.WriteLine("Build completed, creating signed manifest ..."); + var manifestfile = Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest"); + if (File.Exists(manifestfile)) + File.Delete(manifestfile); + + UpdaterManager.CreateSignedManifest( + rtcfg.SignKeys.First(), + null, + Path.Combine(input.BuildPath.FullName, "packages"), + version: releaseInfo.Version.ToString(), + updateFromV1Url: Program.Configuration.ExtraSettings.UpdateFromV1Url, + genericUpdatePageUrl: Program.Configuration.ExtraSettings.GenericUpdatePageUrl, + releaseType: releaseInfo.Channel.ToString().ToLowerInvariant(), + packages: builtPackages.Select(x => new PackageEntry() + { + RemoteUrls = Program.Configuration.ExtraSettings.PackageUrls + .Select(u => + u.Replace("${RELEASE_TYPE}", releaseInfo.Channel.ToString().ToLowerInvariant()) + .Replace("${RELEASE_VERSION}", releaseInfo.Version.ToString()) + .Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd")) + .Replace("${FILENAME}", x.CreatedFile) + ).ToArray(), + PackageTypeId = x.Target.PackageTargetString, + Length = new FileInfo(Path.Combine(input.BuildPath.FullName, x.CreatedFile)).Length, + MD5 = CalculateHash(Path.Combine(input.BuildPath.FullName, x.CreatedFile), "md5"), + SHA256 = CalculateHash(Path.Combine(input.BuildPath.FullName, x.CreatedFile), "sha256") + }) + ); + + File.Move(Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest"), Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.manifest"), true); Console.WriteLine("Build completed, uploading packages ..."); + var files = builtPackages.Select(x => x.CreatedFile).Append("latest-v2.manifest").ToArray(); Console.WriteLine("Upload completed, releasing packages ..."); @@ -576,12 +699,13 @@ public static partial class Build // Clean up the source tree await ProcessHelper.Execute(new[] { - "git", "checkout", - "Duplicati/License/VersionTag.txt", - "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt", - "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt", - "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt" - }, workingDirectory: baseDir); + "git", "checkout", + "Duplicati/License/VersionTag.txt", + "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt", + "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt", + "Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt", + }.Concat(revertableFiles.Select(x => Path.GetRelativePath(baseDir, x))) + , workingDirectory: baseDir); if (input.GitStashPush) await GitPush.TagAndPush(baseDir, releaseInfo); @@ -590,28 +714,137 @@ public static partial class Build } /// - /// Updates the source directory prior to building + /// Injects the version number into some html files + /// + /// The base directory + /// The release info + /// The paths that were modified + private static string[] InjectVersionIntoFiles(string baseDir, ReleaseInfo releaseInfo) + { + var targetfiles = Directory.EnumerateFiles(Path.Combine(baseDir, "Duplicati", "Server", "webroot"), "*", SearchOption.AllDirectories) + .Where(x => x.EndsWith(".html") || x.EndsWith(".js")) + .ToArray(); + + var versionre = @"(?\d+\.\d+\.(\*|(\d+(\.(\*|\d+)))?))"; + var regex = new Regex(@"\?v\=" + versionre); + foreach (var file in targetfiles) + File.WriteAllText( + file, + regex.Replace(File.ReadAllText(file), $"?v={releaseInfo.Version}") + ); + + //FILEMAP.Add("AssemblyRedirects.xml", new Regex(@"newVersion\=\""" + versionre + @"\""")); + + return targetfiles; + } + + /// + /// Updates the source directory prior to building. + /// This writes a stub package manifest inside the source folder, + /// which will be embedded in the excutable to indicate which version it was built from. /// /// The source folder base /// The release info to use - /// The urls to check for updates + /// The runtime configuration /// An awaitable task - static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, string updateUrls) + static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg) { - updateUrls = updateUrls - .Replace("${RELEASE_TYPE}", releaseInfo.Channel.ToString().ToLowerInvariant()) + var urlstring = string.Join(";", Program.Configuration.ExtraSettings.UpdaterUrls.Select(x => + x.Replace("${RELEASE_TYPE}", releaseInfo.Channel.ToString().ToLowerInvariant()) .Replace("${RELEASE_VERSION}", releaseInfo.Version.ToString()) - .Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd")); + .Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd")) + .Replace("${FILENAME}", "latest-v2.manifest") + )); File.WriteAllText(Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"), releaseInfo.Version.ToString()); File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Channel.ToString().ToLowerInvariant()); - File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), updateUrls); - File.Copy( - Path.Combine(baseDir, "Updates", "release_key.txt"), - Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKey.txt"), - true + File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), urlstring); + File.WriteAllLines(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKeys.txt"), rtcfg.SignKeys.Select(x => x.ToXmlString(false))); + + if (!string.IsNullOrWhiteSpace(rtcfg.ChangelogNews)) + File.WriteAllText( + Path.Combine(baseDir, "changelog.txt"), + + rtcfg.ChangelogNews + Environment.NewLine + + File.ReadAllText(Path.Combine(baseDir, "changelog.txt")) + ); + + // Previous versions used to install the assembly redirects after the build + // but it looks like .Net now handles this automatically + // If not, we need to adjust the .csproj files before building + // find "${UPDATE_SOURCE}" - type f - name Duplicati.*.exe - maxdepth 1 - exec cp Installer/ AssemblyRedirects.xml { }.config \; + + var manifestFile = Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "autoupdate.manifest"); + if (File.Exists(manifestFile)) + File.Delete(manifestFile); + + UpdaterManager.CreateSignedManifest( + rtcfg.SignKeys.First(), + null, + Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater"), + version: releaseInfo.Version.ToString(), + updateFromV1Url: Program.Configuration.ExtraSettings.UpdateFromV1Url, + genericUpdatePageUrl: Program.Configuration.ExtraSettings.GenericUpdatePageUrl, + releaseType: releaseInfo.Channel.ToString().ToLowerInvariant() ); + + return Task.CompletedTask; } + + /// + /// Loads a keyfile and decrypts the RSA key inside + /// + /// The keyfile to decrypt + /// The keyfile password + /// Allow asking for a new password if the password did not match + /// The matching key + static RSA LoadKeyFile(string? keyfile, string password, bool askForNewPassword) + { + if (string.IsNullOrWhiteSpace(keyfile)) + throw new Exception("Unable to load keyfile, no keyfile specified"); + + if (!File.Exists(keyfile)) + throw new FileNotFoundException($"Keyfile not found: {keyfile}"); + + try + { + using var ms = new MemoryStream(); + using var fs = File.OpenRead(keyfile); + SharpAESCrypt.SharpAESCrypt.Decrypt(password, fs, ms); + + var rsa = RSA.Create(); + rsa.FromXmlString(System.Text.Encoding.UTF8.GetString(ms.ToArray())); + + return rsa; + } + catch (SharpAESCrypt.SharpAESCrypt.WrongPasswordException) + { + if (!askForNewPassword) + throw; + } + + password = ConsoleHelper.ReadPassword($"Enter password for {keyfile}"); + return LoadKeyFile(keyfile, password, false); + } + + /// + /// Calculates the hash of a file and returns the hash as a base64 string + /// + /// The file to calculate the has for + /// The hash algorithm + /// The base64 encoded hash + static string CalculateHash(string file, string algorithm) + { + using var fs = File.OpenRead(file); + using var hash = algorithm.ToLowerInvariant() switch + { + "md5" => (HashAlgorithm)MD5.Create(), + "sha256" => SHA256.Create(), + _ => throw new Exception($"Unknown hash algorithm: {algorithm}") + }; + + return Convert.ToBase64String(hash.ComputeHash(fs)); + } } diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs index c8b626cf0..927a8069f 100644 --- a/ReleaseBuilder/Configuration.cs +++ b/ReleaseBuilder/Configuration.cs @@ -34,9 +34,11 @@ public enum ReleaseChannel /// /// The configuration files /// The commands +/// Extra settings public record Configuration( ConfigFiles ConfigFiles, - Commands Commands + Commands Commands, + ExtraSettings ExtraSettings ) { /// @@ -46,7 +48,8 @@ public record Configuration( public static Configuration Create() => new( ConfigFiles.Create(), - Commands.Create() + Commands.Create(), + ExtraSettings.Create() ); /// @@ -79,6 +82,21 @@ public record Configuration( return true; } + /// + /// Checks if signing with notarize is possible given the current configuration + /// + /// + public bool IsNotarizePossible() + { + if (!OperatingSystem.IsMacOS()) + return false; + + if (string.IsNullOrWhiteSpace(ConfigFiles.NotarizeProfile)) + return false; + + return true; + } + /// /// Checks if building MSI files is possible given the current configuration /// @@ -132,18 +150,16 @@ public record Configuration( /// The token used for Github uploads /// The token used for Discourse forum announce /// The identity to use for MacOS signing -/// The username for MacOS notarization -/// The password for MacOS notarization +/// The profile to use for MacOS notarization public record ConfigFiles( - string UpdaterKeyfile, + string[] UpdaterKeyfile, string GpgKeyfile, string AuthenticodePfxFile, string AuthenticodePasswordFile, string GithubTokenFile, string DiscourseTokenFile, string CodesignIdentity, - string NotarizeUsername, - string NotarizePassword + string NotarizeProfile ) { /// @@ -167,15 +183,14 @@ public record ConfigFiles( } return new( - ExpandEnv("UPDATER_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-release.key"), + ExpandEnv("UPDATER_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-release.key").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), ExpandEnv("GPG_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-gpgkey.key"), ExpandEnv("AUTHENTICODE_PFXFILE", "${HOME}/.config/signkeys/Duplicati/authenticode.pfx"), ExpandEnv("AUTHENTICODE_PASSWORD", "${HOME}/.config/signkeys/Duplicati/authenticode.key"), ExpandEnv("GITHUB_TOKEN_FILE", "${HOME}/.config/github-api-token"), ExpandEnv("DISCOURSE_TOKEN_FILE", "${HOME}/.config/discourse-api-token"), ExpandEnv("CODESIGN_IDENTITY", ""), - ExpandEnv("NOTARIZE_USERNAME", ""), - ExpandEnv("NOTARIZE_PASSWORD", "@keychain:NOTARIZE_CMDLINE") + ExpandEnv("NOTARIZE_PROFILE", "") ); } } @@ -222,3 +237,29 @@ public record Commands( ); } +/// +/// Extra settings used by the build script, that are not expected to be changed often +/// +/// The URL to use for clients upgrading from earlier versions +/// The URL to redirect to when the update has no specific package +/// The urls where packages are stored +/// The urls where manifest files are stored +public record ExtraSettings( + string UpdateFromV1Url, + string GenericUpdatePageUrl, + string[] PackageUrls, + string[] UpdaterUrls +) +{ + /// + /// Generates a new extra settings instance + /// + /// The extra settings instance + public static ExtraSettings Create() + => new( + GetEnvKey("UPDATE_FROM_V1_URL", "https://duplicati.com/update-from-v1"), + GetEnvKey("GENERIC_UPDATE_PAGE_URL", "https://duplicati.com/download"), + GetEnvKey("PACKAGE_URLS", "https://updates.duplicati.com/${RELEASE_TYPE}/${FILENAME};https://alt.updates.duplicati.com/${RELEASE_TYPE}/${FILENAME}").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + GetEnvKey("UPDATER_URLS", "https://updates.duplicati.com/${RELEASE_TYPE}/${FILENAME};https://alt.updates.duplicati.com/${RELEASE_TYPE}/${FILENAME}").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ); +} diff --git a/ReleaseBuilder/CreateKey/Command.cs b/ReleaseBuilder/CreateKey/Command.cs new file mode 100644 index 000000000..a2f916479 --- /dev/null +++ b/ReleaseBuilder/CreateKey/Command.cs @@ -0,0 +1,47 @@ +using System.CommandLine; +using System.Security.Cryptography; + +namespace ReleaseBuilder.CreateKey; + +public static class Command +{ + public static System.CommandLine.Command Create() + { + var passwordOption = SharedOptions.passwordOption; + + var keyfileArgument = new Argument( + name: "keyfile", + description: "Path to keyfile to use for signing release manifests", + getDefaultValue: () => new FileInfo(Program.Configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault() ?? "./signkey.key") + ); + + var command = new System.CommandLine.Command("create-key", "Creates a new key for signing releases") + { + passwordOption, + keyfileArgument + }; + + command.SetHandler((password, keyfile) => + { + if (keyfile.Exists) + { + Console.WriteLine($"Keyfile already exists at {keyfile.FullName}"); + Program.ReturnCode = 1; + return; + } + + var keyfilePassword = string.IsNullOrEmpty(password) + ? ConsoleHelper.ReadPassword("Enter keyfile password") + : password; + + var newkey = RSA.Create().ToXmlString(true); + using (var fs = File.OpenWrite(keyfile.FullName)) + using (var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(newkey))) + SharpAESCrypt.SharpAESCrypt.Encrypt(keyfilePassword, ms, fs); + + Console.WriteLine($"Keyfile created at {keyfile.FullName}"); + }, passwordOption, keyfileArgument); + + return command; + } +} diff --git a/ReleaseBuilder/EnvHelper.cs b/ReleaseBuilder/EnvHelper.cs index eeac719c6..7af2c912e 100644 --- a/ReleaseBuilder/EnvHelper.cs +++ b/ReleaseBuilder/EnvHelper.cs @@ -27,6 +27,22 @@ public static class EnvHelper ); } + /// + /// Reads the environment key, and expands environment variables inside. + /// If no key is found, the default value is returned + /// + /// The key to use + /// The default value if the key is not set + /// The value + public static string GetEnvKey(string key, string defaultValue) + { + var value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + value = defaultValue ?? string.Empty; + + return value; + } + /// /// Returns an executable path /// diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs index f11a2dee6..2611e881e 100644 --- a/ReleaseBuilder/Program.cs +++ b/ReleaseBuilder/Program.cs @@ -49,14 +49,24 @@ class Program /// public static readonly Configuration Configuration = Configuration.Create(); + /// + /// The return code of the application; shared state + /// + public static int? ReturnCode = 0; + /// /// Invokes the builder /// /// /// - static Task Main(string[] args) - => new RootCommand("Build tool for Duplicati") + static async Task Main(string[] args) + { + var r = await new RootCommand("Build tool for Duplicati") { - CliCommand.Build.Create() + Build.Command.Create(), + CreateKey.Command.Create(), }.InvokeAsync(args); + + return ReturnCode ?? r; + } } \ No newline at end of file diff --git a/ReleaseBuilder/ReleaseBuilder.csproj b/ReleaseBuilder/ReleaseBuilder.csproj index 9d8b2471c..30d6ed212 100644 --- a/ReleaseBuilder/ReleaseBuilder.csproj +++ b/ReleaseBuilder/ReleaseBuilder.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/ReleaseBuilder/SharedOptions.cs b/ReleaseBuilder/SharedOptions.cs new file mode 100644 index 000000000..66f64004b --- /dev/null +++ b/ReleaseBuilder/SharedOptions.cs @@ -0,0 +1,19 @@ +using System.CommandLine; + +namespace ReleaseBuilder; + +/// +/// Options and arguments shared between commands +/// +public static class SharedOptions +{ + /// + /// The password to the key file to use for signing release manifests + /// + public static readonly Option passwordOption = new Option( + name: "--password", + description: "The password to use for the keyfile", + getDefaultValue: () => string.Empty + ); + +} diff --git a/ReleaseBuilder/testfile.key b/ReleaseBuilder/testfile.key new file mode 100644 index 0000000000000000000000000000000000000000..76f0da9b5f0259f05ad5c3eae99101ee784c6379 GIT binary patch literal 1981 zcmZ>C4Q66sP;?G*bqsNJiFb-*2+l|>DsXfSb}p(cC{ZXg)HBvI(KBFZU>I;Pr}TQr z)AYS%DHY~FYvf8@{>%{kBriX2O8z#jpLM22J)3M+@6fqE{jw$JpPx<^!U+=D^W^;& zSl4Uccik%?!4$c>jPFV=mI|0gaax`20S>%B=qRRK!Y!b&rBx_lb!@20f~ zn7?5D$QWU&;_7Lts5j^S!@>sl_uhvJx?3mA4)y;unK$IZjk14vT@Hu$?UmSlPsrGa zM?!~L^?%3TF!|SQ!LoNauCCwGbJ*hYvxr99OZ@+1FO)t!5O+60Kbq^M&5m_m`4j(W z$1cp|e)`a#Ra)b`bnb%rhZpV>ePESQ;OIQD-_qiT;$jD@U02owpnle-*4I4CuGAF zqq*z2;f3s{Ox2V2xID?xR<|_Bq z2_ozM*1fy)N98Aj%kKp?UHi8yvprR2JngshABVf7pjX;AZJDlR{=Gs08HRSY(Q%Ro zPZzgeSn_tkb)6mVX-58 zw(_O*@&6G9RolFZs<*8>7kQ&`wV9~lnq`k#r^~H&%hC7o2n{id-lplM%bHSs%zj4t z8)X5m>>JgM9Tiij9*HRVTyts1jP^-csZx^V>`IucRznyX}~@E7d|f7d?Z zDywu6Ty*Im`_kH;^O1`u&2o4#=?=s6urKp$vb=kjtp8icfB8mJkLi+am!9OYswVTT zy<7fJ=aj+hz4-;RJ~2duYcsuE8e7h1S<9*)Oh=<+2vyV0(XbL*wET$uUm5JrB0Zc|ZFXwJRg$ zg3=9#;Lht?z5Yu1#D96!)*QM%>qKYR6*k*_sjLO(F3gpC{#Et)e#_`tdrlv|w(T7E zv8TGTuH?RdapB*;|IYHPKFi{Myk?s&I#;FSa}xJj-&s|E=PWd}XsdFuJXtbzYYzY8 zmDvhn$$kvXT$z6-e3AOH^5m>HY-it{e63VEZSyy&U9q=CIJxHXyxUV-ouPI(Y3>Ds z70RbF&d;pvH;_1Exxn@Ub8t}#^Um`zRh`K*PAp{NpFDNplAS>_(nFT7-}>s^)BO2) zFEw>}vcz^a_PttQqf|9Fh`D8&)_zfWbJpUZOJx!rCmf!b=UUy5!ud#y9ar?okep0iwn#@^`sO&rW>P(Wv z>Z&QcYo({`WGw%lC_L`~H{WgZB^G}|jvTLl^WtxksZ-uvpIt$b|C59Gg4?&ATNj_N z`(>5ZznK!wrlx`ar?O>SzI45Rl8KtYH374Ye2rquw5OG3D`|-3Ihxyhiw8XoV!kGM zu>bSSnR&IVww1nm{=j}#mEw``KE57NUHNi7>$gQc8_T-)Rja8ct9$;)?b~7h^VH;G z?a$^u9j+2e`G0xT&sRLnzZNh0c5U*O%82%)1ueH%^mzgz_R!*>;C$-x6`1gdae=SiPB|aHf$8B9(bT?T$%BIyY_)5S+hOirZJ>@3- zUdY?nA<*4F>r~H%1ox^ZC1uqrt5*J+YW3>yO+(w3v}?u{Hd6msa?bt!&OR+cUae@a>7(Ij26yM^1K= zhW6>J7Om|+gz7r329+7E+SMj4N4a8*8V1tjxSTpJlPqk^Xf;c^x8A$ye=FGDQEIsJktys6ADgFB+*T}W)@7f&Z-Bh`GC4Q66sP;?G*bqsNJiFb-*2+l|>DsXfSb}p(cC{ZXg)HBvI(KBFZU>I=Fue7K8 z!u7vTwytHHx?}fry|gt}-urr!H5~$t>$gwkI=?Pxmr&J{{$G;+kCvDj%o37zE#cUt z;TO@)vM!+g-`(TuC4HCV2Hc--^XNrHZsA)C|HmC~qtm>nD#^%pZK^shA+v+KBIWtT z_gCge$}ZoV<`LSJWAb~(-RcJ-k1q>aPL_9>-?lTtMZC)6-GQQy;YS1{CkgbOj-8gG zAo6(C>gD&klK+;zR{WHvEna9CSHEeoQ}adHr2_9~DXhx5H&bjcpSjZ=xh2LuyT6sq z%kww+78w6?)kmg>fw%Y8r@KUoHYhsJ{>l?#CR6#h`rg#MlHI&d`I(=K#n`!q8JrDw zZ=9yCw{Wui?K_jF?rh5uT;Q#BlSOaumM62~{s_2s{B+Z)(3tf=GtcP$obL_fOj{id^`dhrr)3a!=E+Fzx<=iWN7$hS(sP_Q^3;p;N5(2 zy_;$dDrs)qG-(QxR`@v`kC3X^oMjpnX6hNYPMp2OnR+IT<<_jmA8Fwqet6%|s*<)e z2Xxm4qD-b?539)WFjNk_Wt)3@xs%kO*eQdro>-#-!^EDxR9 zc|7XqORo7V&i|@@`RD(Pj)E64N)vZD7(XvK=OOWpL<;z;@1Dbx_TB050DdrTbBxawt$a6*D+2q5M z4~ZX_Ii0cl7O+?ru(WxDYw|Af2OfgCv$A!s3#U()zjN$` zThqzy`Gu+E{dKdFS=PxXmaM0HxFI?b1N9{z1p{8-#(-A$}OfP z2dDIIIMp)2dVPe^A%~(5K5BdTC(QhQ)9zp=_w?T%uCt5XujtazPn`YlrpK<$Th*Gs zci)<{C670_+T=|CyY7bAT#jQY_N#@LUei&1;m5W{`hOAgT4|Q#9{x!_6Ye~=x^}_1 zqJmxRxTHh7^7SrrwFvIb&%Ix+x7KNu$=snUx=XoaLzVln?$Ykoj@bJf=9{*jYMAXh zsrz*NqbP+pX6*0l9^JfUzANGIer*}+;6|@jyKgr{N{{iCE!cXHm&LVo?L`k=-_tuU zvT~?-GGE!cpvL(^ag}g$W$Ikrt1PQiJC7`xv(_-wd3}u82JsmZ0Zb|n-M)%G<(_@l zQbDic=v^TfnT6?Le79Dg*31@Ve7<4UZvVY~KV*(N@FdTXn-#vnVw&&=pN+15b2KMi z{hs=GcB;aQj7={Y8?HQH?<~$Zyyxr0wY!Z?EX&trE$rY~a_WVOVfN$YvJ$Dm&7J+x zS@QF5t;}C^P$g8zaQBjhErJg!(<2v{RqtCC%>Q9Up7Mg}zS4anx20bR?=E3D6un)r zLPYfVf7#nHeu^7nP8XdKkUP*V;`nG$D<|hmXW8pZXZ~vXytKR2dyQUWra=yi(Tz37 zTIa3oI&PzK>&%g|!y6KI9O6D>Gy9=#P*j`J6_NIxhrin02-?B2rdYyYzd4##)<<2k}QZ!w>f8G|t(*;eSzcImb>j$IDK|$Ir?9 zem6yY%84>j&48Fqn@v(0(q?q%@qd5e{71G%JkaQf`U^crb|wD(Tb(sKK2*MO;1FL> zzg;caL_aGhG@9*v>shSxCuS&-jwFg+*q{55IYiwlZ32c%n_Jfs*oEqTVxwOyHuwkB+To9DdEYqF2U zvT%kl6}u{*jkl)dPOJ$GjVfNLzA94LHE41@qseuXw`YFsU_G3@F|^K|-#3P_?!|P` f3CFY&g2JYpdat|ai6KFmQyzXh5~rc literal 0 HcmV?d00001 diff --git a/Updates/beta.manifest b/Updates/beta.manifest deleted file mode 100644 index a97d2c94f..000000000 --- a/Updates/beta.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Beta", -} \ No newline at end of file diff --git a/Updates/canary.manifest b/Updates/canary.manifest deleted file mode 100644 index 5bdf0c5a8..000000000 --- a/Updates/canary.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Canary", -} \ No newline at end of file diff --git a/Updates/debug.manifest b/Updates/debug.manifest deleted file mode 100644 index 9820622bd..000000000 --- a/Updates/debug.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Debug", -} \ No newline at end of file diff --git a/Updates/debug_changeinfo.txt b/Updates/debug_changeinfo.txt deleted file mode 100644 index 0cebd4798..000000000 --- a/Updates/debug_changeinfo.txt +++ /dev/null @@ -1 +0,0 @@ -Debug snapshot diff --git a/Updates/debug_key.txt b/Updates/debug_key.txt deleted file mode 100644 index 01b1eda73..000000000 --- a/Updates/debug_key.txt +++ /dev/null @@ -1 +0,0 @@ -hAQowZDOHUng9erCNk/dWTDjmj4RPQ1aU3l6VPwt+pJo+Axd7BFw6VC+tcCe5ArA/KTuRleVER0ARdWMAl5dGaAzbXrUhYBPCBnamMJomddAYQniKUwMbH1QYLlLy/My+BVqCYFYRubc+Mwb0vPvXQgaXGOI4DrVC/85KIXzyIc=EQ== diff --git a/Updates/experimental.manifest b/Updates/experimental.manifest deleted file mode 100644 index 49d0f683e..000000000 --- a/Updates/experimental.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Experimental", -} \ No newline at end of file diff --git a/Updates/nightly.manifest b/Updates/nightly.manifest deleted file mode 100644 index 9738bf153..000000000 --- a/Updates/nightly.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Nightly", -} \ No newline at end of file diff --git a/Updates/release_changeinfo.txt b/Updates/release_changeinfo.txt deleted file mode 100644 index c4ef80ad7..000000000 --- a/Updates/release_changeinfo.txt +++ /dev/null @@ -1 +0,0 @@ -Test release \ No newline at end of file diff --git a/Updates/release_key.txt b/Updates/release_key.txt deleted file mode 100644 index 736377a57..000000000 --- a/Updates/release_key.txt +++ /dev/null @@ -1 +0,0 @@ -krad8Af4dJQfasOtYpThF5b0ZJW0p4gt2hNQx0vHOuoOXSWqh7mk+XhrF1G3WHkzbBops/VphjtEWOWM6Duh+/4e5NviiGovDVD8g/EWXa336SB04vF6U3CoIAFw3T+ZAAv0Ovmywcu71a8unEbgPWlIsWITvWzo7Et+TdTOBYM=EQ== \ No newline at end of file diff --git a/Updates/stable.manifest b/Updates/stable.manifest deleted file mode 100644 index 9b9a7aace..000000000 --- a/Updates/stable.manifest +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ReleaseType": "Stable", -} \ No newline at end of file diff --git a/build-debug-update.sh b/build-debug-update.sh index 6660cd38b..d4c9bbb0c 100755 --- a/build-debug-update.sh +++ b/build-debug-update.sh @@ -22,7 +22,7 @@ echo echo "${RELEASE_NAME}" > Duplicati/License/VersionTag.txt echo "${UPDATE_MANIFEST_URLS}" > Duplicati/Library/AutoUpdater/AutoUpdateURL.txt -cp "Updates/debug_key.txt" Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt +cp "Updates/debug_key.txt" Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt rm -rf Duplicati/GUI/Duplicati.GUI.TrayIcon/bin/Debug diff --git a/build-release.sh b/build-release.sh index adf668cba..552eee42f 100755 --- a/build-release.sh +++ b/build-release.sh @@ -135,7 +135,7 @@ fi echo "${RELEASE_NAME}" > "Duplicati/License/VersionTag.txt" echo "${RELEASE_TYPE}" > "Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt" echo "${UPDATE_MANIFEST_URLS}" > "Duplicati/Library/AutoUpdater/AutoUpdateURL.txt" -cp "Updates/release_key.txt" "Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt" +cp "Updates/release_key.txt" "Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt" RELEASE_CHANGEINFO=$(cat ${RELEASE_CHANGELOG_FILE}) if [ "x${RELEASE_CHANGEINFO}" == "x" ]; then diff --git a/deploy-debug.sh b/deploy-debug.sh index 7f1449f06..d793c0b76 100755 --- a/deploy-debug.sh +++ b/deploy-debug.sh @@ -9,7 +9,7 @@ RELEASE_VERSION="2.0.0.${RELEASE_INC_VERSION}" echo "${RELEASE_NAME}" > Duplicati/License/VersionTag.txt cp "Updates/debug_urls.txt" Duplicati/Library/AutoUpdater/AutoUpdateURL.txt -cp "Updates/debug_key.txt" Duplicati/Library/AutoUpdater/AutoUpdateSignKey.txt +cp "Updates/debug_key.txt" Duplicati/Library/AutoUpdater/AutoUpdateSignKeys.txt mono BuildTools/UpdateVersionStamp/bin/Debug/UpdateVersionStamp.exe --version="${RELEASE_VERSION}" xbuild /p:Configuration=Debug Duplicati.sln From 9f0810612a09b5fbbb6409a76aa126f3399bf0d4 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 2 Apr 2024 22:31:55 +0200 Subject: [PATCH 23/91] Changed logic for reporting exceptions, so all statistics are reported despite an exception stopping the backup --- .../Interface/IGenericCallbackModule.cs | 3 +- Duplicati/Library/Main/Controller.cs | 72 ++++++++++--------- .../Library/Modules/Builtin/ReportHelper.cs | 15 ++-- .../DuplicatiFormatSerializer.cs | 13 +++- .../IResultFormatSerializer.cs | 4 +- .../JsonFormatSerializer.cs | 6 +- .../Library/Modules/Builtin/RunScript.cs | 9 +-- .../Modules/Builtin/SendHttpMessage.cs | 4 +- .../Modules/Builtin/SendJabberMessage.cs | 4 +- 9 files changed, 76 insertions(+), 54 deletions(-) diff --git a/Duplicati/Library/Interface/IGenericCallbackModule.cs b/Duplicati/Library/Interface/IGenericCallbackModule.cs index a4d8f7087..972d227e8 100644 --- a/Duplicati/Library/Interface/IGenericCallbackModule.cs +++ b/Duplicati/Library/Interface/IGenericCallbackModule.cs @@ -38,6 +38,7 @@ namespace Duplicati.Library.Interface /// Called when the operation finishes /// /// The result object, if this derives from an exception, the operation failed - void OnFinish(object result); + /// The exception that stopped the backup, or null + void OnFinish(object result, Exception exception); } } diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index 727611dfa..53872261b 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -441,7 +441,7 @@ namespace Duplicati.Library.Main r.Interrupted = false; } - OnOperationComplete(result); + OnOperationComplete(result, null); Logging.Log.WriteInformationMessage(LOGTAG, "CompletedOperation", Strings.Controller.CompletedOperationMessage(m_options.MainAction)); @@ -469,23 +469,26 @@ namespace Duplicati.Library.Main db.WriteResults(); } - OnOperationComplete(result); + // Do not propagate the cancel exception + OnOperationComplete(result, null); } catch { } } else { // Perform the module shutdown - OnOperationComplete(ex); + OnOperationComplete(ex, ex); } return result; } else { - try + Logging.Log.WriteErrorMessage(LOGTAG, "FailedOperation", ex, Strings.Controller.FailedOperationMessage(m_options.MainAction, ex.Message)); + + if (result is BasicResults basicResults) { - if (result is BasicResults basicResults) + try { basicResults.OperationProgressUpdater.UpdatePhase(OperationPhase.Error); basicResults.Fatal = true; @@ -498,13 +501,18 @@ namespace Duplicati.Library.Main db.WriteResults(); } } + + // Report the result, and the failure + OnOperationComplete(result, ex); + } + catch { } + } + else + { + // Perform the module shutdown + OnOperationComplete(ex, ex); } - catch { } - - OnOperationComplete(ex); - - Logging.Log.WriteErrorMessage(LOGTAG, "FailedOperation", ex, Strings.Controller.FailedOperationMessage(m_options.MainAction, ex.Message)); throw; } @@ -538,13 +546,13 @@ namespace Duplicati.Library.Main System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiLocale; } - private void OnOperationComplete(object result) + private void OnOperationComplete(object result, Exception exception) { if (m_options != null && m_options.LoadedModules != null) { foreach (KeyValuePair mx in m_options.LoadedModules) if (mx.Key && mx.Value is IGenericCallbackModule module) - try { module.OnFinish(result); } + try { module.OnFinish(result, exception); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"OnFinishError{mx.Key}", ex, "OnFinish callback {0} failed: {1}", mx.Key, ex.Message); } foreach (KeyValuePair mx in m_options.LoadedModules) diff --git a/Duplicati/Library/Modules/Builtin/ReportHelper.cs b/Duplicati/Library/Modules/Builtin/ReportHelper.cs index 583200f14..8c8b3f263 100644 --- a/Duplicati/Library/Modules/Builtin/ReportHelper.cs +++ b/Duplicati/Library/Modules/Builtin/ReportHelper.cs @@ -297,8 +297,9 @@ namespace Duplicati.Library.Modules.Builtin /// The expanded template. /// The input template. /// The result object. + /// An optional exception that has stopped the backup /// If set to true, the result is intended for a subject or title line. - protected virtual string ReplaceTemplate(string input, object result, bool subjectline) + protected virtual string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { // For JSON, ignore the template and just use the contents if (ExportFormat == ResultExportFormat.Json && !subjectline) @@ -339,7 +340,7 @@ namespace Duplicati.Library.Modules.Builtin if (input.IndexOf($"%{kv.Key}%", StringComparison.OrdinalIgnoreCase) >= 0) extra[kv.Key] = kv.Value; - return m_resultFormatSerializer.Serialize(result, LogLines, extra); + return m_resultFormatSerializer.Serialize(result, exception, LogLines, extra); } else { @@ -355,7 +356,7 @@ namespace Duplicati.Library.Modules.Builtin else { if (input.IndexOf("%RESULT%", StringComparison.OrdinalIgnoreCase) >= 0) - input = Regex.Replace(input, "\\%RESULT\\%", m_resultFormatSerializer.Serialize(result, LogLines, null), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + input = Regex.Replace(input, "\\%RESULT\\%", m_resultFormatSerializer.Serialize(result, exception, LogLines, null), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } foreach (KeyValuePair kv in m_options) @@ -394,7 +395,7 @@ namespace Duplicati.Library.Modules.Builtin } } - public void OnFinish(object result) + public void OnFinish(object result, Exception exception) { // Dispose the current log scope if (m_logscope != null) @@ -412,7 +413,7 @@ namespace Duplicati.Library.Modules.Builtin return; ParsedResultType level; - if (result is Exception) + if (result is Exception || exception != null) level = ParsedResultType.Fatal; else if (result != null && result is IBasicResults results) level = results.ParsedResult; @@ -438,8 +439,8 @@ namespace Duplicati.Library.Modules.Builtin if (body != DEFAULT_BODY && System.IO.Path.IsPathRooted(body) && System.IO.File.Exists(body)) body = System.IO.File.ReadAllText(body); - body = ReplaceTemplate(body, result, false); - subject = ReplaceTemplate(subject, result, true); + body = ReplaceTemplate(body, result, exception, false); + subject = ReplaceTemplate(subject, result, exception, true); SendMessage(subject, body); } diff --git a/Duplicati/Library/Modules/Builtin/ResultSerialization/DuplicatiFormatSerializer.cs b/Duplicati/Library/Modules/Builtin/ResultSerialization/DuplicatiFormatSerializer.cs index e57596fc0..78f15de10 100644 --- a/Duplicati/Library/Modules/Builtin/ResultSerialization/DuplicatiFormatSerializer.cs +++ b/Duplicati/Library/Modules/Builtin/ResultSerialization/DuplicatiFormatSerializer.cs @@ -37,12 +37,19 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization /// /// The serialized result string. /// The result to serialize. + /// The exception, if any /// The log lines to serialize. /// Additional parameters to include - public string Serialize(object result, IEnumerable loglines, Dictionary additional) + public string Serialize(object result, Exception failException, IEnumerable loglines, Dictionary additional) { StringBuilder sb = new StringBuilder(); + // Prepend the error message as the first two lines, to mimic previous behavior with only the exception text + if (failException != null && result != failException) + { + sb.AppendLine(Serialize(failException, null, null, null)); + } + if (result == null) { sb.Append("null?"); @@ -105,7 +112,7 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization } else { - var ignore = new string[] { + var ignore = new string[] { nameof(IBasicResults.Warnings), nameof(IBasicResults.Errors), nameof(IBasicResults.Messages) @@ -115,7 +122,7 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization } if (additional != null && additional.Count > 0) - sb.AppendLine(Serialize(additional, null, null)); + sb.AppendLine(Serialize(additional, null, null, null)); if (loglines != null && loglines.Any()) { diff --git a/Duplicati/Library/Modules/Builtin/ResultSerialization/IResultFormatSerializer.cs b/Duplicati/Library/Modules/Builtin/ResultSerialization/IResultFormatSerializer.cs index f84cc463a..2eb2f2d29 100644 --- a/Duplicati/Library/Modules/Builtin/ResultSerialization/IResultFormatSerializer.cs +++ b/Duplicati/Library/Modules/Builtin/ResultSerialization/IResultFormatSerializer.cs @@ -18,6 +18,7 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Collections.Generic; namespace Duplicati.Library.Modules.Builtin @@ -32,9 +33,10 @@ namespace Duplicati.Library.Modules.Builtin /// /// The serialized result string. /// The result to serialize. + /// An optional failure exception, or null /// The log lines to serialize. /// Additional parameters to include - string Serialize(object result, IEnumerable loglines, Dictionary additional); + string Serialize(object result, Exception exception, IEnumerable loglines, Dictionary additional); /// /// Returns the format that the serializer represents diff --git a/Duplicati/Library/Modules/Builtin/ResultSerialization/JsonFormatSerializer.cs b/Duplicati/Library/Modules/Builtin/ResultSerialization/JsonFormatSerializer.cs index a004922e3..05d17aa0c 100644 --- a/Duplicati/Library/Modules/Builtin/ResultSerialization/JsonFormatSerializer.cs +++ b/Duplicati/Library/Modules/Builtin/ResultSerialization/JsonFormatSerializer.cs @@ -76,16 +76,18 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization /// /// The serialized result string. /// The result to serialize. + /// The exception, if any /// The log lines to serialize. /// Additional parameters to include - public string Serialize(object result, IEnumerable loglines, Dictionary additional) + public string Serialize(object result, Exception exception, IEnumerable loglines, Dictionary additional) { return JsonConvert.SerializeObject( new { Data = result, Extra = additional, - LogLines = loglines + LogLines = loglines, + Exception = exception?.ToString() }, new JsonSerializerSettings() diff --git a/Duplicati/Library/Modules/Builtin/RunScript.cs b/Duplicati/Library/Modules/Builtin/RunScript.cs index 2fce8e9d5..379f45c33 100644 --- a/Duplicati/Library/Modules/Builtin/RunScript.cs +++ b/Duplicati/Library/Modules/Builtin/RunScript.cs @@ -170,7 +170,7 @@ namespace Duplicati.Library.Modules.Builtin m_localpath = localpath; } - public void OnFinish (object result) + public void OnFinish (object result, Exception exception) { // Dispose the current log scope if (m_logscope != null) @@ -184,7 +184,8 @@ namespace Duplicati.Library.Modules.Builtin return; ParsedResultType level; - if (result is OperationAbortException oae) + OperationAbortException oae = result as OperationAbortException ?? exception as OperationAbortException; + if (oae != null) { switch (oae.AbortReason) { @@ -202,7 +203,7 @@ namespace Duplicati.Library.Modules.Builtin break; } } - else if (result is Exception) + else if (result is Exception || exception != null) level = ParsedResultType.Fatal; else if (result != null && result is IBasicResults results) level = results.ParsedResult; @@ -212,7 +213,7 @@ namespace Duplicati.Library.Modules.Builtin using (TempFile tmpfile = new TempFile()) { using (var streamWriter = new StreamWriter(tmpfile)) - streamWriter.Write(resultFormatSerializer.Serialize(result, m_logstorage, null)); + streamWriter.Write(resultFormatSerializer.Serialize(result, exception, m_logstorage, null)); Execute(m_finishScript, "AFTER", m_operationName, ref m_remoteurl, ref m_localpath, m_timeout, false, m_options, tmpfile, level); } diff --git a/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs b/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs index 33b725dff..18a3b9051 100644 --- a/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs +++ b/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs @@ -206,13 +206,13 @@ namespace Duplicati.Library.Modules.Builtin { #endregion - protected override string ReplaceTemplate(string input, object result, bool subjectline) + protected override string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { // No need to do the expansion as we throw away the result if (subjectline) return string.Empty; - return base.ReplaceTemplate(input, result, subjectline); + return base.ReplaceTemplate(input, result, exception, subjectline); } protected override void SendMessage(string subject, string body) { diff --git a/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs b/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs index e1cd01d34..0d3bbe495 100644 --- a/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs +++ b/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs @@ -185,12 +185,12 @@ namespace Duplicati.Library.Modules.Builtin #endregion - protected override string ReplaceTemplate(string input, object result, bool subjectline) + protected override string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { // No need to do the expansion as we throw away the result if (subjectline) return string.Empty; - return base.ReplaceTemplate(input, result, subjectline); + return base.ReplaceTemplate(input, result, exception, subjectline); } protected override void SendMessage(string subject, string body) From 23065d44ef142c104d47ef84e083b51cdf5d54ff Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 3 Apr 2024 11:24:57 +0200 Subject: [PATCH 24/91] Version bump to v2.0.7.102-2.0.7.102_canary_2024-04-03 You can download this build from: Binaries: https://updates.duplicati.com/canary/duplicati-2.0.7.102_canary_2024-04-03.zip Signature file: https://updates.duplicati.com/canary/duplicati-2.0.7.102_canary_2024-04-03.zip.sig ASCII signature file: https://updates.duplicati.com/canary/duplicati-2.0.7.102_canary_2024-04-03.zip.sig.asc MD5: c08ebf88cc6b4c164b6dcb3da5a2af94 SHA1: c593036adda4b2599023e88398c9aa59f57cb91b SHA256: 46df0472bce7e63554808dd6924b957eb2cd4d7c08b76129d590c2d3c9aa40c2 --- Updates/build_version.txt | 2 +- changelog.txt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Updates/build_version.txt b/Updates/build_version.txt index 398050c62..257e56326 100644 --- a/Updates/build_version.txt +++ b/Updates/build_version.txt @@ -1 +1 @@ -101 +102 diff --git a/changelog.txt b/changelog.txt index 7d7bc1cc0..503b23f36 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,17 @@ +2024-04-03 - 2.0.7.102_canary_2024-04-03 +========== +This build is intended to be the last build that uses .Net4 (aka .Net Desktop). +Future builds are expected to use .Net8 and will require a manual update, +because the .Net builds are no longer operating system independent. + +The upside is that there are fewer dependencies (no more Mono), +and execution times are greatly improved. + +* Removed donation messages +* Updated MacOS Installer license text +* Updated installer to support future manual upgrade +* Added information to reports when encountering an exception + 2024-03-08 - 2.0.7.101_canary_2024-03-08 ========== * Updated license to MIT, thanks @kenkendk From 02d476d16cfac0b98c41a0b66ab43cee1df4fb3d Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 4 Apr 2024 15:44:28 +0200 Subject: [PATCH 25/91] Added support for writing data to stdin --- ReleaseBuilder/ProcessHelper.cs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs index ebf388bae..76cccc1c5 100644 --- a/ReleaseBuilder/ProcessHelper.cs +++ b/ReleaseBuilder/ProcessHelper.cs @@ -16,8 +16,9 @@ public static class ProcessHelper /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console + /// Function to write to stdin /// An awaitable task - public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) + public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); @@ -34,7 +35,7 @@ public static class ProcessHelper WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = false, - RedirectStandardInput = false, + RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); @@ -43,6 +44,9 @@ public static class ProcessHelper ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); + if (writeStdIn != null) + await writeStdIn(p.StandardInput).ConfigureAwait(false); + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); @@ -75,8 +79,9 @@ public static class ProcessHelper /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console + /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) + public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); @@ -93,7 +98,7 @@ public static class ProcessHelper WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = true, - RedirectStandardInput = false, + RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); @@ -102,6 +107,9 @@ public static class ProcessHelper ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); + if (writeStdIn != null) + await writeStdIn(p.StandardInput).ConfigureAwait(false); + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); @@ -121,8 +129,9 @@ public static class ProcessHelper /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console + /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) + public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); @@ -139,7 +148,7 @@ public static class ProcessHelper WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = true, - RedirectStandardInput = false, + RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); @@ -148,6 +157,9 @@ public static class ProcessHelper ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); + if (writeStdIn != null) + await writeStdIn(p.StandardInput).ConfigureAwait(false); + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); @@ -166,8 +178,9 @@ public static class ProcessHelper /// The folder where the log files are written /// Function to create custom filenames for the log files /// Default value is null which will treat anything non-zero as an error + /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null) + public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); @@ -186,7 +199,7 @@ public static class ProcessHelper WorkingDirectory = workingDirectory, RedirectStandardError = true, RedirectStandardOutput = true, - RedirectStandardInput = false, + RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); @@ -198,6 +211,9 @@ public static class ProcessHelper var t1 = p.StandardOutput.BaseStream.CopyToAsync(logstdout, cancellationToken); var t2 = p.StandardError.BaseStream.CopyToAsync(logstderr, cancellationToken); + if (writeStdIn != null) + await writeStdIn(p.StandardInput).ConfigureAwait(false); + await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); From 605deefebd800cfec7b88133a5f8992ea92a769b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 4 Apr 2024 15:53:11 +0200 Subject: [PATCH 26/91] Added support for creating the GPG signatures for all files --- ReleaseBuilder/Build/Command.GpgSign.cs | 52 +++ ReleaseBuilder/Build/Command.RuntimeConfig.cs | 320 ++++++++++++++++++ ReleaseBuilder/Build/Command.cs | 315 ++--------------- 3 files changed, 399 insertions(+), 288 deletions(-) create mode 100644 ReleaseBuilder/Build/Command.GpgSign.cs create mode 100644 ReleaseBuilder/Build/Command.RuntimeConfig.cs diff --git a/ReleaseBuilder/Build/Command.GpgSign.cs b/ReleaseBuilder/Build/Command.GpgSign.cs new file mode 100644 index 000000000..6f9b2f13e --- /dev/null +++ b/ReleaseBuilder/Build/Command.GpgSign.cs @@ -0,0 +1,52 @@ +namespace ReleaseBuilder.Build; + +public static partial class Command +{ + /// + /// Implementation of the gpg sign command + /// + private static class GpgSign + { + /// + /// Performs a GPG sign operation on the files + /// + /// The files to sign + /// The runtime configuration + /// An awaitable task + public static async Task SignReleaseFiles(IEnumerable files, RuntimeConfig rtcfg) + { + var (gpgid, passphrase) = GetGpgIdAndPassphrase(rtcfg); + + foreach (var file in files) + foreach (var armored in new[] { true, false }) + await ProcessHelper.Execute( + [ + Program.Configuration.Commands.Gpg!, + "--pinentry-mode", "loopback", + "--passphrase-fd", "0", + "--batch", "--yes", + armored ? "--armor" : string.Empty, + "-u", gpgid, + "--output", file + (armored ? "sig.asc" : ".sig"), + "--detach-sign", file + ], + workingDirectory: Path.GetDirectoryName(file), + writeStdIn: (stdin) => stdin.WriteLineAsync(passphrase) + ); + } + + /// + /// Gets the GPG ID and passphrase from the keyfile + /// + /// The runtime configuration + /// The GPG ID and passphrase + static (string GpgId, string GpgPassphrase) GetGpgIdAndPassphrase(RuntimeConfig rtcfg) + { + using var ms = new MemoryStream(); + using var fs = File.OpenRead(Program.Configuration.ConfigFiles.GpgKeyfile); + SharpAESCrypt.SharpAESCrypt.Decrypt(rtcfg.KeyfilePassword, fs, ms); + var parts = System.Text.Encoding.UTF8.GetString(ms.ToArray()).Split('\n', 2, StringSplitOptions.RemoveEmptyEntries); + return (parts[0], parts[1]); + } + } +} diff --git a/ReleaseBuilder/Build/Command.RuntimeConfig.cs b/ReleaseBuilder/Build/Command.RuntimeConfig.cs new file mode 100644 index 000000000..ab6d8b72c --- /dev/null +++ b/ReleaseBuilder/Build/Command.RuntimeConfig.cs @@ -0,0 +1,320 @@ +using System.Security.Cryptography; + +namespace ReleaseBuilder.Build; + +public static partial class Command +{ + /// + /// Setup of the current runtime information + /// + private class RuntimeConfig + { + /// + /// Constructs a new + /// + /// The release info to use + /// The keyfile password to use + /// The sign keys + /// The changelog news + /// The command input + public RuntimeConfig(ReleaseInfo releaseInfo, IEnumerable signKeys, string keyfilePassword, string changelogNews, CommandInput input) + { + ReleaseInfo = releaseInfo; + SignKeys = signKeys; + KeyfilePassword = keyfilePassword; + ChangelogNews = changelogNews; + Input = input; + } + + /// + /// The cached password for the pfx file + /// + private string? _pfxPassword = null; + + /// + /// The commandline input + /// + private CommandInput Input { get; } + + /// + /// The release info for this run + /// + public ReleaseInfo ReleaseInfo { get; } + + /// + /// The keyfile password for this run + /// + public IEnumerable SignKeys { get; } + + /// + /// The primary password + /// + public string KeyfilePassword { get; } + + /// + /// The changelog news + /// + public string ChangelogNews { get; } + + /// + /// Gets the PFX password and throws if not possible + /// + public string PfxPassword + => string.IsNullOrWhiteSpace(_pfxPassword) + ? _pfxPassword = GetAuthenticodePassword(KeyfilePassword) + : _pfxPassword; + + /// + /// Cache value for checking if authenticode signing is enabled + /// + private bool? _useAuthenticodeSigning; + + /// + /// Checks if Authenticode signing should be enabled + /// + public void ToggleAuthenticodeSigning() + { + if (!_useAuthenticodeSigning.HasValue) + { + if (Input.DisableAuthenticode) + { + _useAuthenticodeSigning = false; + return; + } + + if (Program.Configuration.IsAuthenticodePossible()) + _useAuthenticodeSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for osslsigncode, continue without signing executables?", "Y", "n") == "Y") + { + _useAuthenticodeSigning = false; + return; + } + + throw new Exception("Configuration is not set up for osslsigncode"); + } + } + } + + /// + /// Cache value for checking if codesign is possible + /// + private bool? _useCodeSignSigning; + + /// + /// Checks if codesign is enabled + /// + public void ToggleSignCodeSigning() + { + if (!_useCodeSignSigning.HasValue) + { + if (Input.DisableSignCode) + { + _useCodeSignSigning = false; + return; + } + + if (!OperatingSystem.IsMacOS()) + _useCodeSignSigning = false; + else if (Program.Configuration.IsCodeSignPossible()) + _useCodeSignSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for signcode, continue without signing executables?", "Y", "n") == "Y") + { + _useCodeSignSigning = false; + return; + } + + throw new Exception("Configuration is not set up for signcode"); + } + } + } + + /// + /// Cache value for checking if docker build is enabled + /// + private bool? _dockerBuild; + + /// + /// Checks if docker build is enabled + /// + public async Task ToggleDockerBuild() + { + if (!_dockerBuild.HasValue) + { + try + { + var res = await ProcessHelper.ExecuteWithOutput([Program.Configuration.Commands.Docker!, "ps"], suppressStdErr: true); + _dockerBuild = true; + } + catch + { + + if (ConsoleHelper.ReadInput("Docker does not seem to be running, continue without docker builds?", "Y", "n") == "Y") + { + _dockerBuild = false; + return; + } + + throw new Exception("Docker is not running, and is required for building Docker images"); + } + } + } + + /// + /// Cache value for checking if notarize is enabled + /// + private bool? _useNotarizeSigning; + + /// + /// Checks if notarize signing is enabled + /// + public void ToggleNotarizeSigning() + { + if (!_useNotarizeSigning.HasValue) + { + if (Input.DisableNotarizeSigning) + { + _useNotarizeSigning = false; + return; + } + + if (!OperatingSystem.IsMacOS()) + _useNotarizeSigning = false; + else if (Program.Configuration.IsNotarizePossible()) + _useNotarizeSigning = true; + else + { + if (ConsoleHelper.ReadInput("Configuration missing for notarize, continue without notarizing executables?", "Y", "n") == "Y") + { + _useNotarizeSigning = false; + return; + } + + throw new Exception("Configuration is not set up for notarize"); + } + } + } + + /// + /// Cache value for checking if GPG signing is enabled + /// + private bool? _useGpgSigning; + + public void ToggleGpgSigning() + { + if (!_useGpgSigning.HasValue) + { + if (Input.DisableGpgSigning) + { + _useGpgSigning = false; + return; + } + + if (ConsoleHelper.ReadInput("Configuration missing for gpg, continue without gpg signing packages?", "Y", "n") == "Y") + { + _useGpgSigning = false; + return; + } + + throw new Exception("Configuration is not set up for gpg"); + } + } + + /// + /// Returns a value indicating if codesign is enabled + /// + public bool UseCodeSignSigning => _useCodeSignSigning!.Value; + + /// + /// Returns a value indicating if authenticode signing is enabled + /// + public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value; + + /// + /// Returns a value indicating if notarize is enabled + /// + public bool UseNotarizeSigning => _useNotarizeSigning!.Value; + + /// + /// Returns a value indicating if GPG signing is enabled + /// + public bool UseGPGSigning => _useGpgSigning!.Value; + + /// + /// Returns a value indicating if docker build is enabled + /// + public bool UseDockerBuild => _dockerBuild!.Value; + + /// + /// Gets the MacOS app bundle name + /// + public string MacOSAppName => Input.MacOSAppName; + + /// + /// The docker repository to use + /// + public string DockerRepo => Input.DockerRepo; + + /// + /// Gets a value indicating if pushing should be enabled + /// + public bool PushToDocker => !Input.DisableDockerPush; + + /// + /// Decrypts the password file and returns the PFX password + /// + /// Password for the password file + /// The Authenticode password + private string GetAuthenticodePassword(string keyfilepassword) + => EncryptionHelper.DecryptPasswordFile(Program.Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword); + + /// + /// Performs authenticode signing if enabled + /// + /// The file to sign + /// An awaitable task + public Task AuthenticodeSign(string file) + => UseAuthenticodeSigning + ? ProcessRunner.OsslCodeSign( + Program.Configuration.Commands.OsslSignCode!, + Program.Configuration.ConfigFiles.AuthenticodePfxFile, + PfxPassword, + file) + : Task.CompletedTask; + + /// + /// Performs codesign on the given file + /// + /// The file to sign + /// The entitlements to apply + /// An awaitable task + public Task Codesign(string file, string entitlements) + => UseCodeSignSigning + ? ProcessRunner.MacOSCodeSign( + Program.Configuration.Commands.Codesign!, + Program.Configuration.ConfigFiles.CodesignIdentity, + entitlements, + file + ) + : Task.CompletedTask; + + /// + /// Performs productsign on the given file + /// + /// The file to sign + /// An awaitable task + public Task Productsign(string file) + => UseCodeSignSigning + ? ProcessRunner.MacOSProductSign( + Program.Configuration.Commands.Productsign!, + Program.Configuration.ConfigFiles.CodesignIdentity, + file + ) + : Task.CompletedTask; + + } + +} diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index 53350519a..b98b1222a 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -63,290 +63,6 @@ public static partial class Command /// private static readonly IReadOnlyList FedoraCLIDepends = []; - /// - /// Setup of the current runtime information - /// - /// The release info for the current build - /// The keyfile password - private class RuntimeConfig - { - /// - /// Constructs a new - /// - /// The release info to use - /// The keyfile password to use - /// The sign keys - /// The changelog news - /// The command input - public RuntimeConfig(ReleaseInfo releaseInfo, IEnumerable signKeys, string keyfilePassword, string changelogNews, CommandInput input) - { - ReleaseInfo = releaseInfo; - SignKeys = signKeys; - KeyfilePassword = keyfilePassword; - ChangelogNews = changelogNews; - Input = input; - } - - /// - /// The cached password for the pfx file - /// - private string? _pfxPassword = null; - - /// - /// The commandline input - /// - private CommandInput Input { get; } - - /// - /// The release info for this run - /// - public ReleaseInfo ReleaseInfo { get; } - - /// - /// The keyfile password for this run - /// - public IEnumerable SignKeys { get; } - - /// - /// The primary password - /// - public string KeyfilePassword { get; } - - /// - /// The changelog news - /// - public string ChangelogNews { get; } - - /// - /// Gets the PFX password and throws if not possible - /// - public string PfxPassword - => string.IsNullOrWhiteSpace(_pfxPassword) - ? _pfxPassword = GetAuthenticodePassword(KeyfilePassword) - : _pfxPassword; - - /// - /// Cache value for checking if authenticode signing is enabled - /// - private bool? _useAuthenticodeSigning; - - /// - /// Checks if Authenticode signing should be enabled - /// - public void ToggleAuthenticodeSigning() - { - if (!_useAuthenticodeSigning.HasValue) - { - if (Input.DisableAuthenticode) - { - _useAuthenticodeSigning = false; - return; - } - - if (Program.Configuration.IsAuthenticodePossible()) - _useAuthenticodeSigning = true; - else - { - if (ConsoleHelper.ReadInput("Configuration missing for osslsigncode, continue without signing executables?", "Y", "n") == "Y") - { - _useAuthenticodeSigning = false; - return; - } - - throw new Exception("Configuration is not set up for osslsigncode"); - } - } - } - - /// - /// Cache value for checking if codesign is possible - /// - private bool? _useCodeSignSigning; - - /// - /// Checks if codesign is enabled - /// - public void ToggleSignCodeSigning() - { - if (!_useCodeSignSigning.HasValue) - { - if (Input.DisableSignCode) - { - _useCodeSignSigning = false; - return; - } - - if (!OperatingSystem.IsMacOS()) - _useCodeSignSigning = false; - else if (Program.Configuration.IsCodeSignPossible()) - _useCodeSignSigning = true; - else - { - if (ConsoleHelper.ReadInput("Configuration missing for signcode, continue without signing executables?", "Y", "n") == "Y") - { - _useCodeSignSigning = false; - return; - } - - throw new Exception("Configuration is not set up for signcode"); - } - } - } - - /// - /// Cache value for checking if docker build is enabled - /// - private bool? _dockerBuild; - - /// - /// Checks if docker build is enabled - /// - public async Task ToggleDockerBuild() - { - if (!_dockerBuild.HasValue) - { - try - { - var res = await ProcessHelper.ExecuteWithOutput([Program.Configuration.Commands.Docker!, "ps"], suppressStdErr: true); - _dockerBuild = true; - } - catch - { - - if (ConsoleHelper.ReadInput("Docker does not seem to be running, continue without docker builds?", "Y", "n") == "Y") - { - _dockerBuild = false; - return; - } - - throw new Exception("Docker is not running, and is required for building Docker images"); - } - } - } - - /// - /// Cache value for checking if notarize is enabled - /// - private bool? _useNotarizeSigning; - - /// - /// Checks if notarize signing is enabled - /// - public void ToggleNotarizeSigning() - { - if (!_useNotarizeSigning.HasValue) - { - if (Input.DisableNotarizeSigning) - { - _useNotarizeSigning = false; - return; - } - - if (!OperatingSystem.IsMacOS()) - _useNotarizeSigning = false; - else if (Program.Configuration.IsNotarizePossible()) - _useNotarizeSigning = true; - else - { - if (ConsoleHelper.ReadInput("Configuration missing for notarize, continue without notarizing executables?", "Y", "n") == "Y") - { - _useNotarizeSigning = false; - return; - } - - throw new Exception("Configuration is not set up for notarize"); - } - } - } - - /// - /// Returns a value indicating if codesign is enabled - /// - public bool UseCodeSignSigning => _useCodeSignSigning!.Value; - - /// - /// Returns a value indicating if authenticode signing is enabled - /// - public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value; - - /// - /// Returns a value indicating if notarize is enabled - /// - public bool UseNotarizeSigning => _useNotarizeSigning!.Value; - - /// - /// Returns a value indicating if docker build is enabled - /// - public bool UseDockerBuild => _dockerBuild!.Value; - - /// - /// Gets the MacOS app bundle name - /// - public string MacOSAppName => Input.MacOSAppName; - - /// - /// The docker repository to use - /// - public string DockerRepo => Input.DockerRepo; - - /// - /// Gets a value indicating if pushing should be enabled - /// - public bool PushToDocker => !Input.DisableDockerPush; - - /// - /// Decrypts the password file and returns the PFX password - /// - /// Password for the password file - /// The Authenticode password - private string GetAuthenticodePassword(string keyfilepassword) - => EncryptionHelper.DecryptPasswordFile(Program.Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword); - - /// - /// Performs authenticode signing if enabled - /// - /// The file to sign - /// An awaitable task - public Task AuthenticodeSign(string file) - => UseAuthenticodeSigning - ? ProcessRunner.OsslCodeSign( - Program.Configuration.Commands.OsslSignCode!, - Program.Configuration.ConfigFiles.AuthenticodePfxFile, - PfxPassword, - file) - : Task.CompletedTask; - - /// - /// Performs codesign on the given file - /// - /// The file to sign - /// The entitlements to apply - /// An awaitable task - public Task Codesign(string file, string entitlements) - => UseCodeSignSigning - ? ProcessRunner.MacOSCodeSign( - Program.Configuration.Commands.Codesign!, - Program.Configuration.ConfigFiles.CodesignIdentity, - entitlements, - file - ) - : Task.CompletedTask; - - /// - /// Performs productsign on the given file - /// - /// The file to sign - /// An awaitable task - public Task Productsign(string file) - => UseCodeSignSigning - ? ProcessRunner.MacOSProductSign( - Program.Configuration.Commands.Productsign!, - Program.Configuration.ConfigFiles.CodesignIdentity, - file - ) - : Task.CompletedTask; - - } /// /// Structure for keeping all variables for a single release @@ -468,6 +184,12 @@ public static partial class Command getDefaultValue: () => false ); + var disableGpgSigningOption = new Option( + name: "--disable-gpg-signing", + description: "Disables GPG signing of packages", + getDefaultValue: () => false + ); + var command = new System.CommandLine.Command("build", "Builds the packages for a release") { gitStashPushOption, releaseChannelOption, @@ -482,7 +204,8 @@ public static partial class Command disableDockerPushOption, dockerRepoOption, changelogFileOption, - disableNotarizeSigningOption + disableNotarizeSigningOption, + disableGpgSigningOption }; command.Handler = CommandHandler.Create(DoBuild); @@ -506,6 +229,7 @@ public static partial class Command /// The docker repository to push to /// The path to the changelog file /// If notarize signing should be disabled + /// If GPG signing should be disabled record CommandInput( PackageTarget[] Targets, DirectoryInfo BuildPath, @@ -520,7 +244,8 @@ public static partial class Command string MacOSAppName, string DockerRepo, FileInfo ChangelogFile, - bool DisableNotarizeSigning + bool DisableNotarizeSigning, + bool DisableGpgSigning ); static async Task DoBuild(CommandInput input) @@ -612,6 +337,7 @@ public static partial class Command rtcfg.ToggleAuthenticodeSigning(); rtcfg.ToggleSignCodeSigning(); rtcfg.ToggleNotarizeSigning(); + rtcfg.ToggleGpgSigning(); await rtcfg.ToggleDockerBuild(); if (!rtcfg.UseDockerBuild) @@ -688,10 +414,23 @@ public static partial class Command }) ); - File.Move(Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest"), Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.manifest"), true); + File.Move( + Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest"), + Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.manifest"), + true + ); + + var files = builtPackages.Select(x => x.CreatedFile).Append("latest-v2.manifest").ToList(); + + // Create the GPG signatures for the files + if (rtcfg.UseGPGSigning) + { + Console.WriteLine("Creating GPG signatures ..."); + await GpgSign.SignReleaseFiles(files, rtcfg); + } Console.WriteLine("Build completed, uploading packages ..."); - var files = builtPackages.Select(x => x.CreatedFile).Append("latest-v2.manifest").ToArray(); + Console.WriteLine("Upload completed, releasing packages ..."); From 79409a83594a6c1524d6d1b065b8aa5bbc2ad49a Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 4 Apr 2024 16:08:45 +0200 Subject: [PATCH 27/91] Added support for building debug and with custom version --- ReleaseBuilder/.vscode/launch.json | 3 +++ ReleaseBuilder/Build/Command.cs | 41 ++++++++++++++++++++++-------- ReleaseBuilder/Configuration.cs | 6 ++++- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json index f903eb3ef..1a55abb38 100644 --- a/ReleaseBuilder/.vscode/launch.json +++ b/ReleaseBuilder/.vscode/launch.json @@ -13,6 +13,7 @@ // "args": ["create-key", "testfile.key", "--password", "test1234"], "args": [ "build", + "debug", "--disable-docker-push", "true", "--git-stash-push", "false", "--targets", "win-x64-gui.msi", @@ -37,7 +38,9 @@ "--disable-authenticode", "true", "--disable-signcode", "true", "--disable-notarize-signing", "true", + "--disable-gpg-signing", "true", "--password", "test1234", + "--version", "2.0.8.100" ], "env": { "UPDATER_KEYFILE": "${workspaceFolder}/testfile.key:${workspaceFolder}/testfile.key2", diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index b98b1222a..6d6f26cfb 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -86,6 +86,15 @@ public static partial class Command /// The release info public static ReleaseInfo Create(ReleaseChannel type, int incVersion) => new ReleaseInfo(new Version(2, 0, 0, incVersion), type, DateTime.Today); + + /// + /// Create a new release info + /// + /// The release type + /// The version + /// The release info + public static ReleaseInfo Create(ReleaseChannel type, Version version) + => new ReleaseInfo(version, type, DateTime.Today); } /// @@ -94,6 +103,12 @@ public static partial class Command /// The command public static System.CommandLine.Command Create() { + var releaseChannelArgument = new Argument( + name: "channel", + description: "The release channel", + getDefaultValue: () => ReleaseChannel.Canary + ); + var buildTargetOption = new Option( name: "--targets", description: "The possible build targets, multiple arguments supported. Use the format os-arch.package, example: x64-win.msi.", @@ -110,12 +125,6 @@ public static partial class Command return requested; }); - var releaseChannelOption = new Argument( - name: "channel", - description: "The release channel", - getDefaultValue: () => ReleaseChannel.Canary - ); - var gitStashPushOption = new Option( name: "--git-stash-push", description: "Performs a git stash command before running the build, and a git commit after updating files", @@ -190,9 +199,16 @@ public static partial class Command getDefaultValue: () => false ); + var versionOverrideOption = new Option( + name: "--version", + description: "Sets a custom version to use", + getDefaultValue: () => null + ); + + var command = new System.CommandLine.Command("build", "Builds the packages for a release") { gitStashPushOption, - releaseChannelOption, + releaseChannelArgument, buildTempOption, buildTargetOption, solutionFileOption, @@ -205,7 +221,8 @@ public static partial class Command dockerRepoOption, changelogFileOption, disableNotarizeSigningOption, - disableGpgSigningOption + disableGpgSigningOption, + versionOverrideOption }; command.Handler = CommandHandler.Create(DoBuild); @@ -220,6 +237,7 @@ public static partial class Command /// The solution path /// If the git stash should be performed /// The release channel + /// The version override to use /// If the builds should be kept /// If authenticode signing should be disabled /// If signcode should be disabled @@ -236,6 +254,7 @@ public static partial class Command FileInfo SolutionFile, bool GitStashPush, ReleaseChannel Channel, + string? Version, bool KeepBuilds, bool DisableAuthenticode, bool DisableSignCode, @@ -314,7 +333,9 @@ public static partial class Command var changelogNews = File.ReadAllText(input.ChangelogFile.FullName); - var releaseInfo = ReleaseInfo.Create(input.Channel, int.Parse(File.ReadAllText(versionFilePath)) + 1); + var releaseInfo = string.IsNullOrWhiteSpace(input.Version) + ? ReleaseInfo.Create(input.Channel, int.Parse(File.ReadAllText(versionFilePath)) + 1) + : ReleaseInfo.Create(input.Channel, Version.Parse(input.Version)); Console.WriteLine($"Building {releaseInfo.ReleaseName} ..."); var keyfilePassword = string.IsNullOrEmpty(input.Password) @@ -344,7 +365,7 @@ public static partial class Command { var unsupportedBuilds = buildTargets.Where(x => x.Package == PackageType.Docker || x.Package == PackageType.Deb || x.Package == PackageType.RPM).ToList(); if (unsupportedBuilds.Any()) - throw new Exception($"Docker build requested but not enabled, and the following packages are not supported: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}"); + throw new Exception($"The following packages cannot be built without Docker: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}"); } if (!input.KeepBuilds && Directory.Exists(input.BuildPath.FullName)) diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs index 927a8069f..bd1c3487c 100644 --- a/ReleaseBuilder/Configuration.cs +++ b/ReleaseBuilder/Configuration.cs @@ -26,7 +26,11 @@ public enum ReleaseChannel /// /// Nightly, unmonitored builds /// - Nightly + Nightly, + /// + /// Debug builds + /// + Debug } /// From 3731622722713319d5c22ca3aba880d7e4706d24 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 4 Apr 2024 18:25:16 +0200 Subject: [PATCH 28/91] Fixed deb package format --- ReleaseBuilder/.vscode/launch.json | 2 +- ReleaseBuilder/Build/Command.CreatePackage.cs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json index 1a55abb38..56e1baa32 100644 --- a/ReleaseBuilder/.vscode/launch.json +++ b/ReleaseBuilder/.vscode/launch.json @@ -40,7 +40,7 @@ "--disable-notarize-signing", "true", "--disable-gpg-signing", "true", "--password", "test1234", - "--version", "2.0.8.100" + "--version", "2.0.8.101" ], "env": { "UPDATER_KEYFILE": "${workspaceFolder}/testfile.key:${workspaceFolder}/testfile.key2", diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index 5c60fc552..6b3af1b7e 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -493,12 +493,22 @@ public static partial class Command .Replace("%DATE%", DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss +0000", CultureInfo.InvariantCulture)) ); + // Custom arch, from: https://wiki.debian.org/SupportedArchitectures + var debArchString = target.Arch switch + { + ArchType.x86 => "i386", + ArchType.x64 => "amd64", + ArchType.Arm64 => "arm64", + ArchType.Arm7 => "armhf", + _ => throw new Exception($"Architeture not supported: {target.ArchString}") + }; + // Write a custom control file File.WriteAllText( Path.Combine(pkgroot, "DEBIAN", "control"), File.ReadAllText(Path.Combine(installerDir, "control.template.txt")) .Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString()) - .Replace("%ARCH%", target.ArchString) + .Replace("%ARCH%", debArchString) .Replace("%DEPENDS%", string.Join(", ", target.Interface == InterfaceType.GUI ? DebianGUIDepends : DebianCLIDepends)) From d318467b09371f718a83c34a41ede934f59ee5f0 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 15:24:27 +0200 Subject: [PATCH 29/91] Removed Spatial library --- .../Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj b/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj index f05fd5dbf..f09aadced 100644 --- a/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj +++ b/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj @@ -13,7 +13,6 @@ - From b2bea69f6eafd93e75e7d6ebc932a4ac90d8a4e5 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 15:24:48 +0200 Subject: [PATCH 30/91] Added support for loading licenses from outside the binary folder on MacOS --- Duplicati.Library.RestAPI/RESTMethods/Licenses.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Duplicati.Library.RestAPI/RESTMethods/Licenses.cs b/Duplicati.Library.RestAPI/RESTMethods/Licenses.cs index f787f5bd5..39bd5d754 100644 --- a/Duplicati.Library.RestAPI/RESTMethods/Licenses.cs +++ b/Duplicati.Library.RestAPI/RESTMethods/Licenses.cs @@ -19,6 +19,7 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.IO; namespace Duplicati.Server.WebServer.RESTMethods { @@ -26,7 +27,16 @@ namespace Duplicati.Server.WebServer.RESTMethods { public void GET(string key, RequestInfo info) { - var path = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location), "licenses"); + var exefolder = System.IO.Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location); + var path = System.IO.Path.Combine(exefolder, "licenses"); + if (Duplicati.Library.Common.Platform.IsClientOSX && !Directory.Exists(path)) + { + // Go up one, as the licenses cannot be in the binary folder in MacOS Packages + exefolder = Path.GetDirectoryName(exefolder); + var test = Path.Combine(exefolder, "Licenses"); + if (Directory.Exists(test)) + path = test; + } info.OutputOK(Duplicati.License.LicenseReader.ReadLicenses(path)); } } From ff560c6fef19fe81057e15ea0bc2f82365cb0c50 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 15:25:08 +0200 Subject: [PATCH 31/91] Simplified the reading of MacOS config file --- ReleaseBuilder/Configuration.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs index bd1c3487c..a7ffff1a7 100644 --- a/ReleaseBuilder/Configuration.cs +++ b/ReleaseBuilder/Configuration.cs @@ -177,8 +177,9 @@ public record ConfigFiles( if (File.Exists(gatekeeperSettingsFile)) { var kvp = File.ReadAllLines(gatekeeperSettingsFile) - .Where(x => !string.IsNullOrWhiteSpace(x) && x.StartsWith("export ")) - .Select(x => x.Substring("export ".Length).Trim().Split("=", 2)) + .Where(x => !string.IsNullOrWhiteSpace(x) && x.IndexOf('=') > 0) + .Select(x => x.StartsWith("export ") ? x.Substring("export ".Length).Trim() : x) + .Select(x => x.Trim().Split("=", 2)) .Where(x => x.Length == 2) .Select(x => new { Key = x[0], Value = x[1] }); @@ -194,7 +195,7 @@ public record ConfigFiles( ExpandEnv("GITHUB_TOKEN_FILE", "${HOME}/.config/github-api-token"), ExpandEnv("DISCOURSE_TOKEN_FILE", "${HOME}/.config/discourse-api-token"), ExpandEnv("CODESIGN_IDENTITY", ""), - ExpandEnv("NOTARIZE_PROFILE", "") + ExpandEnv("NOTARIZE_PROFILE", "duplicati-notarize") ); } } From 8f5025af620e21e4877050afa46078620f1ef7eb Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 15:26:39 +0200 Subject: [PATCH 32/91] Fixed code signing on MacOS --- ReleaseBuilder/Build/Command.Compile.Post.cs | 38 ++++++++++++++++--- ReleaseBuilder/Build/Command.CreatePackage.cs | 11 +++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/ReleaseBuilder/Build/Command.Compile.Post.cs b/ReleaseBuilder/Build/Command.Compile.Post.cs index 680c31f6e..b02adbb6d 100644 --- a/ReleaseBuilder/Build/Command.Compile.Post.cs +++ b/ReleaseBuilder/Build/Command.Compile.Post.cs @@ -41,12 +41,17 @@ public static partial class Command } } + /// + /// Set of files that are unwanted despite the OS + /// + static readonly IReadOnlyList UnwantedCommonFiles = ["System.Reactive.xml"]; + /// /// 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) + static IEnumerable UnwantedFolders(OSType os) => os switch { OSType.Windows => ["lvm-scripts"], @@ -60,14 +65,15 @@ public static partial class Command /// /// The OS to get unwanted files for /// The files that are unwanted - static string[] UnwantedFiles(OSType os) - => os switch + static IEnumerable UnwantedFiles(OSType os) + => UnwantedCommonFiles.Concat(os switch { OSType.Windows => [], OSType.MacOS => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")], OSType.Linux => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")], _ => throw new Exception($"Not supported os: {os}") - }; + }) + .Distinct(); /// @@ -200,10 +206,30 @@ public static partial class Command overwrite: true ); + // Rename the executables, as symlinks are not supported in DMG files + foreach (var x in ExecutableRenames) + File.Move(Path.Combine(binDir, x.Key), Path.Combine(binDir, x.Value)); + + // Move the licenses out of the code folder as the signing tool trips on it + var licenseTarget = Path.Combine(tmpApp, "Contents", "Licenses"); + Directory.Move(Path.Combine(binDir, "licenses"), licenseTarget); + if (rtcfg.UseCodeSignSigning) { + Console.WriteLine("Performing MacOS code signing ..."); + + // Executables cannot be signed before their dependencies are signed + // So they are placed last in the list + var executables = ExecutableRenames.Values.Select(x => Path.Combine(binDir, x)); + + var signtargets = Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories) + .Except(executables) + .Concat(executables) + .Distinct() + .ToList(); + var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); - foreach (var f in Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories)) + foreach (var f in signtargets) await rtcfg.Codesign(f, entitlementFile); await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile); @@ -214,7 +240,7 @@ public static partial class Command 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")); + Directory.Delete(Path.GetDirectoryName(tmpApp) ?? throw new Exception("Unexpected empty path"), true); } /// diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index 6b3af1b7e..1767be1d2 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -304,9 +304,16 @@ public static partial class Command // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true); - await PackageSupport.InstallPackageIdentifier(appFolder, target); + await PackageSupport.InstallPackageIdentifier(Path.Combine(appFolder, "Contents", "MacOS"), target); await PackageSupport.SetExecutableFlags(appFolder, rtcfg); - await PackageSupport.MakeSymlinks(appFolder); + + // After injecting the package_type_id, resign + if (rtcfg.UseCodeSignSigning) + { + await rtcfg.Codesign(Path.Combine(appFolder, "Contents", "MacOS", "package_type_id.txt"), Path.Combine(installerDir, "Entitlements.plist")); + await rtcfg.Codesign(Path.Combine(appFolder, "Contents", "MacOS", "duplicati"), Path.Combine(installerDir, "Entitlements.plist")); + await rtcfg.Codesign(Path.Combine(appFolder), Path.Combine(installerDir, "Entitlements.plist")); + } // Set permissions inside DMG file if (!OperatingSystem.IsWindows()) From b89861721b4311de83be94da2cd086bb24c95fea Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 16:46:09 +0200 Subject: [PATCH 33/91] Fixed MacOS signing of pkg files --- ReleaseBuilder/Build/Command.CreatePackage.cs | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index 1767be1d2..1b86182a0 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -244,6 +244,32 @@ public static partial class Command Directory.Delete(buildTmp, true); } + /// + /// Install the package identifier into the app bundle, and performs resigning of the binaries + /// + /// The folder where the app bundle is located + /// The installer dir where the installer files are located + /// The package target to create the file for + /// The runtime config + /// An awaitable task + static async Task PrepareAndReSignAppBundle(string appFolder, string installerDir, PackageTarget target, RuntimeConfig rtcfg) + { + await PackageSupport.InstallPackageIdentifier(Path.Combine(appFolder, "Contents", "MacOS"), target); + await PackageSupport.SetExecutableFlags(appFolder, rtcfg); + + // After injecting the package_type_id, resign + if (rtcfg.UseCodeSignSigning) + { + var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); + var updates = new[] { Path.Combine(appFolder, "Contents", "MacOS", "package_type_id.txt") } + .Concat(ExecutableRenames.Values.Select(x => Path.Combine(appFolder, "Contents", "MacOS", x))) + .Append(appFolder); + + foreach (var x in updates) + await rtcfg.Codesign(x, entitlementFile); + } + } + /// /// Builds a DMG package asynchronously. /// @@ -304,16 +330,7 @@ public static partial class Command // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true); - await PackageSupport.InstallPackageIdentifier(Path.Combine(appFolder, "Contents", "MacOS"), target); - await PackageSupport.SetExecutableFlags(appFolder, rtcfg); - - // After injecting the package_type_id, resign - if (rtcfg.UseCodeSignSigning) - { - await rtcfg.Codesign(Path.Combine(appFolder, "Contents", "MacOS", "package_type_id.txt"), Path.Combine(installerDir, "Entitlements.plist")); - await rtcfg.Codesign(Path.Combine(appFolder, "Contents", "MacOS", "duplicati"), Path.Combine(installerDir, "Entitlements.plist")); - await rtcfg.Codesign(Path.Combine(appFolder), Path.Combine(installerDir, "Entitlements.plist")); - } + await PrepareAndReSignAppBundle(appFolder, installerDir, target, rtcfg); // Set permissions inside DMG file if (!OperatingSystem.IsWindows()) @@ -357,9 +374,7 @@ public static partial class Command // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true); - await PackageSupport.InstallPackageIdentifier(appFolder, target); - await PackageSupport.SetExecutableFlags(appFolder, rtcfg); - await PackageSupport.MakeSymlinks(appFolder); + await PrepareAndReSignAppBundle(appFolder, installerDir, target, rtcfg); // Copy the source script files var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" }; @@ -400,9 +415,9 @@ public static partial class Command // 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] + ["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 From d15ed599d9e849f46157d17c4d27addb31a606bd Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 16:50:21 +0200 Subject: [PATCH 34/91] Prevent crashes on debug builds --- Duplicati/Library/AutoUpdater/UpdateInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Duplicati/Library/AutoUpdater/UpdateInfo.cs b/Duplicati/Library/AutoUpdater/UpdateInfo.cs index 8b69c1854..20ff1a32a 100644 --- a/Duplicati/Library/AutoUpdater/UpdateInfo.cs +++ b/Duplicati/Library/AutoUpdater/UpdateInfo.cs @@ -70,7 +70,7 @@ namespace Duplicati.Library.AutoUpdater /// /// Link to a generic download page /// - public string GenericUpdatePageUrl; + public string GenericUpdatePageUrl = string.Empty; /// /// Finds a package that matches the From 149703d0c83b5b6071fc7c35149e46e47bce8f31 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Apr 2024 16:50:53 +0200 Subject: [PATCH 35/91] Report package id in system info --- Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs b/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs index f69cfa148..6bb57c0fc 100644 --- a/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs +++ b/Duplicati.Library.RestAPI/RESTMethods/SystemInfo.cs @@ -66,6 +66,7 @@ namespace Duplicati.Server.WebServer.RESTMethods CaseSensitiveFilesystem = Duplicati.Library.Utility.Utility.IsFSCaseSensitive, MonoVersion = Duplicati.Library.Utility.Utility.IsMono ? Duplicati.Library.Utility.Utility.MonoVersion.ToString() : null, MachineName = System.Environment.MachineName, + PackageTypeId = Duplicati.Library.AutoUpdater.UpdaterManager.PackageTypeId, UserName = System.Environment.UserName, NewLine = System.Environment.NewLine, CLRVersion = System.Environment.Version.ToString(), From 847be2d00aebbc1e65ee7bf4ca914108af870fad Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sat, 6 Apr 2024 10:27:33 +0200 Subject: [PATCH 36/91] More fixes to support transistions to new updater --- Duplicati/Library/AutoUpdater/UpdaterManager.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 17629c68b..cdf8b1287 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -296,6 +296,13 @@ namespace Duplicati.Library.AutoUpdater if (rt > channel) return null; + // In case the manifest does not contain a URL, use the one from this assembly + if (string.IsNullOrWhiteSpace(update.GenericUpdatePageUrl)) + update.GenericUpdatePageUrl = SelfVersion.GenericUpdatePageUrl; + + // In case there is no url, fall back to the project download page + if (string.IsNullOrWhiteSpace(update.GenericUpdatePageUrl)) + update.GenericUpdatePageUrl = "https://duplicati.com/download"; LastUpdateCheckVersion = update; return update; } From 13367f4ab4acc708143e48c2a9d661e05e5016d7 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sat, 6 Apr 2024 10:41:43 +0200 Subject: [PATCH 37/91] Added POSIX execute bits for zip packages --- ReleaseBuilder/Build/Command.CreatePackage.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index 1b86182a0..90a956249 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -165,17 +165,28 @@ public static partial class Command if (File.Exists(zipFile)) File.Delete(zipFile); + var executableExtensions = new HashSet([".sh", ".bat", ".py", ".exe"], StringComparer.OrdinalIgnoreCase); + using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create)) { foreach (var f in Directory.EnumerateFiles(buildRoot, "*", SearchOption.AllDirectories)) { var relpath = Path.GetRelativePath(buildRoot, f); + var isRenamedExecutable = ExecutableRenames.ContainsKey(relpath); // Use more friendly names for executables on non-Windows platforms - if (target.OS != OSType.Windows && ExecutableRenames.ContainsKey(relpath)) + if (target.OS != OSType.Windows && isRenamedExecutable) relpath = ExecutableRenames[relpath]; var entry = zip.CreateEntry(Path.Combine(dirName, relpath), CompressionLevel.Optimal); + + var isExecutable = isRenamedExecutable || executableExtensions.Contains(Path.GetExtension(f)); + + // Set execute/permission flags + entry.ExternalAttributes = isExecutable + ? Convert.ToInt32("755", 8) << 16 + : Convert.ToInt32("644", 8) << 16; + using (var stream = entry.Open()) using (var file = File.OpenRead(f)) await file.CopyToAsync(stream); From 52dccc74d789787f6b9d146bab4c115ba6b6d2f3 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sat, 6 Apr 2024 10:59:59 +0200 Subject: [PATCH 38/91] Updated support for executables in Linux zip archives. Prefixed package names with `duplicati-` --- ReleaseBuilder/Build/Command.CreatePackage.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index 90a956249..d0656614b 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -88,7 +88,7 @@ public static partial class Command if (!Directory.Exists(packageFolder)) Directory.CreateDirectory(packageFolder); - var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}"); + var packageFile = Path.Combine(packageFolder, $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}"); // Fix up non-conforming package names if (target.Package == PackageType.Deb) @@ -114,7 +114,7 @@ public static partial class Command switch (target.Package) { case PackageType.Zip: - await BuildZipPackage(Path.Combine(buildRoot, $"{target.BuildTargetString}"), rtcfg.ReleaseInfo.ReleaseName, tempFile, target, rtcfg); + await BuildZipPackage(Path.Combine(buildRoot, target.BuildTargetString), $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.BuildTargetString}", tempFile, target, rtcfg); break; case PackageType.MSI: @@ -166,6 +166,7 @@ public static partial class Command File.Delete(zipFile); var executableExtensions = new HashSet([".sh", ".bat", ".py", ".exe"], StringComparer.OrdinalIgnoreCase); + var executables = new List(); using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create)) { @@ -187,6 +188,9 @@ public static partial class Command ? Convert.ToInt32("755", 8) << 16 : Convert.ToInt32("644", 8) << 16; + if (isExecutable) + executables.Add(relpath); + using (var stream = entry.Open()) using (var file = File.OpenRead(f)) await file.CopyToAsync(stream); @@ -199,13 +203,16 @@ public static partial class Command if (target.OS != OSType.Windows) { - using (var stream = zip.CreateEntry(Path.Combine(dirName, "set-permissions.sh"), CompressionLevel.Optimal).Open()) + var setEntry = zip.CreateEntry(Path.Combine(dirName, "set-permissions.sh"), CompressionLevel.Optimal); + setEntry.ExternalAttributes = Convert.ToInt32("755", 8) << 16; + + using (var stream = setEntry.Open()) using (var writer = new StreamWriter(stream)) { writer.WriteLine("#!/bin/sh"); - writer.WriteLine("# This script sets the executable flags for the Duplicati binaries"); + writer.WriteLine("# This script sets the executable flags for the Duplicati binaries and support scripts"); writer.WriteLine("set -e"); - foreach (var x in ExecutableRenames.Values) + foreach (var x in executables) writer.WriteLine($"chmod +x {x}"); } } From b5e9ce18cd9b7667b303492dec81e191e465d1c3 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sat, 6 Apr 2024 11:01:08 +0200 Subject: [PATCH 39/91] Updated old non-https urls --- Duplicati/CommandLine/CLI/help.txt | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Duplicati/CommandLine/CLI/help.txt b/Duplicati/CommandLine/CLI/help.txt index 3005d305e..7e3287b1a 100644 --- a/Duplicati/CommandLine/CLI/help.txt +++ b/Duplicati/CommandLine/CLI/help.txt @@ -19,7 +19,7 @@ See duplicati.commandline.exe help for more information. Formats: date, time, size, encryption, compression Advanced: mail, advanced, returncodes, filter, filter-groups,