Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packaging/BetterMail.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions scripts/package-linux-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions scripts/package-release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions src/BetterMail.App/BetterMail.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<ProjectReference Include="../BetterMail.Microsoft365/BetterMail.Microsoft365.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="../../asset-pack/04-desktop/linux/hicolor/512x512/apps/bettermail.png"
LogicalName="BetterMail.LinuxIcon.png" />
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="..\..\asset-pack\06-banners\website-header-1500x500.png"
Link="Assets\BetterMailSettingsBanner.png" />
Expand Down
64 changes: 64 additions & 0 deletions src/BetterMail.App/LinuxDesktopIntegration.cs
Original file line number Diff line number Diff line change
@@ -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) + "\"";
}
}
26 changes: 1 addition & 25 deletions src/BetterMail.App/MailtoActivation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
{
Expand Down
2 changes: 2 additions & 0 deletions src/BetterMail.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand All @@ -42,6 +43,7 @@ public static void Main(string[] args)
public static AppBuilder BuildAvaloniaApp() => AppBuilder
.Configure<App>()
.UsePlatformDetect()
.With(new X11PlatformOptions { WmClass = "BetterMail" })
.WithInterFont()
.LogToTrace();
}
53 changes: 53 additions & 0 deletions tests/BetterMail.Tests/LinuxDesktopIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => LinuxDesktopIntegration.Install(executable, Path.GetTempPath()));
}
}
Loading