Merge pull request #6710 from duplicati/feature/add-appimage-builds

Added support for building AppImage
This commit is contained in:
Kenneth Skovhede
2026-01-21 14:25:41 +01:00
committed by GitHub
9 changed files with 450 additions and 123 deletions
+3
View File
@@ -31,6 +31,9 @@
"--targets", "win-arm64-agent.msi",
"--targets", "linux-x64-gui.zip",
"--targets", "linux-arm64-gui.zip",
"--targets", "linux-x64-gui.appimage",
"--targets", "linux-arm64-gui.appimage",
"--targets", "linux-arm7-gui.appimage",
"--targets", "osx-x64-gui.dmg",
"--targets", "osx-arm64-gui.dmg",
"--targets", "osx-x64-gui.pkg",
@@ -39,6 +39,40 @@ public static partial class Command
/// <param name="CreatedFile">The created package file path</param>
public record BuiltPackage(PackageTarget Target, string CreatedFile);
/// <summary>
/// Generates the metainfo content with the releases block injected
/// </summary>
/// <param name="baseDir">The base directory</param>
/// <param name="metainfoPath">The path to the source metainfo file</param>
/// <returns>The modified metainfo content</returns>
public static string GetMetainfoWithReleases(string baseDir, string metainfoPath)
{
var versionPath = Path.Combine(baseDir, "ReleaseBuilder", "build_version.txt");
var changelogPath = Path.Combine(baseDir, "ReleaseBuilder", "changelog-news.txt");
var version = File.ReadAllText(versionPath).Trim();
var changelog = File.ReadAllText(changelogPath).Trim();
var metainfo = File.ReadAllText(metainfoPath);
var sb = new StringBuilder();
sb.AppendLine(" <releases>");
sb.AppendLine($" <release version=\"{version}\" date=\"{DateTime.UtcNow:yyyy-MM-dd}\">");
sb.AppendLine(" <description>");
sb.AppendLine($" <p>{System.Security.SecurityElement.Escape(changelog)}</p>");
sb.AppendLine(" </description>");
sb.AppendLine(" </release>");
sb.AppendLine(" </releases>");
// Insert before </component>
var insertIndex = metainfo.LastIndexOf("</component>");
if (insertIndex >= 0)
{
metainfo = metainfo.Insert(insertIndex, sb.ToString());
}
return metainfo;
}
/// <summary>
/// Builds the packages for the specified build targets.
/// </summary>
@@ -140,6 +174,10 @@ public static partial class Command
await BuildZipPackage(Path.Combine(buildRoot, target.BuildTargetString), $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.BuildTargetString}", tempFile, target, rtcfg);
break;
case PackageType.AppImage:
await BuildAppImagePackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.MSI:
await BuildMsiPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
@@ -253,6 +291,170 @@ public static partial class Command
}
}
/// <summary>
/// Builds an AppImage package asynchronously.
/// </summary>
/// <param name="baseDir">The base directory.</param>
/// <param name="buildRoot">The build root directory.</param>
/// <param name="appImageFile">The AppImage file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
static async Task BuildAppImagePackage(string baseDir, string buildRoot, string appImageFile, PackageTarget target, RuntimeConfig rtcfg)
{
if (target.OS != OSType.Linux)
throw new Exception($"AppImage is only supported for Linux targets, got: {target.OS}");
if (target.Interface != InterfaceType.GUI)
throw new Exception($"AppImage is only supported for GUI interface, got: {target.Interface}");
var tmpRoot = Path.Combine(buildRoot, "tmp-appimage");
if (Directory.Exists(tmpRoot))
Directory.Delete(tmpRoot, true);
Directory.CreateDirectory(tmpRoot);
var appDir = Path.Combine(tmpRoot, "AppDir");
var appDirUsr = Path.Combine(appDir, "usr");
var appDirLib = Path.Combine(appDirUsr, "lib", "duplicati");
var appDirBin = Path.Combine(appDirUsr, "bin");
Directory.CreateDirectory(appDirLib);
Directory.CreateDirectory(appDirBin);
EnvHelper.CopyDirectory(
Path.Combine(buildRoot, target.BuildTargetString),
appDirLib,
recursive: true);
await PackageSupport.InstallPackageIdentifier(appDirLib, target);
await PackageSupport.RenameExecutables(appDirLib);
await PackageSupport.SetPermissionFlags(appDirLib, rtcfg);
var availableExecutables = ExecutableRenames.Values
.Where(x => File.Exists(Path.Combine(appDirLib, x)))
.ToList();
if (!availableExecutables.Any())
throw new Exception("No executables found in AppImage payload");
if (OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("AppImage packaging is not supported on Windows, use WSL or Docker");
foreach (var executable in availableExecutables)
{
var linkPath = Path.Combine(appDirBin, executable);
await ProcessHelper.Execute([
"ln", "-s",
Path.Combine("..", "lib", "duplicati", executable),
linkPath
]);
}
var sharedDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "shared");
var appImageResourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "appimage");
File.Copy(
Path.Combine(sharedDir, "desktop", "duplicati.desktop"),
Path.Combine(appDir, "com.duplicati.app.desktop"),
true
);
var applicationsDir = Path.Combine(appDirUsr, "share", "applications");
Directory.CreateDirectory(applicationsDir);
File.Copy(
Path.Combine(sharedDir, "desktop", "duplicati.desktop"),
Path.Combine(applicationsDir, "com.duplicati.app.desktop"),
true
);
var metainfoDir = Path.Combine(appDirUsr, "share", "metainfo");
Directory.CreateDirectory(metainfoDir);
// Use the correct reverse-DNS filename for metadata and desktop file to satisfy appstreamcli
var metainfoSrc = Path.Combine(sharedDir, "metainfo", "com.duplicati.app.metainfo.xml");
var metainfoDest = Path.Combine(metainfoDir, "com.duplicati.app.appdata.xml");
var metainfoContent = GetMetainfoWithReleases(baseDir, metainfoSrc);
// Update launchable to match new desktop filename
metainfoContent = metainfoContent.Replace("duplicati.desktop", "com.duplicati.app.desktop");
File.WriteAllText(metainfoDest, metainfoContent);
if (OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Creating unresolved symlinks is not supported on Windows, use WSL or Docker");
var iconSource = Path.Combine(sharedDir, "pixmaps", "duplicati.png");
File.Copy(iconSource, Path.Combine(appDir, "duplicati.png"), true);
File.Copy(iconSource, Path.Combine(appDir, ".DirIcon"), true);
var defaultExecutable = availableExecutables.Contains("duplicati")
? "duplicati"
: availableExecutables.First();
var appRunTemplate = File.ReadAllText(Path.Combine(appImageResourcesDir, "AppRun.template"));
var appRunPayload = appRunTemplate
.Replace("%EXECUTABLE_LIST%", string.Join("\n", availableExecutables.Select(x => $" echo \" - {x}\"")))
.Replace("%INVOCATION_CASES%", string.Join("\n", availableExecutables.Select(x => $" {x}) exec \"${{HERE}}/usr/bin/{x}\" \"$@\" ;;")))
.Replace("%FLAG_CASES%", string.Join("\n", availableExecutables.Select(x =>
$" --{x})\n shift\n exec \"${{HERE}}/usr/bin/{x}\" \"$@\"\n ;;")))
.Replace("%DEFAULT_EXECUTABLE%", defaultExecutable);
File.WriteAllText(Path.Combine(appDir, "AppRun"), appRunPayload);
if (!OperatingSystem.IsWindows())
{
var filemode755 = UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute;
File.SetUnixFileMode(Path.Combine(appDir, "AppRun"), filemode755);
}
File.Copy(
Path.Combine(appImageResourcesDir, "Dockerfile.build"),
Path.Combine(tmpRoot, "Dockerfile"),
true
);
await ProcessHelper.Execute([
"docker", "build",
"-t", "duplicati/appimage-build:latest",
tmpRoot
], workingDirectory: tmpRoot);
var appImageArch = target.Arch switch
{
ArchType.x64 => "x86_64",
ArchType.Arm64 => "aarch64",
ArchType.Arm7 => "armhf",
_ => throw new Exception($"Unsupported AppImage architecture: {target.Arch}")
};
var appImageName = Path.GetFileName(appImageFile);
var appImageTempPath = Path.Combine(tmpRoot, appImageName);
if (File.Exists(appImageTempPath))
File.Delete(appImageTempPath);
await ProcessHelper.Execute([
"docker", "run",
"--workdir", "/build",
"--volume", $"{tmpRoot}:/build:rw",
"-e", "APPIMAGE_EXTRACT_AND_RUN=1",
"-e", $"ARCH={appImageArch}",
"duplicati/appimage-build:latest",
"/usr/local/bin/appimagetool",
"/build/AppDir",
$"/build/{appImageName}"
]);
if (File.Exists(appImageFile))
File.Delete(appImageFile);
File.Move(appImageTempPath, appImageFile);
Directory.Delete(tmpRoot, true);
}
/// <summary>
/// Builds an MSI package asynchronously.
/// </summary>
@@ -904,6 +1106,11 @@ public static partial class Command
Path.Combine(pkgroot, "usr", "share", "applications", "duplicati.desktop")
));
var metainfoDest = Path.Combine(pkgroot, "usr", "share", "metainfo", "com.duplicati.app.metainfo.xml");
var metainfoDir = Path.GetDirectoryName(metainfoDest)!;
if (!Directory.Exists(metainfoDir)) Directory.CreateDirectory(metainfoDir);
File.WriteAllText(metainfoDest, GetMetainfoWithReleases(baseDir, Path.Combine(sharedDir, "metainfo", "com.duplicati.app.metainfo.xml")));
supportFiles.AddRange(
new[] { "duplicati.png", "duplicati.svg", "duplicati.xpm" }
.Select(f => (
@@ -1211,6 +1418,7 @@ public static partial class Command
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.WriteAllText(Path.Combine(sources, "com.duplicati.app.metainfo.xml"), CreatePackage.GetMetainfoWithReleases(baseDir, Path.Combine(sharedDir, "metainfo", "com.duplicati.app.metainfo.xml")));
File.Copy(Path.Combine(resourcesDir, "duplicati-install-recursive.sh"), Path.Combine(sources, "duplicati-install-recursive.sh"));
if (target.Interface != InterfaceType.Agent)
{
+11 -1
View File
@@ -83,6 +83,16 @@ public static partial class Command
"Duplicati.WindowsService.csproj"
};
/// <summary>
/// The packages that require Docker to build
/// </summary>
private static readonly IReadOnlySet<PackageType> DockerDependingTargets = new HashSet<PackageType> {
PackageType.Docker,
PackageType.Deb,
PackageType.RPM,
PackageType.AppImage
};
/// <summary>
/// Some executables have shorter names that follow the Linux convention of all-lowercase
/// </summary>
@@ -596,7 +606,7 @@ public static partial class Command
if (!rtcfg.UseDockerBuild)
{
var unsupportedBuilds = buildTargets.Where(x => x.Package == PackageType.Docker || x.Package == PackageType.Deb || x.Package == PackageType.RPM).ToList();
var unsupportedBuilds = buildTargets.Where(x => DockerDependingTargets.Contains(x.Package)).ToList();
if (unsupportedBuilds.Any())
throw new Exception($"The following packages cannot be built without Docker: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}");
}
+5
View File
@@ -74,6 +74,10 @@ public enum PackageType
/// </summary>
Zip,
/// <summary>
/// The AppImage package
/// </summary>
AppImage,
/// <summary>
/// The Windows installer format
/// </summary>
MSI,
@@ -171,6 +175,7 @@ public record PackageTarget(OSType OS, ArchType Arch, InterfaceType Interface, P
=> package switch
{
PackageType.Zip => "zip",
PackageType.AppImage => "appimage",
PackageType.MSI => "msi",
PackageType.Deb => "deb",
PackageType.RPM => "rpm",
+124 -121
View File
@@ -1,122 +1,125 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
namespace ReleaseBuilder;
/// <summary>
/// Entry point for the executable
/// </summary>
class Program
{
/// <summary>
/// The supported build packages
/// </summary>
public static readonly IReadOnlyList<PackageTarget> 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",
"win-x64-agent.msi",
"win-arm64-agent.msi",
"win-x64-agent.zip",
"win-arm64-agent.zip",
"linux-x64-gui.zip",
"linux-x64-gui.deb",
"linux-x64-gui.rpm",
"linux-x64-cli.zip",
"linux-x64-cli.deb",
"linux-x64-cli.rpm",
"linux-x64-cli.docker",
"linux-x64-cli.spk",
"linux-x64-agent.zip",
"linux-x64-agent.deb",
"linux-x64-agent.rpm",
"linux-x64-agent.docker",
"linux-arm7-gui.zip",
"linux-arm7-gui.deb",
"linux-arm7-cli.zip",
"linux-arm7-cli.deb",
"linux-arm7-cli.docker",
"linux-arm7-cli.spk",
"linux-arm7-agent.zip",
"linux-arm7-agent.deb",
"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.docker",
"linux-arm64-cli.spk",
"linux-arm64-agent.zip",
"linux-arm64-agent.deb",
"linux-arm64-agent.rpm",
"linux-arm64-agent.docker",
"osx-x64-gui.dmg",
"osx-x64-gui.pkg",
"osx-x64-agent.pkg",
"osx-x64-cli.pkg",
"osx-arm64-gui.dmg",
"osx-arm64-gui.pkg",
"osx-arm64-agent.pkg",
"osx-arm64-cli.pkg",
}
.Select(x => PackageTarget.ParsePackageId(x))
.Distinct()
.ToList();
/// <summary>
/// The return code of the application; shared state
/// </summary>
public static int? ReturnCode = 0;
/// <summary>
/// Invokes the builder
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
static async Task<int> Main(string[] args)
{
try
{
ReturnCode = await new RootCommand("Build tool for Duplicati")
{
Build.Command.Create(),
CreateKey.Command.Create(),
}.InvokeAsync(args);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
ReturnCode = 1;
}
return ReturnCode ?? 2;
}
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
namespace ReleaseBuilder;
/// <summary>
/// Entry point for the executable
/// </summary>
class Program
{
/// <summary>
/// The supported build packages
/// </summary>
public static readonly IReadOnlyList<PackageTarget> 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",
"win-x64-agent.msi",
"win-arm64-agent.msi",
"win-x64-agent.zip",
"win-arm64-agent.zip",
"linux-x64-gui.zip",
"linux-x64-gui.deb",
"linux-x64-gui.rpm",
"linux-x64-gui.appimage",
"linux-x64-cli.zip",
"linux-x64-cli.deb",
"linux-x64-cli.rpm",
"linux-x64-cli.docker",
"linux-x64-cli.spk",
"linux-x64-agent.zip",
"linux-x64-agent.deb",
"linux-x64-agent.rpm",
"linux-x64-agent.docker",
"linux-arm7-gui.zip",
"linux-arm7-gui.deb",
"linux-arm7-gui.appimage",
"linux-arm7-cli.zip",
"linux-arm7-cli.deb",
"linux-arm7-cli.docker",
"linux-arm7-cli.spk",
"linux-arm7-agent.zip",
"linux-arm7-agent.deb",
"linux-arm64-gui.zip",
"linux-arm64-gui.deb",
"linux-arm64-gui.rpm",
"linux-arm64-gui.appimage",
"linux-arm64-cli.zip",
"linux-arm64-cli.deb",
"linux-arm64-cli.rpm",
"linux-arm64-cli.docker",
"linux-arm64-cli.spk",
"linux-arm64-agent.zip",
"linux-arm64-agent.deb",
"linux-arm64-agent.rpm",
"linux-arm64-agent.docker",
"osx-x64-gui.dmg",
"osx-x64-gui.pkg",
"osx-x64-agent.pkg",
"osx-x64-cli.pkg",
"osx-arm64-gui.dmg",
"osx-arm64-gui.pkg",
"osx-arm64-agent.pkg",
"osx-arm64-cli.pkg",
}
.Select(x => PackageTarget.ParsePackageId(x))
.Distinct()
.ToList();
/// <summary>
/// The return code of the application; shared state
/// </summary>
public static int? ReturnCode = 0;
/// <summary>
/// Invokes the builder
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
static async Task<int> Main(string[] args)
{
try
{
ReturnCode = await new RootCommand("Build tool for Duplicati")
{
Build.Command.Create(),
CreateKey.Command.Create(),
}.InvokeAsync(args);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
ReturnCode = 1;
}
return ReturnCode ?? 2;
}
}
@@ -0,0 +1,30 @@
#!/bin/bash
set -e
HERE="$(dirname "$(readlink -f "${0}")")"
export PATH="${HERE}/usr/bin/:${PATH}"
show_help() {
echo "Usage: $(basename "${ARGV0:-$0}") [--<executable>] [args]"
echo
echo "Available executables:"
%EXECUTABLE_LIST%
echo
echo "You can invoke the AppImage via a symlink name or with --<executable>."
}
if [[ "${1}" == "--help" || "${1}" == "-h" ]]; then
show_help
exit 0
fi
INVOCATION_NAME="$(basename "${ARGV0:-$0}")"
case "$INVOCATION_NAME" in
%INVOCATION_CASES%
esac
case "$1" in
%FLAG_CASES%
esac
exec "${HERE}/usr/bin/%DEFAULT_EXECUTABLE%" "$@"
@@ -0,0 +1,28 @@
# We remove the hardcoded platform here so it builds for the host's native arch by default
FROM ubuntu:latest
# Use the automatic Buildx variable
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
appstream \
ca-certificates \
curl \
file \
gnupg \
libglib2.0-0 \
squashfs-tools \
&& rm -rf /var/lib/apt/lists/*
RUN case "${TARGETARCH}" in \
"amd64") ARCH="x86_64" ;; \
"arm64") ARCH="aarch64" ;; \
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \
esac \
&& curl -L -o /usr/local/bin/appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" \
&& chmod +x /usr/local/bin/appimagetool
# Necessary for AppImages to run in many container environments
ENV APPIMAGE_EXTRACT_AND_RUN=1
@@ -1,5 +1,5 @@
[Desktop Entry]
Categories=System;Archiving;FileTools;Filesystem;
Categories=System;Archiving;FileTools;
Type=Application
Name=Duplicati
GenericName= Backup tool
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.duplicati.app</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Duplicati</name>
<summary>Create and maintain local and remote backup copies of your data</summary>
<developer id="com.duplicati.app">
<name>Duplicati Team</name>
</developer>
<content_rating type="oars-1.1" />
<description>
<p>
Duplicati is a free backup client that securely stores encrypted,
incremental, compressed backups on cloud storage services and remote file servers.
</p>
<p>
It works with Amazon S3, Windows Live SkyDrive, Google Drive,
OneDrive or WebDAV, SSH, FTP (and many more).
</p>
<p>
Duplicati has built-in AES-256 encryption and backups can be signed using GNU Privacy Guard.
A built-in scheduler and a Web-based interface makes it easy to maintain a regular backup.
</p>
</description>
<launchable type="desktop-id">duplicati.desktop</launchable>
<url type="homepage">https://www.duplicati.com/</url>
<url type="bugtracker">https://github.com/duplicati/duplicati/issues</url>
<url type="vcs-browser">https://github.com/duplicati/duplicati</url>
<provides>
<binary>duplicati</binary>
</provides>
</component>