From 0bfada1eee5ac0dc93cf962edf1f401fb41ef105 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:08:16 +0000 Subject: [PATCH] Install Linux launcher icons and include AppImage icon metadata --- packaging/BetterMail.desktop | 1 + scripts/package-linux-appimage.sh | 1 + scripts/package-release.ps1 | 4 ++ src/BetterMail.App/BetterMail.App.csproj | 2 + src/BetterMail.App/LinuxDesktopIntegration.cs | 64 +++++++++++++++++++ src/BetterMail.App/MailtoActivation.cs | 26 +------- src/BetterMail.App/Program.cs | 2 + .../LinuxDesktopIntegrationTests.cs | 53 +++++++++++++++ 8 files changed, 128 insertions(+), 25 deletions(-) create mode 100644 src/BetterMail.App/LinuxDesktopIntegration.cs create mode 100644 tests/BetterMail.Tests/LinuxDesktopIntegrationTests.cs 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())); + } +}