", 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/UpgradeLog.htm b/Installer/Windows/UpgradeLog.htm
deleted file mode 100644
index 371f58c3a..000000000
Binary files a/Installer/Windows/UpgradeLog.htm and /dev/null differ
diff --git a/Installer/Windows/WixInstaller.sln b/Installer/Windows/WixInstaller.sln
deleted file mode 100644
index ead79c206..000000000
--- a/Installer/Windows/WixInstaller.sln
+++ /dev/null
@@ -1,28 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 14
-VisualStudioVersion = 14.0.23107.0
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "WixInstaller", "WixInstaller.wixproj", "{AB757800-A4CB-478B-A359-33786D0E8A9C}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Release|x64 = Release|x64
- Release|x86 = Release|x86
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Debug|x64.ActiveCfg = Debug|x64
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Debug|x64.Build.0 = Debug|x64
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Debug|x86.ActiveCfg = Debug|x86
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Debug|x86.Build.0 = Debug|x86
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Release|x64.ActiveCfg = Release|x64
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Release|x64.Build.0 = Release|x64
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Release|x86.ActiveCfg = Release|x86
- {AB757800-A4CB-478B-A359-33786D0E8A9C}.Release|x86.Build.0 = Release|x86
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/Installer/Windows/WixInstaller.wixproj b/Installer/Windows/WixInstaller.wixproj
deleted file mode 100644
index 416dee29b..000000000
--- a/Installer/Windows/WixInstaller.wixproj
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
- Debug
- x64
- 3.9
- {ab757800-a4cb-478b-a359-33786d0e8a9c}
- 2.0
- Duplicati
- Package
- $(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets
- $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets
- HarvestPath=Duplicati\
- true
-
-
- bin\$(Platform)\$(Configuration)\
- obj\$(Platform)\$(Configuration)\
- Debug;Win64=no;$(HeatDefinitions)
-
-
-
-
- bin\$(Platform)\$(Configuration)\
- obj\$(Platform)\$(Configuration)\
-
-
- Win64=no;$(HeatDefinitions)
-
-
- Debug;Win64=yes
- bin\$(Platform)\$(Configuration)\
- obj\$(Platform)\$(Configuration)\
-
-
- Debug;Win64=yes;$(HeatDefinitions)
-
-
- bin\$(Platform)\$(Configuration)\
- obj\$(Platform)\$(Configuration)\
-
-
- Win64=yes;$(HeatDefinitions)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- $(WixExtDir)\WixUtilExtension.dll
-
-
- $(WixExtDir)\WixNetFxExtension.dll
-
-
- $(WixExtDir)\WixUIExtension.dll
-
-
-
-
-
-
-
-
-
-
-
-
-
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/Installer/Windows/build-msi.bat b/Installer/Windows/build-msi.bat
deleted file mode 100644
index 827a60d37..000000000
--- a/Installer/Windows/build-msi.bat
+++ /dev/null
@@ -1,123 +0,0 @@
-@echo off
-
-rmdir /s /q Duplicati
-del /q Duplicati.msi
-del /q Duplicati-32bit.msi
-
-IF NOT EXIST "%1" (
- echo File not found, please supply a zip file with the build as the first argument
- goto EXIT
-)
-
-call "%VS140COMNTOOLS%vsvars32.bat"
-
-7z x -oDuplicati %1
-
-IF EXIST "..\oem" (
- echo Installing OEM files
- xcopy ..\oem\* Duplicati\webroot\oem /e /s /y /i
-)
-
-IF EXIST "..\..\oem" (
- echo Installing OEM files
- xcopy ..\..\oem\* Duplicati\webroot\ /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem" (
- echo Installing OEM files
- xcopy ..\..\..\oem\* Duplicati\webroot\ /e /s /y /i
-)
-
-IF EXIST "..\oem-app-name.txt" (
- echo Installing OEM override file
- xcopy ..\oem-app-name.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\oem-app-name.txt" (
- echo Installing OEM override file
- xcopy ..\..\oem-app-name.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem-app-name.txt" (
- echo Installing OEM override file
- xcopy ..\..\..\oem-app-name.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\oem-update-url.txt" (
- echo Installing OEM override file
- xcopy ..\oem-update-url.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\oem-update-url.txt" (
- echo Installing OEM override file
- xcopy ..\..\oem-update-url.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem-update-url.txt" (
- echo Installing OEM override file
- xcopy ..\..\..\oem-update-url.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\oem-update-key.txt" (
- echo Installing OEM override file
- xcopy ..\oem-update-key.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\oem-update-key.txt" (
- echo Installing OEM override file
- xcopy ..\..\oem-update-key.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem-update-key.txt" (
- echo Installing OEM override file
- xcopy ..\..\..\oem-update-key.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\oem-update-readme.txt" (
- echo Installing OEM override file
- xcopy ..\oem-update-readme.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\oem-update-readme.txt" (
- echo Installing OEM override file
- xcopy ..\..\oem-update-readme.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem-update-readme.txt" (
- echo Installing OEM override file
- xcopy ..\..\..\oem-update-readme.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\oem-update-installid.txt" (
- echo Installing OEM override file
- xcopy ..\oem-update-installid.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\oem-update-installid.txt" (
- echo Installing OEM override file
- xcopy ..\..\oem-update-installid.txt Duplicati /e /s /y /i
-)
-
-IF EXIST "..\..\..\oem-update-installid.txt" (
- echo Installing OEM override file
- xcopy ..\..\..\oem-update-installid.txt Duplicati /e /s /y /i
-)
-
-rmdir /s /q obj
-rmdir /s /q bin
-
-copy UpgradeData.wxi UpgradeData.wxi.orig
-UpdateVersion.exe Duplicati\Duplicati.GUI.TrayIcon.exe UpgradeData.wxi
-
-msbuild /property:Configuration=Release /property:Platform=x64
-move bin\x64\Release\Duplicati.msi Duplicati.msi
-
-msbuild /property:Configuration=Release /property:Platform=x86
-move bin\x86\Release\Duplicati.msi Duplicati-32bit.msi
-
-copy UpgradeData.wxi.orig UpgradeData.wxi
-del UpgradeData.wxi.orig
-
-rmdir /s /q Duplicati
-
-:EXIT
\ No newline at end of file
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/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/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/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..9b5b93640 100644
--- a/Installer/debian/docker/debian/control
+++ b/Installer/debian/control.template.txt
@@ -1,24 +1,23 @@
Source: duplicati
Section: utils
Priority: extra
+Build-Depends: debhelper-compat (= 13)
Maintainer: Kenneth Skovhede
-Build-Depends: debhelper (>= 8.0.0), dotnet-sdk-6.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.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/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/Installer/debian/docker/debian/duplicati.default b/Installer/debian/systemd/duplicati.default
similarity index 100%
rename from Installer/debian/docker/debian/duplicati.default
rename to Installer/debian/systemd/duplicati.default
diff --git a/Installer/debian/docker/debian/duplicati.service b/Installer/debian/systemd/duplicati.service
similarity index 100%
rename from Installer/debian/docker/debian/duplicati.service
rename to Installer/debian/systemd/duplicati.service
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-binary.sh b/Installer/fedora/docker-build-binary.sh
deleted file mode 100755
index a35cc2c49..000000000
--- a/Installer/fedora/docker-build-binary.sh
+++ /dev/null
@@ -1,84 +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)
-BUILDDATE=$(date +%Y%m%d)
-BUILDTAG_RAW=$(echo "${FILENAME}" | cut -d "." -f 1-4 | cut -d "-" -f 2-4)
-BUILDTAG="${BUILDTAG_RAW//-}"
-CWD=$(pwd)
-
-echo "BUILDTAG: ${BUILDTAG}"
-echo "Version: ${VERSION}"
-echo "Builddate: ${BUILDDATE}"
-echo "Dirname: ${DIRNAME}"
-
-#DIRNAME="duplicati-${BUILDDATE}"
-if [ -d "${DIRNAME}" ]; then
- rm -rf "${DIRNAME}"
-fi
-unzip -q -d "${DIRNAME}" "$1"
-
-
-cp ../debian/*-launcher.sh "${DIRNAME}"
-cp ../debian/duplicati.png "${DIRNAME}"
-cp ../debian/duplicati.desktop "${DIRNAME}"
-
-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
-
-tar -cjf "${DIRNAME}.tar.bz2" "${DIRNAME}"
-rm -rf "${DIRNAME}"
-
-RPMBUILD="${CWD}/${DIRNAME}-rpmbuild"
-if [ -d "${RPMBUILD}" ]; then
- rm -rf "${RPMBUILD}"
-fi
-
-mkdir -p "${RPMBUILD}"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
-
-mv "${DIRNAME}.tar.bz2" "${RPMBUILD}/SOURCES/"
-cp 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"
-
-echo "%global _builddate ${BUILDDATE}" > "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-echo "%global _buildversion ${VERSION}" >> "${RPMBUILD}/SOURCES/duplicati-buildinfo.spec"
-echo "%global _buildtag ${BUILDTAG}" >> "${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 -bb duplicati-binary.spec
-
-mv "${RPMBUILD}/RPMS/noarch/"*.rpm .
-rm -rf "${RPMBUILD}"
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/buildroot/duplicati.xpm b/Installer/fedora/docker/buildroot/duplicati.xpm
deleted file mode 100644
index 0166172b5..000000000
--- a/Installer/fedora/docker/buildroot/duplicati.xpm
+++ /dev/null
@@ -1,62 +0,0 @@
-/* XPM */
-static char *appicon_32x32[] = {
-/* columns rows colors chars-per-pixel */
-"32 32 24 1 ",
-" c #1D4775",
-". c #365B84",
-"X c #375C85",
-"o c #336697",
-"O c #4976A2",
-"+ c #4977A2",
-"@ c #4A77A3",
-"# c #517DA6",
-"$ c #65B1DD",
-"% c #77BAE1",
-"& c #BDCAD7",
-"* c #BECAD7",
-"= c #BFCBD8",
-"- c #BACBDC",
-"; c #BDCEDE",
-": c #C4D3E1",
-"> c #C5D3E1",
-", c #C5D4E2",
-"< c #D4E9F5",
-"1 c #F0F4F7",
-"2 c #F4F7F9",
-"3 c #FEFEFE",
-"4 c white",
-"5 c None",
-/* pixels */
-"55555555555555555555555555555555",
-"55555555555555555555555555555555",
-"55555555554444444444444444444455",
-"55555555544444444444444444444445",
-"5555555544&. .&44",
-"5555555544. .44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5555555544 44",
-"5544444444 44",
-"5444444444 44",
-"44,+oooo24. .44",
-"44+ooooo=4=. X=44",
-"44oooooo#14444444444444444444445",
-"44ooooooo@=244444444444444444455",
-"44oooooooooooooo44$$$$$$$4455555",
-"44oooooooooooooo44$$$$$$$4455555",
-"44oooooooooooooo44$$$$$$$4455555",
-"44oooooooooooooo44$$$$$$%4455555",
-"44oooooooooooooo44$$$$$%<4455555",
-"44@oooooooooooo@4444444444555555",
-"44,@oooooooooo@,4444444445555555",
-"54444444444444444555555555555555",
-"55444444444444445555555555555555",
-"55555555555555555555555555555555"
-};
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-install-binaries.sh b/Installer/fedora/duplicati-install-binaries.sh
new file mode 100755
index 000000000..faa3037e2
--- /dev/null
+++ b/Installer/fedora/duplicati-install-binaries.sh
@@ -0,0 +1,14 @@
+#!/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
+
+BUILDROOT=$1
+EXEC_PREFIX=$2
+NAMER=$3
+
+# Fix permissions
+REPL: chmod 755 ${BUILDROOT}${EXEC_PREFIX}/lib/${NAMER}/%SOURCE%
+
+# Setup symlinks
+REPL: ln -s ../lib/${NAMER}/%SOURCE% ${BUILDROOT}${EXEC_PREFIX}/bin/%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-binary.spec b/Installer/fedora/duplicati.spec.template.txt
similarity index 57%
rename from Installer/fedora/duplicati-binary.spec
rename to Installer/fedora/duplicati.spec.template.txt
index 0de8cc9e7..0bd38c205 100644
--- a/Installer/fedora/duplicati-binary.spec
+++ b/Installer/fedora/duplicati.spec.template.txt
@@ -1,24 +1,17 @@
-# TODO:
-# - check where Tools/* scripts should really be
-# - try to fix every mono compiler warning
-# - fix rpmlint warnings
+%define _build_id_links none
# 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 +21,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
-Source3: %{namer}.service
-Source4: %{namer}.default
+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,56 +56,38 @@ 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/
+install -d %{buildroot}%{_unitdir}
+install -d %{buildroot}%{_sysconfdir}/sysconfig/
+
+# 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}" "%{namer}"
-# 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}
-install -p -D -m 644 %{_topdir}/SOURCES/%{namer}.default %{_sysconfdir}/sysconfig/
+install -p -D -m 644 %{_topdir}/SOURCES/%{namer}.service %{buildroot}%{_unitdir}
+install -p -D -m 644 %{_topdir}/SOURCES/%{namer}.default %{buildroot}%{_sysconfdir}/sysconfig/%{namer}
%post
/bin/touch --no-create %{_datadir}/icons/hicolor || :
@@ -137,12 +110,19 @@ install -p -D -m 644 %{_topdir}/SOURCES/%{namer}.default %{_sysconfdir}/sysconfi
%files
%doc changelog.txt licenses/license.txt
-%{_bindir}/*
%{_datadir}/*/*
%{_exec_prefix}/lib/*
+%{_exec_prefix}/bin/*
+%{_sysconfdir}/sysconfig/*
%changelog
+* Tue Apr 9 2024 Kenneth Skovhede - 2.0.0-0.20240409.git
+- Fix package binaries installing and systemd service
+
+* 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/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/Installer/fedora/make-binary-package.sh b/Installer/fedora/make-binary-package.sh
deleted file mode 100755
index 4446f5604..000000000
--- a/Installer/fedora/make-binary-package.sh
+++ /dev/null
@@ -1,65 +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)
-BUILDDATE=$(date +%Y%m%d)
-BUILDTAG_RAW=$(echo "${FILENAME}" | cut -d "." -f 1-4 | cut -d "-" -f 2-4)
-BUILDTAG="${BUILDTAG_RAW//-}"
-
-echo "BUILDTAG: ${BUILDTAG}"
-echo "Version: ${VERSION}"
-echo "Builddate: ${BUILDDATE}"
-echo "Dirname: ${DIRNAME}"
-
-#DIRNAME="duplicati-${BUILDDATE}"
-if [ -d "${DIRNAME}" ]; then
- rm -rf "${DIRNAME}"
-fi
-unzip -q -d "${DIRNAME}" "$1"
-
-
-cp ../debian/*-launcher.sh "${DIRNAME}"
-cp ../debian/duplicati.png "${DIRNAME}"
-cp ../debian/duplicati.desktop "${DIRNAME}"
-
-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
-
-tar -caf "${DIRNAME}.tar.bz2" "${DIRNAME}"
-rm -rf "${DIRNAME}"
-
-mv "${DIRNAME}.tar.bz2" ~/rpmbuild/SOURCES/
-cp 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
-
-echo "%global _builddate ${BUILDDATE}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _buildversion ${VERSION}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-echo "%global _buildtag ${BUILDTAG}" >> ~/rpmbuild/SOURCES/duplicati-buildinfo.spec
-
-rpmbuild -bb duplicati-binary.spec
-
diff --git a/Installer/fedora/duplicati.default b/Installer/fedora/systemd/duplicati.default
similarity index 100%
rename from Installer/fedora/duplicati.default
rename to Installer/fedora/systemd/duplicati.default
diff --git a/Installer/fedora/duplicati.service b/Installer/fedora/systemd/duplicati.service
similarity index 100%
rename from Installer/fedora/duplicati.service
rename to Installer/fedora/systemd/duplicati.service
diff --git a/Installer/debian/duplicati.desktop b/Installer/shared/desktop/duplicati.desktop
similarity index 100%
rename from Installer/debian/duplicati.desktop
rename to Installer/shared/desktop/duplicati.desktop
diff --git a/Installer/debian/duplicati.png b/Installer/shared/pixmaps/duplicati.png
similarity index 100%
rename from Installer/debian/duplicati.png
rename to Installer/shared/pixmaps/duplicati.png
diff --git a/Installer/debian/duplicati.svg b/Installer/shared/pixmaps/duplicati.svg
similarity index 100%
rename from Installer/debian/duplicati.svg
rename to Installer/shared/pixmaps/duplicati.svg
diff --git a/Installer/debian/duplicati.xpm b/Installer/shared/pixmaps/duplicati.xpm
similarity index 100%
rename from Installer/debian/duplicati.xpm
rename to Installer/shared/pixmaps/duplicati.xpm
diff --git a/README.md b/README.md
index 4131f697f..ca903e8d4 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Download
The latest version of Duplicati is a beta version for the Duplicati 2.0 release.
-[Click here to download the latest Duplicati 2.0 beta release.](http://www.duplicati.com/download)
+[Click here to download the latest Duplicati 2.0 beta release.](https://duplicati.com/download)
The beta release will automatically notify you of updates and allows you to upgrade with a single click (or command in the terminal).
For even more [bleeding edge access, check the latest releases](https://github.com/duplicati/duplicati/releases) or choose another update channel in the UI or on the commandline.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4afedae14..e3ddd0642 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -35,7 +35,7 @@ Duplicati 根据 LGPL 许可证授权,并可用于 Windows、OSX 和 Linux (.N
Duplicati 的最新版本是 Duplicati 2.0 发布的测试版。
-[点击此处下载最新的 Duplicati 2.0 测试版。](http://www.duplicati.com/download)
+[点击此处下载最新的 Duplicati 2.0 测试版。](https://duplicati.com/download)
测试版将自动通知您更新,并允许您通过单击(或在终端中的命令)升级。
要获取更多[前沿版本,查看最新发布](https://github.com/duplicati/duplicati/releases)或在 UI 或命令行中选择另一个更新渠道。
diff --git a/ReleaseBuilder/.vscode/launch.json b/ReleaseBuilder/.vscode/launch.json
new file mode 100644
index 000000000..37f295ef6
--- /dev/null
+++ b/ReleaseBuilder/.vscode/launch.json
@@ -0,0 +1,61 @@
+{
+ // 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": ["create-key", "testfile.key", "--password", "test1234"],
+ "args": [
+ "build",
+ "debug",
+ "--disable-docker-push", "true",
+ "--git-stash-push", "false",
+ "--targets", "win-arm64-gui.zip",
+ "--targets", "win-x86-gui.zip",
+ "--targets", "win-x86-gui.msi",
+ "--targets", "win-x64-gui.msi",
+ "--targets", "win-x64-gui.zip",
+ "--targets", "linux-x64-gui.zip",
+ "--targets", "linux-arm64-gui.zip",
+ "--targets", "osx-x64-gui.dmg",
+ "--targets", "osx-arm64-gui.dmg",
+ "--targets", "osx-x64-gui.pkg",
+ "--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",
+ "--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",
+ "--disable-notarize-signing", "true",
+ "--disable-gpg-signing", "true",
+ "--disable-s3-upload", "true",
+ "--disable-github-upload", "true",
+ "--disable-update-server-reload", "true",
+ "--disable-discourse-announce", "true",
+ "--password", "test1234",
+ "--version", "2.0.8.104"
+ ],
+ "env": {
+ "UPDATER_KEYFILE": "${workspaceFolder}/testfile.key:${workspaceFolder}/testfile.key2"
+ },
+ "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/Build/Command.Compile.Post.cs b/ReleaseBuilder/Build/Command.Compile.Post.cs
new file mode 100644
index 000000000..e5cffc05e
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.Compile.Post.cs
@@ -0,0 +1,279 @@
+using System.Text.RegularExpressions;
+
+namespace ReleaseBuilder.Build;
+
+public static partial class Command
+{
+ ///
+ /// Helper methods cleaning and signing build outputs
+ ///
+ private static class PostCompile
+ {
+ ///
+ /// Prepares a target directory with fixes that are done post-build, but before making the individual packages
+ ///
+ /// The source directory
+ /// The output build directory to modify
+ /// The target operating system
+ /// The build target string os-arch-interface
+ /// The runtime config
+ /// A flag that allows re-using existing builds
+ /// An awaitable task
+ public static async Task PrepareTargetDirectory(string baseDir, string buildDir, OSType os, string buildTargetString, RuntimeConfig rtcfg, bool keepBuilds)
+ {
+ await RemoveUnwantedFiles(os, buildDir);
+
+ switch (os)
+ {
+ case OSType.Windows:
+ await SignWindowsExecutables(buildDir, rtcfg);
+ break;
+
+ case OSType.MacOS:
+ await BundleMacOSApplication(baseDir, buildDir, buildTargetString, rtcfg, keepBuilds);
+ break;
+
+ case OSType.Linux:
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Set of files that are unwanted despite the OS
+ ///
+ static readonly IReadOnlyList UnwantedCommonFiles = [
+ "System.Reactive.xml" // Extra documentation file
+ ];
+
+ ///
+ /// Set of folders that are unwanted despite the OS
+ ///
+ static readonly IReadOnlyList UnwantedCommonFolders = [
+ "Duplicati", // Debug folder that gets picked up during builds
+ "control_dir", // Debug folder for lock files
+ ];
+
+ ///
+ /// A list of folders that are unwanted for a given OS target
+ ///
+ /// The OS to get unwanted folders for
+ /// The unwanted folders
+ static IEnumerable UnwantedFolders(OSType os)
+ => UnwantedCommonFolders.Concat(os switch
+ {
+ OSType.Windows => ["lvm-scripts"],
+ OSType.MacOS => ["lvm-scripts", "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 IEnumerable UnwantedFiles(OSType os)
+ => UnwantedCommonFiles.Concat(os switch
+ {
+ OSType.Windows => [],
+ OSType.MacOS => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
+ OSType.Linux => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
+ _ => throw new Exception($"Not supported os: {os}")
+ })
+ .Distinct();
+
+
+ ///
+ /// The unwanted filenames
+ ///
+ /// The operating system to get the unwanted filenames for
+ /// The list of unwanted filenames
+ static IEnumerable UnwantedFileGlobExps(OSType os)
+ => new[] {
+ "Thumbs.db",
+ "desktop.ini",
+ ".DS_Store",
+ "*.bak",
+ "*.pdb",
+ "*.mdb",
+ "._*",
+ os == OSType.Windows ? "*.sh" : "*.bat"
+ };
+
+ ///
+ /// Returns a regular expression mapping files that are not wanted in the build folders
+ ///
+ /// The operating system to get the unwanted filenames for
+ /// A regular expression for matching unwanted filenames
+ static Regex UnwantedFilePatterns(OSType os)
+ => new Regex(@$"^({string.Join("|", UnwantedFileGlobExps(os).Select(x => x.Replace(".", "\\.").Replace("*", ".*")))})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ ///
+ /// Removes unwanted contents from the build folders
+ ///
+ /// The operating system the folder is targeting
+ /// The directory where the build output is placed
+ /// An awaitable task
+ static Task RemoveUnwantedFiles(OSType os, string buildDir)
+ {
+ foreach (var d in UnwantedFolders(os).Select(x => Path.Combine(buildDir, x)))
+ if (Directory.Exists(d))
+ Directory.Delete(d, true);
+
+ foreach (var f in UnwantedFiles(os).Select(x => Path.Combine(buildDir, x)))
+ if (File.Exists(f))
+ File.Delete(f);
+
+ var patterns = UnwantedFilePatterns(os);
+ foreach (var f in Directory.EnumerateFiles(buildDir, "*", SearchOption.AllDirectories).Where(x => patterns.IsMatch(Path.GetFileName(x))))
+ if (File.Exists(f))
+ File.Delete(f);
+
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Creates the MacOS folder structure by moving all files into a .app folder
+ ///
+ /// The source folder
+ /// The MacOS build output
+ /// The build-target string os-arch-interface
+ /// The runtime configuration
+ /// A flag that allows re-using existing builds
+ /// An awaitable task
+ static async Task BundleMacOSApplication(string baseDir, string buildDir, string buildTargetString, RuntimeConfig rtcfg, bool keepBuilds)
+ {
+ var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir");
+ // Create target .app folder
+ var appDir = Path.Combine(
+ buildroot,
+ $"{buildTargetString}-{rtcfg.MacOSAppName}"
+ );
+
+ if (Directory.Exists(appDir))
+ {
+ if (keepBuilds)
+ {
+ Console.WriteLine("App folder already exsists, skipping MacOS application build");
+ return;
+ }
+
+ Directory.Delete(appDir, true);
+ }
+
+ // Prepare the .app folder structure
+ var tmpApp = Path.Combine(buildroot, "tmpapp", rtcfg.MacOSAppName);
+
+ var folders = new[] {
+ Path.Combine("Contents"),
+ Path.Combine("Contents", "MacOS"),
+ Path.Combine("Contents", "Resources"),
+ };
+
+ if (Directory.Exists(tmpApp))
+ Directory.Delete(tmpApp, true);
+
+ Directory.CreateDirectory(tmpApp);
+ foreach (var f in folders)
+ Directory.CreateDirectory(Path.Combine(tmpApp, f));
+
+ // Copy the primary contents into the binary folder
+ var binDir = Path.Combine(tmpApp, "Contents", "MacOS");
+ EnvHelper.CopyDirectory(buildDir, binDir, recursive: true);
+
+ // Patch the plist and place the icon from the resources
+ var 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
+ );
+
+ // 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 signtargets)
+ await rtcfg.Codesign(f, entitlementFile);
+
+ await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile);
+ }
+
+ if (!OperatingSystem.IsWindows())
+ foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly))
+ File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
+
+ Directory.Move(tmpApp, appDir);
+ Directory.Delete(Path.GetDirectoryName(tmpApp) ?? throw new Exception("Unexpected empty path"), true);
+ }
+
+ ///
+ /// Signs all .exe and .dll files with Authenticode
+ ///
+ /// The folder to sign files in
+ /// The runtime config
+ /// An awaitable task
+ static async Task SignWindowsExecutables(string buildDir, RuntimeConfig rtcfg)
+ {
+ var cfg = Program.Configuration;
+ if (!rtcfg.UseAuthenticodeSigning)
+ return;
+
+ var filenames = Directory.EnumerateFiles(buildDir, "Duplicati.*", SearchOption.TopDirectoryOnly)
+ .Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ Console.WriteLine($"Performing Authenticode signing of {filenames.Count} files");
+
+ foreach (var file in filenames)
+ await rtcfg.AuthenticodeSign(file);
+ }
+ }
+}
diff --git a/ReleaseBuilder/Build/Command.Compile.cs b/ReleaseBuilder/Build/Command.Compile.cs
new file mode 100644
index 000000000..c52bb0cb9
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.Compile.cs
@@ -0,0 +1,91 @@
+namespace ReleaseBuilder.Build;
+
+public static partial class Command
+{
+ ///
+ /// 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: 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", archstring,
+ $"/p:AssemblyVersion={releaseInfo.Version}",
+ $"/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");
+ }
+
+ // Perform any post-build steps, cleaning and signing as needed
+ await PostCompile.PrepareTargetDirectory(baseDir, tmpfolder, target.OS, target.BuildTargetString, rtcfg, keepBuilds);
+
+ Directory.Move(tmpfolder, outputFolder);
+ }
+
+ Console.WriteLine("Completed!");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs
new file mode 100644
index 000000000..a683f4795
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.CreatePackage.cs
@@ -0,0 +1,835 @@
+using System.Globalization;
+using System.IO.Compression;
+
+namespace ReleaseBuilder.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.
+ ///
+ /// 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 builtPackages = new List();
+
+ var packagesToBuild = buildTargets.Distinct().ToList();
+ if (packagesToBuild.Count == 1)
+ Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}");
+ else
+ Console.WriteLine($"Building {packagesToBuild.Count} packages");
+
+ // 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} ...");
+ builtPackages.Add(new BuiltPackage(target, 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, $"duplicati-{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, "");
+ }
+ }
+
+ return builtPackages;
+ }
+
+ ///
+ /// 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, $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
+
+ // Fix up non-conforming package names
+ // Temporary disable
+ // 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 packageFile;
+ }
+
+ 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(Path.Combine(buildRoot, target.BuildTargetString), $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.BuildTargetString}", 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.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);
+ // break;
+
+ default:
+ throw new Exception($"Unsupported package type: {target.Package}");
+ }
+
+ if (rtcfg.UseNotarizeSigning && target.Package == PackageType.DMG || target.Package == PackageType.MacPkg)
+ {
+ // # Notarize and staple takes a while...
+ Console.WriteLine($"Performing notarize and staple of {packageFile} ...");
+ await ProcessHelper.Execute(["xcrun", "notarytool", "submit", tempFile, "--keychain-profile", Program.Configuration.ConfigFiles.NotarizeProfile, "--wait"]);
+ await ProcessHelper.Execute(["xcrun", "stapler", "staple", tempFile]);
+ }
+
+
+ File.Move(tempFile, packageFile);
+
+ return packageFile;
+ }
+
+ ///
+ /// 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 dirName, string zipFile, PackageTarget target, RuntimeConfig rtcfg)
+ {
+ if (File.Exists(zipFile))
+ 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))
+ {
+ 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 && 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;
+
+ if (isExecutable)
+ executables.Add(relpath);
+
+ using (var stream = entry.Open())
+ 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)
+ {
+ 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 and support scripts");
+ writer.WriteLine("set -e");
+ foreach (var x in executables)
+ writer.WriteLine($"chmod +x {x}");
+ }
+ }
+ }
+ }
+
+ ///
+ /// 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 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([
+ 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);
+
+ 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.
+ ///
+ /// 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, rtcfg.MacOSAppName);
+ if (Directory.Exists(appFolder))
+ Directory.Delete(appFolder, true);
+
+ // Place the prepared folder
+ EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
+ await PrepareAndReSignAppBundle(appFolder, installerDir, target, rtcfg);
+
+ // 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, rtcfg.MacOSAppName);
+ if (Directory.Exists(appFolder))
+ Directory.Delete(appFolder, true);
+
+ // Place the prepared folder
+ EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
+ await PrepareAndReSignAppBundle(appFolder, installerDir, target, rtcfg);
+
+ // 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 async Task BuildDebPackage(string baseDir, string buildRoot, string debFile, PackageTarget target, RuntimeConfig rtcfg)
+ {
+ // 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"));
+
+ // Copy main files
+ EnvHelper.CopyDirectory(
+ Path.Combine(buildRoot, target.BuildTargetString),
+ 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())
+ {
+ 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",
+ Path.Combine("..", "lib", "duplicati", e.Key),
+ Path.Combine(pkgroot, "usr", "bin", e.Value)
+ ]);
+ }
+ }
+
+ // Copy debian files
+ var installerDir = Path.Combine(baseDir, "Installer", "debian");
+
+ // Write in the release notes
+ if (!string.IsNullOrEmpty(rtcfg.ChangelogNews))
+ File.WriteAllText(Path.Combine(pkgroot, "DEBIAN", "releasenotes"), rtcfg.ChangelogNews);
+
+ // 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))
+ );
+
+ // 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%", debArchString)
+ .Replace("%DEPENDS%", string.Join(", ", target.Interface == InterfaceType.GUI
+ ? DebianGUIDepends
+ : DebianCLIDepends))
+ );
+
+ // Install various helper files
+ var sharedDir = Path.Combine(baseDir, "Installer", "shared");
+ var supportFiles = new List<(string Source, string Destination)>{
+ (
+ Path.Combine(installerDir, "systemd", "duplicati.default"),
+ Path.Combine(pkgroot, "etc", "default", "duplicati")
+ ),
+ (
+ Path.Combine(installerDir, "systemd", "duplicati.service"),
+ Path.Combine(pkgroot, "lib", "systemd", "system", "duplicati.service")
+ ),
+ (
+ Path.Combine(sharedDir, "desktop", "duplicati.desktop"),
+ Path.Combine(pkgroot, "usr", "share", "applications", "duplicati.desktop")
+ )
+ };
+
+ supportFiles.AddRange(
+ new[] { "duplicati.png", "duplicati.svg", "duplicati.xpm" }
+ .Select(f => (
+ Path.Combine(sharedDir, "pixmaps", f),
+ Path.Combine(pkgroot, "usr", "share", "pixmaps", f)
+ ))
+ );
+
+ foreach (var f in supportFiles)
+ {
+ var dir = Path.GetDirectoryName(f.Destination);
+ if (!Directory.Exists(dir) && dir != null)
+ Directory.CreateDirectory(dir);
+ File.Copy(f.Source, f.Destination, true);
+
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(f.Destination, UnixFileMode.OtherRead | UnixFileMode.GroupRead | UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+
+ // Copy 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";
+
+ // Docker desktop has some sync issues
+ await Task.Delay(TimeSpan.FromSeconds(5));
+
+ // 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);
+ }
+ }
+
+ ///
+ /// 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)));
+
+ // Move in extra files for building
+ var sharedDir = Path.Combine(baseDir, "Installer", "shared");
+ File.Copy(Path.Combine(sharedDir, "pixmaps", "duplicati.xpm"), Path.Combine(sources, "duplicati.xpm"));
+ File.Copy(Path.Combine(sharedDir, "pixmaps", "duplicati.png"), Path.Combine(sources, "duplicati.png"));
+ File.Copy(Path.Combine(sharedDir, "desktop", "duplicati.desktop"), Path.Combine(sources, "duplicati.desktop"));
+ File.Copy(Path.Combine(installerDir, "systemd", "duplicati.service"), Path.Combine(sources, "duplicati.service"));
+ File.Copy(Path.Combine(installerDir, "systemd", "duplicati.default"), Path.Combine(sources, "duplicati.default"));
+ File.Copy(Path.Combine(installerDir, "duplicati-install-recursive.sh"), Path.Combine(sources, "duplicati-install-recursive.sh"));
+
+ var executables = ExecutableRenames.AsEnumerable();
+ if (target.Interface == InterfaceType.Cli)
+ executables = executables.Where(x => !GUIProjects.Contains(x.Key));
+
+ // Write custom script to install executable 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.spec.template.txt"))
+ .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
+ );
+
+ // Build a Docker image to build with
+ await ProcessHelper.Execute([
+ "docker", "build",
+ "-t", "duplicati/fedora-build:latest",
+ tmpbuild
+ ], workingDirectory: tmpbuild);
+
+ // Install the build script
+ // This is required because rpmbuild reads file mode
+ // in a way that is not compatible with Docker desktop bind mounts
+ File.Copy(
+ Path.Combine(installerDir, "inside-docker.sh"),
+ Path.Combine(tmpbuild, "inside-docker.sh"),
+ true
+ );
+
+ // 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
+ ///
+ /// 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/v8",
+ 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, suppressStdErr: true); }
+ 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/Build/Command.GitPush.cs b/ReleaseBuilder/Build/Command.GitPush.cs
new file mode 100644
index 000000000..38a39b3a5
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.GitPush.cs
@@ -0,0 +1,56 @@
+namespace ReleaseBuilder.Build;
+
+public static partial class Command
+{
+ ///
+ /// 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
+
+ // 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.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"
+ }, 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.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"
+ }, workingDirectory: baseDir);
+
+ // The push the release
+ await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir);
+ }
+ }
+}
diff --git a/ReleaseBuilder/Build/Command.GpgSign.cs b/ReleaseBuilder/Build/Command.GpgSign.cs
new file mode 100644
index 000000000..3b32fe8fd
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.GpgSign.cs
@@ -0,0 +1,75 @@
+using System.IO.Compression;
+
+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 signature file to create
+ /// The runtime configuration
+ /// An awaitable task
+ public static async Task SignReleaseFiles(IEnumerable files, string signaturefile, RuntimeConfig rtcfg)
+ {
+ var tmpfile = signaturefile + ".tmp";
+ if (File.Exists(tmpfile))
+ File.Delete(tmpfile);
+
+ var (gpgid, passphrase) = GetGpgIdAndPassphrase(rtcfg);
+ using (var zip = ZipFile.Open(tmpfile, ZipArchiveMode.Create))
+ {
+ foreach (var file in files)
+ foreach (var armored in new[] { true, false })
+ {
+ var outputfile = file + (armored ? ".sig.asc" : ".sig");
+ var outputpath = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, outputfile);
+
+ await ProcessHelper.Execute(
+ [
+ Program.Configuration.Commands.Gpg!,
+ "--pinentry-mode", "loopback",
+ "--passphrase-fd", "0",
+ "--batch", "--yes",
+ armored ? "--armor" : "--no-armor",
+ "-u", gpgid,
+ "--output", outputfile,
+ "--detach-sign", file
+ ],
+ workingDirectory: Path.GetDirectoryName(file),
+ writeStdIn: (stdin) => stdin.WriteLineAsync(passphrase)
+ );
+
+ zip.CreateEntryFromFile(outputpath, outputfile);
+ File.Delete(outputpath);
+ }
+
+ // Add information about the signing key
+ using (var stream = zip.CreateEntry("sign-key.txt", CompressionLevel.Optimal).Open())
+ stream.Write(System.Text.Encoding.UTF8.GetBytes($"{gpgid}\nhttps://pgp.mit.edu/pks/lookup?op=get&search={gpgid}\n"));
+ }
+
+ File.Move(tmpfile, signaturefile, true);
+ }
+
+ ///
+ /// 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.PackageSupport.cs b/ReleaseBuilder/Build/Command.PackageSupport.cs
new file mode 100644
index 000000000..0a1512028
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.PackageSupport.cs
@@ -0,0 +1,56 @@
+namespace ReleaseBuilder.Build;
+
+public static partial class Command
+{
+ ///
+ /// Support for building packages
+ ///
+ private static class PackageSupport
+ {
+ ///
+ /// 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, 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(targetDir ?? ".", 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 = 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)
+ if (File.Exists(x))
+ EnvHelper.AddFilemode(x, filemode);
+ }
+
+ 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/Build/Command.RuntimeConfig.cs b/ReleaseBuilder/Build/Command.RuntimeConfig.cs
new file mode 100644
index 000000000..aa947933d
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.RuntimeConfig.cs
@@ -0,0 +1,482 @@
+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;
+
+ ///
+ /// Checks if GPG signing is enabled
+ ///
+ public void ToggleGpgSigning()
+ {
+ if (!_useGpgSigning.HasValue)
+ {
+ if (Input.DisableGpgSigning)
+ {
+ _useGpgSigning = false;
+ return;
+ }
+
+ if (Program.Configuration.IsGpgPossible())
+ _useGpgSigning = true;
+ else
+ {
+ 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");
+ }
+ }
+ }
+
+ ///
+ /// Cache value for checking if S3 upload is enabled
+ ///
+ private bool? _useS3Upload;
+
+ ///
+ /// Checks if S3 upload is enabled
+ ///
+ public void ToggleS3Upload()
+ {
+ if (!_useS3Upload.HasValue)
+ {
+ if (Input.DisableS3Upload)
+ {
+ _useS3Upload = false;
+ return;
+ }
+
+ if (Program.Configuration.IsAwsUploadPossible())
+ _useS3Upload = true;
+ else
+ {
+ if (ConsoleHelper.ReadInput("Configuration missing for awscli, continue without uploading to S3?", "Y", "n") == "Y")
+ {
+ _useS3Upload = false;
+ return;
+ }
+
+ throw new Exception("Configuration is not set up for awscli");
+ }
+ }
+ }
+
+ ///
+ /// Cache value for checking if Github upload is enabled
+ ///
+ private bool? _useGithubUpload;
+
+ ///
+ /// Checks if Github upload is enabled
+ ///
+ /// The release channel to use
+ public void ToggleGithubUpload(ReleaseChannel channel)
+ {
+ if (!_useGithubUpload.HasValue)
+ {
+ if (Input.DisableGithubUpload || channel == ReleaseChannel.Debug || channel == ReleaseChannel.Nightly)
+ {
+ _useGithubUpload = false;
+ return;
+ }
+
+ if (Program.Configuration.IsGithubUploadPossible())
+ _useGithubUpload = true;
+ else
+ {
+ if (ConsoleHelper.ReadInput("Configuration is missing a Github token, continue without uploading to Github?", "Y", "n") == "Y")
+ {
+ _useGithubUpload = false;
+ return;
+ }
+
+ throw new Exception("Configuration is not set up for github releases");
+ }
+ }
+ }
+
+ ///
+ /// Cache value for checking if update server reload is enabled
+ ///
+ private bool? _useUpdateServerReload;
+
+ ///
+ /// Checks if update server reload is enabled
+ ///
+ public void ToggleUpdateServerReload()
+ {
+ if (!_useUpdateServerReload.HasValue)
+ {
+ if (Input.DisableUpdateServerReload)
+ {
+ _useUpdateServerReload = false;
+ return;
+ }
+
+ if (Program.Configuration.IsUpdateServerReloadPossible())
+ _useUpdateServerReload = true;
+ else
+ {
+ if (ConsoleHelper.ReadInput("Configuration missing for update server, continue without reloading the update server?", "Y", "n") == "Y")
+ {
+ _useUpdateServerReload = false;
+ return;
+ }
+
+ throw new Exception("Configuration is not set up for update server");
+ }
+ }
+ }
+
+ ///
+ /// Cache value for checking if forum posting is enabled
+ ///
+ private bool? _useDiscourseAnnounce;
+
+ ///
+ /// Checks if forum posting is enabled
+ ///
+ /// The release channel to use
+ public void ToogleDiscourseAnnounce(ReleaseChannel channel)
+ {
+ if (!_useDiscourseAnnounce.HasValue)
+ {
+ if (Input.DisableDiscordAnnounce || channel == ReleaseChannel.Debug || channel == ReleaseChannel.Nightly)
+ {
+ _useDiscourseAnnounce = false;
+ return;
+ }
+
+ if (Program.Configuration.IsDiscourseAnnouncePossible())
+ _useDiscourseAnnounce = true;
+ else
+ {
+ if (ConsoleHelper.ReadInput("Configuration missing for forum posting, continue without posting to the forum?", "Y", "n") == "Y")
+ {
+ _useDiscourseAnnounce = false;
+ return;
+ }
+
+ throw new Exception("Configuration is not set up for forum posting");
+ }
+ }
+ }
+
+ ///
+ /// 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;
+
+ ///
+ /// Returns a value indicating if S3 upload is enabled
+ ///
+ public bool UseS3Upload => _useS3Upload!.Value;
+
+ ///
+ /// Returns a value indicating if Github upload is enabled
+ ///
+ public bool UseGithubUpload => _useGithubUpload!.Value;
+
+ ///
+ /// Returns a value indicating if update server reload is enabled
+ ///
+ public bool UseUpdateServerReload => _useUpdateServerReload!.Value;
+
+ ///
+ /// Returns a value indicating if forum posting is enabled
+ ///
+ public bool UseForumPosting => _useDiscourseAnnounce!.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).Trim();
+
+ ///
+ /// 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.Upload.cs b/ReleaseBuilder/Build/Command.Upload.cs
new file mode 100644
index 000000000..3f3d6c000
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.Upload.cs
@@ -0,0 +1,224 @@
+using System.Net.Http.Json;
+using Amazon;
+using Amazon.S3;
+using Amazon.S3.Transfer;
+
+namespace ReleaseBuilder.Build;
+
+public static partial class Command
+{
+ ///
+ /// Implementation of the various upload functions
+ ///
+ static class Upload
+ {
+ ///
+ /// The Github structure for post data
+ ///
+ /// The tag to create the release for
+ /// The commit hash or name
+ /// The name of the release
+ /// The release message
+ /// Flag toggling draft release
+ /// Flag toggling the prerelease tag
+ /// Flag togling release note generator
+ private record GhReleaseInfo(
+ string tag_name,
+ string target_commitish,
+ string name,
+ string body,
+ bool draft,
+ bool prerelease,
+ bool generate_release_notes
+ );
+
+ ///
+ /// The Github structure for response data
+ ///
+ /// The URL to the assets
+ /// The URL to the release
+ /// The release ID
+ private record GhReleaseResponse(
+ string assets_url,
+ string url,
+ int id
+ );
+
+ ///
+ /// Uploads a local file to a remote location
+ ///
+ /// The path to the file on the local filesystem
+ /// The name of the file on the remote target
+ public record UploadFile(string Path, string Name);
+
+ ///
+ /// Single entry in the installer json support file
+ ///
+ /// The remote package url
+ /// The MD5 hash of the file
+ /// The SHA256 hash of the file
+ private record PackageEntry(string url, string md5, string sha256);
+
+ ///
+ /// Creates the package list in JSON format
+ ///
+ /// The packages to include in the file
+ /// The runtime configuration
+ /// The JSON string
+ public static string CreatePackageJson(IEnumerable packages, RuntimeConfig rtcfg)
+ {
+ var entries = packages.Select(f => (f.Target.PackageTargetString, Entry: new PackageEntry(
+ url: $"https://updates.duplicati.com/{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/{System.Web.HttpUtility.UrlEncode(Path.GetFileName(f.CreatedFile))}",
+ md5: CalculateHash(f.CreatedFile, "md5"),
+ sha256: CalculateHash(f.CreatedFile, "sha256")
+ )))
+ .ToDictionary(x => x.PackageTargetString, x => x.Entry);
+
+ return System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ }
+
+ ///
+ /// Upload release files to S3 storage
+ ///
+ /// The files to upload
+ /// The runtime configuration
+ /// An awaitable task
+ public static async Task UploadToS3(IEnumerable files, RuntimeConfig rtcfg)
+ {
+ var totalSize = files.Sum(f => new FileInfo(f.Path).Length);
+ Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to S3...");
+
+ var chain = new Amazon.Runtime.CredentialManagement.CredentialProfileStoreChain();
+ if (!chain.TryGetAWSCredentials(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsCredentials))
+ throw new Exception($"The aws-cli profile '{Program.Configuration.ConfigFiles.AwsUploadProfile}' could not be found.");
+
+ chain.TryGetProfile(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsProfile);
+ var config = new AmazonS3Config
+ {
+ RegionEndpoint = awsProfile?.Region ?? RegionEndpoint.USEast1,
+ UseArnRegion = false,
+ ServiceURL = awsProfile?.EndpointUrl ?? "https://s3.amazonaws.com"
+ };
+
+ using var client = new AmazonS3Client(awsCredentials, config);
+ var fileTransferUtility = new TransferUtility(client);
+ var size = 0L;
+
+ foreach (var file in files)
+ {
+ await fileTransferUtility.UploadAsync(
+ file.Path,
+ Program.Configuration.ConfigFiles.AwsUploadBucket,
+ $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/{file.Name}"
+ );
+
+ var filesize = new FileInfo(file.Path).Length;
+ size += filesize;
+ Console.WriteLine($"{size / (double)totalSize * 100:F1}% - Uploaded {file.Name} ({Duplicati.Library.Utility.Utility.FormatSizeString(filesize)})");
+ }
+ }
+
+ ///
+ /// Upload release files to Github
+ ///
+ /// The files to upload
+ /// The runtime configuration
+ /// An awaitable task
+ public static async Task UploadToGithub(IEnumerable files, RuntimeConfig rtcfg)
+ {
+ using var httpClient = new HttpClient();
+ var totalSize = files.Sum(f => new FileInfo(f.Path).Length);
+ Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to Github...");
+
+ var ghtoken = File.ReadAllText(Program.Configuration.ConfigFiles.GithubTokenFile).Trim();
+ var owner = "duplicati";
+ var repo = "duplicati";
+
+ var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.github.com/repos/{owner}/{repo}/releases");
+ request.Headers.Add("Accept", "application/vnd.github+json");
+ request.Headers.Add("Authorization", $"Bearer {ghtoken}");
+ request.Headers.Add("X-GitHub-Api-Version", "2022-11-28");
+ request.Content = JsonContent.Create(new GhReleaseInfo(
+ tag_name: $"v{rtcfg.ReleaseInfo.ReleaseName}",
+ target_commitish: "master",
+ name: $"v{rtcfg.ReleaseInfo.ReleaseName}",
+ body: rtcfg.ChangelogNews,
+ draft: false,
+ prerelease: rtcfg.ReleaseInfo.Channel != ReleaseChannel.Stable,
+ generate_release_notes: false
+ ));
+
+ var response = await httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+ var releasedata = await response.Content.ReadFromJsonAsync()
+ ?? throw new Exception("Failed to read release data");
+
+ var size = 0L;
+
+ // Upload the files to the same tag
+ foreach (var file in files)
+ {
+ request = new HttpRequestMessage(HttpMethod.Post, $"https://uploads.github.com/repos/{owner}/{repo}/releases/{releasedata.id}/assets?name={System.Net.WebUtility.UrlEncode(Path.GetFileName(file.Name))}");
+ request.Headers.Add("Accept", "application/vnd.github+json");
+ request.Headers.Add("Authorization", $"Bearer {ghtoken}");
+ request.Headers.Add("X-GitHub-Api-Version", "2022-11-28");
+ request.Headers.Add("Content-Type", "application/octet-stream");
+
+ using var fileStream = File.OpenRead(file.Path);
+ request.Content = new StreamContent(fileStream);
+
+ response = await httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var filesize = new FileInfo(file.Path).Length;
+ size += filesize;
+ Console.WriteLine($"{size / (double)totalSize * 100:F1}% - Uploaded {file.Name} ({Duplicati.Library.Utility.Utility.FormatSizeString(filesize)})");
+ }
+ }
+
+ ///
+ /// Reload the update server cache
+ ///
+ /// The runtime configuration
+ /// An awaitable task
+ public static async Task ReloadUpdateServer(RuntimeConfig rtcfg)
+ {
+ using var client = new HttpClient();
+ var req = new HttpRequestMessage(HttpMethod.Post, "https://updates.duplicati.com/reload");
+
+ var reloadToken = Program.Configuration.ConfigFiles.ReloadUpdatesApiKey;
+ req.Headers.Add("X-API-KEY", reloadToken);
+ req.Content = JsonContent.Create(new[] {
+ $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.json",
+ $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.js",
+ $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.manifest",
+ });
+
+ var response = await client.SendAsync(req);
+ response.EnsureSuccessStatusCode();
+ }
+
+ ///
+ /// Post the release to the Duplicati forum
+ ///
+ /// The runtime configuration
+ /// An awaitable task
+ public static async Task PostToForum(RuntimeConfig rtcfg)
+ {
+ using var client = new HttpClient();
+ var req = new HttpRequestMessage(HttpMethod.Post, "https://forum.duplicati.com/posts");
+
+ var discourseToken = File.ReadAllText(Program.Configuration.ConfigFiles.DiscourseTokenFile).Trim().Split(":", 2);
+ req.Headers.Add("Api-Username", discourseToken[0]);
+ req.Headers.Add("Api-Key", discourseToken[1]);
+ req.Headers.Add("Accept", "application/json");
+ req.Content = new FormUrlEncodedContent([
+ new KeyValuePair("category", "10"),
+ new KeyValuePair("title", $"Release: ${rtcfg.ReleaseInfo.Version} (${rtcfg.ReleaseInfo.Channel}) ${rtcfg.ReleaseInfo.Timestamp:yyyy-MM-dd}"),
+ new KeyValuePair("raw", $"# [${rtcfg.ReleaseInfo.ReleaseName}](https://github.com/duplicati/duplicati/releases/tag/v${rtcfg.ReleaseInfo.ReleaseName})\n\n{rtcfg.ChangelogNews}")
+ ]);
+
+ await client.SendAsync(req);
+ }
+ }
+}
diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs
new file mode 100644
index 000000000..d208fb8da
--- /dev/null
+++ b/ReleaseBuilder/Build/Command.cs
@@ -0,0 +1,739 @@
+using System.CommandLine;
+using System.CommandLine.NamingConventionBinder;
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+using Duplicati.Library.AutoUpdater;
+
+namespace ReleaseBuilder.Build;
+
+///
+/// The build command implementation
+///
+public static partial class Command
+{
+ ///
+ /// The primary project to build for GUI builds
+ ///
+ 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
+ ///
+ /// Note that the values here mirror the values in the AutoUpdater.PackageHelper, so changes should be coordinated between the two
+ private static readonly IDictionary ExecutableRenames = new Dictionary(StringComparer.InvariantCultureIgnoreCase)
+ {
+ { "Duplicati.CommandLine.BackendTester", "duplicati-backend-tester"},
+ { "Duplicati.CommandLine.BackendTool", "duplicati-backend-tool" },
+ { "Duplicati.CommandLine.RecoveryTool", "duplicati-recovery-tool" },
+ { "Duplicati.CommandLine.AutoUpdater", "duplicati-autoupdater" },
+ { "Duplicati.CommandLine.ConfigurationImporter", "duplicati-configuration-importer" },
+ { "Duplicati.CommandLine", "duplicati-cli" },
+ { "Duplicati.Server", "duplicati-server"},
+ { "Duplicati.GUI.TrayIcon", "duplicati" }
+ };
+
+ ///
+ /// The supported versions of libicu for Debian
+ ///
+ private static string DebianLibIcuVersions => "libicu | " + string.Join(" | ",
+ "72;71;70;69;68;67;66;65;63;60;57;55;52"
+ .Split(";", StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => $"libicu{x}"));
+
+ ///
+ /// The supported versions of libssl for Debian
+ ///
+ private static string DebianLibSslVersions => "libssl3 | libssl1.1";
+
+ ///
+ /// The packages that are required for GUI builds
+ ///
+ private static readonly IReadOnlyList DebianGUIDepends = ["libice6", "libsm6", "libfontconfig1", DebianLibIcuVersions, DebianLibSslVersions];
+ ///
+ /// The packages that are required for CLI builds
+ ///
+ private static readonly IReadOnlyList DebianCLIDepends = [DebianLibIcuVersions, DebianLibSslVersions];
+
+ ///
+ /// The packages that are required for GUI builds
+ ///
+ private static readonly IReadOnlyList FedoraGUIDepends = ["libICE", "libSM", "fontconfig", "libicu"];
+ ///
+ /// The packages that are required for CLI builds
+ ///
+ private static readonly IReadOnlyList FedoraCLIDepends = ["libicu"];
+
+
+ ///
+ /// Structure for keeping all variables for a single release
+ ///
+ /// The version to use
+ /// The release channel
+ /// The release timestamp
+ private record ReleaseInfo(Version Version, ReleaseChannel Channel, DateTime Timestamp)
+ {
+ ///
+ /// Gets the string name for the release
+ ///
+ public string ReleaseName => $"{Version}_{Channel.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}";
+
+
+ ///
+ /// Create a new release info
+ ///
+ /// The release type
+ /// The incremental version
+ /// 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);
+ }
+
+ ///
+ /// Creates the build 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.",
+ 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 gitStashPushOption = new Option(
+ 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-builds",
+ 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-file",
+ description: "Path to the Duplicati.sln file",
+ getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.sln")))
+ );
+
+ 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 = SharedOptions.passwordOption;
+
+ 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 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 disableGpgSigningOption = new Option(
+ name: "--disable-gpg-signing",
+ description: "Disables GPG signing of packages",
+ getDefaultValue: () => false
+ );
+
+ var versionOverrideOption = new Option(
+ name: "--version",
+ description: "Sets a custom version to use",
+ getDefaultValue: () => null
+ );
+
+ var disableS3UploadOption = new Option(
+ name: "--disable-s3-upload",
+ description: "Disables uploading to S3",
+ getDefaultValue: () => false
+ );
+
+ var disableGithubUploadOption = new Option(
+ name: "--disable-github-upload",
+ description: "Disables uploading to Github",
+ getDefaultValue: () => false
+ );
+
+ var disableUpdateServerReloadOption = new Option(
+ name: "--disable-update-server-reload",
+ description: "Disables reloading the update server",
+ getDefaultValue: () => false
+ );
+
+ var disableDiscordAnnounceOption = new Option(
+ name: "--disable-discourse-announce",
+ description: "Disables posting to the forum",
+ getDefaultValue: () => false
+ );
+
+ var command = new System.CommandLine.Command("build", "Builds the packages for a release") {
+ gitStashPushOption,
+ releaseChannelArgument,
+ buildTempOption,
+ buildTargetOption,
+ solutionFileOption,
+ keepBuildsOption,
+ disableAuthenticodeOption,
+ disableCodeSignOption,
+ passwordOption,
+ macOsAppNameOption,
+ disableDockerPushOption,
+ dockerRepoOption,
+ changelogFileOption,
+ disableNotarizeSigningOption,
+ disableGpgSigningOption,
+ versionOverrideOption,
+ disableS3UploadOption,
+ disableGithubUploadOption,
+ disableUpdateServerReloadOption,
+ disableDiscordAnnounceOption
+ };
+
+ 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 version override to use
+ /// 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
+ /// The path to the changelog file
+ /// If notarize signing should be disabled
+ /// If GPG signing should be disabled
+ /// If S3 upload should be disabled
+ /// If Github upload should be disabled
+ /// If the update server should not be reloaded
+ /// If forum posting should be disabled
+ record CommandInput(
+ PackageTarget[] Targets,
+ DirectoryInfo BuildPath,
+ FileInfo SolutionFile,
+ bool GitStashPush,
+ ReleaseChannel Channel,
+ string? Version,
+ bool KeepBuilds,
+ bool DisableAuthenticode,
+ bool DisableSignCode,
+ string Password,
+ bool DisableDockerPush,
+ string MacOSAppName,
+ string DockerRepo,
+ FileInfo ChangelogFile,
+ bool DisableNotarizeSigning,
+ bool DisableGpgSigning,
+ bool DisableS3Upload,
+ bool DisableGithubUpload,
+ bool DisableUpdateServerReload,
+ bool DisableDiscordAnnounce
+ );
+
+ 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("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(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}");
+
+ var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables", "net8"), "*", SearchOption.TopDirectoryOnly)
+ .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
+ .ToList();
+
+ 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(primaryGUI);
+ sourceProjects.Remove(primaryCLI);
+ sourceProjects.Add(primaryCLI);
+ sourceProjects.Add(primaryGUI);
+
+ 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.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;
+ }
+
+ if (input.ChangelogFile.LastAccessTimeUtc < DateTime.UtcNow.AddDays(-1))
+ {
+ Console.WriteLine($"Changelog news file was last modified {input.ChangelogFile.LastAccessTimeUtc:yyyy-MM-dd}, indicating a stale file");
+ Console.WriteLine($"Please update the file with the changelog news for the release");
+ Program.ReturnCode = 1;
+ return;
+ }
+
+ var changelogNews = File.ReadAllText(input.ChangelogFile.FullName);
+
+ 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 = input.Password;
+ if (string.IsNullOrWhiteSpace(keyfilePassword))
+ keyfilePassword = EnvHelper.GetEnvKey("KEYFILE_PASSWORD", "");
+ if (string.IsNullOrWhiteSpace(keyfilePassword))
+ keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile 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,
+ changelogNews,
+ input);
+
+ rtcfg.ToggleAuthenticodeSigning();
+ rtcfg.ToggleSignCodeSigning();
+ rtcfg.ToggleNotarizeSigning();
+ rtcfg.ToggleGpgSigning();
+ rtcfg.ToggleS3Upload();
+ rtcfg.ToggleGithubUpload(rtcfg.ReleaseInfo.Channel);
+ rtcfg.ToogleDiscourseAnnounce(rtcfg.ReleaseInfo.Channel);
+ rtcfg.ToggleUpdateServerReload();
+ await rtcfg.ToggleDockerBuild();
+
+ 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($"The following packages cannot be built without Docker: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}");
+ }
+
+ if (!input.KeepBuilds && Directory.Exists(input.BuildPath.FullName))
+ {
+ Console.WriteLine($"Deleting build folder: {input.BuildPath.FullName}");
+ Directory.Delete(input.BuildPath.FullName, true);
+ }
+
+ if (!Directory.Exists(input.BuildPath.FullName))
+ Directory.CreateDirectory(input.BuildPath.FullName);
+
+ // Generally, the builds should happen with a clean source tree,
+ // but this can be disabled for debugging
+ if (input.GitStashPush)
+ 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, 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
+ var builtPackages = await CreatePackage.BuildPackages(baseDir, input.BuildPath.FullName, buildTargets, input.KeepBuilds, rtcfg);
+ var files = builtPackages.Select(x => x.CreatedFile).ToList();
+
+
+ // Build the signed manifest to be uploaded to remote storage
+ var manifestfile = Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest");
+ if (File.Exists(manifestfile) && new FileInfo(manifestfile).LastWriteTimeUtc > files.Max(x => new FileInfo(x).LastWriteTimeUtc))
+ {
+ Console.WriteLine("Manifest file already exists, skipping ...");
+ }
+ else
+ {
+ Console.WriteLine("Build completed, creating signed 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")
+ })
+ );
+ }
+
+ // Create the GPG signatures for the files
+ if (rtcfg.UseGPGSigning)
+ {
+ var sigfile = Path.Combine(input.BuildPath.FullName, "packages", $"duplicati-{releaseInfo.ReleaseName}.signatures.zip");
+ if (File.Exists(sigfile) && new FileInfo(sigfile).LastWriteTimeUtc > files.Max(x => new FileInfo(x).LastWriteTimeUtc))
+ {
+ Console.WriteLine("Signature file already exists, skipping ...");
+ }
+ else
+ {
+ Console.WriteLine("Creating GPG signatures ...");
+ await GpgSign.SignReleaseFiles(files, sigfile, rtcfg);
+ }
+
+ files.Add(sigfile);
+ }
+
+ if (rtcfg.UseS3Upload || rtcfg.UseGithubUpload)
+ {
+ Console.WriteLine("Build completed, uploading packages ...");
+ if (rtcfg.UseS3Upload)
+ {
+ var manifestNames = new[] { $"duplicati-{releaseInfo.ReleaseName}.manifest", "latest-v2.manifest" };
+
+ var packageJson = Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.json");
+ var packageJs = Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.js");
+ var content = Upload.CreatePackageJson(builtPackages, rtcfg);
+
+ File.WriteAllText(packageJson, content);
+ File.WriteAllText(packageJs, $"duplicati_installers = {content};");
+
+ var uploads = files.Select(x => new Upload.UploadFile(x, Path.GetFileName(x)))
+ .Concat(manifestNames.Select(x => new Upload.UploadFile(manifestfile, x)))
+ .Append(new Upload.UploadFile(packageJson, Path.GetFileName(packageJson)))
+ .Append(new Upload.UploadFile(packageJs, Path.GetFileName(packageJs)));
+
+ await Upload.UploadToS3(uploads, rtcfg);
+ }
+
+ if (rtcfg.UseGithubUpload)
+ await Upload.UploadToGithub(
+ files.Select(x => new Upload.UploadFile(x, Path.GetFileName(x))),
+ rtcfg
+ );
+ }
+
+ if (rtcfg.UseUpdateServerReload)
+ {
+ Console.WriteLine("Release completed, reloading update server ...");
+ await Upload.ReloadUpdateServer(rtcfg);
+ }
+
+ if (rtcfg.UseForumPosting)
+ {
+ Console.WriteLine("Release completed, posting release notes ...");
+ await Upload.PostToForum(rtcfg);
+ }
+
+ // 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/AutoUpdateSignKeys.txt",
+ }.Concat(revertableFiles.Select(x => Path.GetRelativePath(baseDir, x)))
+ , workingDirectory: baseDir);
+
+ if (input.GitStashPush)
+ {
+ await GitPush.TagAndPush(baseDir, releaseInfo);
+
+ // Contents are added to changelog, so we can remove the file
+ input.ChangelogFile.Delete();
+ }
+
+ Console.WriteLine("All done!");
+ }
+
+ ///
+ /// Injects the version number into some html files
+ ///
+ /// The base directory
+ /// The release info
+ /// The paths that were modified
+ private static IEnumerable 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"))
+ .ToList();
+
+ 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}")
+ );
+
+ var wixFile = Path.Combine(baseDir, "Installer", "Windows", "UpgradeData.wxi");
+ File.WriteAllText(
+ wixFile,
+ Regex.Replace(
+ File.ReadAllText(wixFile),
+ @"\<\?define ProductVersion\=\""" + versionre + @"\"" \?\>",
+ $""
+ )
+ );
+
+ targetfiles.Add(wixFile);
+
+ 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 runtime configuration
+ /// An awaitable task
+ static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg)
+ {
+ 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("${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"), urlstring);
+ File.WriteAllLines(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKeys.txt"), rtcfg.SignKeys.Select(x => x.ToXmlString(false)));
+
+ if (!string.IsNullOrWhiteSpace(rtcfg.ChangelogNews))
+ {
+ var current = File.ReadAllText(Path.Combine(baseDir, "changelog.txt"));
+ var previousEntry = current.IndexOf(rtcfg.ChangelogNews);
+ if (previousEntry >= 0 && previousEntry < 200)
+ {
+ Console.WriteLine("Note: release already in changelog, skipping ...");
+ }
+ else
+ {
+ File.WriteAllText(
+ Path.Combine(baseDir, "changelog.txt"),
+
+ string.Join(Environment.NewLine, [
+ $"{rtcfg.ReleaseInfo.Timestamp:yyy-MM-dd} - {rtcfg.ReleaseInfo.ReleaseName}",
+ "==========",
+ rtcfg.ChangelogNews.Trim(),
+ "",
+ current
+ ])
+ );
+ }
+ }
+
+ // 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
new file mode 100644
index 000000000..62c16ba07
--- /dev/null
+++ b/ReleaseBuilder/Configuration.cs
@@ -0,0 +1,351 @@
+namespace ReleaseBuilder;
+
+using static EnvHelper;
+
+///
+/// The release channels
+///
+public enum ReleaseChannel
+{
+ ///
+ /// 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,
+ ///
+ /// Debug builds
+ ///
+ Debug
+}
+
+///
+/// Represents the environment configuration
+///
+/// The configuration files
+/// The commands
+/// Extra settings
+public record Configuration(
+ ConfigFiles ConfigFiles,
+ Commands Commands,
+ ExtraSettings ExtraSettings
+)
+{
+ ///
+ /// Creates a new
+ ///
+ /// The new configuration
+ public static Configuration Create()
+ => new(
+ ConfigFiles.Create(),
+ Commands.Create(),
+ ExtraSettings.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) || string.IsNullOrWhiteSpace(Commands.Productsign))
+ return false;
+
+ 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
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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;
+ }
+
+ ///
+ /// Checks if AWS uploads are possible
+ ///
+ /// A boolean indicating if AWS uploading is possible
+ public bool IsAwsUploadPossible()
+ {
+ if (string.IsNullOrWhiteSpace(ConfigFiles.AwsUploadProfile) || string.IsNullOrWhiteSpace(ConfigFiles.AwsUploadBucket))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Checks if Github uploads are possible
+ ///
+ public bool IsGithubUploadPossible()
+ {
+ if (string.IsNullOrWhiteSpace(ConfigFiles.GithubTokenFile))
+ return false;
+
+ if (!File.Exists(ConfigFiles.GithubTokenFile))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Checks if update server reloads are possible
+ ///
+ /// A boolean indicating if update server reloads are possible
+ public bool IsUpdateServerReloadPossible()
+ {
+ if (string.IsNullOrWhiteSpace(ConfigFiles.ReloadUpdatesApiKey))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Checks if Discourse announcements are possible
+ ///
+ /// A boolean indicating if Discourse announcements are possible
+ public bool IsDiscourseAnnouncePossible()
+ {
+ if (string.IsNullOrWhiteSpace(ConfigFiles.DiscourseTokenFile))
+ return false;
+
+ if (!File.Exists(ConfigFiles.DiscourseTokenFile))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Checks if GPG signing is possible
+ ///
+ /// A boolean indicating if GPG signing is possible
+ public bool IsGpgPossible()
+ {
+ if (string.IsNullOrWhiteSpace(ConfigFiles.GpgKeyfile) || string.IsNullOrWhiteSpace(Commands.Gpg))
+ return false;
+
+ if (!File.Exists(ConfigFiles.GpgKeyfile))
+ 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;
+}
+
+///
+/// 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 profile to use for MacOS notarization
+/// The profile used by the aws-cli tool for uploads
+/// The S3 bucket to upload files to
+public record ConfigFiles(
+ string[] UpdaterKeyfile,
+ string GpgKeyfile,
+ string AuthenticodePfxFile,
+ string AuthenticodePasswordFile,
+ string GithubTokenFile,
+ string DiscourseTokenFile,
+ string CodesignIdentity,
+ string NotarizeProfile,
+ string AwsUploadProfile,
+ string AwsUploadBucket,
+ string ReloadUpdatesApiKey
+)
+{
+ ///
+ /// Parses an environment file and sets the environment variables,
+ /// similar to the `source` command in bash
+ ///
+ /// The path to the file
+ private static void ParseEnvironmentFile(string path)
+ {
+ if (File.Exists(path))
+ {
+ var kvp = File.ReadAllLines(path)
+ .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] });
+
+ foreach (var k in kvp)
+ Environment.SetEnvironmentVariable(k.Key, k.Value);
+ }
+ }
+
+ ///
+ /// Generates a new config files instance
+ ///
+ /// The config files instance
+
+ public static ConfigFiles Create()
+ {
+ ParseEnvironmentFile(ExpandEnv("BUILD_SETTINGS_FILE", "${HOME}/.config/build-settings"));
+ ParseEnvironmentFile(ExpandEnv("GATEKEEPER_SETTINGS_FILE", "${HOME}/.config/signkeys/Duplicati/macos-gatekeeper"));
+
+ return new(
+ 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_PROFILE", "duplicati-notarize"),
+ ExpandEnv("AWS_UPLOAD_PROFILE", "duplicati-upload"),
+ ExpandEnv("AWS_UPLOAD_BUCKET", "updates.duplicati.com"),
+ ExpandEnv("RELOAD_UPDATES_API_KEY", "")
+ );
+ }
+}
+
+///
+/// Configuration of commands used by the build script
+///
+/// The "build" command
+/// The "gpg" command
+/// The "aws" command
+/// The "osslsigncode" command
+/// The "codesign" command
+/// The "productsign" command
+/// The "wix" command
+/// The "docker" command
+public record Commands(
+ string Dotnet,
+ string? Gpg,
+ string? OsslSignCode,
+ string? Codesign,
+ string? Productsign,
+ string? Wix,
+ string? Docker
+)
+{
+ ///
+ /// 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(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("docker", "DOCKER")
+ );
+}
+
+///
+/// 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/ConsoleHelper.cs b/ReleaseBuilder/ConsoleHelper.cs
new file mode 100644
index 000000000..f8609b34c
--- /dev/null
+++ b/ReleaseBuilder/ConsoleHelper.cs
@@ -0,0 +1,63 @@
+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/CreateKey/Command.cs b/ReleaseBuilder/CreateKey/Command.cs
new file mode 100644
index 000000000..2412a4697
--- /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(1024).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/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..7af2c912e
--- /dev/null
+++ b/ReleaseBuilder/EnvHelper.cs
@@ -0,0 +1,249 @@
+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
+ );
+ }
+
+ ///
+ /// 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
+ ///
+ /// 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}");
+
+ 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, Path.Combine(targetPath, Path.GetRelativePath(sourceDir, f)), true);
+ else if (recursive && Directory.Exists(f))
+ {
+ 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
+ ///
+ /// 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..b51c8f983
--- /dev/null
+++ b/ReleaseBuilder/PackageTarget.cs
@@ -0,0 +1,248 @@
+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 Spk format
+ ///
+ SynologySpk
+}
+
+///
+/// The interface type
+///
+public enum InterfaceType
+{
+ ///
+ /// The GUI interface
+ ///
+ GUI,
+ ///
+ /// The commandline interface
+ ///
+ Cli
+}
+
+///
+/// Mapping of a package target
+///
+/// The operating system
+/// The CPU architecture
+/// The interface type
+/// The installer package
+public record PackageTarget(OSType OS, ArchType Arch, InterfaceType Interface, 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.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
+ ///
+ public string OSString => OSToString(OS);
+
+ ///
+ /// Gets the RID string for the CPU architecture
+ ///
+ public string ArchString => ArchToString(Arch);
+
+ ///
+ /// Gets the id string for the interface
+ ///
+ public string InterfaceString => InterfaceToString(Interface);
+
+ ///
+ /// 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);
+ ///
+ /// 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 => $"{BuildTargetString}.{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+)\.(?\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 (!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, interfaceType, package);
+ }
+}
\ No newline at end of file
diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs
new file mode 100644
index 000000000..76cccc1c5
--- /dev/null
+++ b/ReleaseBuilder/ProcessHelper.cs
@@ -0,0 +1,224 @@
+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
+ /// 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, Func? writeStdIn = 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 = !suppressStdErr,
+ RedirectStandardOutput = false,
+ RedirectStandardInput = writeStdIn != null,
+ UseShellExecute = false,
+ }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
+
+ // Forward error messages to stderr
+ var t = suppressStdErr
+ ? 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}");
+
+ 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
+ /// 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, bool suppressStdErr = false)
+ {
+ foreach (var c in commands)
+ await Execute(c, workingDirectory, cancellationToken, codeIsError, suppressStdErr).ConfigureAwait(false);
+ }
+
+ ///
+ /// 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
+ /// 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, Func? writeStdIn = 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 = !suppressStdErr,
+ RedirectStandardOutput = true,
+ RedirectStandardInput = writeStdIn != null,
+ UseShellExecute = false,
+ }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
+
+ var tstdout = p.StandardOutput.ReadToEndAsync(cancellationToken);
+ var tstderr = suppressStdErr
+ ? 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}");
+
+ 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
+ /// 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, Func? writeStdIn = 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 = !suppressStdErr,
+ RedirectStandardOutput = true,
+ RedirectStandardInput = writeStdIn != null,
+ UseShellExecute = false,
+ }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned");
+
+ var tstdout = p.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken);
+ var tstderr = suppressStdErr
+ ? 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}");
+
+ await tstderr.ConfigureAwait(false);
+ await tstdout.ConfigureAwait(false);
+ }
+
+ ///
+ /// 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
+ /// 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, Func? writeStdIn = 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 = writeStdIn != null,
+ 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);
+
+ 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}");
+
+ await t1.ConfigureAwait(false);
+ await t2.ConfigureAwait(false);
+ }
+}
diff --git a/ReleaseBuilder/ProcessRunner.cs b/ReleaseBuilder/ProcessRunner.cs
new file mode 100644
index 000000000..93a05179a
--- /dev/null
+++ b/ReleaseBuilder/ProcessRunner.cs
@@ -0,0 +1,95 @@
+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
+ ]);
+
+ ///
+ /// 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
new file mode 100644
index 000000000..56d7e69bf
--- /dev/null
+++ b/ReleaseBuilder/Program.cs
@@ -0,0 +1,72 @@
+
+using System.CommandLine;
+
+namespace ReleaseBuilder;
+
+///
+/// Entry point for the executable
+///
+class Program
+{
+ ///
+ /// The supported build packages
+ ///
+ public static readonly IReadOnlyList SupportedPackageTargets = new[] {
+ "win-x64-gui.zip",
+ "win-x64-gui.msi",
+ "win-x86-gui.zip",
+ "win-x86-gui.msi",
+ "win-arm64-gui.zip",
+ // "win-arm64-gui.msi", // Not supported by wixl
+ "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", // Need to add new integration with DSM WebUI
+ "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", // Need to add new integration with DSM WebUI
+ "linux-arm7-cli.docker",
+ "osx-x64-gui.dmg",
+ "osx-x64-gui.pkg",
+ "osx-arm64-gui.dmg",
+ "osx-arm64-gui.pkg",
+ }
+ .Select(x => PackageTarget.ParsePackageId(x))
+ .Distinct()
+ .ToList();
+
+ ///
+ /// The environment shared configuration
+ ///
+ public static readonly Configuration Configuration = Configuration.Create();
+
+ ///
+ /// The return code of the application; shared state
+ ///
+ public static int? ReturnCode = 0;
+
+ ///
+ /// Invokes the builder
+ ///
+ ///
+ ///
+ static async Task Main(string[] args)
+ {
+ var r = await new RootCommand("Build tool for Duplicati")
+ {
+ Build.Command.Create(),
+ CreateKey.Command.Create(),
+ }.InvokeAsync(args);
+
+ return ReturnCode ?? r;
+ }
+}
\ No newline at end of file
diff --git a/ReleaseBuilder/README.md b/ReleaseBuilder/README.md
new file mode 100644
index 000000000..39a6ef534
--- /dev/null
+++ b/ReleaseBuilder/README.md
@@ -0,0 +1,70 @@
+# Release Builder tool
+
+This folder contains the `ReleaseBuilder` tool which builds installers and packages for all supported operating systems.
+
+The tool is tested and used only on MacOS, but can be configured for other operating systems, including WSL and to some extent Windows.
+
+The setup is a mix of environment variables, settings files and commandline arguments.
+
+# Required environment
+
+The environment variables used by the tool are:
+
+- `UPDATER_KEYFILE`: The file containing the key used to sign the manifest. Create one with the `create-key` command.
+- `GPG_KEYFILE`: A file encrypted with AESCrypt and the general password. The file has two lines: `GPGID` and `GPGPassphrase` which are given to the `GPG` command.
+- `AUTHENTICODE_PFXFILE`: A PFX file containing a signing key for Authenticode signing of Windows executables (needs to be purchased at a certificate vendor).
+- `AUTHENTICODE_PASSWORD`: A file encrypted with AESCrypt and the general password. The file contains a single line, which is the password used for `osslsigncode`
+- `GITHUB_TOKEN_FILE`: A personal access token for Github API used to create releases and upload packages to Github.
+- `DISCOURSE_TOKEN_FILE`: A personal access token for Discourse, used to create release posts.
+- `CODESIGN_IDENTITY`: The identity used for MacOS `codesign` when signing the binary packages.
+- `NOTARIZE_PROFILE`: The name of the profile used for notarizing MacOS packages.
+- `AWS_UPLOAD_PROFILE`: The name of the AWS profile (from `aws-cli`) used to upload packages.
+- `AWS_UPLOAD_BUCKET`: The name of the bucket where the package are uploaded to.
+- `RELOAD_UPDATES_API_KEY`: A key used to refresh the manifest files being cached by the update servers.
+
+# Tools used
+
+These tools are used by the build process, each tool can be changed by using the environment variable name:
+
+- `DOTNET=dotnet`: Mandatory. Used to build the binary contents that will be packaged.
+- `GPG=gpg`: Optional. Used to created signed release files.
+- `SIGNTOOL=osslsigncode`: Optional. Used to make Authenticode signing of Windows executables (using `SIGNTOOL=signtool.exe` on Windows)
+- `CODESIGN=codesign`: Optional. Used to sign MacOS binaries and packages.
+- `PRODUCTSIGN=productsign`: Optional. Used to sign MacOS packages.
+- `WIX=wixl`: Optional. Required to build the Windows MSI packages.
+- `DOCKER=docker`: Optional. Required to build some Linux packages.
+
+# Invoking the tool
+
+The tool has commandline documentation, which can be invoked with `dotnet run help`.
+
+The two commands that are currently implemented are:
+
+- `dotnet run create-key`
+- `dotnet run build `
+
+The `create-key` command creates a RSA 1024 bit signing, and encrypts it with the given password.
+
+The `build` command creates the packages. An example command that builds all packages would be:
+
+```
+dotnet build debug --version 1.0.0.1
+```
+
+To build only a single platform relase, disable all the features and choose the architectures:
+
+```
+dotnet build debug --version 1.0.0.1 \
+ --disable-docker-push true \
+ --keep-builds true \
+ --disable-authenticode true \
+ --disable-signcode true \
+ --disable-notarize-signing true \
+ --disable-gpg-signing true \
+ --disable-s3-upload true \
+ --disable-github-upload true \
+ --disable-update-server-reload true \
+ --disable-discourse-announce true \
+ --targets win-x86-gui.zip \
+ --targets win-x64-gui.zip \
+```
diff --git a/ReleaseBuilder/ReleaseBuilder.csproj b/ReleaseBuilder/ReleaseBuilder.csproj
new file mode 100644
index 000000000..cc5019a30
--- /dev/null
+++ b/ReleaseBuilder/ReleaseBuilder.csproj
@@ -0,0 +1,21 @@
+
+
+
+ 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
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/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
diff --git a/ReleaseBuilder/testfile.key b/ReleaseBuilder/testfile.key
new file mode 100644
index 000000000..7d020abee
Binary files /dev/null and b/ReleaseBuilder/testfile.key differ
diff --git a/ReleaseBuilder/testfile.key2 b/ReleaseBuilder/testfile.key2
new file mode 100644
index 000000000..35e594343
Binary files /dev/null and b/ReleaseBuilder/testfile.key2 differ
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/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/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/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
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
diff --git a/guiTests/guiTest.py b/guiTests/guiTest.py
index 552f3996a..8573de790 100644
--- a/guiTests/guiTest.py
+++ b/guiTests/guiTest.py
@@ -24,13 +24,41 @@ parser.add_argument(
parser.set_defaults(headless=True)
cmdopt = parser.parse_args()
-# local
-print("Using LOCAL webdriver")
-profile = webdriver.FirefoxProfile()
-profile.set_preference("intl.accept_languages", "en")
-options = Options()
-options.headless = True
-driver = webdriver.Firefox(profile, options=options)
+if "TRAVIS_BUILD_NUMBER" in os.environ:
+ from selenium.webdriver.firefox.options import Options
+ if "SAUCE_USERNAME" not in os.environ:
+ print("No sauce labs login credentials found. Stopping tests...")
+ sys.exit(0)
+
+ capabilities = {'browserName': "firefox"}
+ capabilities['platform'] = "Windows 7"
+ capabilities['version'] = "48.0"
+ capabilities['screenResolution'] = "1280x1024"
+ capabilities["build"] = os.environ["TRAVIS_BUILD_NUMBER"]
+ capabilities["tunnel-identifier"] = os.environ["TRAVIS_JOB_NUMBER"]
+
+ # connect to sauce labs
+ username = os.environ["SAUCE_USERNAME"]
+ access_key = os.environ["SAUCE_ACCESS_KEY"]
+ hub_url = "%s:%s@localhost:4445" % (username, access_key)
+ driver = webdriver.Remote(command_executor="http://%s/wd/hub" % hub_url, desired_capabilities=capabilities)
+elif cmdopt.use_chrome:
+ print("using LOCAL Chrome webdriver")
+ from selenium.webdriver.chrome.options import Options
+ import chromedriver_autoinstaller
+ chromedriver_autoinstaller.install()
+ chr_opt = Options()
+ opt = ["--ignore-certificate-errors", "--window-size=1280,800" ]
+ if cmdopt.headless: opt += ["--headless"]
+ for o in opt: chr_opt.add_argument(o)
+ driver = webdriver.Chrome(options=chr_opt)
+else:
+ from selenium.webdriver.firefox.options import Options
+ print("Using LOCAL Firefox webdriver")
+ options = Options()
+ options.set_preference("intl.accept_languages", "en")
+ options.headless = cmdopt.headless
+ driver = webdriver.Firefox(options=options)
def write_random_file(size, filename):
if not os.path.exists(os.path.dirname(filename)):
diff --git a/pipeline/selenium/docker/Dockerfile b/pipeline/selenium/docker/Dockerfile
index 8d9b72f8f..5b80ef02f 100644
--- a/pipeline/selenium/docker/Dockerfile
+++ b/pipeline/selenium/docker/Dockerfile
@@ -1,10 +1,15 @@
-FROM selenium/standalone-firefox
+FROM selenium/standalone-chrome
+SHELL ["/bin/bash", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
-RUN wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O ~/packages-microsoft-prod.deb
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
+ENV AUTOUPDATER_Duplicati_SKIP_UPDATE=1
+RUN source /etc/os-release && wget https://packages.microsoft.com/config/$ID/$VERSION_ID/packages-microsoft-prod.deb -O ~/packages-microsoft-prod.deb
RUN sudo dpkg -i ~/packages-microsoft-prod.deb
-RUN sudo apt-get update && sudo apt-get install -y python3-pip dotnet-sdk-6.0
-RUN pip3 install selenium
+RUN sudo apt update && sudo apt install -y python3-pip dotnet-sdk-8.0
+RUN sudo pip3 install selenium
+RUN sudo pip3 install --upgrade urllib3
+RUN sudo pip3 install chromedriver-autoinstaller
ADD runner.sh /
CMD /runner.sh
\ No newline at end of file
diff --git a/pipeline/selenium/docker/runner.sh b/pipeline/selenium/docker/runner.sh
index bb6cd50ff..9e3607545 100755
--- a/pipeline/selenium/docker/runner.sh
+++ b/pipeline/selenium/docker/runner.sh
@@ -1,9 +1,9 @@
mkdir /home/seluser/published/
chmod +S /home/seluser/published/
+export DOTNET_CLI_TELEMETRY_OPTOUT=1
sudo dotnet publish -o /home/seluser/published/ /sources/Duplicati.sln
-cd
-AUTOUPDATER_Duplicati_SKIP_UPDATE=1 "/home/seluser/published/Duplicati.Server" &
+sudo /home/seluser/published/Duplicati.Server &
timeout 240 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/$0/$1; do sleep 1; echo Checking if server started...; done' 127.0.0.1 8200
echo Running Tests...
-python3 /sources/guiTests/guiTest.py
\ No newline at end of file
+sudo python3 /sources/guiTests/guiTest.py --headless --use-chrome
\ No newline at end of file
diff --git a/thirdparty/alphafs/AlphaFS.dll b/thirdparty/alphafs/AlphaFS.dll
deleted file mode 100644
index 6b94121b9..000000000
Binary files a/thirdparty/alphafs/AlphaFS.dll and /dev/null differ
diff --git a/thirdparty/alphafs/build-info.txt b/thirdparty/alphafs/build-info.txt
deleted file mode 100644
index 2d4317aba..000000000
--- a/thirdparty/alphafs/build-info.txt
+++ /dev/null
@@ -1 +0,0 @@
-The build here is taken from 38970857fecc00501431197832fee257f30496f7, and only the netstandard2.0 library is used.
\ No newline at end of file
diff --git a/thirdparty/alphavss/AlphaVSS.Common.dll b/thirdparty/alphavss/AlphaVSS.Common.dll
deleted file mode 100644
index 99f64d52f..000000000
Binary files a/thirdparty/alphavss/AlphaVSS.Common.dll and /dev/null differ
diff --git a/thirdparty/alphavss/build-diff.patch b/thirdparty/alphavss/build-diff.patch
deleted file mode 100644
index d1fec89fd..000000000
--- a/thirdparty/alphavss/build-diff.patch
+++ /dev/null
@@ -1,87 +0,0 @@
-diff --git a/src/AlphaVSS.Common/AlphaVSS.Common.csproj b/src/AlphaVSS.Common/AlphaVSS.Common.csproj
-index 4ad0e8f..4039061 100644
---- a/src/AlphaVSS.Common/AlphaVSS.Common.csproj
-+++ b/src/AlphaVSS.Common/AlphaVSS.Common.csproj
-@@ -1,7 +1,7 @@
-
-
-
-- net45;netcoreapp3.1
-+ netstandard2.0
- false
-
-
-@@ -34,6 +34,7 @@
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-+
-
-
-
-diff --git a/src/AlphaVSS.Common/Classes/VssFactoryProvider.cs b/src/AlphaVSS.Common/Classes/VssFactoryProvider.cs
-index be2438a..8aae1f9 100644
---- a/src/AlphaVSS.Common/Classes/VssFactoryProvider.cs
-+++ b/src/AlphaVSS.Common/Classes/VssFactoryProvider.cs
-@@ -79,7 +79,7 @@ namespace Alphaleonis.Win32.Vss
- if (assemblyName == null)
- throw new ArgumentNullException(nameof(assemblyName), $"{nameof(assemblyName)} is null.");
-
--#if NETCOREAPP
-+// #if NETCOREAPP || NETSTANDARD
- Log($"Attempting to locate \"{assemblyName.FullName}\"");
- // In .NET Core the native binaries are placed under a runtimes\{rid}\native folder, which is where the platform
- // specific binaries are placed in AlphaVSS nuget packages. However the location after building is different if
-@@ -99,7 +99,7 @@ namespace Alphaleonis.Win32.Vss
- {
- // On non-windows systems, the ':' separator is used, but since we only run on windows we don't need to
- // take that into consideration.
-- searchDirectories = nativeSearchDirectories.Split(';', StringSplitOptions.RemoveEmptyEntries);
-+ searchDirectories = nativeSearchDirectories.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
- Log($"Using native search directories: {String.Join(", ", searchDirectories.Select(s => $"\"{s}\""))}");
- }
-
-@@ -116,7 +116,7 @@ namespace Alphaleonis.Win32.Vss
- try
- {
- Log($"Loading \"{fullAssemblyFileName}\".");
-- var result = AssemblyLoadContext.Default.LoadFromAssemblyPath(fullAssemblyFileName);
-+ var result = System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyPath(fullAssemblyFileName);
- Log($"Loaded \"{result.FullName}\" from \"{result.Location}\".");
- return result;
- }
-@@ -133,20 +133,20 @@ namespace Alphaleonis.Win32.Vss
- }
-
- throw new FileNotFoundException($"Failed to find assembly \"{assemblyName.FullName}\"");
--#elif NETFRAMEWORK
-- Log($"Attempting to load assembly \"{assemblyName.FullName}\"");
-- try
-- {
-- var result = Assembly.Load(assemblyName);
-- Log($"Loaded \"{result.FullName}\" from \"{result.Location}\".");
-- return result;
-- }
-- catch (Exception ex)
-- {
-- Log($"Error loading \"{assemblyName.FullName}\"; {ex}");
-- throw;
-- }
--#endif
-+// #elif NETFRAMEWORK
-+// Log($"Attempting to load assembly \"{assemblyName.FullName}\"");
-+// try
-+// {
-+// var result = Assembly.Load(assemblyName);
-+// Log($"Loaded \"{result.FullName}\" from \"{result.Location}\".");
-+// return result;
-+// }
-+// catch (Exception ex)
-+// {
-+// Log($"Error loading \"{assemblyName.FullName}\"; {ex}");
-+// throw;
-+// }
-+// #endif
- }
- }
- }
diff --git a/thirdparty/alphavss/build-info.txt b/thirdparty/alphavss/build-info.txt
deleted file mode 100644
index b1049e90c..000000000
--- a/thirdparty/alphavss/build-info.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-The build here is taken from 13462e657f7993da5c80f835d219bbe82079ce75, and only the netstandard2.0 library is used.
-The diff shows the changes to the source code.
-
-The native libraries are extracted from the NuGet package:
-https://www.nuget.org/packages/AlphaVSS.Native.NetCore/2.0.0
diff --git a/thirdparty/alphavss/runtimes/win-x64/AlphaVSS.x64.dll b/thirdparty/alphavss/runtimes/win-x64/AlphaVSS.x64.dll
deleted file mode 100644
index a4874cff7..000000000
Binary files a/thirdparty/alphavss/runtimes/win-x64/AlphaVSS.x64.dll and /dev/null differ
diff --git a/thirdparty/alphavss/runtimes/win-x64/Ijwhost.dll b/thirdparty/alphavss/runtimes/win-x64/Ijwhost.dll
deleted file mode 100644
index 0909a28a4..000000000
Binary files a/thirdparty/alphavss/runtimes/win-x64/Ijwhost.dll and /dev/null differ
diff --git a/thirdparty/alphavss/runtimes/win-x86/AlphaVSS.x86.dll b/thirdparty/alphavss/runtimes/win-x86/AlphaVSS.x86.dll
deleted file mode 100644
index f9261fad6..000000000
Binary files a/thirdparty/alphavss/runtimes/win-x86/AlphaVSS.x86.dll and /dev/null differ
diff --git a/thirdparty/alphavss/runtimes/win-x86/Ijwhost.dll b/thirdparty/alphavss/runtimes/win-x86/Ijwhost.dll
deleted file mode 100644
index 5332ff6d6..000000000
Binary files a/thirdparty/alphavss/runtimes/win-x86/Ijwhost.dll and /dev/null differ