diff --git a/packaging/BetterMail.desktop b/packaging/BetterMail.desktop
index c18fcad..ccc7ef6 100644
--- a/packaging/BetterMail.desktop
+++ b/packaging/BetterMail.desktop
@@ -4,6 +4,7 @@ Name=BetterMail
Comment=Fast local-first Microsoft 365 mail
Exec=BetterMail %u
Icon=BetterMail
+StartupWMClass=BetterMail
Categories=Network;Email;
MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail;
Terminal=false
diff --git a/scripts/package-linux-appimage.sh b/scripts/package-linux-appimage.sh
index 0c7764f..3cc8a30 100755
--- a/scripts/package-linux-appimage.sh
+++ b/scripts/package-linux-appimage.sh
@@ -21,4 +21,5 @@ install -m 644 "$icon" "$appdir/BetterMail.svg"
install -m 644 "$icon" "$appdir/usr/share/icons/hicolor/scalable/apps/BetterMail.svg"
linuxdeploy --appdir "$appdir" --desktop-file "$appdir/BetterMail.desktop" --icon-file "$appdir/BetterMail.svg"
+install -m 644 "$root/asset-pack/04-desktop/linux/hicolor/256x256/apps/bettermail.png" "$appdir/.DirIcon"
ARCH=x86_64 appimagetool "$appdir" "$root/artifacts/BetterMail-x86_64.AppImage"
diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1
index 9e8fe44..099e7b3 100644
--- a/scripts/package-release.ps1
+++ b/scripts/package-release.ps1
@@ -65,6 +65,10 @@ if ($Runtime -eq "linux-x64") {
Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts/AppRun") -Destination (Join-Path $appDir "AppRun")
Copy-Item -LiteralPath (Join-Path $repositoryRoot "packaging/BetterMail.desktop") -Destination (Join-Path $appDir "BetterMail.desktop")
Copy-Item -LiteralPath $icon -Destination (Join-Path $appDir "BetterMail.png")
+ Copy-Item -LiteralPath $icon -Destination (Join-Path $appDir ".DirIcon")
+ $iconDirectory = Join-Path $appDir "usr/share/icons/hicolor/512x512/apps"
+ New-Item -ItemType Directory -Force $iconDirectory | Out-Null
+ Copy-Item -LiteralPath $icon -Destination (Join-Path $iconDirectory "BetterMail.png")
chmod +x (Join-Path $appDir "AppRun")
$packDirectory = $appDir
}
diff --git a/src/BetterMail.App/BetterMail.App.csproj b/src/BetterMail.App/BetterMail.App.csproj
index e1a958f..0b8bc22 100644
--- a/src/BetterMail.App/BetterMail.App.csproj
+++ b/src/BetterMail.App/BetterMail.App.csproj
@@ -24,6 +24,8 @@
+
diff --git a/src/BetterMail.App/LinuxDesktopIntegration.cs b/src/BetterMail.App/LinuxDesktopIntegration.cs
new file mode 100644
index 0000000..298632e
--- /dev/null
+++ b/src/BetterMail.App/LinuxDesktopIntegration.cs
@@ -0,0 +1,64 @@
+using System.Diagnostics;
+
+namespace BetterMail.App;
+
+internal static class LinuxDesktopIntegration
+{
+ internal static string DataDirectory =>
+ Environment.GetEnvironmentVariable("XDG_DATA_HOME") is { Length: > 0 } directory && Path.IsPathFullyQualified(directory)
+ ? directory
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share");
+
+ public static void Register()
+ {
+ if (!OperatingSystem.IsLinux() || Environment.GetEnvironmentVariable("APPIMAGE") is not { Length: > 0 } appImage)
+ return;
+ try { Install(appImage, DataDirectory); }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ // Read-only home directories must not prevent opening mail.
+ Trace.TraceWarning("Could not register Linux launcher: {0}", exception.GetType().Name);
+ }
+ }
+
+ internal static void Install(string appImage, string dataDirectory)
+ {
+ if (!Path.IsPathFullyQualified(appImage) || appImage.IndexOfAny(['\r', '\n', '\0']) >= 0)
+ throw new ArgumentException("The AppImage path must be an absolute single-line path.", nameof(appImage));
+
+ var iconPath = Path.Combine(dataDirectory, "icons", "hicolor", "512x512", "apps", "BetterMail.png");
+ Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!);
+ using (var icon = typeof(LinuxDesktopIntegration).Assembly.GetManifestResourceStream("BetterMail.LinuxIcon.png")!)
+ using (var output = File.Create(iconPath))
+ icon.CopyTo(output);
+
+ var applications = Path.Combine(dataDirectory, "applications");
+ Directory.CreateDirectory(applications);
+ // Use a persistent icon, not the temporary AppImage mount. Registering the launcher
+ // does not change the user's default mail application.
+ // env also avoids GLib checking the executable path before decoding literal %%.
+ var desktop = $"""
+ [Desktop Entry]
+ Type=Application
+ Name=BetterMail
+ Comment=Fast local-first Microsoft 365 mail
+ Exec=env -- {QuoteExecutable(appImage)} %u
+ Icon={EscapeValue(iconPath)}
+ StartupWMClass=BetterMail
+ Categories=Network;Email;
+ MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail;
+ Terminal=false
+
+ """;
+ File.WriteAllText(Path.Combine(applications, "bettermail.desktop"), desktop);
+ }
+
+ private static string EscapeValue(string value) => value.Replace("\\", "\\\\").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t");
+
+ private static string QuoteExecutable(string value)
+ {
+ // Exec quoting is decoded after desktop-entry string escaping.
+ var quoted = value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("`", "\\`").Replace("$", "\\$").Replace("%", "%%");
+ return "\"" + EscapeValue(quoted) + "\"";
+ }
+}
diff --git a/src/BetterMail.App/MailtoActivation.cs b/src/BetterMail.App/MailtoActivation.cs
index f98e37f..0ee6794 100644
--- a/src/BetterMail.App/MailtoActivation.cs
+++ b/src/BetterMail.App/MailtoActivation.cs
@@ -275,31 +275,7 @@ public static async Task ChooseAsync()
{
throw new InvalidOperationException("Install the BetterMail AppImage before choosing it as the default mail app.");
}
- if (appImage.Contains((char)34))
- {
- throw new InvalidOperationException("The AppImage path contains an unsupported quote character.");
- }
-
- var applications = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- ".local", "share", "applications");
- Directory.CreateDirectory(applications);
- var desktopPath = Path.Combine(applications, "bettermail.desktop");
- var desktop = """
- [Desktop Entry]
- Type=Application
- Name=BetterMail
- Comment=Fast local-first Microsoft 365 mail
- Exec=__APPIMAGE__ %u
- Icon=BetterMail
- Categories=Network;Email;
- MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail;
- Terminal=false
-
- """;
- desktop = desktop.Replace(
- "__APPIMAGE__", string.Concat((char)34, appImage, (char)34), StringComparison.Ordinal);
- await File.WriteAllTextAsync(desktopPath, desktop);
+ LinuxDesktopIntegration.Install(appImage, LinuxDesktopIntegration.DataDirectory);
using var process = Process.Start(new ProcessStartInfo(
"xdg-mime", "default bettermail.desktop x-scheme-handler/mailto x-scheme-handler/bettermail")
{
diff --git a/src/BetterMail.App/Program.cs b/src/BetterMail.App/Program.cs
index 1015fe4..45bf9ae 100644
--- a/src/BetterMail.App/Program.cs
+++ b/src/BetterMail.App/Program.cs
@@ -16,6 +16,7 @@ public static void Main(string[] args)
}
velopack.Run();
DefaultMailApp.Register();
+ LinuxDesktopIntegration.Register();
var activation = args.FirstOrDefault(argument =>
argument.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
@@ -42,6 +43,7 @@ public static void Main(string[] args)
public static AppBuilder BuildAvaloniaApp() => AppBuilder
.Configure()
.UsePlatformDetect()
+ .With(new X11PlatformOptions { WmClass = "BetterMail" })
.WithInterFont()
.LogToTrace();
}
diff --git a/tests/BetterMail.Tests/LinuxDesktopIntegrationTests.cs b/tests/BetterMail.Tests/LinuxDesktopIntegrationTests.cs
new file mode 100644
index 0000000..d53328a
--- /dev/null
+++ b/tests/BetterMail.Tests/LinuxDesktopIntegrationTests.cs
@@ -0,0 +1,53 @@
+using BetterMail.App;
+
+namespace BetterMail.Tests;
+
+public sealed class LinuxDesktopIntegrationTests
+{
+ [Fact]
+ public void LauncherKeepsItsIconAndTracksMovedAppImage()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-launcher-" + Guid.NewGuid());
+ try
+ {
+ var appImage = Path.Combine(directory, "Original Mail.AppImage");
+ LinuxDesktopIntegration.Install(appImage, directory);
+ var desktopPath = Path.Combine(directory, "applications", "bettermail.desktop");
+ var iconPath = Path.Combine(directory, "icons", "hicolor", "512x512", "apps", "BetterMail.png");
+ Assert.Equal(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, File.ReadAllBytes(iconPath)[..8]);
+ Assert.Contains("StartupWMClass=BetterMail", File.ReadAllText(desktopPath));
+ Assert.Contains("MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail;", File.ReadAllText(desktopPath));
+ Assert.Contains("Icon=" + iconPath.Replace("\\", "\\\\"), File.ReadAllText(desktopPath));
+
+ var moved = Path.Combine(directory, "Moved Mail.AppImage");
+ LinuxDesktopIntegration.Install(moved, directory);
+ var desktop = File.ReadAllText(desktopPath);
+ Assert.DoesNotContain("Original Mail.AppImage", desktop);
+ Assert.Contains("Moved Mail.AppImage\" %u", desktop);
+ Assert.Single(Directory.GetFiles(Path.GetDirectoryName(desktopPath)!));
+ Assert.False(File.Exists(Path.Combine(directory, "applications", "mimeapps.list")));
+ }
+ finally { Directory.Delete(directory, true); }
+ }
+
+ [Fact]
+ public void DesktopExecEscapesSpecialCharacters()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-launcher-" + Guid.NewGuid());
+ try
+ {
+ LinuxDesktopIntegration.Install(Path.Combine(directory, "Mail $`\"%\\.AppImage"), directory);
+ var desktop = File.ReadAllText(Path.Combine(directory, "applications", "bettermail.desktop"));
+ Assert.Contains("Mail \\\\$\\\\`\\\\\"%%\\\\\\\\.AppImage\" %u", desktop);
+ }
+ finally { Directory.Delete(directory, true); }
+ }
+
+ [Theory]
+ [InlineData("relative.AppImage")]
+ [InlineData("/mail\nExec=other")]
+ public void InvalidExecutableDoesNotWriteLauncher(string executable)
+ {
+ Assert.Throws(() => LinuxDesktopIntegration.Install(executable, Path.GetTempPath()));
+ }
+}